diff --git a/.github/ISSUE_TEMPLATE/--bug-report.yaml b/.github/ISSUE_TEMPLATE/--bug-report.yaml index 5d19be11c..3adaa4230 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, pushya-plane] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: @@ -45,7 +45,7 @@ body: - Deploy preview validations: required: true - type: dropdown +- type: dropdown id: browser attributes: label: Browser diff --git a/.github/ISSUE_TEMPLATE/--feature-request.yaml b/.github/ISSUE_TEMPLATE/--feature-request.yaml index 941fbef87..ff9cdd238 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, pushya-plane] +assignees: [srinivaspendem, pushya22] body: - type: markdown attributes: diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml new file mode 100644 index 000000000..60ebe5834 --- /dev/null +++ b/.github/workflows/auto-merge.yml @@ -0,0 +1,97 @@ +name: Auto Merge or Create PR on Push + +on: + workflow_dispatch: + push: + branches: + - "sync/**" + +env: + CURRENT_BRANCH: ${{ github.ref_name }} + SOURCE_BRANCH: ${{ secrets.SYNC_TARGET_BRANCH_NAME }} # The sync branch such as "sync/ce" + TARGET_BRANCH: ${{ secrets.TARGET_BRANCH }} # 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: ${{ secrets.REVIEWER }} + +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 + + Auto_Merge: + if: ${{ needs.Check_Branch.outputs.BRANCH_MATCH == 'true' }} + needs: [Check_Branch] + runs-on: ubuntu-latest + permissions: + pull-requests: write + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4.1.1 + with: + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Setup GH CLI and Git Config + run: | + type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y) + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - id: git-author + name: Setup Git CLI from Github Token + run: | + VIEWER_JSON=$(gh api graphql -f query='query { viewer { name login databaseId }}' --jq '.data.viewer') + VIEWER_NAME=$(jq --raw-output '.name | values' <<< "${VIEWER_JSON}") + VIEWER_LOGIN=$(jq --raw-output '.login' <<< "${VIEWER_JSON}") + VIEWER_DATABASE_ID=$(jq --raw-output '.databaseId' <<< "${VIEWER_JSON}") + + USER_NAME="${VIEWER_NAME:-${VIEWER_LOGIN}}" + USER_EMAIL="${VIEWER_DATABASE_ID}+${VIEWER_LOGIN}@users.noreply.github.com" + + git config --global user.name ${USER_NAME} + git config --global user.email ${USER_EMAIL} + + - name: Check for merge conflicts + id: conflicts + run: | + git fetch origin $TARGET_BRANCH + git checkout $TARGET_BRANCH + # Attempt to merge the main branch into the current branch + if $(git merge --no-commit --no-ff $SOURCE_BRANCH); then + echo "No merge conflicts detected." + echo "HAS_CONFLICTS=false" >> $GITHUB_ENV + else + echo "Merge conflicts detected." + echo "HAS_CONFLICTS=true" >> $GITHUB_ENV + git merge --abort + fi + + - name: Merge Change to Target Branch + if: env.HAS_CONFLICTS == 'false' + run: | + git commit -m "Merge branch '$SOURCE_BRANCH' into $TARGET_BRANCH" + git push origin $TARGET_BRANCH + + - name: Create PR to Target Branch + if: env.HAS_CONFLICTS == 'true' + run: | + # Use GitHub CLI to create PR and specify author and committer + PR_URL=$(gh pr create --base $TARGET_BRANCH --head $SOURCE_BRANCH \ + --title "sync: merge conflicts need to be resolved" \ + --body "" \ + --reviewer $REVIEWER ) + echo "Pull Request created: $PR_URL" + diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 0d3f97068..44bae0efa 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -23,6 +23,10 @@ jobs: gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} gh_buildx_endpoint: ${{ steps.set_env_variables.outputs.BUILDX_ENDPOINT }} + build_frontend: ${{ steps.changed_files.outputs.frontend_any_changed }} + build_space: ${{ steps.changed_files.outputs.space_any_changed }} + build_backend: ${{ steps.changed_files.outputs.backend_any_changed }} + build_proxy: ${{ steps.changed_files.outputs.proxy_any_changed }} steps: - id: set_env_variables @@ -41,7 +45,36 @@ jobs: fi echo "TARGET_BRANCH=${{ env.TARGET_BRANCH }}" >> $GITHUB_OUTPUT + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - name: Get changed files + id: changed_files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + frontend: + - web/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + space: + - space/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' + backend: + - apiserver/** + proxy: + - nginx/** + branch_build_push_frontend: + if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -55,9 +88,9 @@ jobs: - name: Set Frontend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-frontend:latest else TAG=${{ env.FRONTEND_TAG }} fi @@ -77,7 +110,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Frontend to Docker Container Registry uses: docker/build-push-action@v5.1.0 @@ -93,6 +126,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_space: + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -106,9 +140,9 @@ jobs: - name: Set Space Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-space:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-space:latest else TAG=${{ env.SPACE_TAG }} fi @@ -128,7 +162,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Space to Docker Hub uses: docker/build-push-action@v5.1.0 @@ -144,6 +178,7 @@ jobs: DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} branch_build_push_backend: + if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -157,9 +192,9 @@ jobs: - name: Set Backend Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-backend:latest else TAG=${{ env.BACKEND_TAG }} fi @@ -179,7 +214,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Backend to Docker Hub uses: docker/build-push-action@v5.1.0 @@ -194,8 +229,8 @@ jobs: 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 == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -209,9 +244,9 @@ jobs: - name: Set Proxy Docker Tag run: | if [ "${{ github.event_name }}" == "release" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable,${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:${{ github.event.release.tag_name }} elif [ "${{ env.TARGET_BRANCH }}" == "master" ]; then - TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:stable + TAG=${{ secrets.DOCKERHUB_USERNAME }}/plane-proxy:latest else TAG=${{ env.PROXY_TAG }} fi @@ -231,7 +266,7 @@ jobs: endpoint: ${{ env.BUILDX_ENDPOINT }} - name: Check out the repo - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4 - name: Build and Push Plane-Proxy to Docker Hub uses: docker/build-push-action@v5.1.0 diff --git a/.github/workflows/build-test-pull-request.yml b/.github/workflows/build-test-pull-request.yml index 296e965d7..e0014f696 100644 --- a/.github/workflows/build-test-pull-request.yml +++ b/.github/workflows/build-test-pull-request.yml @@ -1,28 +1,19 @@ -name: Build Pull Request Contents +name: Build and Lint on Pull Request on: + workflow_dispatch: pull_request: types: ["opened", "synchronize"] jobs: - build-pull-request-contents: - name: Build Pull Request Contents - runs-on: ubuntu-20.04 - permissions: - pull-requests: read - + get-changed-files: + runs-on: ubuntu-latest + outputs: + apiserver_changed: ${{ steps.changed-files.outputs.apiserver_any_changed }} + web_changed: ${{ steps.changed-files.outputs.web_any_changed }} + space_changed: ${{ steps.changed-files.outputs.deploy_any_changed }} steps: - - name: Checkout Repository to Actions - uses: actions/checkout@v3.3.0 - with: - token: ${{ secrets.ACCESS_TOKEN }} - - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 - with: - node-version: 18.x - cache: "yarn" - + - uses: actions/checkout@v3 - name: Get changed files id: changed-files uses: tj-actions/changed-files@v41 @@ -32,17 +23,82 @@ jobs: - apiserver/** web: - web/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' deploy: - space/** + - packages/** + - 'package.json' + - 'yarn.lock' + - 'tsconfig.json' + - 'turbo.json' - - name: Build Plane's Main App - if: steps.changed-files.outputs.web_any_changed == 'true' - run: | - yarn - yarn build --filter=web + lint-apiserver: + needs: get-changed-files + runs-on: ubuntu-latest + if: needs.get-changed-files.outputs.apiserver_changed == 'true' + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' # Specify the Python version you need + - name: Install Pylint + run: python -m pip install ruff + - name: Install Apiserver Dependencies + run: cd apiserver && pip install -r requirements.txt + - name: Lint apiserver + run: ruff check --fix apiserver - - name: Build Plane's Deploy App - if: steps.changed-files.outputs.deploy_any_changed == 'true' - run: | - yarn - yarn build --filter=space + lint-web: + needs: get-changed-files + if: needs.get-changed-files.outputs.web_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 18.x + - run: yarn install + - run: yarn lint --filter=web + + lint-space: + needs: get-changed-files + if: needs.get-changed-files.outputs.space_changed == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 18.x + - run: yarn install + - run: yarn lint --filter=space + + build-web: + needs: lint-web + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 18.x + - run: yarn install + - run: yarn build --filter=web + + build-space: + needs: lint-space + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Setup Node.js + uses: actions/setup-node@v2 + with: + node-version: 18.x + - run: yarn install + - run: yarn build --filter=space diff --git a/.github/workflows/check-version.yml b/.github/workflows/check-version.yml new file mode 100644 index 000000000..ca8b6f8b3 --- /dev/null +++ b/.github/workflows/check-version.yml @@ -0,0 +1,45 @@ +name: Version Change Before Release + +on: + pull_request: + branches: + - master + +jobs: + check-version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Get PR Branch version + run: echo "PR_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Fetch base branch + run: git fetch origin master:master + + - name: Get Master Branch version + run: | + git checkout master + echo "MASTER_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Get master branch version and compare + run: | + echo "Comparing versions: PR version is $PR_VERSION, Master version is $MASTER_VERSION" + if [ "$PR_VERSION" == "$MASTER_VERSION" ]; then + echo "Version in PR branch is the same as in master. Failing the CI." + exit 1 + else + echo "Version check passed. Versions are different." + fi + env: + PR_VERSION: ${{ env.PR_VERSION }} + MASTER_VERSION: ${{ env.MASTER_VERSION }} diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index 47a85f3ba..8644f04f0 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -2,7 +2,7 @@ name: Create Sync Action on: workflow_dispatch: - push: + push: branches: - preview @@ -17,7 +17,7 @@ jobs: contents: read steps: - name: Checkout Code - uses: actions/checkout@v4.1.1 + uses: actions/checkout@v4.1.1 with: persist-credentials: false fetch-depth: 0 @@ -31,14 +31,25 @@ jobs: sudo apt update sudo apt install gh -y - - name: Push Changes to Target Repo + - name: Push Changes to Target Repo A env: GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} run: | - TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}" - TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}" + TARGET_REPO="${{ secrets.TARGET_REPO_A }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_A_BRANCH_NAME }}" SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" git checkout $SOURCE_BRANCH - git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git" - git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH + git remote add target-origin-a "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-a $SOURCE_BRANCH:$TARGET_BRANCH + + - name: Push Changes to Target Repo B + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ secrets.TARGET_REPO_B }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_B_BRANCH_NAME }}" + SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}" + + git remote add target-origin-b "https://$GH_TOKEN@github.com/$TARGET_REPO.git" + git push target-origin-b $SOURCE_BRANCH:$TARGET_BRANCH diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml new file mode 100644 index 000000000..7b9f5ffcc --- /dev/null +++ b/.github/workflows/feature-deployment.yml @@ -0,0 +1,73 @@ +name: Feature Preview + +on: + workflow_dispatch: + inputs: + web-build: + required: true + type: boolean + default: true + space-build: + required: true + type: boolean + default: false + +jobs: + feature-deploy: + name: Feature Deploy + runs-on: ubuntu-latest + env: + KUBE_CONFIG_FILE: ${{ secrets.KUBE_CONFIG }} + BUILD_WEB: ${{ (github.event.inputs.web-build == '' && true) || github.event.inputs.web-build }} + BUILD_SPACE: ${{ (github.event.inputs.space-build == '' && false) || github.event.inputs.space-build }} + + steps: + - name: Tailscale + uses: tailscale/github-action@v2 + with: + oauth-client-id: ${{ secrets.TAILSCALE_OAUTH_CLIENT_ID }} + oauth-secret: ${{ secrets.TAILSCALE_OAUTH_SECRET }} + tags: tag:ci + + - name: Kubectl Setup + run: | + curl -LO "https://dl.k8s.io/release/${{secrets.KUBE_VERSION}}/bin/linux/amd64/kubectl" + chmod +x kubectl + + mkdir -p ~/.kube + echo "$KUBE_CONFIG_FILE" > ~/.kube/config + chmod 600 ~/.kube/config + + - name: HELM Setup + run: | + curl -fsSL -o get_helm.sh https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 + chmod 700 get_helm.sh + ./get_helm.sh + + - name: App Deploy + run: | + helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ secrets.FEATURE_PREVIEW_HELM_CHART_URL }} + GIT_BRANCH=${{ github.ref_name }} + APP_NAMESPACE=${{ secrets.FEATURE_PREVIEW_NAMESPACE }} + + METADATA=$(helm install feature-preview/${{ secrets.FEATURE_PREVIEW_HELM_CHART_NAME }} \ + --kube-insecure-skip-tls-verify \ + --generate-name \ + --namespace $APP_NAMESPACE \ + --set shared_config.git_repo=${{github.server_url}}/${{ github.repository }}.git \ + --set shared_config.git_branch="$GIT_BRANCH" \ + --set web.enabled=${{ env.BUILD_WEB }} \ + --set space.enabled=${{ env.BUILD_SPACE }} \ + --output json \ + --timeout 1000s) + + APP_NAME=$(echo $METADATA | jq -r '.name') + + INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \ + -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ + jq -r '.spec.rules[0].host') + + echo "****************************************" + echo "APP NAME ::: $APP_NAME" + echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" + echo "****************************************" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 148568d76..f40c1a244 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,6 @@ chmod +x setup.sh docker compose -f docker-compose-local.yml up ``` - ## Missing a Feature? If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. diff --git a/ENV_SETUP.md b/ENV_SETUP.md index bfc300196..df05683ef 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -53,7 +53,6 @@ NGINX_PORT=80 NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" ``` - ## {PROJECT_FOLDER}/apiserver/.env ​ diff --git a/README.md b/README.md index b509fd6f6..6834199ff 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Plane

-

Flexible, extensible open-source project management

+

Open-source project management that unlocks customer value.

@@ -16,6 +16,13 @@ Commit activity per month

+

+ Website • + Releases • + Twitter • + Documentation +

+

-Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind 🧘‍♀️. +Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️ > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). +## ⚡ Installation -## ⚡️ Contributors Quick Start +The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users. -### Prerequisite +If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). -Development system must have docker engine installed and running. +| Installation Methods | Documentation Link | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Docker | [![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=for-the-badge&logo=docker&logoColor=white)](https://docs.plane.so/docker-compose) | +| Kubernetes | [![Kubernetes](https://img.shields.io/badge/kubernetes-%23326ce5.svg?style=for-the-badge&logo=kubernetes&logoColor=white)](https://docs.plane.so/kubernetes) | -### Steps - -Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute - -1. Clone the code locally using `git clone https://github.com/makeplane/plane.git` -1. Switch to the code folder `cd plane` -1. Create your feature or fix branch you plan to work on using `git checkout -b ` -1. Open terminal and run `./setup.sh` -1. Open the code on VSCode or similar equivalent IDE -1. Review the `.env` files available in various folders. Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system -1. Run the docker command to initiate various services `docker compose -f docker-compose-local.yml up -d` - -You are ready to make changes to the code. Do not forget to refresh the browser (in case id does not auto-reload) - -Thats it! - -## 🍙 Self Hosting - -For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/docker-compose) documentation page +`Instance admin` can configure instance settings using our [God-mode](https://docs.plane.so/instance-admin) feature. ## 🚀 Features -- **Issue Planning and Tracking**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to issues for better organization and tracking. -- **Issue Attachments**: Collaborate effectively by attaching files to issues, making it easy for your team to find and share important project-related documents. -- **Layouts**: Customize your project view with your preferred layout - choose from List, Kanban, or Calendar to visualize your project in a way that makes sense to you. -- **Cycles**: Plan sprints with Cycles to keep your team on track and productive. Gain insights into your project's progress with burn-down charts and other useful features. -- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to easily track and plan your project's progress. +- **Issues**: Quickly create issues and add details using a powerful, rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. + +- **Cycles** + Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features. + +- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily. + - **Views**: Create custom filters to display only the issues that matter to you. Save and share your filters in just a few clicks. -- **Pages**: Plane pages function as an AI-powered notepad, allowing you to easily document issues, cycle plans, and module details, and then synchronize them with your issues. -- **Command K**: Enjoy a better user experience with the new Command + K menu. Easily manage and navigate through your projects from one convenient location. -- **GitHub Sync**: Streamline your planning process by syncing your GitHub issues with Plane. Keep all your issues in one place for better tracking and collaboration. + +- **Pages**: Plane pages, equipped with AI and a rich text editor, let you jot down your thoughts on the fly. Format your text, upload images, hyperlink, or sync your existing ideas into an actionable item or issue. + +- **Analytics**: Get insights into all your Plane data in real-time. Visualize issue data to spot trends, remove blockers, and progress your work. + +- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. + +## 🛠️ Contributors Quick Start + +> Development system must have docker engine installed and running. + +Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute + +1. Clone the code locally using: + ``` + git clone https://github.com/makeplane/plane.git + ``` +2. Switch to the code folder: + ``` + cd plane + ``` +3. Create your feature or fix branch you plan to work on using: + ``` + git checkout -b + ``` +4. Open terminal and run: + ``` + ./setup.sh + ``` +5. Open the code on VSCode or similar equivalent IDE. +6. Review the `.env` files available in various folders. + Visit [Environment Setup](./ENV_SETUP.md) to know about various environment variables used in system. +7. Run the docker command to initiate services: + ``` + docker compose -f docker-compose-local.yml up -d + ``` + +You are ready to make changes to the code. Do not forget to refresh the browser (in case it does not auto-reload). + +Thats it! + +## ❤️ Community + +The Plane community can be found on [GitHub Discussions](https://github.com/orgs/makeplane/discussions), and our [Discord server](https://discord.com/invite/A92xrEGCge). Our [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community chanels. + +Ask questions, report bugs, join discussions, voice ideas, make feature requests, or share your projects. + +### Repo Activity + +![Plane Repo Activity](https://repobeats.axiom.co/api/embed/2523c6ed2f77c082b7908c33e2ab208981d76c39.svg "Repobeats analytics image") ## 📸 Screenshots

Plane Views @@ -91,8 +132,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Issue Details @@ -100,7 +140,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Cycles and Modules @@ -109,7 +149,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Analytics @@ -118,7 +158,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Pages @@ -128,7 +168,7 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

Plane Command Menu @@ -136,20 +176,23 @@ For self hosting environment setup, visit the [Self Hosting](https://docs.plane.

-## 📚Documentation - -For full documentation, visit [docs.plane.so](https://docs.plane.so/) - -To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md). - -## ❤️ Community - -The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects. - -To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge). - -Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels. - ## ⛓️ Security -If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. Email engineering@plane.so to disclose any security vulnerabilities. +If you believe you have found a security vulnerability in Plane, we encourage you to responsibly disclose this and not open a public issue. We will investigate all legitimate reports. + +Email squawk@plane.so to disclose any security vulnerabilities. + +## ❤️ Contribute + +There are many ways to contribute to Plane, including: + +- Submitting [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) and [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+) for various components. +- Reviewing [the documentation](https://docs.plane.so/) and submitting [pull requests](https://github.com/makeplane/plane), from fixing typos to adding new features. +- Speaking or writing about Plane or any other ecosystem integration and [letting us know](https://discord.com/invite/A92xrEGCge)! +- Upvoting [popular feature requests](https://github.com/makeplane/plane/issues) to show your support. + +### We couldn't have done this without you. + +
+ + diff --git a/apiserver/package.json b/apiserver/package.json index fb4f8441d..060944406 100644 --- a/apiserver/package.json +++ b/apiserver/package.json @@ -1,4 +1,4 @@ { "name": "plane-api", - "version": "0.15.1" + "version": "0.16.0" } diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 4c8d6e815..b8f194b32 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,8 +1,9 @@ from lxml import html - # Django imports from django.utils import timezone +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError # Third party imports from rest_framework import serializers @@ -284,6 +285,20 @@ class IssueLinkSerializer(BaseSerializer): "updated_at", ] + def validate_url(self, value): + # Check URL format + validate_url = URLValidator() + try: + validate_url(value) + except ValidationError: + raise serializers.ValidationError("Invalid URL format.") + + # Check URL scheme + if not value.startswith(('http://', 'https://')): + raise serializers.ValidationError("Invalid URL scheme.") + + return value + # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( @@ -295,6 +310,17 @@ class IssueLinkSerializer(BaseSerializer): ) return IssueLink.objects.create(**validated_data) + def update(self, instance, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), + issue_id=instance.issue_id, + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + + return super().update(instance, validated_data) + class IssueAttachmentSerializer(BaseSerializer): class Meta: diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 6f66c373e..84931f46b 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -45,7 +45,10 @@ class CycleAPIEndpoint(WebhookMixin, BaseAPIView): return ( Cycle.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("owned_by") @@ -390,7 +393,10 @@ class CycleIssueAPIEndpoint(WebhookMixin, BaseAPIView): ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(cycle_id=self.kwargs.get("cycle_id")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index a759b15f6..bf3313779 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -352,7 +352,10 @@ class LabelAPIEndpoint(BaseAPIView): return ( Label.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("parent") @@ -481,7 +484,10 @@ class IssueLinkAPIEndpoint(BaseAPIView): IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by(self.kwargs.get("order_by", "-created_at")) .distinct() ) @@ -607,11 +613,11 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("issue") - .select_related("actor") + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("workspace", "project", "issue", "actor") .annotate( is_member=Exists( ProjectMember.objects.filter( @@ -647,6 +653,33 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): ) def post(self, request, slug, project_id, issue_id): + + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and request.data.get("external_source") + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + issue_comment = IssueComment.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentSerializer(data=request.data) if serializer.is_valid(): serializer.save( @@ -680,6 +713,29 @@ class IssueCommentAPIEndpoint(WebhookMixin, BaseAPIView): IssueCommentSerializer(issue_comment).data, cls=DjangoJSONEncoder, ) + + # Validation check if the issue already exists + if ( + request.data.get("external_id") + and (issue_comment.external_id != str(request.data.get("external_id"))) + and IssueComment.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get( + "external_source", issue_comment.external_source + ), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Issue Comment with the same external id and external source already exists", + "id": str(issue_comment.id), + }, + status=status.HTTP_409_CONFLICT, + ) + + serializer = IssueCommentSerializer( issue_comment, data=request.data, partial=True ) @@ -734,6 +790,7 @@ class IssueActivityAPIEndpoint(BaseAPIView): .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) .select_related("actor", "workspace", "issue", "project") ).order_by(request.GET.get("order_by", "created_at")) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index d509a53c7..2e5bb85e2 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -273,7 +273,10 @@ class ModuleIssueAPIEndpoint(WebhookMixin, BaseAPIView): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("module") diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 0a262a071..ec10f9bab 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -1,7 +1,5 @@ -# Python imports -from itertools import groupby - # Django imports +from django.db import IntegrityError from django.db.models import Q # Third party imports @@ -26,7 +24,10 @@ class StateAPIEndpoint(BaseAPIView): return ( State.objects.filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") @@ -34,37 +35,51 @@ class StateAPIEndpoint(BaseAPIView): ) def post(self, request, slug, project_id): - serializer = StateSerializer( - data=request.data, context={"project_id": project_id} - ) - if serializer.is_valid(): - if ( - request.data.get("external_id") - and request.data.get("external_source") - and State.objects.filter( - project_id=project_id, - workspace__slug=slug, - external_source=request.data.get("external_source"), - external_id=request.data.get("external_id"), - ).exists() - ): - state = State.objects.filter( - workspace__slug=slug, - project_id=project_id, - external_id=request.data.get("external_id"), - external_source=request.data.get("external_source"), - ).first() - return Response( - { - "error": "State with the same external id and external source already exists", - "id": str(state.id), - }, - status=status.HTTP_409_CONFLICT, - ) + try: + serializer = StateSerializer( + data=request.data, context={"project_id": project_id} + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and State.objects.filter( + project_id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + external_id=request.data.get("external_id"), + external_source=request.data.get("external_source"), + ).first() + return Response( + { + "error": "State with the same external id and external source already exists", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) - serializer.save(project_id=project_id) - return Response(serializer.data, status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + state = State.objects.filter( + workspace__slug=slug, + project_id=project_id, + name=request.data.get("name"), + ).first() + return Response( + { + "error": "State with the same name already exists in the project", + "id": str(state.id), + }, + status=status.HTTP_409_CONFLICT, + ) def get(self, request, slug, project_id, state_id=None): if state_id: diff --git a/apiserver/plane/app/serializers/__init__.py b/apiserver/plane/app/serializers/__init__.py index 28e881060..9bdd4baaf 100644 --- a/apiserver/plane/app/serializers/__init__.py +++ b/apiserver/plane/app/serializers/__init__.py @@ -69,9 +69,13 @@ from .issue import ( RelatedIssueSerializer, IssuePublicSerializer, IssueDetailSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) from .module import ( + ModuleDetailSerializer, ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer, diff --git a/apiserver/plane/app/serializers/base.py b/apiserver/plane/app/serializers/base.py index 446fdb6d5..6693ba931 100644 --- a/apiserver/plane/app/serializers/base.py +++ b/apiserver/plane/app/serializers/base.py @@ -58,9 +58,12 @@ class DynamicBaseSerializer(BaseSerializer): IssueSerializer, LabelSerializer, CycleIssueSerializer, - IssueFlatSerializer, + IssueLiteSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer + InboxIssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) # Expansion mapper @@ -79,12 +82,34 @@ class DynamicBaseSerializer(BaseSerializer): "assignees": UserLiteSerializer, "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, - "parent": IssueSerializer, + "parent": IssueLiteSerializer, "issue_relation": IssueRelationSerializer, - "issue_inbox" : InboxIssueLiteSerializer, + "issue_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_attachment": IssueAttachmentLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, } - - self.fields[field] = expansion[field](many=True if field in ["members", "assignees", "labels", "issue_cycle", "issue_relation", "issue_inbox"] else False) + + self.fields[field] = expansion[field]( + many=( + True + if field + in [ + "members", + "assignees", + "labels", + "issue_cycle", + "issue_relation", + "issue_inbox", + "issue_reactions", + "issue_attachment", + "issue_link", + "sub_issues", + ] + else False + ) + ) return self.fields @@ -105,7 +130,11 @@ class DynamicBaseSerializer(BaseSerializer): LabelSerializer, CycleIssueSerializer, IssueRelationSerializer, - InboxIssueLiteSerializer + InboxIssueLiteSerializer, + IssueLiteSerializer, + IssueReactionLiteSerializer, + IssueAttachmentLiteSerializer, + IssueLinkLiteSerializer, ) # Expansion mapper @@ -124,9 +153,13 @@ class DynamicBaseSerializer(BaseSerializer): "assignees": UserLiteSerializer, "labels": LabelSerializer, "issue_cycle": CycleIssueSerializer, - "parent": IssueSerializer, + "parent": IssueLiteSerializer, "issue_relation": IssueRelationSerializer, - "issue_inbox" : InboxIssueLiteSerializer, + "issue_inbox": InboxIssueLiteSerializer, + "issue_reactions": IssueReactionLiteSerializer, + "issue_attachment": IssueAttachmentLiteSerializer, + "issue_link": IssueLinkLiteSerializer, + "sub_issues": IssueLiteSerializer, } # Check if field in expansion then expand the field if expand in expansion: diff --git a/apiserver/plane/app/serializers/cycle.py b/apiserver/plane/app/serializers/cycle.py index 77c3f16cc..a273b349c 100644 --- a/apiserver/plane/app/serializers/cycle.py +++ b/apiserver/plane/app/serializers/cycle.py @@ -3,10 +3,7 @@ from rest_framework import serializers # Module imports from .base import BaseSerializer -from .user import UserLiteSerializer from .issue import IssueStateSerializer -from .workspace import WorkspaceLiteSerializer -from .project import ProjectLiteSerializer from plane.db.models import ( Cycle, CycleIssue, @@ -14,7 +11,6 @@ from plane.db.models import ( CycleUserProperties, ) - class CycleWriteSerializer(BaseSerializer): def validate(self, data): if ( @@ -30,60 +26,6 @@ class CycleWriteSerializer(BaseSerializer): class Meta: model = Cycle fields = "__all__" - - -class CycleSerializer(BaseSerializer): - is_favorite = serializers.BooleanField(read_only=True) - total_issues = serializers.IntegerField(read_only=True) - cancelled_issues = serializers.IntegerField(read_only=True) - completed_issues = serializers.IntegerField(read_only=True) - started_issues = serializers.IntegerField(read_only=True) - unstarted_issues = serializers.IntegerField(read_only=True) - backlog_issues = serializers.IntegerField(read_only=True) - assignees = serializers.SerializerMethodField(read_only=True) - total_estimates = serializers.IntegerField(read_only=True) - completed_estimates = serializers.IntegerField(read_only=True) - started_estimates = serializers.IntegerField(read_only=True) - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") - status = serializers.CharField(read_only=True) - - def validate(self, data): - if ( - data.get("start_date", None) is not None - and data.get("end_date", None) is not None - and data.get("start_date", None) > data.get("end_date", None) - ): - raise serializers.ValidationError( - "Start date cannot exceed end date" - ) - return data - - def get_assignees(self, obj): - members = [ - { - "avatar": assignee.avatar, - "display_name": assignee.display_name, - "id": assignee.id, - } - for issue_cycle in obj.issue_cycle.prefetch_related( - "issue__assignees" - ).all() - for assignee in issue_cycle.issue.assignees.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in members} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list - - class Meta: - model = Cycle - fields = "__all__" read_only_fields = [ "workspace", "project", @@ -91,6 +33,52 @@ class CycleSerializer(BaseSerializer): ] +class CycleSerializer(BaseSerializer): + # favorite + is_favorite = serializers.BooleanField(read_only=True) + total_issues = serializers.IntegerField(read_only=True) + # state group wise distribution + cancelled_issues = serializers.IntegerField(read_only=True) + completed_issues = serializers.IntegerField(read_only=True) + started_issues = serializers.IntegerField(read_only=True) + unstarted_issues = serializers.IntegerField(read_only=True) + backlog_issues = serializers.IntegerField(read_only=True) + + # active | draft | upcoming | completed + status = serializers.CharField(read_only=True) + + + class Meta: + model = Cycle + fields = [ + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "status", + ] + read_only_fields = fields + + class CycleIssueSerializer(BaseSerializer): issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index 90069bd41..1b884bedf 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -1,5 +1,7 @@ # Django imports from django.utils import timezone +from django.core.validators import URLValidator +from django.core.exceptions import ValidationError # Third Party imports from rest_framework import serializers @@ -432,6 +434,20 @@ class IssueLinkSerializer(BaseSerializer): "issue", ] + def validate_url(self, value): + # Check URL format + validate_url = URLValidator() + try: + validate_url(value) + except ValidationError: + raise serializers.ValidationError("Invalid URL format.") + + # Check URL scheme + if not value.startswith(('http://', 'https://')): + raise serializers.ValidationError("Invalid URL scheme.") + + return value + # Validation if url already exists def create(self, validated_data): if IssueLink.objects.filter( @@ -443,6 +459,33 @@ class IssueLinkSerializer(BaseSerializer): ) return IssueLink.objects.create(**validated_data) + def update(self, instance, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), + issue_id=instance.issue_id, + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + + return super().update(instance, validated_data) + + +class IssueLinkLiteSerializer(BaseSerializer): + + class Meta: + model = IssueLink + fields = [ + "id", + "issue_id", + "title", + "url", + "metadata", + "created_by_id", + "created_at", + ] + read_only_fields = fields + class IssueAttachmentSerializer(BaseSerializer): class Meta: @@ -459,6 +502,21 @@ class IssueAttachmentSerializer(BaseSerializer): ] +class IssueAttachmentLiteSerializer(DynamicBaseSerializer): + + class Meta: + model = IssueAttachment + fields = [ + "id", + "asset", + "attributes", + "issue_id", + "updated_at", + "updated_by_id", + ] + read_only_fields = fields + + class IssueReactionSerializer(BaseSerializer): actor_detail = UserLiteSerializer(read_only=True, source="actor") @@ -473,6 +531,18 @@ class IssueReactionSerializer(BaseSerializer): ] +class IssueReactionLiteSerializer(DynamicBaseSerializer): + + class Meta: + model = IssueReaction + fields = [ + "id", + "actor_id", + "issue_id", + "reaction", + ] + + class CommentReactionSerializer(BaseSerializer): class Meta: model = CommentReaction @@ -503,9 +573,7 @@ class IssueCommentSerializer(BaseSerializer): workspace_detail = WorkspaceLiteSerializer( read_only=True, source="workspace" ) - comment_reactions = CommentReactionSerializer( - read_only=True, many=True - ) + comment_reactions = CommentReactionSerializer(read_only=True, many=True) is_member = serializers.BooleanField(read_only=True) class Meta: @@ -558,18 +626,17 @@ class IssueStateSerializer(DynamicBaseSerializer): class IssueSerializer(DynamicBaseSerializer): # ids - project_id = serializers.PrimaryKeyRelatedField(read_only=True) - state_id = serializers.PrimaryKeyRelatedField(read_only=True) - parent_id = serializers.PrimaryKeyRelatedField(read_only=True) cycle_id = serializers.PrimaryKeyRelatedField(read_only=True) - module_ids = serializers.SerializerMethodField() + module_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, + ) # Many to many - label_ids = serializers.PrimaryKeyRelatedField( - read_only=True, many=True, source="labels" + label_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, ) - assignee_ids = serializers.PrimaryKeyRelatedField( - read_only=True, many=True, source="assignees" + assignee_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, ) # Count items @@ -577,9 +644,6 @@ class IssueSerializer(DynamicBaseSerializer): attachment_count = serializers.IntegerField(read_only=True) link_count = serializers.IntegerField(read_only=True) - # is_subscribed - is_subscribed = serializers.BooleanField(read_only=True) - class Meta: model = Issue fields = [ @@ -606,57 +670,45 @@ class IssueSerializer(DynamicBaseSerializer): "updated_by", "attachment_count", "link_count", - "is_subscribed", "is_draft", "archived_at", ] read_only_fields = fields - def get_module_ids(self, obj): - # Access the prefetched modules and extract module IDs - return [module for module in obj.issue_module.values_list("module_id", flat=True)] - class IssueDetailSerializer(IssueSerializer): - description_html = serializers.CharField() + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField(read_only=True) class Meta(IssueSerializer.Meta): - fields = IssueSerializer.Meta.fields + ['description_html'] + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + ] class IssueLiteSerializer(DynamicBaseSerializer): - workspace_detail = WorkspaceLiteSerializer( - read_only=True, source="workspace" - ) - project_detail = ProjectLiteSerializer(read_only=True, source="project") - state_detail = StateLiteSerializer(read_only=True, source="state") - label_details = LabelLiteSerializer( - read_only=True, source="labels", many=True - ) - assignee_details = UserLiteSerializer( - read_only=True, source="assignees", many=True - ) - sub_issues_count = serializers.IntegerField(read_only=True) - cycle_id = serializers.UUIDField(read_only=True) - module_id = serializers.UUIDField(read_only=True) - attachment_count = serializers.IntegerField(read_only=True) - link_count = serializers.IntegerField(read_only=True) - issue_reactions = IssueReactionSerializer(read_only=True, many=True) class Meta: model = Issue - fields = "__all__" - read_only_fields = [ - "start_date", - "target_date", - "completed_at", - "workspace", - "project", - "created_by", - "updated_by", - "created_at", - "updated_at", + fields = [ + "id", + "sequence_id", + "project_id", ] + read_only_fields = fields + + +class IssueDetailSerializer(IssueSerializer): + description_html = serializers.CharField() + is_subscribed = serializers.BooleanField() + + class Meta(IssueSerializer.Meta): + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + ] + read_only_fields = fields class IssuePublicSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index e94195671..4aabfc50e 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -5,7 +5,6 @@ from rest_framework import serializers from .base import BaseSerializer, DynamicBaseSerializer from .user import UserLiteSerializer from .project import ProjectLiteSerializer -from .workspace import WorkspaceLiteSerializer from plane.db.models import ( User, @@ -19,17 +18,18 @@ from plane.db.models import ( class ModuleWriteSerializer(BaseSerializer): - members = serializers.ListField( + lead_id = serializers.PrimaryKeyRelatedField( + source="lead", + queryset=User.objects.all(), + required=False, + allow_null=True, + ) + member_ids = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) - project_detail = ProjectLiteSerializer(source="project", read_only=True) - workspace_detail = WorkspaceLiteSerializer( - source="workspace", read_only=True - ) - class Meta: model = Module fields = "__all__" @@ -44,7 +44,9 @@ class ModuleWriteSerializer(BaseSerializer): def to_representation(self, instance): data = super().to_representation(instance) - data["members"] = [str(member.id) for member in instance.members.all()] + data["member_ids"] = [ + str(member.id) for member in instance.members.all() + ] return data def validate(self, data): @@ -59,12 +61,10 @@ class ModuleWriteSerializer(BaseSerializer): return data def create(self, validated_data): - members = validated_data.pop("members", None) - + members = validated_data.pop("member_ids", None) project = self.context["project"] module = Module.objects.create(**validated_data, project=project) - if members is not None: ModuleMember.objects.bulk_create( [ @@ -85,7 +85,7 @@ class ModuleWriteSerializer(BaseSerializer): return module def update(self, instance, validated_data): - members = validated_data.pop("members", None) + members = validated_data.pop("member_ids", None) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -142,7 +142,6 @@ class ModuleIssueSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") class Meta: model = ModuleLink @@ -170,12 +169,9 @@ class ModuleLinkSerializer(BaseSerializer): class ModuleSerializer(DynamicBaseSerializer): - project_detail = ProjectLiteSerializer(read_only=True, source="project") - lead_detail = UserLiteSerializer(read_only=True, source="lead") - members_detail = UserLiteSerializer( - read_only=True, many=True, source="members" + member_ids = serializers.ListField( + child=serializers.UUIDField(), required=False, allow_null=True ) - link_module = ModuleLinkSerializer(read_only=True, many=True) is_favorite = serializers.BooleanField(read_only=True) total_issues = serializers.IntegerField(read_only=True) cancelled_issues = serializers.IntegerField(read_only=True) @@ -186,15 +182,46 @@ class ModuleSerializer(DynamicBaseSerializer): class Meta: model = Module - fields = "__all__" - read_only_fields = [ - "workspace", - "project", - "created_by", - "updated_by", + fields = [ + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", "created_at", "updated_at", ] + read_only_fields = fields + + + +class ModuleDetailSerializer(ModuleSerializer): + + link_module = ModuleLinkSerializer(read_only=True, many=True) + + class Meta(ModuleSerializer.Meta): + fields = ModuleSerializer.Meta.fields + ['link_module'] class ModuleFavoriteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 999233442..6840fa8f7 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -95,8 +95,7 @@ class ProjectLiteSerializer(BaseSerializer): "identifier", "name", "cover_image", - "icon_prop", - "emoji", + "logo_props", "description", ] read_only_fields = fields diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 234c2824d..4ee70450b 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -2,6 +2,7 @@ from django.urls import path from plane.app.views import ( + IssueListEndpoint, IssueViewSet, LabelViewSet, BulkCreateIssueLabelsEndpoint, @@ -25,6 +26,11 @@ from plane.app.views import ( urlpatterns = [ + path( + "workspaces//projects//issues/list/", + IssueListEndpoint.as_view(), + name="project-issue", + ), path( "workspaces//projects//issues/", IssueViewSet.as_view( @@ -84,11 +90,13 @@ urlpatterns = [ BulkImportIssuesEndpoint.as_view(), name="project-issues-bulk", ), + # deprecated endpoint TODO: remove once confirmed path( "workspaces//my-issues/", UserWorkSpaceIssues.as_view(), name="workspace-issues", ), + ## path( "workspaces//projects//issues//sub-issues/", SubIssuesEndpoint.as_view(), @@ -251,23 +259,15 @@ urlpatterns = [ name="project-issue-archive", ), path( - "workspaces//projects//archived-issues//", + "workspaces//projects//issues//archive/", IssueArchiveViewSet.as_view( { "get": "retrieve", - "delete": "destroy", + "post": "archive", + "delete": "unarchive", } ), - name="project-issue-archive", - ), - path( - "workspaces//projects//unarchive//", - IssueArchiveViewSet.as_view( - { - "post": "unarchive", - } - ), - name="project-issue-archive", + name="project-issue-archive-unarchive", ), ## End Issue Archives ## Issue Relation diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 7e64e586a..8b21bb9e1 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -22,6 +22,9 @@ from plane.app.views import ( WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + ExportWorkspaceUserActivityEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) @@ -189,6 +192,11 @@ urlpatterns = [ WorkspaceUserActivityEndpoint.as_view(), name="workspace-user-activity", ), + path( + "workspaces//user-activity//export/", + ExportWorkspaceUserActivityEndpoint.as_view(), + name="export-workspace-user-activity", + ), path( "workspaces//user-profile//", WorkspaceUserProfileEndpoint.as_view(), @@ -219,4 +227,14 @@ urlpatterns = [ WorkspaceEstimatesEndpoint.as_view(), name="workspace-estimate", ), + path( + "workspaces//modules/", + WorkspaceModulesEndpoint.as_view(), + name="workspace-modules", + ), + path( + "workspaces//cycles/", + WorkspaceCyclesEndpoint.as_view(), + name="workspace-cycles", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 0a959a667..910ea006d 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -49,6 +49,9 @@ from .workspace import ( WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + ExportWorkspaceUserActivityEndpoint, + WorkspaceModulesEndpoint, + WorkspaceCyclesEndpoint, ) from .state import StateViewSet from .view import ( @@ -67,6 +70,7 @@ from .cycle import ( ) from .asset import FileAssetEndpoint, UserAssetsEndpoint, FileAssetViewSet from .issue import ( + IssueListEndpoint, IssueViewSet, WorkSpaceIssuesEndpoint, IssueActivityEndpoint, @@ -183,4 +187,6 @@ from .webhook import ( from .dashboard import ( DashboardEndpoint, WidgetsEndpoint -) \ No newline at end of file +) + +from .error_404 import custom_404_view diff --git a/apiserver/plane/app/views/analytic.py b/apiserver/plane/app/views/analytic.py index 04a77f789..6eb914b23 100644 --- a/apiserver/plane/app/views/analytic.py +++ b/apiserver/plane/app/views/analytic.py @@ -1,6 +1,7 @@ # Django imports from django.db.models import Count, Sum, F, Q from django.db.models.functions import ExtractMonth +from django.utils import timezone # Third party imports from rest_framework import status @@ -331,8 +332,9 @@ class DefaultAnalyticsEndpoint(BaseAPIView): .order_by("state_group") ) + current_year = timezone.now().year issue_completed_month_wise = ( - base_issues.filter(completed_at__isnull=False) + base_issues.filter(completed_at__year=current_year) .annotate(month=ExtractMonth("completed_at")) .values("month") .annotate(count=Count("*")) diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index 29b4bbf8b..b2a27252c 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -66,15 +66,15 @@ class ConfigurationEndpoint(BaseAPIView): }, { "key": "SLACK_CLIENT_ID", - "default": os.environ.get("SLACK_CLIENT_ID", "1"), + "default": os.environ.get("SLACK_CLIENT_ID", None), }, { "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), + "default": os.environ.get("POSTHOG_API_KEY", None), }, { "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), + "default": os.environ.get("POSTHOG_HOST", None), }, { "key": "UNSPLASH_ACCESS_KEY", @@ -181,11 +181,11 @@ class MobileConfigurationEndpoint(BaseAPIView): }, { "key": "POSTHOG_API_KEY", - "default": os.environ.get("POSTHOG_API_KEY", "1"), + "default": os.environ.get("POSTHOG_API_KEY", None), }, { "key": "POSTHOG_HOST", - "default": os.environ.get("POSTHOG_HOST", "1"), + "default": os.environ.get("POSTHOG_HOST", None), }, { "key": "UNSPLASH_ACCESS_KEY", diff --git a/apiserver/plane/app/views/cycle.py b/apiserver/plane/app/views/cycle.py index 63d8d28ae..85e1e9f2e 100644 --- a/apiserver/plane/app/views/cycle.py +++ b/apiserver/plane/app/views/cycle.py @@ -20,7 +20,10 @@ from django.core import serializers from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework.response import Response @@ -33,7 +36,6 @@ from plane.app.serializers import ( CycleIssueSerializer, CycleFavoriteSerializer, IssueSerializer, - IssueStateSerializer, CycleWriteSerializer, CycleUserPropertiesSerializer, ) @@ -51,7 +53,6 @@ from plane.db.models import ( IssueAttachment, Label, CycleUserProperties, - IssueSubscriber, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters @@ -73,7 +74,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def get_queryset(self): - subquery = CycleFavorite.objects.filter( + favorite_subquery = CycleFavorite.objects.filter( user=self.request.user, cycle_id=OuterRef("pk"), project_id=self.kwargs.get("project_id"), @@ -84,11 +85,28 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) - .select_related("project") - .select_related("workspace") - .select_related("owned_by") - .annotate(is_favorite=Exists(subquery)) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .select_related("project", "workspace", "owned_by") + .prefetch_related( + Prefetch( + "issue_cycle__issue__assignees", + queryset=User.objects.only( + "avatar", "first_name", "id" + ).distinct(), + ) + ) + .prefetch_related( + Prefetch( + "issue_cycle__issue__labels", + queryset=Label.objects.only( + "name", "color", "id" + ).distinct(), + ) + ) + .annotate(is_favorite=Exists(favorite_subquery)) .annotate( total_issues=Count( "issue_cycle", @@ -148,29 +166,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) .annotate( status=Case( When( @@ -190,20 +185,16 @@ class CycleViewSet(WebhookMixin, BaseViewSet): output_field=CharField(), ) ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__assignees", - queryset=User.objects.only( - "avatar", "first_name", "id" - ).distinct(), - ) - ) - .prefetch_related( - Prefetch( - "issue_cycle__issue__labels", - queryset=Label.objects.only( - "name", "color", "id" - ).distinct(), + .annotate( + assignee_ids=Coalesce( + ArrayAgg( + "issue_cycle__issue__assignees__id", + distinct=True, + filter=~Q( + issue_cycle__issue__assignees__id__isnull=True + ), + ), + Value([], output_field=ArrayField(UUIDField())), ) ) .order_by("-is_favorite", "name") @@ -213,12 +204,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): def list(self, request, slug, project_id): queryset = self.get_queryset() cycle_view = request.GET.get("cycle_view", "all") - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] + # Update the order by queryset = queryset.order_by("-is_favorite", "-created_at") # Current Cycle @@ -228,9 +215,35 @@ class CycleViewSet(WebhookMixin, BaseViewSet): end_date__gte=timezone.now(), ) - data = CycleSerializer(queryset, many=True).data + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) - if len(data): + if data: assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=data[0]["id"], @@ -315,19 +328,45 @@ class CycleViewSet(WebhookMixin, BaseViewSet): } if data[0]["start_date"] and data[0]["end_date"]: - data[0]["distribution"][ - "completion_chart" - ] = burndown_plot( - queryset=queryset.first(), - slug=slug, - project_id=project_id, - cycle_id=data[0]["id"], + data[0]["distribution"]["completion_chart"] = ( + burndown_plot( + queryset=queryset.first(), + slug=slug, + project_id=project_id, + cycle_id=data[0]["id"], + ) ) return Response(data, status=status.HTTP_200_OK) - cycles = CycleSerializer(queryset, many=True).data - return Response(cycles, status=status.HTTP_200_OK) + data = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + return Response(data, status=status.HTTP_200_OK) def create(self, request, slug, project_id): if ( @@ -337,7 +376,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): request.data.get("start_date", None) is not None and request.data.get("end_date", None) is not None ): - serializer = CycleSerializer(data=request.data) + serializer = CycleWriteSerializer(data=request.data) if serializer.is_valid(): serializer.save( project_id=project_id, @@ -346,12 +385,36 @@ class CycleViewSet(WebhookMixin, BaseViewSet): cycle = ( self.get_queryset() .filter(pk=serializer.data["id"]) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) .first() ) - serializer = CycleSerializer(cycle) - return Response( - serializer.data, status=status.HTTP_201_CREATED - ) + return Response(cycle, status=status.HTTP_201_CREATED) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) @@ -364,10 +427,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def partial_update(self, request, slug, project_id, pk): - cycle = Cycle.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk + queryset = ( + self.get_queryset() + .filter(workspace__slug=slug, project_id=project_id, pk=pk) ) - + cycle = queryset.first() request_data = request.data if ( @@ -375,7 +439,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): and cycle.end_date < timezone.now().date() ): if "sort_order" in request_data: - # Can only change sort order + # Can only change sort order for a completed cycle`` request_data = { "sort_order": request_data.get( "sort_order", cycle.sort_order @@ -394,12 +458,71 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) if serializer.is_valid(): serializer.save() - return Response(serializer.data, status=status.HTTP_200_OK) + cycle = queryset.values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ).first() + return Response(cycle, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) - + queryset = self.get_queryset().filter(pk=pk) + data = ( + self.get_queryset() + .filter(pk=pk) + .values( + # necessary fields + "id", + "workspace_id", + "project_id", + # model fields + "name", + "description", + "start_date", + "end_date", + "owned_by_id", + "view_props", + "sort_order", + "external_source", + "external_id", + "progress_snapshot", + # meta fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "assignee_ids", + "status", + ) + .first() + ) + queryset = queryset.first() # Assignee Distribution assignee_distribution = ( Issue.objects.filter( @@ -488,7 +611,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): .order_by("label_name") ) - data = CycleSerializer(queryset).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, @@ -570,7 +692,10 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(cycle_id=self.kwargs.get("cycle_id")) .select_related("project") .select_related("workspace") @@ -589,20 +714,18 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ] order_by = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") - issues = ( + queryset = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .filter(project_id=project_id) .filter(workspace__slug=slug) + .filter(**filters) .select_related("workspace", "project", "state", "parent") - .prefetch_related("assignees", "labels", "issue_module__module") + .prefetch_related( + "assignees", + "labels", + "issue_module__module", + "issue_cycle__cycle", + ) .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) @@ -621,22 +744,79 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): .values("count") ) .annotate( - is_subscribed=Exists( - IssueSubscriber.objects.filter( - subscriber=self.request.user, issue_id=OuterRef("id") - ) + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by(order_by) ) - serializer = IssueSerializer( - issues, many=True, fields=fields if fields else None - ) - return Response(serializer.data, status=status.HTTP_200_OK) + if self.fields: + issues = IssueSerializer( + queryset, many=True, fields=fields if fields else None + ).data + else: + issues = queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -658,52 +838,52 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): ) # Get all CycleIssues already created - cycle_issues = list(CycleIssue.objects.filter(issue_id__in=issues)) - update_cycle_issue_activity = [] - record_to_create = [] - records_to_update = [] + cycle_issues = list( + CycleIssue.objects.filter( + ~Q(cycle_id=cycle_id), issue_id__in=issues + ) + ) + existing_issues = [ + str(cycle_issue.issue_id) for cycle_issue in cycle_issues + ] + new_issues = list(set(issues) - set(existing_issues)) - for issue in issues: - cycle_issue = [ - cycle_issue - for cycle_issue in cycle_issues - if str(cycle_issue.issue_id) in issues - ] - # Update only when cycle changes - if len(cycle_issue): - if cycle_issue[0].cycle_id != cycle_id: - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_issue[0].cycle_id), - "new_cycle_id": str(cycle_id), - "issue_id": str(cycle_issue[0].issue_id), - } - ) - cycle_issue[0].cycle_id = cycle_id - records_to_update.append(cycle_issue[0]) - else: - record_to_create.append( - CycleIssue( - project_id=project_id, - workspace=cycle.workspace, - created_by=request.user, - updated_by=request.user, - cycle=cycle, - issue_id=issue, - ) + # New issues to create + created_records = CycleIssue.objects.bulk_create( + [ + CycleIssue( + project_id=project_id, + workspace_id=cycle.workspace_id, + created_by_id=request.user.id, + updated_by_id=request.user.id, + cycle_id=cycle_id, + issue_id=issue, ) - - CycleIssue.objects.bulk_create( - record_to_create, - batch_size=10, - ignore_conflicts=True, - ) - CycleIssue.objects.bulk_update( - records_to_update, - ["cycle"], + for issue in new_issues + ], batch_size=10, ) + # Updated Issues + updated_records = [] + update_cycle_issue_activity = [] + # Iterate over each cycle_issue in cycle_issues + for cycle_issue in cycle_issues: + # Update the cycle_issue's cycle_id + cycle_issue.cycle_id = cycle_id + # Add the modified cycle_issue to the records_to_update list + updated_records.append(cycle_issue) + # Record the update activity + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_issue.cycle_id), + "new_cycle_id": str(cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Update the cycle issues + CycleIssue.objects.bulk_update(updated_records, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( type="cycle.activity.created", @@ -715,7 +895,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): { "updated_cycle_issues": update_cycle_issue_activity, "created_cycle_issues": serializers.serialize( - "json", record_to_create + "json", created_records ), } ), @@ -723,16 +903,7 @@ class CycleIssueViewSet(WebhookMixin, BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - - # Return all Cycle Issues - issues = self.get_queryset().values_list("issue_id", flat=True) - - return Response( - IssueSerializer( - Issue.objects.filter(pk__in=issues), many=True - ).data, - status=status.HTTP_200_OK, - ) + return Response({"message": "success"}, status=status.HTTP_201_CREATED) def destroy(self, request, slug, project_id, cycle_id, issue_id): cycle_issue = CycleIssue.objects.get( @@ -776,6 +947,7 @@ class CycleDateCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # Check if any cycle intersects in the given interval cycles = Cycle.objects.filter( Q(workspace__slug=slug) & Q(project_id=project_id) @@ -785,7 +957,6 @@ class CycleDateCheckEndpoint(BaseAPIView): | Q(start_date__gte=start_date, end_date__lte=end_date) ) ).exclude(pk=cycle_id) - if cycles.exists(): return Response( { @@ -909,29 +1080,6 @@ class TransferCycleIssueEndpoint(BaseAPIView): ), ) ) - .annotate( - total_estimates=Sum("issue_cycle__issue__estimate_point") - ) - .annotate( - completed_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="completed", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - .annotate( - started_estimates=Sum( - "issue_cycle__issue__estimate_point", - filter=Q( - issue_cycle__issue__state__group="started", - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) ) # Pass the new_cycle queryset to burndown_plot @@ -942,6 +1090,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): cycle_id=cycle_id, ) + # Get the assignee distribution assignee_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -980,7 +1129,22 @@ class TransferCycleIssueEndpoint(BaseAPIView): ) .order_by("display_name") ) + # assignee distribution serialized + assignee_distribution_data = [ + { + "display_name": item["display_name"], + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), + "avatar": item["avatar"], + "total_issues": item["total_issues"], + "completed_issues": item["completed_issues"], + "pending_issues": item["pending_issues"], + } + for item in assignee_distribution + ] + # Get the label distribution label_distribution = ( Issue.objects.filter( issue_cycle__cycle_id=cycle_id, @@ -1023,7 +1187,9 @@ class TransferCycleIssueEndpoint(BaseAPIView): assignee_distribution_data = [ { "display_name": item["display_name"], - "assignee_id": str(item["assignee_id"]) if item["assignee_id"] else None, + "assignee_id": ( + str(item["assignee_id"]) if item["assignee_id"] else None + ), "avatar": item["avatar"], "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], @@ -1032,11 +1198,14 @@ class TransferCycleIssueEndpoint(BaseAPIView): for item in assignee_distribution ] + # Label distribution serilization label_distribution_data = [ { "label_name": item["label_name"], "color": item["color"], - "label_id": str(item["label_id"]) if item["label_id"] else None, + "label_id": ( + str(item["label_id"]) if item["label_id"] else None + ), "total_issues": item["total_issues"], "completed_issues": item["completed_issues"], "pending_issues": item["pending_issues"], @@ -1055,10 +1224,7 @@ class TransferCycleIssueEndpoint(BaseAPIView): "started_issues": old_cycle.first().started_issues, "unstarted_issues": old_cycle.first().unstarted_issues, "backlog_issues": old_cycle.first().backlog_issues, - "total_estimates": old_cycle.first().total_estimates, - "completed_estimates": old_cycle.first().completed_estimates, - "started_estimates": old_cycle.first().started_estimates, - "distribution":{ + "distribution": { "labels": label_distribution_data, "assignees": assignee_distribution_data, "completion_chart": completion_chart, diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index 1366a2886..9078d2ab5 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -14,7 +14,12 @@ from django.db.models import ( JSONField, Func, Prefetch, + IntegerField, ) +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce from django.utils import timezone # Third Party imports @@ -34,6 +39,8 @@ from plane.db.models import ( IssueLink, IssueAttachment, IssueRelation, + IssueAssignee, + User, ) from plane.app.serializers import ( IssueActivitySerializer, @@ -54,6 +61,7 @@ def dashboard_overview_stats(self, request, slug): pending_issues_count = Issue.issue_objects.filter( ~Q(state__group__in=["completed", "cancelled"]), + target_date__lt=timezone.now().date(), project__project_projectmember__is_active=True, project__project_projectmember__member=request.user, workspace__slug=slug, @@ -130,7 +138,32 @@ def dashboard_assigned_issues(self, request, slug): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .order_by("created_at") + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ) # Priority Ordering @@ -182,11 +215,11 @@ def dashboard_assigned_issues(self, request, slug): if issue_type == "overdue": overdue_issues_count = assigned_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__lt=timezone.now() + target_date__lt=timezone.now(), ).count() overdue_issues = assigned_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__lt=timezone.now() + target_date__lt=timezone.now(), )[:5] return Response( { @@ -201,11 +234,11 @@ def dashboard_assigned_issues(self, request, slug): if issue_type == "upcoming": upcoming_issues_count = assigned_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__gte=timezone.now() + target_date__gte=timezone.now(), ).count() upcoming_issues = assigned_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__gte=timezone.now() + target_date__gte=timezone.now(), )[:5] return Response( { @@ -259,6 +292,32 @@ def dashboard_created_issues(self, request, slug): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .order_by("created_at") ) @@ -309,11 +368,11 @@ def dashboard_created_issues(self, request, slug): if issue_type == "overdue": overdue_issues_count = created_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__lt=timezone.now() + target_date__lt=timezone.now(), ).count() overdue_issues = created_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__lt=timezone.now() + target_date__lt=timezone.now(), )[:5] return Response( { @@ -326,11 +385,11 @@ def dashboard_created_issues(self, request, slug): if issue_type == "upcoming": upcoming_issues_count = created_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__gte=timezone.now() + target_date__gte=timezone.now(), ).count() upcoming_issues = created_issues.filter( state__group__in=["backlog", "unstarted", "started"], - target_date__gte=timezone.now() + target_date__gte=timezone.now(), )[:5] return Response( { @@ -447,7 +506,9 @@ def dashboard_recent_projects(self, request, slug): ).exclude(id__in=unique_project_ids) # Append additional project IDs to the existing list - unique_project_ids.update(additional_projects.values_list("id", flat=True)) + unique_project_ids.update( + additional_projects.values_list("id", flat=True) + ) return Response( list(unique_project_ids)[:4], @@ -456,90 +517,97 @@ def dashboard_recent_projects(self, request, slug): def dashboard_recent_collaborators(self, request, slug): - # Fetch all project IDs where the user belongs to - user_projects = Project.objects.filter( - project_projectmember__member=request.user, - project_projectmember__is_active=True, - workspace__slug=slug, - ).values_list("id", flat=True) - - # Fetch all users who have performed an activity in the projects where the user exists - users_with_activities = ( + # Subquery to count activities for each project member + activity_count_subquery = ( IssueActivity.objects.filter( workspace__slug=slug, - project_id__in=user_projects, + actor=OuterRef("member"), + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .values("actor") - .exclude(actor=request.user) - .annotate(num_activities=Count("actor")) - .order_by("-num_activities") - )[:7] - - # Get the count of active issues for each user in users_with_activities - users_with_active_issues = [] - for user_activity in users_with_activities: - user_id = user_activity["actor"] - active_issue_count = Issue.objects.filter( - assignees__in=[user_id], - state__group__in=["unstarted", "started"], - ).count() - users_with_active_issues.append( - {"user_id": user_id, "active_issue_count": active_issue_count} - ) - - # Insert the logged-in user's ID and their active issue count at the beginning - active_issue_count = Issue.objects.filter( - assignees__in=[request.user], - state__group__in=["unstarted", "started"], - ).count() - - if users_with_activities.count() < 7: - # Calculate the additional collaborators needed - additional_collaborators_needed = 7 - users_with_activities.count() - - # Fetch additional collaborators from the project_member table - additional_collaborators = list( - set( - ProjectMember.objects.filter( - ~Q(member=request.user), - project_id__in=user_projects, - workspace__slug=slug, - ) - .exclude( - member__in=[ - user["actor"] for user in users_with_activities - ] - ) - .values_list("member", flat=True) - ) - ) - - additional_collaborators = additional_collaborators[ - :additional_collaborators_needed - ] - - # Append additional collaborators to the list - for collaborator_id in additional_collaborators: - active_issue_count = Issue.objects.filter( - assignees__in=[collaborator_id], - state__group__in=["unstarted", "started"], - ).count() - users_with_active_issues.append( - { - "user_id": str(collaborator_id), - "active_issue_count": active_issue_count, - } - ) - - users_with_active_issues.insert( - 0, - {"user_id": request.user.id, "active_issue_count": active_issue_count}, + .annotate(num_activities=Count("pk")) + .values("num_activities") ) - return Response(users_with_active_issues, status=status.HTTP_200_OK) + # Get all project members and annotate them with activity counts + project_members_with_activities = ( + ProjectMember.objects.filter( + workspace__slug=slug, + project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, + ) + .annotate( + num_activities=Coalesce( + Subquery(activity_count_subquery), + Value(0), + output_field=IntegerField(), + ), + is_current_user=Case( + When(member=request.user, then=Value(0)), + default=Value(1), + output_field=IntegerField(), + ), + ) + .values_list("member", flat=True) + .order_by("is_current_user", "-num_activities") + .distinct() + ) + search = request.query_params.get("search", None) + if search: + project_members_with_activities = ( + project_members_with_activities.filter( + Q(member__display_name__icontains=search) + | Q(member__first_name__icontains=search) + | Q(member__last_name__icontains=search) + ) + ) + + return self.paginate( + request=request, + queryset=project_members_with_activities, + controller=self.get_results_controller, + ) class DashboardEndpoint(BaseAPIView): + def get_results_controller(self, project_members_with_activities): + user_active_issue_counts = ( + User.objects.filter(id__in=project_members_with_activities) + .annotate( + active_issue_count=Count( + Case( + When( + issue_assignee__issue__state__group__in=[ + "unstarted", + "started", + ], + then=1, + ), + output_field=IntegerField(), + ) + ) + ) + .values("active_issue_count", user_id=F("id")) + ) + # Create a dictionary to store the active issue counts by user ID + active_issue_counts_dict = { + user["user_id"]: user["active_issue_count"] + for user in user_active_issue_counts + } + + # Preserve the sequence of project members with activities + paginated_results = [ + { + "user_id": member_id, + "active_issue_count": active_issue_counts_dict.get( + member_id, 0 + ), + } + for member_id in project_members_with_activities + ] + return paginated_results + def create(self, request, slug): serializer = DashboardSerializer(data=request.data) if serializer.is_valid(): @@ -566,7 +634,9 @@ class DashboardEndpoint(BaseAPIView): dashboard_type = request.GET.get("dashboard_type", None) if dashboard_type == "home": dashboard, created = Dashboard.objects.get_or_create( - type_identifier=dashboard_type, owned_by=request.user, is_default=True + type_identifier=dashboard_type, + owned_by=request.user, + is_default=True, ) if created: @@ -583,7 +653,9 @@ class DashboardEndpoint(BaseAPIView): updated_dashboard_widgets = [] for widget_key in widgets_to_fetch: - widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True) + widget = Widget.objects.filter( + key=widget_key + ).values_list("id", flat=True) if widget: updated_dashboard_widgets.append( DashboardWidget( diff --git a/apiserver/plane/app/views/error_404.py b/apiserver/plane/app/views/error_404.py new file mode 100644 index 000000000..3c31474e0 --- /dev/null +++ b/apiserver/plane/app/views/error_404.py @@ -0,0 +1,5 @@ +# views.py +from django.http import JsonResponse + +def custom_404_view(request, exception=None): + return JsonResponse({"error": "Page not found."}, status=404) diff --git a/apiserver/plane/app/views/inbox.py b/apiserver/plane/app/views/inbox.py index f76c74d9c..ed32a14fe 100644 --- a/apiserver/plane/app/views/inbox.py +++ b/apiserver/plane/app/views/inbox.py @@ -3,8 +3,12 @@ import json # Django import from django.utils import timezone -from django.db.models import Q, Count, OuterRef, Func, F, Prefetch +from django.db.models import Q, Count, OuterRef, Func, F, Prefetch, Exists from django.core.serializers.json import DjangoJSONEncoder +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework import status @@ -21,12 +25,14 @@ from plane.db.models import ( IssueLink, IssueAttachment, ProjectMember, + IssueReaction, + IssueSubscriber, ) from plane.app.serializers import ( + IssueCreateSerializer, IssueSerializer, InboxSerializer, InboxIssueSerializer, - IssueCreateSerializer, IssueDetailSerializer, ) from plane.utils.issue_filters import issue_filters @@ -92,7 +98,7 @@ class InboxIssueViewSet(BaseViewSet): Issue.objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_inbox__inbox_id=self.kwargs.get("inbox_id") + issue_inbox__inbox_id=self.kwargs.get("inbox_id"), ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") @@ -127,14 +133,75 @@ class InboxIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() def list(self, request, slug, project_id, inbox_id): filters = issue_filters(request.query_params, "GET") - issue_queryset = self.get_queryset().filter(**filters).order_by("issue_inbox__snoozed_till", "issue_inbox__status") - issues_data = IssueSerializer(issue_queryset, expand=self.expand, many=True).data + issue_queryset = ( + self.get_queryset() + .filter(**filters) + .order_by("issue_inbox__snoozed_till", "issue_inbox__status") + ) + if self.expand: + issues = IssueSerializer( + issue_queryset, expand=self.expand, many=True + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response( - issues_data, + issues, status=status.HTTP_200_OK, ) @@ -199,8 +266,8 @@ class InboxIssueViewSet(BaseViewSet): source=request.data.get("source", "in-app"), ) - issue = (self.get_queryset().filter(pk=issue.id).first()) - serializer = IssueSerializer(issue ,expand=self.expand) + issue = self.get_queryset().filter(pk=issue.id).first() + serializer = IssueSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, inbox_id, issue_id): @@ -230,11 +297,7 @@ class InboxIssueViewSet(BaseViewSet): issue_data = request.data.pop("issue", False) if bool(issue_data): - issue = Issue.objects.get( - pk=inbox_issue.issue_id, - workspace__slug=slug, - project_id=project_id, - ) + issue = self.get_queryset().filter(pk=inbox_issue.issue_id).first() # Only allow guests and viewers to edit name and description if project_member.role <= 10: # viewers and guests since only viewers and guests @@ -320,20 +383,54 @@ class InboxIssueViewSet(BaseViewSet): if state is not None: issue.state = state issue.save() - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue, expand=self.expand) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) else: - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue ,expand=self.expand) + issue = self.get_queryset().filter(pk=issue_id).first() + serializer = IssueSerializer(issue, expand=self.expand) return Response(serializer.data, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, inbox_id, issue_id): - issue = self.get_queryset().filter(pk=issue_id).first() - serializer = IssueDetailSerializer(issue, expand=self.expand,) + issue = ( + self.get_queryset() + .filter(pk=issue_id) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if issue is None: + return Response({"error": "Requested object was not found"}, status=status.HTTP_404_NOT_FOUND) + + serializer = IssueDetailSerializer(issue) return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, inbox_id, issue_id): diff --git a/apiserver/plane/app/views/integration/slack.py b/apiserver/plane/app/views/integration/slack.py index 410e6b332..c22ee3e52 100644 --- a/apiserver/plane/app/views/integration/slack.py +++ b/apiserver/plane/app/views/integration/slack.py @@ -36,7 +36,10 @@ class SlackProjectSyncViewSet(BaseViewSet): workspace__slug=self.kwargs.get("slug"), project_id=self.kwargs.get("project_id"), ) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) ) def create(self, request, slug, project_id, workspace_integration_id): diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index edefade16..14e0b6a9a 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -4,7 +4,6 @@ import random from itertools import chain # Django imports -from django.db import models from django.utils import timezone from django.db.models import ( Prefetch, @@ -12,19 +11,21 @@ from django.db.models import ( Func, F, Q, - Count, Case, Value, CharField, When, Exists, Max, - IntegerField, ) from django.core.serializers.json import DjangoJSONEncoder from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third Party imports from rest_framework.response import Response @@ -67,15 +68,11 @@ from plane.db.models import ( Label, IssueLink, IssueAttachment, - State, IssueSubscriber, ProjectMember, IssueReaction, CommentReaction, - ProjectDeployBoard, - IssueVote, IssueRelation, - ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.grouper import group_results @@ -83,44 +80,30 @@ from plane.utils.issue_filters import issue_filters from collections import defaultdict -class IssueViewSet(WebhookMixin, BaseViewSet): - def get_serializer_class(self): - return ( - IssueCreateSerializer - if self.action in ["create", "update", "partial_update"] - else IssueSerializer - ) +class IssueListEndpoint(BaseAPIView): - model = Issue - webhook_event = "issue" permission_classes = [ ProjectEntityPermission, ] - search_fields = [ - "name", - ] + def get(self, request, slug, project_id): + issue_ids = request.GET.get("issues", False) - filterset_fields = [ - "state__name", - "assignees__id", - "workspace__id", - ] + if not issue_ids: + return Response( + {"error": "Issues are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) - def get_queryset(self): - return ( + issue_ids = [issue_id for issue_id in issue_ids.split(",") if issue_id != ""] + + queryset = ( Issue.issue_objects.filter( - project_id=self.kwargs.get("project_id") + workspace__slug=slug, project_id=project_id, pk__in=issue_ids ) .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -144,10 +127,34 @@ class IssueViewSet(WebhookMixin, BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() - @method_decorator(gzip_page) - def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") # Custom ordering for priority and state @@ -162,7 +169,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = self.get_queryset().filter(**filters) + issue_queryset = queryset.filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -224,9 +231,236 @@ class IssueViewSet(WebhookMixin, BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueSerializer( - issue_queryset, many=True, fields=self.fields, expand=self.expand - ).data + if self.fields or self.expand: + issues = IssueSerializer( + queryset, many=True, fields=self.fields, expand=self.expand + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) + + +class IssueViewSet(WebhookMixin, BaseViewSet): + def get_serializer_class(self): + return ( + IssueCreateSerializer + if self.action in ["create", "update", "partial_update"] + else IssueSerializer + ) + + model = Issue + webhook_event = "issue" + permission_classes = [ + ProjectEntityPermission, + ] + + search_fields = [ + "name", + ] + + filterset_fields = [ + "state__name", + "assignees__id", + "workspace__id", + ] + + def get_queryset(self): + return ( + Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id") + ) + .filter(workspace__slug=self.kwargs.get("slug")) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + ).distinct() + + @method_decorator(gzip_page) + def list(self, request, slug, project_id): + filters = issue_filters(request.query_params, "GET") + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + priority_order = ( + priority_order + if order_by_param == "priority" + else priority_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(priority_order) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + + # State Ordering + elif order_by_param in [ + "state__name", + "state__group", + "-state__name", + "-state__group", + ]: + state_order = ( + state_order + if order_by_param in ["state__name", "state__group"] + else state_order[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "-labels__name", + "assignees__first_name", + "-assignees__first_name", + ]: + issue_queryset = issue_queryset.annotate( + max_values=Max( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-max_values" + if order_by_param.startswith("-") + else "max_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + + # Only use serializer when expand or fields else return by values + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + expand=self.expand, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -259,28 +493,97 @@ class IssueViewSet(WebhookMixin, BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) issue = ( - self.get_queryset().filter(pk=serializer.data["id"]).first() + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + .first() ) - serializer = IssueSerializer(issue) - return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueDetailSerializer( - issue, fields=self.fields, expand=self.expand - ).data, - status=status.HTTP_200_OK, - ) + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) def partial_update(self, request, slug, project_id, pk=None): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + current_instance = json.dumps( IssueSerializer(issue).data, cls=DjangoJSONEncoder ) + requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) serializer = IssueCreateSerializer( issue, data=request.data, partial=True @@ -299,18 +602,13 @@ class IssueViewSet(WebhookMixin, BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueSerializer(issue).data, status=status.HTTP_200_OK - ) + return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) issue.delete() issue_activity.delay( type="issue.activity.deleted", @@ -318,7 +616,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), - current_instance=current_instance, + current_instance={}, epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), @@ -326,6 +624,7 @@ class IssueViewSet(WebhookMixin, BaseViewSet): return Response(status=status.HTTP_204_NO_CONTENT) +# TODO: deprecated remove once confirmed class UserWorkSpaceIssues(BaseAPIView): @method_decorator(gzip_page) def get(self, request, slug): @@ -380,12 +679,6 @@ class UserWorkSpaceIssues(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) .filter(**filters) ).distinct() @@ -470,6 +763,7 @@ class UserWorkSpaceIssues(BaseAPIView): return Response(issues, status=status.HTTP_200_OK) +# TODO: deprecated remove once confirmed class WorkSpaceIssuesEndpoint(BaseAPIView): permission_classes = [ WorkSpaceAdminPermission, @@ -479,7 +773,10 @@ class WorkSpaceIssuesEndpoint(BaseAPIView): def get(self, request, slug): issues = ( Issue.issue_objects.filter(workspace__slug=slug) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") ) serializer = IssueSerializer(issues, many=True) @@ -502,6 +799,7 @@ class IssueActivityEndpoint(BaseAPIView): .filter( ~Q(field__in=["comment", "vote", "reaction", "draft"]), project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) .filter(**filters) @@ -511,6 +809,7 @@ class IssueActivityEndpoint(BaseAPIView): IssueComment.objects.filter(issue_id=issue_id) .filter( project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) .filter(**filters) @@ -562,7 +861,10 @@ class IssueCommentViewSet(WebhookMixin, BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("issue") @@ -724,7 +1026,10 @@ class LabelViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("parent") @@ -772,20 +1077,9 @@ class SubIssuesEndpoint(BaseAPIView): Issue.issue_objects.filter( parent_id=issue_id, workspace__slug=slug ) - .select_related("project") - .select_related("workspace") - .select_related("state") - .select_related("parent") - .prefetch_related("assignees") - .prefetch_related("labels") - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) + .select_related("workspace", "project", "state", "parent") + .prefetch_related("assignees", "labels", "issue_module__module") + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -800,11 +1094,39 @@ class SubIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) .annotate(state_group=F("state__group")) ) @@ -814,13 +1136,36 @@ class SubIssuesEndpoint(BaseAPIView): for sub_issue in sub_issues: result[sub_issue.state_group].append(str(sub_issue.id)) - serializer = IssueSerializer( - sub_issues, - many=True, + sub_issues = sub_issues.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", ) return Response( { - "sub_issues": serializer.data, + "sub_issues": sub_issues, "state_distribution": result, }, status=status.HTTP_200_OK, @@ -897,7 +1242,10 @@ class IssueLinkViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1085,7 +1433,7 @@ class IssueArchiveViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -1108,15 +1456,36 @@ class IssueArchiveViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ) @method_decorator(gzip_page) def list(self, request, slug, project_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") @@ -1132,10 +1501,7 @@ class IssueArchiveViewSet(BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - ) + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -1202,20 +1568,114 @@ class IssueArchiveViewSet(BaseViewSet): if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) ) - - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response(issues, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk=None): - issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueDetailSerializer( - issue, fields=self.fields, expand=self.expand - ).data, - status=status.HTTP_200_OK, + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) + + def archive(self, request, slug, project_id, pk=None): + issue = Issue.issue_objects.get( + workspace__slug=slug, + project_id=project_id, + pk=pk, ) + if issue.state.group not in ["completed", "cancelled"]: + return Response( + {"error": "Can only archive completed or cancelled state group issue"}, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps({"archived_at": str(timezone.now().date()), "automation": False}), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue.archived_at = timezone.now().date() + issue.save() + + return Response({"archived_at": str(issue.archived_at)}, status=status.HTTP_200_OK) + def unarchive(self, request, slug, project_id, pk=None): issue = Issue.objects.get( @@ -1240,7 +1700,7 @@ class IssueArchiveViewSet(BaseViewSet): issue.archived_at = None issue.save() - return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) class IssueSubscriberViewSet(BaseViewSet): @@ -1276,7 +1736,10 @@ class IssueSubscriberViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1360,7 +1823,10 @@ class IssueReactionViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1429,7 +1895,10 @@ class CommentReactionViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(comment_id=self.kwargs.get("comment_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) @@ -1499,7 +1968,10 @@ class IssueRelationViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(issue_id=self.kwargs.get("issue_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .select_related("issue") @@ -1580,15 +2052,17 @@ class IssueRelationViewSet(BaseViewSet): issue_relation = IssueRelation.objects.bulk_create( [ IssueRelation( - issue_id=issue - if relation_type == "blocking" - else issue_id, - related_issue_id=issue_id - if relation_type == "blocking" - else issue, - relation_type="blocked_by" - if relation_type == "blocking" - else relation_type, + issue_id=( + issue if relation_type == "blocking" else issue_id + ), + related_issue_id=( + issue_id if relation_type == "blocking" else issue + ), + relation_type=( + "blocked_by" + if relation_type == "blocking" + else relation_type + ), project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, @@ -1669,19 +2143,11 @@ class IssueDraftViewSet(BaseViewSet): def get_queryset(self): return ( - Issue.objects.filter( - project_id=self.kwargs.get("project_id") - ) + Issue.objects.filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) .filter(is_draft=True) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), - ) - ) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -1705,6 +2171,32 @@ class IssueDraftViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() @method_decorator(gzip_page) @@ -1728,10 +2220,7 @@ class IssueDraftViewSet(BaseViewSet): order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = ( - self.get_queryset() - .filter(**filters) - ) + issue_queryset = self.get_queryset().filter(**filters) # Priority Ordering if order_by_param == "priority" or order_by_param == "-priority": @@ -1793,9 +2282,42 @@ class IssueDraftViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data + # Only use serializer when expand else return by values + if self.expand or self.fields: + issues = IssueSerializer( + issue_queryset, + many=True, + fields=self.fields, + expand=self.expand, + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): @@ -1830,24 +2352,24 @@ class IssueDraftViewSet(BaseViewSet): issue = ( self.get_queryset().filter(pk=serializer.data["id"]).first() ) - return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED) + return Response( + IssueSerializer(issue).data, status=status.HTTP_201_CREATED + ) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): - issue = Issue.objects.get( - workspace__slug=slug, project_id=project_id, pk=pk - ) - serializer = IssueSerializer(issue, data=request.data, partial=True) + issue = self.get_queryset().filter(pk=pk).first() + + if not issue: + return Response( + {"error": "Issue does not exist"}, + status=status.HTTP_404_NOT_FOUND, + ) + + serializer = IssueCreateSerializer(issue, data=request.data, partial=True) if serializer.is_valid(): - if request.data.get( - "is_draft" - ) is not None and not request.data.get("is_draft"): - serializer.save( - created_at=timezone.now(), updated_at=timezone.now() - ) - else: - serializer.save() + serializer.save() issue_activity.delay( type="issue_draft.activity.updated", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), @@ -1862,25 +2384,57 @@ class IssueDraftViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) - return Response(serializer.data, status=status.HTTP_200_OK) + return Response(status=status.HTTP_204_NO_CONTENT) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk=None): - issue = self.get_queryset().filter(pk=pk).first() - return Response( - IssueSerializer( - issue, fields=self.fields, expand=self.expand - ).data, - status=status.HTTP_200_OK, - ) + issue = ( + self.get_queryset() + .filter(pk=pk) + .prefetch_related( + Prefetch( + "issue_reactions", + queryset=IssueReaction.objects.select_related( + "issue", "actor" + ), + ) + ) + .prefetch_related( + Prefetch( + "issue_attachment", + queryset=IssueAttachment.objects.select_related("issue"), + ) + ) + .prefetch_related( + Prefetch( + "issue_link", + queryset=IssueLink.objects.select_related("created_by"), + ) + ) + .annotate( + is_subscribed=Exists( + IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=OuterRef("pk"), + subscriber=request.user, + ) + ) + ) + ).first() + + if not issue: + return Response( + {"error": "The required object does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + serializer = IssueDetailSerializer(issue, expand=self.expand) + return Response(serializer.data, status=status.HTTP_200_OK) def destroy(self, request, slug, project_id, pk=None): issue = Issue.objects.get( workspace__slug=slug, project_id=project_id, pk=pk ) - current_instance = json.dumps( - IssueSerializer(issue).data, cls=DjangoJSONEncoder - ) issue.delete() issue_activity.delay( type="issue_draft.activity.deleted", @@ -1888,7 +2442,7 @@ class IssueDraftViewSet(BaseViewSet): actor_id=str(request.user.id), issue_id=str(pk), project_id=str(project_id), - current_instance=current_instance, + current_instance={}, epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), diff --git a/apiserver/plane/app/views/module.py b/apiserver/plane/app/views/module.py index 4792a1f79..3b52db64f 100644 --- a/apiserver/plane/app/views/module.py +++ b/apiserver/plane/app/views/module.py @@ -4,11 +4,12 @@ import json # Django Imports from django.utils import timezone from django.db.models import Prefetch, F, OuterRef, Func, Exists, Count, Q -from django.core import serializers from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.core.serializers.json import DjangoJSONEncoder - +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party imports from rest_framework.response import Response @@ -24,6 +25,7 @@ from plane.app.serializers import ( ModuleFavoriteSerializer, IssueSerializer, ModuleUserPropertiesSerializer, + ModuleDetailSerializer, ) from plane.app.permissions import ( ProjectEntityPermission, @@ -38,11 +40,9 @@ from plane.db.models import ( ModuleFavorite, IssueLink, IssueAttachment, - IssueSubscriber, ModuleUserProperties, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot @@ -62,7 +62,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ) def get_queryset(self): - subquery = ModuleFavorite.objects.filter( + favorite_subquery = ModuleFavorite.objects.filter( user=self.request.user, module_id=OuterRef("pk"), project_id=self.kwargs.get("project_id"), @@ -73,7 +73,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .get_queryset() .filter(project_id=self.kwargs.get("project_id")) .filter(workspace__slug=self.kwargs.get("slug")) - .annotate(is_favorite=Exists(subquery)) + .annotate(is_favorite=Exists(favorite_subquery)) .select_related("project") .select_related("workspace") .select_related("lead") @@ -145,6 +145,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) + .annotate( + member_ids=Coalesce( + ArrayAgg( + "members__id", + distinct=True, + filter=~Q(members__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ) + ) .order_by("-is_favorite", "-created_at") ) @@ -157,25 +167,84 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): if serializer.is_valid(): serializer.save() - module = Module.objects.get(pk=serializer.data["id"]) - serializer = ModuleSerializer(module) - return Response(serializer.data, status=status.HTTP_201_CREATED) + module = ( + self.get_queryset() + .filter(pk=serializer.data["id"]) + .values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) + ).first() + return Response(module, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def list(self, request, slug, project_id): queryset = self.get_queryset() - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - modules = ModuleSerializer( - queryset, many=True, fields=fields if fields else None - ).data + if self.fields: + modules = ModuleSerializer( + queryset, + many=True, + fields=self.fields, + ).data + else: + modules = queryset.values( # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ) return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().get(pk=pk) + queryset = self.get_queryset().filter(pk=pk) assignee_distribution = ( Issue.objects.filter( @@ -269,16 +338,16 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): .order_by("label_name") ) - data = ModuleSerializer(queryset).data + data = ModuleDetailSerializer(queryset.first()).data data["distribution"] = { "assignees": assignee_distribution, "labels": label_distribution, "completion_chart": {}, } - if queryset.start_date and queryset.target_date: + if queryset.first().start_date and queryset.first().target_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset, + queryset=queryset.first(), slug=slug, project_id=project_id, module_id=pk, @@ -289,6 +358,47 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): status=status.HTTP_200_OK, ) + def partial_update(self, request, slug, project_id, pk): + queryset = self.get_queryset().filter(pk=pk) + serializer = ModuleWriteSerializer( + queryset.first(), data=request.data, partial=True + ) + + if serializer.is_valid(): + serializer.save() + module = queryset.values( + # Required fields + "id", + "workspace_id", + "project_id", + # Model fields + "name", + "description", + "description_text", + "description_html", + "start_date", + "target_date", + "status", + "lead_id", + "member_ids", + "view_props", + "sort_order", + "external_source", + "external_id", + # computed fields + "is_favorite", + "total_issues", + "cancelled_issues", + "completed_issues", + "started_issues", + "unstarted_issues", + "backlog_issues", + "created_at", + "updated_at", + ).first() + return Response(module, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + def destroy(self, request, slug, project_id, pk): module = Module.objects.get( workspace__slug=slug, project_id=project_id, pk=pk @@ -331,17 +441,15 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ProjectEntityPermission, ] - def get_queryset(self): return ( Issue.issue_objects.filter( project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), - issue_module__module_id=self.kwargs.get("module_id") + issue_module__module_id=self.kwargs.get("module_id"), ) .select_related("workspace", "project", "state", "parent") - .prefetch_related("labels", "assignees") - .prefetch_related('issue_module__module') + .prefetch_related("assignees", "labels", "issue_module__module") .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) @@ -365,6 +473,32 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) ).distinct() @method_decorator(gzip_page) @@ -376,15 +510,44 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ] filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) - serializer = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ) - return Response(serializer.data, status=status.HTTP_200_OK) + if self.fields or self.expand: + issues = IssueSerializer( + issue_queryset, many=True, fields=fields if fields else None + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): issues = request.data.get("issues", []) - if not len(issues): + if not issues: return Response( {"error": "Issues are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -420,15 +583,12 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): ) for issue in issues ] - issues = (self.get_queryset().filter(pk__in=issues)) - serializer = IssueSerializer(issues , many=True) - return Response(serializer.data, status=status.HTTP_201_CREATED) - + return Response({"message": "success"}, status=status.HTTP_201_CREATED) # create multiple module inside an issue def create_issue_modules(self, request, slug, project_id, issue_id): modules = request.data.get("modules", []) - if not len(modules): + if not modules: return Response( {"error": "Modules are required"}, status=status.HTTP_400_BAD_REQUEST, @@ -466,10 +626,7 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): for module in modules ] - issue = (self.get_queryset().filter(pk=issue_id).first()) - serializer = IssueSerializer(issue) - return Response(serializer.data, status=status.HTTP_201_CREATED) - + return Response({"message": "success"}, status=status.HTTP_201_CREATED) def destroy(self, request, slug, project_id, module_id, issue_id): module_issue = ModuleIssue.objects.get( @@ -484,7 +641,9 @@ class ModuleIssueViewSet(WebhookMixin, BaseViewSet): actor_id=str(request.user.id), issue_id=str(issue_id), project_id=str(project_id), - current_instance=json.dumps({"module_name": module_issue.module.name}), + current_instance=json.dumps( + {"module_name": module_issue.module.name} + ), epoch=int(timezone.now().timestamp()), notification=True, origin=request.META.get("HTTP_ORIGIN"), @@ -514,7 +673,10 @@ class ModuleLinkViewSet(BaseViewSet): .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) .filter(module_id=self.kwargs.get("module_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .order_by("-created_at") .distinct() ) diff --git a/apiserver/plane/app/views/page.py b/apiserver/plane/app/views/page.py index 1d8ff1fbb..7ecf22fa8 100644 --- a/apiserver/plane/app/views/page.py +++ b/apiserver/plane/app/views/page.py @@ -60,7 +60,10 @@ class PageViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(parent__isnull=True) .filter(Q(owned_by=self.request.user) | Q(access=0)) .select_related("project") diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index 5d2f95673..6f9b2618e 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -77,6 +77,12 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ] def get_queryset(self): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") return self.filter_queryset( super() .get_queryset() @@ -147,6 +153,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ) ) ) + .annotate(sort_order=Subquery(sort_order)) .prefetch_related( Prefetch( "project_projectmember", @@ -166,16 +173,8 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): for field in request.GET.get("fields", "").split(",") if field ] - - sort_order_query = ProjectMember.objects.filter( - member=request.user, - project_id=OuterRef("pk"), - workspace__slug=self.kwargs.get("slug"), - is_active=True, - ).values("sort_order") projects = ( self.get_queryset() - .annotate(sort_order=Subquery(sort_order_query)) .order_by("sort_order", "name") ) if request.GET.get("per_page", False) and request.GET.get( @@ -204,7 +203,7 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): serializer.save() # Add the user as Administrator to the project - project_member = ProjectMember.objects.create( + _ = ProjectMember.objects.create( project_id=serializer.data["id"], member=request.user, role=20, diff --git a/apiserver/plane/app/views/search.py b/apiserver/plane/app/views/search.py index ccef3d18f..a2ed1c015 100644 --- a/apiserver/plane/app/views/search.py +++ b/apiserver/plane/app/views/search.py @@ -48,8 +48,8 @@ class GlobalSearchEndpoint(BaseAPIView): return ( Project.objects.filter( q, - Q(project_projectmember__member=self.request.user) - | Q(network=2), + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, workspace__slug=slug, ) .distinct() @@ -71,6 +71,7 @@ class GlobalSearchEndpoint(BaseAPIView): issues = Issue.issue_objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -95,6 +96,7 @@ class GlobalSearchEndpoint(BaseAPIView): cycles = Cycle.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -118,6 +120,7 @@ class GlobalSearchEndpoint(BaseAPIView): modules = Module.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -141,6 +144,7 @@ class GlobalSearchEndpoint(BaseAPIView): pages = Page.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -164,6 +168,7 @@ class GlobalSearchEndpoint(BaseAPIView): issue_views = IssueView.objects.filter( q, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, workspace__slug=slug, ) @@ -236,6 +241,7 @@ class IssueSearchEndpoint(BaseAPIView): issues = Issue.issue_objects.filter( workspace__slug=slug, project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, ) if workspace_search == "false": diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index 242061e18..34b3d1dcc 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -31,7 +31,10 @@ class StateViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .filter(~Q(name="Triage")) .select_related("project") .select_related("workspace") diff --git a/apiserver/plane/app/views/view.py b/apiserver/plane/app/views/view.py index 27f31f7a9..ade445fae 100644 --- a/apiserver/plane/app/views/view.py +++ b/apiserver/plane/app/views/view.py @@ -1,6 +1,6 @@ # Django imports from django.db.models import ( - Prefetch, + Q, OuterRef, Func, F, @@ -13,16 +13,21 @@ from django.db.models import ( ) from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.db.models import Prefetch, OuterRef, Exists +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField # Third party imports from rest_framework.response import Response from rest_framework import status # Module imports -from . import BaseViewSet, BaseAPIView +from . import BaseViewSet from plane.app.serializers import ( - GlobalViewSerializer, IssueViewSerializer, IssueSerializer, IssueViewFavoriteSerializer, @@ -30,22 +35,16 @@ from plane.app.serializers import ( from plane.app.permissions import ( WorkspaceEntityPermission, ProjectEntityPermission, - WorkspaceViewerPermission, - ProjectLitePermission, ) from plane.db.models import ( Workspace, - GlobalView, IssueView, Issue, IssueViewFavorite, - IssueReaction, IssueLink, IssueAttachment, - IssueSubscriber, ) from plane.utils.issue_filters import issue_filters -from plane.utils.grouper import group_results class GlobalViewViewSet(BaseViewSet): @@ -87,13 +86,60 @@ class GlobalViewIssuesViewSet(BaseViewSet): .values("count") ) .filter(workspace__slug=self.kwargs.get("slug")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") - .prefetch_related( - Prefetch( - "issue_reactions", - queryset=IssueReaction.objects.select_related("actor"), + .annotate(cycle_id=F("issue_cycle__cycle_id")) + .annotate( + link_count=IssueLink.objects.filter(issue=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + attachment_count=IssueAttachment.objects.filter( + issue=OuterRef("id") ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), ) ) @@ -121,30 +167,7 @@ class GlobalViewIssuesViewSet(BaseViewSet): issue_queryset = ( self.get_queryset() .filter(**filters) - .filter(project__project_projectmember__member=self.request.user) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate( - link_count=IssueLink.objects.filter(issue=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - attachment_count=IssueAttachment.objects.filter( - issue=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues_count=Issue.issue_objects.filter( - parent=OuterRef("id") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ) # Priority Ordering @@ -207,10 +230,39 @@ class GlobalViewIssuesViewSet(BaseViewSet): else: issue_queryset = issue_queryset.order_by(order_by_param) - serializer = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ) - return Response(serializer.data, status=status.HTTP_200_OK) + if self.fields: + issues = IssueSerializer( + issue_queryset, many=True, fields=self.fields + ).data + else: + issues = issue_queryset.values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + return Response(issues, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): @@ -235,7 +287,10 @@ class IssueViewViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) - .filter(project__project_projectmember__member=self.request.user) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) .select_related("project") .select_related("workspace") .annotate(is_favorite=Exists(subquery)) diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index f4d3dbbb5..7c4a5db8d 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -1,9 +1,12 @@ # Python imports import jwt +import csv +import io from datetime import date, datetime from dateutil.relativedelta import relativedelta # Django imports +from django.http import HttpResponse from django.db import IntegrityError from django.conf import settings from django.utils import timezone @@ -22,9 +25,14 @@ from django.db.models import ( When, Max, IntegerField, + Sum, ) from django.db.models.functions import ExtractWeek, Cast, ExtractDay from django.db.models.fields import DateField +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Value, UUIDField +from django.db.models.functions import Coalesce # Third party modules from rest_framework import status @@ -73,6 +81,9 @@ from plane.db.models import ( WorkspaceUserProperties, Estimate, EstimatePoint, + Module, + ModuleLink, + Cycle, ) from plane.app.permissions import ( WorkSpaceBasePermission, @@ -85,6 +96,12 @@ from plane.app.permissions import ( from plane.bgtasks.workspace_invitation_task import workspace_invitation from plane.utils.issue_filters import issue_filters from plane.bgtasks.event_tracking_task import workspace_invite_event +from plane.app.serializers.module import ( + ModuleSerializer, +) +from plane.app.serializers.cycle import ( + CycleSerializer, +) class WorkSpaceViewSet(BaseViewSet): @@ -546,7 +563,6 @@ class WorkSpaceMemberViewSet(BaseViewSet): .get_queryset() .filter( workspace__slug=self.kwargs.get("slug"), - member__is_bot=False, is_active=True, ) .select_related("workspace", "workspace__owner") @@ -754,7 +770,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): project_ids = ( ProjectMember.objects.filter( member=request.user, - member__is_bot=False, is_active=True, ) .values_list("project_id", flat=True) @@ -764,7 +779,6 @@ class WorkspaceProjectMemberEndpoint(BaseAPIView): # Get all the project members in which the user is involved project_members = ProjectMember.objects.filter( workspace__slug=slug, - member__is_bot=False, project_id__in=project_ids, is_active=True, ).select_related("project", "member", "workspace") @@ -1075,6 +1089,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .annotate(state_group=F("state__group")) @@ -1090,6 +1105,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .values("priority") @@ -1112,6 +1128,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): Issue.issue_objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, created_by_id=user_id, ) .filter(**filters) @@ -1123,6 +1140,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .filter(**filters) .count() @@ -1134,6 +1152,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, assignees__in=[user_id], project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, ) .filter(**filters) .count() @@ -1145,6 +1164,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): assignees__in=[user_id], state__group="completed", project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .count() @@ -1155,6 +1175,7 @@ class WorkspaceUserProfileStatsEndpoint(BaseAPIView): workspace__slug=slug, subscriber_id=user_id, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .count() @@ -1204,6 +1225,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): ~Q(field__in=["comment", "vote", "reaction", "draft"]), workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True, actor=user_id, ).select_related("actor", "workspace", "issue", "project") @@ -1219,6 +1241,66 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): ) +class ExportWorkspaceUserActivityEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceEntityPermission, + ] + + def generate_csv_from_rows(self, rows): + """Generate CSV buffer from rows.""" + csv_buffer = io.StringIO() + writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL) + [writer.writerow(row) for row in rows] + csv_buffer.seek(0) + return csv_buffer + + def post(self, request, slug, user_id): + + if not request.data.get("date"): + return Response( + {"error": "Date is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + user_activities = IssueActivity.objects.filter( + ~Q(field__in=["comment", "vote", "reaction", "draft"]), + workspace__slug=slug, + created_at__date=request.data.get("date"), + project__project_projectmember__member=request.user, + actor_id=user_id, + ).select_related("actor", "workspace", "issue", "project")[:10000] + + header = [ + "Actor name", + "Issue ID", + "Project", + "Created at", + "Updated at", + "Action", + "Field", + "Old value", + "New value", + ] + rows = [ + ( + activity.actor.display_name, + f"{activity.project.identifier} - {activity.issue.sequence_id if activity.issue else ''}", + activity.project.name, + activity.created_at, + activity.updated_at, + activity.verb, + activity.field, + activity.old_value, + activity.new_value, + ) + for activity in user_activities + ] + csv_buffer = self.generate_csv_from_rows([header] + rows) + response = HttpResponse(csv_buffer.getvalue(), content_type="text/csv") + response["Content-Disposition"] = 'attachment; filename="workspace-user-activity.csv"' + return response + + class WorkspaceUserProfileEndpoint(BaseAPIView): def get(self, request, slug, user_id): user_data = User.objects.get(pk=user_id) @@ -1234,6 +1316,7 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): Project.objects.filter( workspace__slug=slug, project_projectmember__member=request.user, + project_projectmember__is_active=True, ) .annotate( created_issues=Count( @@ -1283,10 +1366,6 @@ class WorkspaceUserProfileEndpoint(BaseAPIView): ) .values( "id", - "name", - "identifier", - "emoji", - "icon_prop", "created_issues", "assigned_issues", "completed_issues", @@ -1343,6 +1422,7 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): | Q(issue_subscribers__subscriber_id=user_id), workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) .filter(**filters) .select_related("workspace", "project", "state", "parent") @@ -1370,6 +1450,32 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .order_by("created_at") ).distinct() @@ -1448,6 +1554,7 @@ class WorkspaceLabelsEndpoint(BaseAPIView): labels = Label.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) serializer = LabelSerializer(labels, many=True).data return Response(serializer, status=status.HTTP_200_OK) @@ -1462,6 +1569,7 @@ class WorkspaceStatesEndpoint(BaseAPIView): states = State.objects.filter( workspace__slug=slug, project__project_projectmember__member=request.user, + project__project_projectmember__is_active=True ) serializer = StateSerializer(states, many=True).data return Response(serializer, status=status.HTTP_200_OK) @@ -1490,6 +1598,192 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): return Response(serializer.data, status=status.HTTP_200_OK) +class WorkspaceModulesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + modules = ( + Module.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("lead") + .prefetch_related("members") + .prefetch_related( + Prefetch( + "link_module", + queryset=ModuleLink.objects.select_related( + "module", "created_by" + ), + ) + ) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) + .annotate( + completed_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_module__issue__state__group", + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + ) + + serializer = ModuleSerializer(modules, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + +class WorkspaceCyclesEndpoint(BaseAPIView): + permission_classes = [ + WorkspaceViewerPermission, + ] + + def get(self, request, slug): + cycles = ( + Cycle.objects.filter(workspace__slug=slug) + .select_related("project") + .select_related("workspace") + .select_related("owned_by") + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + completed_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + cancelled_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + unstarted_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + backlog_issues=Count( + "issue_cycle__issue__state__group", + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + total_estimates=Sum("issue_cycle__issue__estimate_point") + ) + .annotate( + completed_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .annotate( + started_estimates=Sum( + "issue_cycle__issue__estimate_point", + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + serializer = CycleSerializer(cycles, many=True).data + return Response(serializer, status=status.HTTP_200_OK) + + class WorkspaceUserPropertiesEndpoint(BaseAPIView): permission_classes = [ WorkspaceViewerPermission, diff --git a/apiserver/plane/bgtasks/email_notification_task.py b/apiserver/plane/bgtasks/email_notification_task.py index 9e9b348e1..2a98c6b33 100644 --- a/apiserver/plane/bgtasks/email_notification_task.py +++ b/apiserver/plane/bgtasks/email_notification_task.py @@ -1,21 +1,33 @@ from datetime import datetime from bs4 import BeautifulSoup - # Third party imports from celery import shared_task +from sentry_sdk import capture_exception # Django imports from django.utils import timezone from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags +from django.conf import settings # Module imports from plane.db.models import EmailNotificationLog, User, Issue from plane.license.utils.instance_value import get_email_configuration from plane.settings.redis import redis_instance +# acquire and delete redis lock +def acquire_lock(lock_id, expire_time=300): + redis_client = redis_instance() + """Attempt to acquire a lock with a specified expiration time.""" + return redis_client.set(lock_id, 'true', nx=True, ex=expire_time) + +def release_lock(lock_id): + """Release a lock.""" + redis_client = redis_instance() + redis_client.delete(lock_id) + @shared_task def stack_email_notification(): # get all email notifications @@ -142,135 +154,155 @@ def process_html_content(content): processed_content_list.append(processed_content) return processed_content_list + @shared_task def send_email_notification( issue_id, notification_data, receiver_id, email_notification_ids ): + # Convert UUIDs to a sorted, concatenated string + sorted_ids = sorted(email_notification_ids) + ids_str = "_".join(str(id) for id in sorted_ids) + lock_id = f"send_email_notif_{issue_id}_{receiver_id}_{ids_str}" + + # acquire the lock for sending emails try: - ri = redis_instance() - base_api = (ri.get(str(issue_id)).decode()) - data = create_payload(notification_data=notification_data) + if acquire_lock(lock_id=lock_id): + # get the redis instance + ri = redis_instance() + base_api = (ri.get(str(issue_id)).decode()) + data = create_payload(notification_data=notification_data) - # Get email configurations - ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_FROM, - ) = get_email_configuration() + # Get email configurations + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() - receiver = User.objects.get(pk=receiver_id) - issue = Issue.objects.get(pk=issue_id) - template_data = [] - total_changes = 0 - comments = [] - actors_involved = [] - for actor_id, changes in data.items(): - actor = User.objects.get(pk=actor_id) - total_changes = total_changes + len(changes) - comment = changes.pop("comment", False) - mention = changes.pop("mention", False) - actors_involved.append(actor_id) - if comment: - comments.append( - { - "actor_comments": comment, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } + receiver = User.objects.get(pk=receiver_id) + issue = Issue.objects.get(pk=issue_id) + template_data = [] + total_changes = 0 + comments = [] + actors_involved = [] + for actor_id, changes in data.items(): + actor = User.objects.get(pk=actor_id) + total_changes = total_changes + len(changes) + comment = changes.pop("comment", False) + mention = changes.pop("mention", False) + actors_involved.append(actor_id) + if comment: + comments.append( + { + "actor_comments": comment, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + if mention: + mention["new_value"] = process_html_content(mention.get("new_value")) + mention["old_value"] = process_html_content(mention.get("old_value")) + comments.append( + { + "actor_comments": mention, + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + } + ) + activity_time = changes.pop("activity_time") + # Parse the input string into a datetime object + formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") + + if changes: + template_data.append( + { + "actor_detail": { + "avatar_url": actor.avatar, + "first_name": actor.first_name, + "last_name": actor.last_name, + }, + "changes": changes, + "issue_details": { + "name": issue.name, + "identifier": f"{issue.project.identifier}-{issue.sequence_id}", + }, + "activity_time": str(formatted_time), + } ) - if mention: - mention["new_value"] = process_html_content(mention.get("new_value")) - mention["old_value"] = process_html_content(mention.get("old_value")) - comments.append( - { - "actor_comments": mention, - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - } - ) - activity_time = changes.pop("activity_time") - # Parse the input string into a datetime object - formatted_time = datetime.strptime(activity_time, "%Y-%m-%d %H:%M:%S").strftime("%H:%M %p") - if changes: - template_data.append( - { - "actor_detail": { - "avatar_url": actor.avatar, - "first_name": actor.first_name, - "last_name": actor.last_name, - }, - "changes": changes, - "issue_details": { - "name": issue.name, - "identifier": f"{issue.project.identifier}-{issue.sequence_id}", - }, - "activity_time": str(formatted_time), - } - ) + summary = "Updates were made to the issue by" - summary = "Updates were made to the issue by" - - # Send the mail - subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" - context = { - "data": template_data, - "summary": summary, - "actors_involved": len(set(actors_involved)), - "issue": { - "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", - "name": issue.name, + # Send the mail + subject = f"{issue.project.identifier}-{issue.sequence_id} {issue.name}" + context = { + "data": template_data, + "summary": summary, + "actors_involved": len(set(actors_involved)), + "issue": { + "issue_identifier": f"{str(issue.project.identifier)}-{str(issue.sequence_id)}", + "name": issue.name, + "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", + }, + "receiver": { + "email": receiver.email, + }, "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - }, - "receiver": { - "email": receiver.email, - }, - "issue_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/{str(issue.id)}", - "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", - "workspace":str(issue.project.workspace.slug), - "project": str(issue.project.name), - "user_preference": f"{base_api}/profile/preferences/email", - "comments": comments, - } - html_content = render_to_string( - "emails/notifications/issue-updates.html", context - ) - text_content = strip_tags(html_content) - - try: - connection = get_connection( - host=EMAIL_HOST, - port=int(EMAIL_PORT), - username=EMAIL_HOST_USER, - password=EMAIL_HOST_PASSWORD, - use_tls=EMAIL_USE_TLS == "1", + "project_url": f"{base_api}/{str(issue.project.workspace.slug)}/projects/{str(issue.project.id)}/issues/", + "workspace":str(issue.project.workspace.slug), + "project": str(issue.project.name), + "user_preference": f"{base_api}/profile/preferences/email", + "comments": comments, + } + html_content = render_to_string( + "emails/notifications/issue-updates.html", context ) + text_content = strip_tags(html_content) - msg = EmailMultiAlternatives( - subject=subject, - body=text_content, - from_email=EMAIL_FROM, - to=[receiver.email], - connection=connection, - ) - msg.attach_alternative(html_content, "text/html") - msg.send() + try: + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + ) - EmailNotificationLog.objects.filter( - pk__in=email_notification_ids - ).update(sent_at=timezone.now()) + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=EMAIL_FROM, + to=[receiver.email], + connection=connection, + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + + EmailNotificationLog.objects.filter( + pk__in=email_notification_ids + ).update(sent_at=timezone.now()) + + # release the lock + release_lock(lock_id=lock_id) + return + except Exception as e: + capture_exception(e) + # release the lock + release_lock(lock_id=lock_id) + return + else: + print("Duplicate task recived. Skipping...") return - except Exception as e: + except (Issue.DoesNotExist, User.DoesNotExist) as e: + if settings.DEBUG: print(e) - return - except Issue.DoesNotExist: + release_lock(lock_id=lock_id) return diff --git a/apiserver/plane/bgtasks/export_task.py b/apiserver/plane/bgtasks/export_task.py index b99e4b1d9..d8522e769 100644 --- a/apiserver/plane/bgtasks/export_task.py +++ b/apiserver/plane/bgtasks/export_task.py @@ -292,6 +292,7 @@ def issue_export_task( workspace__id=workspace_id, project_id__in=project_ids, project__project_projectmember__member=exporter_instance.initiated_by_id, + project__project_projectmember__is_active=True ) .select_related( "project", "workspace", "state", "parent", "created_by" diff --git a/apiserver/plane/bgtasks/importer_task.py b/apiserver/plane/bgtasks/importer_task.py index 421521363..7a1dc4fc6 100644 --- a/apiserver/plane/bgtasks/importer_task.py +++ b/apiserver/plane/bgtasks/importer_task.py @@ -60,15 +60,6 @@ def service_importer(service, importer_id): batch_size=100, ) - _ = [ - send_welcome_slack.delay( - str(user.id), - True, - f"{user.email} was imported to Plane from {service}", - ) - for user in new_users - ] - workspace_users = User.objects.filter( email__in=[ user.get("email").strip().lower() diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index b86ab5e78..2a16ee911 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -483,17 +483,23 @@ def track_archive_at( ) ) else: + if requested_data.get("automation"): + comment = "Plane has archived the issue" + new_value = "archive" + else: + comment = "Actor has archived the issue" + new_value = "manual_archive" issue_activities.append( IssueActivity( issue_id=issue_id, project_id=project_id, workspace_id=workspace_id, - comment="Plane has archived the issue", + comment=comment, verb="updated", actor_id=actor_id, field="archived_at", old_value=None, - new_value="archive", + new_value=new_value, epoch=epoch, ) ) diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index 974a545fc..c6c4d7515 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -79,7 +79,7 @@ def archive_old_issues(): issue_activity.delay( type="issue.activity.updated", requested_data=json.dumps( - {"archived_at": str(archive_at)} + {"archived_at": str(archive_at), "automation": True} ), actor_id=str(project.created_by_id), issue_id=issue.id, diff --git a/apiserver/plane/db/migrations/0061_project_logo_props.py b/apiserver/plane/db/migrations/0061_project_logo_props.py new file mode 100644 index 000000000..d8752d9dd --- /dev/null +++ b/apiserver/plane/db/migrations/0061_project_logo_props.py @@ -0,0 +1,54 @@ +# Generated by Django 4.2.7 on 2024-03-03 16:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + def update_project_logo_props(apps, schema_editor): + Project = apps.get_model("db", "Project") + + bulk_update_project_logo = [] + # Iterate through projects and update logo_props + for project in Project.objects.all(): + project.logo_props["in_use"] = "emoji" if project.emoji else "icon" + project.logo_props["emoji"] = { + "value": project.emoji if project.emoji else "", + "url": "", + } + project.logo_props["icon"] = { + "name": ( + project.icon_prop.get("name", "") + if project.icon_prop + else "" + ), + "color": ( + project.icon_prop.get("color", "") + if project.icon_prop + else "" + ), + } + bulk_update_project_logo.append(project) + + # Bulk update logo_props for all projects + Project.objects.bulk_update( + bulk_update_project_logo, ["logo_props"], batch_size=1000 + ) + + dependencies = [ + ("db", "0060_cycle_progress_snapshot"), + ] + + operations = [ + migrations.AlterField( + model_name="issuelink", + name="url", + field=models.TextField(), + ), + migrations.AddField( + model_name="project", + name="logo_props", + field=models.JSONField(default=dict), + ), + migrations.RunPython(update_project_logo_props), + ] diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index d5ed4247a..5bd0b3397 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -320,7 +320,7 @@ class IssueAssignee(ProjectBaseModel): class IssueLink(ProjectBaseModel): title = models.CharField(max_length=255, null=True, blank=True) - url = models.URLField() + url = models.TextField() issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_link" ) diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index b93174724..bb4885d14 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -107,6 +107,7 @@ class Project(BaseModel): close_in = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(12)] ) + logo_props = models.JSONField(default=dict) default_state = models.ForeignKey( "db.State", on_delete=models.SET_NULL, diff --git a/apiserver/plane/db/models/user.py b/apiserver/plane/db/models/user.py index f254a3cb7..0377ccb8b 100644 --- a/apiserver/plane/db/models/user.py +++ b/apiserver/plane/db/models/user.py @@ -12,15 +12,9 @@ from django.contrib.auth.models import ( PermissionsMixin, ) from django.db.models.signals import post_save -from django.conf import settings from django.dispatch import receiver from django.utils import timezone -# Third party imports -from sentry_sdk import capture_exception -from slack_sdk import WebClient -from slack_sdk.errors import SlackApiError - def get_default_onboarding(): return { @@ -144,25 +138,6 @@ class User(AbstractBaseUser, PermissionsMixin): super(User, self).save(*args, **kwargs) -@receiver(post_save, sender=User) -def send_welcome_slack(sender, instance, created, **kwargs): - try: - if created and not instance.is_bot: - # Send message on slack as well - if settings.SLACK_BOT_TOKEN: - client = WebClient(token=settings.SLACK_BOT_TOKEN) - try: - _ = client.chat_postMessage( - channel="#trackers", - text=f"New user {instance.email} has signed up and begun the onboarding journey.", - ) - except SlackApiError as e: - print(f"Got an error: {e.response['error']}") - return - except Exception as e: - capture_exception(e) - return - @receiver(post_save, sender=User) def create_user_notification(sender, instance, created, **kwargs): diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index f03209250..5c8947e73 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -1,4 +1,5 @@ """Global Settings""" + # Python imports import os import ssl @@ -307,7 +308,9 @@ if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get( traces_sample_rate=1, send_default_pii=True, environment=os.environ.get("SENTRY_ENVIRONMENT", "development"), - profiles_sample_rate=1.0, + profiles_sample_rate=float( + os.environ.get("SENTRY_PROFILE_SAMPLE_RATE", 0.5) + ), ) diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index 669f3ea73..3b042ea1f 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -7,6 +7,7 @@ from django.views.generic import TemplateView from django.conf import settings +handler404 = "plane.app.views.error_404.custom_404_view" urlpatterns = [ path("", TemplateView.as_view(template_name="index.html")), diff --git a/apiserver/plane/utils/issue_search.py b/apiserver/plane/utils/issue_search.py index d38b1f4c3..3b6dea332 100644 --- a/apiserver/plane/utils/issue_search.py +++ b/apiserver/plane/utils/issue_search.py @@ -9,11 +9,11 @@ from plane.db.models import Issue def search_issues(query, queryset): - fields = ["name", "sequence_id"] + fields = ["name", "sequence_id", "project__identifier"] q = Q() for field in fields: if field == "sequence_id" and len(query) <= 20: - sequences = re.findall(r"[A-Za-z0-9]{1,12}-\d+", query) + sequences = re.findall(r"\b\d+\b", query) for sequence_id in sequences: q |= Q(**{"sequence_id": sequence_id}) else: diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 194bf8d90..eb0f54201 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -30,7 +30,7 @@ openpyxl==3.1.2 beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 -cryptography==42.0.0 +cryptography==42.0.4 lxml==4.9.3 boto3==1.28.40 diff --git a/apiserver/runtime.txt b/apiserver/runtime.txt index d45f665de..424240cc0 100644 --- a/apiserver/runtime.txt +++ b/apiserver/runtime.txt @@ -1 +1 @@ -python-3.11.7 \ No newline at end of file +python-3.11.8 \ No newline at end of file diff --git a/deploy/1-click/README.md b/deploy/1-click/README.md new file mode 100644 index 000000000..08bc35b28 --- /dev/null +++ b/deploy/1-click/README.md @@ -0,0 +1,82 @@ +# 1-Click Self-Hosting + +In this guide, we will walk you through the process of setting up a 1-click self-hosted environment. Self-hosting allows you to have full control over your applications and data. It's a great way to ensure privacy, control, and customization. + +Let's get started! + +## Installing Plane + +Installing Plane is a very easy and minimal step process. + +### Prerequisite + +- Operating System (latest): Debian / Ubuntu / Centos +- Supported CPU Architechture: AMD64 / ARM64 / x86_64 / aarch64 + +### Downloading Latest Stable Release + +``` +curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh - + +``` + +
+ Downloading Preview Release + +``` +export BRANCH=preview + +curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh - + +``` + +NOTE: `Preview` builds do not support ARM64/AARCH64 CPU architecture + +
+ +-- + +Expect this after a successful install + +![Install Output](images/install.png) + +Access the application on a browser via http://server-ip-address + +--- + +### Get Control of your Plane Server Setup + +Plane App is available via the command `plane-app`. Running the command `plane-app --help` helps you to manage Plane + +![Plane Help](images/help.png) + +Basic Operations: + +1. Start Server using `plane-app start` +1. Stop Server using `plane-app stop` +1. Restart Server using `plane-app restart` + +Advanced Operations: + +1. Configure Plane using `plane-app --configure`. This will give you options to modify + + - NGINX Port (default 80) + - Domain Name (default is the local server public IP address) + - File Upload Size (default 5MB) + - External Postgres DB Url (optional - default empty) + - External Redis URL (optional - default empty) + - AWS S3 Bucket (optional - to be configured only in case the user wants to use an S3 Bucket) + +1. Upgrade Plane using `plane-app --upgrade`. This will get the latest stable version of Plane files (docker-compose.yaml, .env, and docker images) + +1. Updating Plane App installer using `plane-app --update-installer` will update the `plane-app` utility. + +1. Uninstall Plane using `plane-app --uninstall`. This will uninstall the Plane application from the server and all docker containers but do not remove the data stored in Postgres, Redis, and Minio. + +1. Plane App can be reinstalled using `plane-app --install`. + +Application Data is stored in the mentioned folders: + +1. DB Data: /opt/plane/data/postgres +1. Redis Data: /opt/plane/data/redis +1. Minio Data: /opt/plane/data/minio diff --git a/deploy/1-click/images/help.png b/deploy/1-click/images/help.png new file mode 100644 index 000000000..c14603a4b Binary files /dev/null and b/deploy/1-click/images/help.png differ diff --git a/deploy/1-click/images/install.png b/deploy/1-click/images/install.png new file mode 100644 index 000000000..c8ba1e5f8 Binary files /dev/null and b/deploy/1-click/images/install.png differ diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh index 917d08fdf..9a0eac902 100644 --- a/deploy/1-click/install.sh +++ b/deploy/1-click/install.sh @@ -1,17 +1,20 @@ #!/bin/bash +export GIT_REPO=makeplane/plane + # Check if the user has sudo access if command -v curl &> /dev/null; then sudo curl -sSL \ -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) + https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) else sudo wget -q \ -O /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) + https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) fi sudo chmod +x /usr/local/bin/plane-app -sudo sed -i 's/export DEPLOY_BRANCH=${BRANCH:-master}/export DEPLOY_BRANCH='${BRANCH:-master}'/' /usr/local/bin/plane-app +sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app +sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app -plane-app --help +plane-app -i #--help diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index 2d6ef0a6f..e6bd24b9e 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -90,9 +90,9 @@ function prepare_environment() { show_message "- Updating OS with required tools ✋" >&2 sudo "$PACKAGE_MANAGER" update -y - sudo "$PACKAGE_MANAGER" upgrade -y + # sudo "$PACKAGE_MANAGER" upgrade -y - local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap") + local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq") for tool in "${required_tools[@]}"; do if ! command -v $tool &> /dev/null; then @@ -150,11 +150,11 @@ function download_plane() { show_message "Downloading Plane Setup Files ✋" >&2 sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ - https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) + https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ - https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) + https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) # if .env does not exists rename variables-upgrade.env to .env if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then @@ -202,7 +202,7 @@ function printUsageInstructions() { } function build_local_image() { show_message "- Downloading Plane Source Code ✋" >&2 - REPO=https://github.com/makeplane/plane.git + REPO=https://github.com/$CODE_REPO.git CURR_DIR=$PWD PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null @@ -290,40 +290,40 @@ function configure_plane() { fi - smtp_host=$(read_env "EMAIL_HOST") - smtp_user=$(read_env "EMAIL_HOST_USER") - smtp_password=$(read_env "EMAIL_HOST_PASSWORD") - smtp_port=$(read_env "EMAIL_PORT") - smtp_from=$(read_env "EMAIL_FROM") - smtp_tls=$(read_env "EMAIL_USE_TLS") - smtp_ssl=$(read_env "EMAIL_USE_SSL") + # smtp_host=$(read_env "EMAIL_HOST") + # smtp_user=$(read_env "EMAIL_HOST_USER") + # smtp_password=$(read_env "EMAIL_HOST_PASSWORD") + # smtp_port=$(read_env "EMAIL_PORT") + # smtp_from=$(read_env "EMAIL_FROM") + # smtp_tls=$(read_env "EMAIL_USE_TLS") + # smtp_ssl=$(read_env "EMAIL_USE_SSL") - SMTP_SETTINGS=$(dialog \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --backtitle "Plane Configuration" \ - --title "SMTP Settings" \ - --form "" \ - 0 0 0 \ - "Host:" 1 1 "$smtp_host" 1 10 80 0 \ - "User:" 2 1 "$smtp_user" 2 10 80 0 \ - "Password:" 3 1 "$smtp_password" 3 10 80 0 \ - "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ - "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ - "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ - "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ - 2>&1 1>&3) + # SMTP_SETTINGS=$(dialog \ + # --ok-label "Next" \ + # --cancel-label "Skip" \ + # --backtitle "Plane Configuration" \ + # --title "SMTP Settings" \ + # --form "" \ + # 0 0 0 \ + # "Host:" 1 1 "$smtp_host" 1 10 80 0 \ + # "User:" 2 1 "$smtp_user" 2 10 80 0 \ + # "Password:" 3 1 "$smtp_password" 3 10 80 0 \ + # "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ + # "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ + # "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ + # "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ + # 2>&1 1>&3) - save_smtp_settings=0 - if [ $? -eq 0 ]; then - save_smtp_settings=1 - smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) - smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) - smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) - smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) - smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) - smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) - fi + # save_smtp_settings=0 + # if [ $? -eq 0 ]; then + # save_smtp_settings=1 + # smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) + # smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) + # smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) + # smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) + # smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) + # smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) + # fi external_pgdb_url=$(dialog \ --backtitle "Plane Configuration" \ --title "Using External Postgres Database ?" \ @@ -383,15 +383,6 @@ function configure_plane() { domain_name: $domain_name upload_limit: $upload_limit - save_smtp_settings: $save_smtp_settings - smtp_host: $smtp_host - smtp_user: $smtp_user - smtp_password: $smtp_password - smtp_port: $smtp_port - smtp_from: $smtp_from - smtp_tls: $smtp_tls - smtp_ssl: $smtp_ssl - save_aws_settings: $save_aws_settings aws_region: $aws_region aws_access_key: $aws_access_key @@ -413,15 +404,15 @@ function configure_plane() { fi # check enable smpt settings value - if [ $save_smtp_settings == 1 ]; then - update_env "EMAIL_HOST" "$smtp_host" - update_env "EMAIL_HOST_USER" "$smtp_user" - update_env "EMAIL_HOST_PASSWORD" "$smtp_password" - update_env "EMAIL_PORT" "$smtp_port" - update_env "EMAIL_FROM" "$smtp_from" - update_env "EMAIL_USE_TLS" "$smtp_tls" - update_env "EMAIL_USE_SSL" "$smtp_ssl" - fi + # if [ $save_smtp_settings == 1 ]; then + # update_env "EMAIL_HOST" "$smtp_host" + # update_env "EMAIL_HOST_USER" "$smtp_user" + # update_env "EMAIL_HOST_PASSWORD" "$smtp_password" + # update_env "EMAIL_PORT" "$smtp_port" + # update_env "EMAIL_FROM" "$smtp_from" + # update_env "EMAIL_USE_TLS" "$smtp_tls" + # update_env "EMAIL_USE_SSL" "$smtp_ssl" + # fi # check enable aws settings value if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then @@ -493,13 +484,24 @@ function install() { check_for_docker_images last_installed_on=$(read_config "INSTALLATION_DATE") - if [ "$last_installed_on" == "" ]; then - configure_plane - fi - printUsageInstructions - - update_config "INSTALLATION_DATE" "$(date)" + # if [ "$last_installed_on" == "" ]; then + # configure_plane + # fi + update_env "NGINX_PORT" "80" + update_env "DOMAIN_NAME" "$MY_IP" + update_env "WEB_URL" "http://$MY_IP" + update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP" + + update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')" + + if command -v crontab &> /dev/null; then + sudo touch /etc/cron.daily/makeplane + sudo chmod +x /etc/cron.daily/makeplane + sudo echo "0 2 * * * root /usr/local/bin/plane-app --upgrade" > /etc/cron.daily/makeplane + sudo crontab /etc/cron.daily/makeplane + fi + show_message "Plane Installed Successfully ✅" show_message "" else @@ -539,12 +541,15 @@ function upgrade() { prepare_environment if [ $? -eq 0 ]; then + stop_server download_plane if [ $? -eq 0 ]; then check_for_docker_images upgrade_configuration update_config "UPGRADE_DATE" "$(date)" - + + start_server + show_message "" show_message "Plane Upgraded Successfully ✅" show_message "" @@ -601,6 +606,11 @@ function uninstall() { sudo rm $PLANE_INSTALL_DIR/variables-upgrade.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/config.env &> /dev/null sudo rm $PLANE_INSTALL_DIR/docker-compose.yaml &> /dev/null + + if command -v crontab &> /dev/null; then + sudo crontab -r &> /dev/null + sudo rm /etc/cron.daily/makeplane &> /dev/null + fi # rm -rf $PLANE_INSTALL_DIR &> /dev/null show_message "- Configuration Cleaned ✅" @@ -642,7 +652,39 @@ function start_server() { while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do sleep 1 done + # wait for migrator container to exit with status 0 before starting the application + migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator") + + # if migrator container is running, wait for it to exit + if [ -n "$migrator_container_id" ]; then + while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do + show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2 + sleep 1 + done + fi + + # if migrator exit status is not 0, show error message and exit + if [ -n "$migrator_container_id" ]; then + migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) + if [ $migrator_exit_code -ne 0 ]; then + # show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2 + show_message "Plane Server failed to start ❌" "replace_last_line" >&2 + stop_server + exit 1 + fi + fi + + api_container_id=$(sudo docker container ls -q -f "name=plane-api") + while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete"; + do + show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2 + sleep 1 + done show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 + show_message "---------------------------------------------------------------" >&2 + show_message "Access the Plane application at http://$MY_IP" >&2 + show_message "---------------------------------------------------------------" >&2 + else show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 fi @@ -694,7 +736,7 @@ function update_installer() { show_message "Updating Plane Installer ✋" >&2 sudo curl -H 'Cache-Control: no-cache, no-store' \ -s -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/makeplane/plane/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) + https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null show_message "Plane Installer Updated ✅" "replace_last_line" >&2 @@ -711,12 +753,14 @@ fi PLANE_INSTALL_DIR=/opt/plane DATA_DIR=$PLANE_INSTALL_DIR/data -LOG_DIR=$PLANE_INSTALL_DIR/log +LOG_DIR=$PLANE_INSTALL_DIR/logs +CODE_REPO=${GIT_REPO:-makeplane/plane} OS_SUPPORTED=false CPU_ARCH=$(uname -m) PROGRESS_MSG="" USE_GLOBAL_IMAGES=0 PACKAGE_MANAGER="" +MY_IP=$(curl -s ifconfig.me) if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then USE_GLOBAL_IMAGES=1 @@ -740,6 +784,9 @@ elif [ "$1" == "restart" ]; then restart_server elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then install + start_server + show_message "" >&2 + show_message "To view help, use plane-app --help " >&2 elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then configure_plane printUsageInstructions diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 60861878c..07e5ea9f6 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -56,8 +56,6 @@ x-app-env : &app-env - BUCKET_NAME=${BUCKET_NAME:-uploads} - FILE_SIZE_LIMIT=${FILE_SIZE_LIMIT:-5242880} - - services: web: <<: *app-env @@ -138,7 +136,6 @@ services: command: postgres -c 'max_connections=1000' volumes: - pgdata:/var/lib/postgresql/data - plane-redis: <<: *app-env image: redis:6.2.7-alpine diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 30f2d15d7..16b6ea7c3 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -13,6 +13,23 @@ YELLOW='\033[1;33m' GREEN='\033[0;32m' NC='\033[0m' # No Color +function print_header() { +clear + +cat <<"EOF" +--------------------------------------- + ____ _ +| _ \| | __ _ _ __ ___ +| |_) | |/ _` | '_ \ / _ \ +| __/| | (_| | | | | __/ +|_| |_|\__,_|_| |_|\___| + +--------------------------------------- +Project management tool from the future +--------------------------------------- +EOF +} + function buildLocalImage() { if [ "$1" == "--force-build" ]; then DO_BUILD="1" @@ -110,7 +127,7 @@ function download() { exit 0 fi else - docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml pull + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH pull fi echo "" @@ -121,19 +138,48 @@ function download() { } function startServices() { - cd $PLANE_INSTALL_DIR - docker compose up -d --quiet-pull - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH up -d --quiet-pull + + local migrator_container_id=$(docker container ls -aq -f "name=plane-app-migrator") + if [ -n "$migrator_container_id" ]; then + local idx=0 + while docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do + local message=">>> Waiting for Data Migration to finish" + local dots=$(printf '%*s' $idx | tr ' ' '.') + echo -ne "\r$message$dots" + ((idx++)) + sleep 1 + done + fi + printf "\r\033[K" + + # if migrator exit status is not 0, show error message and exit + if [ -n "$migrator_container_id" ]; then + local migrator_exit_code=$(docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) + if [ $migrator_exit_code -ne 0 ]; then + echo "Plane Server failed to start ❌" + stopServices + exit 1 + fi + fi + + local api_container_id=$(docker container ls -q -f "name=plane-app-api") + local idx2=0 + while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q "."; + do + local message=">>> Waiting for API Service to Start" + local dots=$(printf '%*s' $idx2 | tr ' ' '.') + echo -ne "\r$message$dots" + ((idx2++)) + sleep 1 + done + printf "\r\033[K" } function stopServices() { - cd $PLANE_INSTALL_DIR - docker compose down - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH down } function restartServices() { - cd $PLANE_INSTALL_DIR - docker compose restart - cd $SCRIPT_DIR + docker compose -f $DOCKER_FILE_PATH --env-file=$DOCKER_ENV_PATH restart } function upgrade() { echo "***** STOPPING SERVICES ****" @@ -144,47 +190,137 @@ function upgrade() { download echo "***** PLEASE VALIDATE AND START SERVICES ****" +} +function viewSpecificLogs(){ + local SERVICE_NAME=$1 + if docker-compose -f $DOCKER_FILE_PATH ps | grep -q "$SERVICE_NAME"; then + echo "Service '$SERVICE_NAME' is running." + else + echo "Service '$SERVICE_NAME' is not running." + fi + + docker compose -f $DOCKER_FILE_PATH logs -f $SERVICE_NAME +} +function viewLogs(){ + + ARG_SERVICE_NAME=$2 + + if [ -z "$ARG_SERVICE_NAME" ]; + then + echo + echo "Select a Service you want to view the logs for:" + echo " 1) Web" + echo " 2) Space" + echo " 3) API" + echo " 4) Worker" + echo " 5) Beat-Worker" + echo " 6) Migrator" + echo " 7) Proxy" + echo " 8) Redis" + echo " 9) Postgres" + echo " 10) Minio" + echo " 0) Back to Main Menu" + echo + read -p "Service: " DOCKER_SERVICE_NAME + + until (( DOCKER_SERVICE_NAME >= 0 && DOCKER_SERVICE_NAME <= 10 )); do + echo "Invalid selection. Please enter a number between 1 and 11." + read -p "Service: " DOCKER_SERVICE_NAME + done + + if [ -z "$DOCKER_SERVICE_NAME" ]; + then + echo "INVALID SERVICE NAME SUPPLIED" + else + case $DOCKER_SERVICE_NAME in + 1) viewSpecificLogs "web";; + 2) viewSpecificLogs "space";; + 3) viewSpecificLogs "api";; + 4) viewSpecificLogs "worker";; + 5) viewSpecificLogs "beat-worker";; + 6) viewSpecificLogs "migrator";; + 7) viewSpecificLogs "proxy";; + 8) viewSpecificLogs "plane-redis";; + 9) viewSpecificLogs "plane-db";; + 10) viewSpecificLogs "plane-minio";; + 0) askForAction;; + *) echo "INVALID SERVICE NAME SUPPLIED";; + esac + fi + elif [ -n "$ARG_SERVICE_NAME" ]; + then + ARG_SERVICE_NAME=$(echo "$ARG_SERVICE_NAME" | tr '[:upper:]' '[:lower:]') + case $ARG_SERVICE_NAME in + web) viewSpecificLogs "web";; + space) viewSpecificLogs "space";; + api) viewSpecificLogs "api";; + worker) viewSpecificLogs "worker";; + beat-worker) viewSpecificLogs "beat-worker";; + migrator) viewSpecificLogs "migrator";; + proxy) viewSpecificLogs "proxy";; + redis) viewSpecificLogs "plane-redis";; + postgres) viewSpecificLogs "plane-db";; + minio) viewSpecificLogs "plane-minio";; + *) echo "INVALID SERVICE NAME SUPPLIED";; + esac + else + echo "INVALID SERVICE NAME SUPPLIED" + fi } function askForAction() { - echo - echo "Select a Action you want to perform:" - echo " 1) Install (${CPU_ARCH})" - echo " 2) Start" - echo " 3) Stop" - echo " 4) Restart" - echo " 5) Upgrade" - echo " 6) Exit" - echo - read -p "Action [2]: " ACTION - until [[ -z "$ACTION" || "$ACTION" =~ ^[1-6]$ ]]; do - echo "$ACTION: invalid selection." + local DEFAULT_ACTION=$1 + + if [ -z "$DEFAULT_ACTION" ]; + then + echo + echo "Select a Action you want to perform:" + echo " 1) Install (${CPU_ARCH})" + echo " 2) Start" + echo " 3) Stop" + echo " 4) Restart" + echo " 5) Upgrade" + echo " 6) View Logs" + echo " 7) Exit" + echo read -p "Action [2]: " ACTION - done - echo + until [[ -z "$ACTION" || "$ACTION" =~ ^[1-7]$ ]]; do + echo "$ACTION: invalid selection." + read -p "Action [2]: " ACTION + done + if [ -z "$ACTION" ]; + then + ACTION=2 + fi + echo + fi - if [ "$ACTION" == "1" ] + if [ "$ACTION" == "1" ] || [ "$DEFAULT_ACTION" == "install" ] then install askForAction - elif [ "$ACTION" == "2" ] || [ "$ACTION" == "" ] + elif [ "$ACTION" == "2" ] || [ "$DEFAULT_ACTION" == "start" ] then startServices askForAction - elif [ "$ACTION" == "3" ] + elif [ "$ACTION" == "3" ] || [ "$DEFAULT_ACTION" == "stop" ] then stopServices askForAction - elif [ "$ACTION" == "4" ] + elif [ "$ACTION" == "4" ] || [ "$DEFAULT_ACTION" == "restart" ] then restartServices askForAction - elif [ "$ACTION" == "5" ] + elif [ "$ACTION" == "5" ] || [ "$DEFAULT_ACTION" == "upgrade" ] then upgrade askForAction - elif [ "$ACTION" == "6" ] + elif [ "$ACTION" == "6" ] || [ "$DEFAULT_ACTION" == "logs" ] + then + viewLogs $@ + askForAction + elif [ "$ACTION" == "7" ] then exit 0 else @@ -217,4 +353,8 @@ then fi mkdir -p $PLANE_INSTALL_DIR/archive -askForAction +DOCKER_FILE_PATH=$PLANE_INSTALL_DIR/docker-compose.yaml +DOCKER_ENV_PATH=$PLANE_INSTALL_DIR/.env + +print_header +askForAction $@ diff --git a/package.json b/package.json index 762ce322a..9239a9b41 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", - "version": "0.15.1", + "version": "0.16.0", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/editor/core/package.json b/packages/editor/core/package.json index 7f7f4831a..198b21b0f 100644 --- a/packages/editor/core/package.json +++ b/packages/editor/core/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-core", - "version": "0.15.1", + "version": "0.16.0", "description": "Core Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -59,8 +59,7 @@ "@types/node": "18.15.3", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "eslint": "^7.32.0", - "eslint-config-next": "13.2.4", + "eslint-config-custom": "*", "postcss": "^8.4.29", "tailwind-config-custom": "*", "tsconfig": "*", diff --git a/packages/editor/core/src/lib/editor-commands.ts b/packages/editor/core/src/lib/editor-commands.ts index 4a56f07c2..6524d1ff5 100644 --- a/packages/editor/core/src/lib/editor-commands.ts +++ b/packages/editor/core/src/lib/editor-commands.ts @@ -97,8 +97,8 @@ export const insertTableCommand = (editor: Editor, range?: Range) => { } } } - if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); - else editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); + if (range) editor.chain().focus().deleteRange(range).insertTable({ rows: 3, cols: 3 }).run(); + else editor.chain().focus().insertTable({ rows: 3, cols: 3 }).run(); }; export const unsetLinkEditor = (editor: Editor) => { diff --git a/packages/editor/core/src/styles/editor.css b/packages/editor/core/src/styles/editor.css index b0d2a1021..dbbea671e 100644 --- a/packages/editor/core/src/styles/editor.css +++ b/packages/editor/core/src/styles/editor.css @@ -170,68 +170,6 @@ ul[data-type="taskList"] li[data-checked="true"] > div > p { } } -#editor-container { - table { - border-collapse: collapse; - table-layout: fixed; - margin: 0.5em 0 0.5em 0; - - border: 1px solid rgb(var(--color-border-200)); - width: 100%; - - td, - th { - min-width: 1em; - border: 1px solid rgb(var(--color-border-200)); - padding: 10px 15px; - vertical-align: top; - box-sizing: border-box; - position: relative; - transition: background-color 0.3s ease; - - > * { - margin-bottom: 0; - } - } - - th { - font-weight: bold; - text-align: left; - background-color: rgb(var(--color-primary-100)); - } - - td:hover { - background-color: rgba(var(--color-primary-300), 0.1); - } - - .selectedCell:after { - z-index: 2; - position: absolute; - content: ""; - left: 0; - right: 0; - top: 0; - bottom: 0; - background-color: rgba(var(--color-primary-300), 0.1); - pointer-events: none; - } - - .column-resize-handle { - position: absolute; - right: -2px; - top: 0; - bottom: -2px; - width: 2px; - background-color: rgb(var(--color-primary-400)); - pointer-events: none; - } - } -} - -.tableWrapper { - overflow-x: auto; -} - .resize-cursor { cursor: ew-resize; cursor: col-resize; diff --git a/packages/editor/core/src/styles/table.css b/packages/editor/core/src/styles/table.css index 8a47a8c59..3ba17ee1b 100644 --- a/packages/editor/core/src/styles/table.css +++ b/packages/editor/core/src/styles/table.css @@ -9,15 +9,15 @@ border-collapse: collapse; table-layout: fixed; margin: 0; - margin-bottom: 3rem; - border: 1px solid rgba(var(--color-border-200)); + margin-bottom: 1rem; + border: 2px solid rgba(var(--color-border-300)); width: 100%; } .tableWrapper table td, .tableWrapper table th { min-width: 1em; - border: 1px solid rgba(var(--color-border-200)); + border: 1px solid rgba(var(--color-border-300)); padding: 10px 15px; vertical-align: top; box-sizing: border-box; @@ -43,7 +43,8 @@ .tableWrapper table th { font-weight: bold; text-align: left; - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; + color: #171717; } .tableWrapper table th * { @@ -62,14 +63,43 @@ pointer-events: none; } +.colorPicker { + display: grid; + padding: 8px 8px; + grid-template-columns: repeat(6, 1fr); + gap: 5px; +} + +.colorPickerLabel { + font-size: 0.85rem; + color: #6b7280; + padding: 8px 8px; + padding-bottom: 0px; +} + +.colorPickerItem { + margin: 2px 0px; + width: 24px; + height: 24px; + border-radius: 4px; + border: none; + cursor: pointer; +} + +.divider { + background-color: #e5e7eb; + height: 1px; + margin: 3px 0; +} + .tableWrapper table .column-resize-handle { position: absolute; right: -2px; top: 0; bottom: -2px; width: 4px; - z-index: 99; - background-color: rgba(var(--color-primary-400)); + z-index: 5; + background-color: #d9e4ff; pointer-events: none; } @@ -81,7 +111,7 @@ .tableWrapper .tableControls .rowsControl { transition: opacity ease-in 100ms; position: absolute; - z-index: 99; + z-index: 5; display: flex; justify-content: center; align-items: center; @@ -112,7 +142,7 @@ } .tableWrapper .tableControls .rowsControlDiv { - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; border: 1px solid rgba(var(--color-border-200)); border-radius: 2px; background-size: 1.25rem; @@ -127,7 +157,7 @@ } .tableWrapper .tableControls .columnsControlDiv { - background-color: rgba(var(--color-primary-100)); + background-color: #d9e4ff; border: 1px solid rgba(var(--color-border-200)); border-radius: 2px; background-size: 1.25rem; @@ -144,10 +174,12 @@ .tableWrapper .tableControls .tableColorPickerToolbox { border: 1px solid rgba(var(--color-border-300)); background-color: rgba(var(--color-background-100)); + border-radius: 5px; + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1); padding: 0.25rem; display: flex; flex-direction: column; - width: 200px; + width: max-content; gap: 0.25rem; } @@ -158,7 +190,7 @@ align-items: center; gap: 0.5rem; border: none; - padding: 0.1rem; + padding: 0.3rem 0.5rem 0.1rem 0.1rem; border-radius: 4px; cursor: pointer; transition: all 0.2s; @@ -166,16 +198,14 @@ .tableWrapper .tableControls .tableToolbox .toolboxItem:hover, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem:hover { - background-color: rgba(var(--color-background-100), 0.5); + background-color: rgba(var(--color-background-80), 0.6); } .tableWrapper .tableControls .tableToolbox .toolboxItem .iconContainer, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer { - border: 1px solid rgba(var(--color-border-300)); - border-radius: 3px; - padding: 4px; + padding: 4px 0px; display: flex; align-items: center; justify-content: center; @@ -187,8 +217,8 @@ .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .iconContainer svg, .tableWrapper .tableControls .tableToolbox .toolboxItem .colorContainer svg, .tableWrapper .tableControls .tableColorPickerToolbox .toolboxItem .colorContainer svg { - width: 2rem; - height: 2rem; + width: 1rem; + height: 1rem; } .tableToolbox { diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts index 0854092a9..ec6c540da 100644 --- a/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/clickHandler.ts @@ -15,9 +15,15 @@ export function clickHandler(options: ClickHandlerOptions): Plugin { return false; } - const eventTarget = event.target as HTMLElement; + let a = event.target as HTMLElement; + const els = []; - if (eventTarget.nodeName !== "A") { + while (a.nodeName !== "DIV") { + els.push(a); + a = a.parentNode as HTMLElement; + } + + if (!els.find((value) => value.nodeName === "A")) { return false; } @@ -28,9 +34,7 @@ export function clickHandler(options: ClickHandlerOptions): Plugin { const target = link?.target ?? attrs.target; if (link && href) { - if (view.editable) { - window.open(href, target); - } + window.open(href, target); return true; } diff --git a/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts index 83e38054c..475bf28d9 100644 --- a/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts +++ b/packages/editor/core/src/ui/extensions/custom-link/helpers/pasteHandler.ts @@ -33,16 +33,8 @@ export function pasteHandler(options: PasteHandlerOptions): Plugin { return false; } - const html = event.clipboardData?.getData("text/html"); - - const hrefRegex = /href="([^"]*)"/; - - const existingLink = html?.match(hrefRegex); - - const url = existingLink ? existingLink[1] : link.href; - options.editor.commands.setMark(options.type, { - href: url, + href: link.href, }); return true; diff --git a/packages/editor/core/src/ui/extensions/custom-link/index.tsx b/packages/editor/core/src/ui/extensions/custom-link/index.ts similarity index 69% rename from packages/editor/core/src/ui/extensions/custom-link/index.tsx rename to packages/editor/core/src/ui/extensions/custom-link/index.ts index e66d18904..88e7abfe5 100644 --- a/packages/editor/core/src/ui/extensions/custom-link/index.tsx +++ b/packages/editor/core/src/ui/extensions/custom-link/index.ts @@ -1,41 +1,76 @@ -import { Mark, markPasteRule, mergeAttributes } from "@tiptap/core"; +import { Mark, markPasteRule, mergeAttributes, PasteRuleMatch } from "@tiptap/core"; import { Plugin } from "@tiptap/pm/state"; import { find, registerCustomProtocol, reset } from "linkifyjs"; - -import { autolink } from "src/ui/extensions/custom-link/helpers/autolink"; -import { clickHandler } from "src/ui/extensions/custom-link/helpers/clickHandler"; -import { pasteHandler } from "src/ui/extensions/custom-link/helpers/pasteHandler"; +import { autolink } from "./helpers/autolink"; +import { clickHandler } from "./helpers/clickHandler"; +import { pasteHandler } from "./helpers/pasteHandler"; export interface LinkProtocolOptions { scheme: string; optionalSlashes?: boolean; } +export const pasteRegex = + /https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z]{2,}\b(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)(?:[-a-zA-Z0-9@:%._+~#=?!&/]*)/gi; + export interface LinkOptions { + /** + * If enabled, it adds links as you type. + */ autolink: boolean; - inclusive: boolean; + /** + * An array of custom protocols to be registered with linkifyjs. + */ protocols: Array; + /** + * If enabled, links will be opened on click. + */ openOnClick: boolean; + /** + * If enabled, links will be inclusive i.e. if you move your cursor to the + * link text, and start typing, it'll be a part of the link itself. + */ + inclusive: boolean; + /** + * Adds a link to the current selection if the pasted content only contains an url. + */ linkOnPaste: boolean; + /** + * A list of HTML attributes to be rendered. + */ HTMLAttributes: Record; + /** + * A validation function that modifies link verification for the auto linker. + * @param url - The url to be validated. + * @returns - True if the url is valid, false otherwise. + */ validate?: (url: string) => boolean; } declare module "@tiptap/core" { interface Commands { link: { + /** + * Set a link mark + */ setLink: (attributes: { href: string; target?: string | null; rel?: string | null; class?: string | null; }) => ReturnType; + /** + * Toggle a link mark + */ toggleLink: (attributes: { href: string; target?: string | null; rel?: string | null; class?: string | null; }) => ReturnType; + /** + * Unset a link mark + */ unsetLink: () => ReturnType; }; } @@ -150,37 +185,31 @@ export const CustomLinkExtension = Mark.create({ addPasteRules() { return [ markPasteRule({ - find: (text) => - find(text) - .filter((link) => { - if (this.options.validate) { - return this.options.validate(link.value); - } - return true; - }) - .filter((link) => link.isLink) - .map((link) => ({ - text: link.value, - index: link.start, - data: link, - })), - type: this.type, - getAttributes: (match, pasteEvent) => { - const html = pasteEvent?.clipboardData?.getData("text/html"); - const hrefRegex = /href="([^"]*)"/; + find: (text) => { + const foundLinks: PasteRuleMatch[] = []; - const existingLink = html?.match(hrefRegex); + if (text) { + const links = find(text).filter((item) => item.isLink); - if (existingLink) { - return { - href: existingLink[1], - }; + if (links.length) { + links.forEach((link) => + foundLinks.push({ + text: link.value, + data: { + href: link.href, + }, + index: link.start, + }) + ); + } } - return { - href: match.data?.href, - }; + return foundLinks; }, + type: this.type, + getAttributes: (match) => ({ + href: match.data?.href, + }), }), ]; }, diff --git a/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts b/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts new file mode 100644 index 000000000..2af845b7a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/horizontal-rule/horizontal-rule.ts @@ -0,0 +1,111 @@ +import { isNodeSelection, mergeAttributes, Node, nodeInputRule } from "@tiptap/core"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; + +export interface HorizontalRuleOptions { + HTMLAttributes: Record; +} + +declare module "@tiptap/core" { + interface Commands { + horizontalRule: { + /** + * Add a horizontal rule + */ + setHorizontalRule: () => ReturnType; + }; + } +} + +export const CustomHorizontalRule = Node.create({ + name: "horizontalRule", + + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + group: "block", + + parseHTML() { + return [{ tag: "hr" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["hr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; + }, + + addCommands() { + return { + setHorizontalRule: + () => + ({ chain, state }) => { + const { selection } = state; + const { $from: $originFrom, $to: $originTo } = selection; + + const currentChain = chain(); + + if ($originFrom.parentOffset === 0) { + currentChain.insertContentAt( + { + from: Math.max($originFrom.pos - 1, 0), + to: $originTo.pos, + }, + { + type: this.name, + } + ); + } else if (isNodeSelection(selection)) { + currentChain.insertContentAt($originTo.pos, { + type: this.name, + }); + } else { + currentChain.insertContent({ type: this.name }); + } + + return ( + currentChain + // set cursor after horizontal rule + .command(({ tr, dispatch }) => { + if (dispatch) { + const { $to } = tr.selection; + const posAfter = $to.end(); + + if ($to.nodeAfter) { + if ($to.nodeAfter.isTextblock) { + tr.setSelection(TextSelection.create(tr.doc, $to.pos + 1)); + } else if ($to.nodeAfter.isBlock) { + tr.setSelection(NodeSelection.create(tr.doc, $to.pos)); + } else { + tr.setSelection(TextSelection.create(tr.doc, $to.pos)); + } + } else { + // add node after horizontal rule if it’s the end of the document + const node = $to.parent.type.contentMatch.defaultType?.create(); + + if (node) { + tr.insert(posAfter, node); + tr.setSelection(TextSelection.create(tr.doc, posAfter + 1)); + } + } + + tr.scrollIntoView(); + } + + return true; + }) + .run() + ); + }, + }; + }, + + addInputRules() { + return [ + nodeInputRule({ + find: /^(?:---|—-|___\s|\*\*\*\s)$/, + type: this.type, + }), + ]; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 5bfba3b0f..7da381e98 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -25,7 +25,9 @@ import { DeleteImage } from "src/types/delete-image"; import { IMentionSuggestion } from "src/types/mention-suggestion"; import { RestoreImage } from "src/types/restore-image"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; -import { CustomCodeInlineExtension } from "./code-inline"; +import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; +import { CustomTypographyExtension } from "src/ui/extensions/typography"; +import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule"; export const CoreEditorExtensions = ( mentionConfig: { @@ -54,9 +56,7 @@ export const CoreEditorExtensions = ( }, code: false, codeBlock: false, - horizontalRule: { - HTMLAttributes: { class: "mt-4 mb-4" }, - }, + horizontalRule: false, blockquote: false, dropcursor: { color: "rgba(var(--color-text-100))", @@ -66,6 +66,10 @@ export const CoreEditorExtensions = ( CustomQuoteExtension.configure({ HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, }), + + CustomHorizontalRule.configure({ + HTMLAttributes: { class: "mt-4 mb-4" }, + }), CustomKeymap, ListKeymap, CustomLinkExtension.configure({ @@ -79,6 +83,7 @@ export const CoreEditorExtensions = ( "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), + CustomTypographyExtension, ImageExtension(deleteFile, restoreFile, cancelUploadImage).configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", diff --git a/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts b/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts index aedb59411..403bd3f02 100644 --- a/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts +++ b/packages/editor/core/src/ui/extensions/table/table-cell/table-cell.ts @@ -13,7 +13,7 @@ export const TableCell = Node.create({ }; }, - content: "paragraph+", + content: "block+", addAttributes() { return { @@ -33,7 +33,10 @@ export const TableCell = Node.create({ }, }, background: { - default: "none", + default: null, + }, + textColor: { + default: null, }, }; }, @@ -50,7 +53,7 @@ export const TableCell = Node.create({ return [ "td", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { - style: `background-color: ${node.attrs.background}`, + style: `background-color: ${node.attrs.background}; color: ${node.attrs.textColor}`, }), 0, ]; diff --git a/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts b/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts index c0decdbf8..bd994f467 100644 --- a/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts +++ b/packages/editor/core/src/ui/extensions/table/table-header/table-header.ts @@ -33,7 +33,7 @@ export const TableHeader = Node.create({ }, }, background: { - default: "rgb(var(--color-primary-100))", + default: "none", }, }; }, diff --git a/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts b/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts index 28c9a9a48..f961c0582 100644 --- a/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts +++ b/packages/editor/core/src/ui/extensions/table/table-row/table-row.ts @@ -13,6 +13,17 @@ export const TableRow = Node.create({ }; }, + addAttributes() { + return { + background: { + default: null, + }, + textColor: { + default: null, + }, + }; + }, + content: "(tableCell | tableHeader)*", tableRole: "row", @@ -22,6 +33,12 @@ export const TableRow = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ["tr", mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0]; + const style = HTMLAttributes.background + ? `background-color: ${HTMLAttributes.background}; color: ${HTMLAttributes.textColor}` + : ""; + + const attributes = mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { style }); + + return ["tr", attributes, 0]; }, }); diff --git a/packages/editor/core/src/ui/extensions/table/table/icons.ts b/packages/editor/core/src/ui/extensions/table/table/icons.ts index c08710ec3..f73c55c09 100644 --- a/packages/editor/core/src/ui/extensions/table/table/icons.ts +++ b/packages/editor/core/src/ui/extensions/table/table/icons.ts @@ -1,7 +1,7 @@ export const icons = { colorPicker: ``, - deleteColumn: ``, - deleteRow: ``, + deleteColumn: ``, + deleteRow: ``, insertLeftTableIcon: ` `, + toggleColumnHeader: ``, + toggleRowHeader: ``, insertBottomTableIcon: ` = { placement: "right", }; -function setCellsBackgroundColor(editor: Editor, backgroundColor: string) { +function setCellsBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { return editor .chain() .focus() .updateAttributes("tableCell", { - background: backgroundColor, - }) - .updateAttributes("tableHeader", { - background: backgroundColor, + background: color.backgroundColor, + textColor: color.textColor, }) .run(); } +function setTableRowBackgroundColor(editor: Editor, color: { backgroundColor: string; textColor: string }) { + const { state, dispatch } = editor.view; + const { selection } = state; + if (!(selection instanceof CellSelection)) { + return false; + } + + // Get the position of the hovered cell in the selection to determine the row. + const hoveredCell = selection.$headCell || selection.$anchorCell; + + // Find the depth of the table row node + let rowDepth = hoveredCell.depth; + while (rowDepth > 0 && hoveredCell.node(rowDepth).type.name !== "tableRow") { + rowDepth--; + } + + // If we couldn't find a tableRow node, we can't set the background color + if (hoveredCell.node(rowDepth).type.name !== "tableRow") { + return false; + } + + // Get the position where the table row starts + const rowStartPos = hoveredCell.start(rowDepth); + + // Create a transaction that sets the background color on the tableRow node. + const tr = state.tr.setNodeMarkup(rowStartPos - 1, null, { + ...hoveredCell.node(rowDepth).attrs, + background: color.backgroundColor, + textColor: color.textColor, + }); + + dispatch(tr); + return true; +} + const columnsToolboxItems: ToolboxItem[] = [ { - label: "Add Column Before", + label: "Toggle column header", + icon: icons.toggleColumnHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderColumn().run(), + }, + { + label: "Add column before", icon: icons.insertLeftTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnBefore().run(), }, { - label: "Add Column After", + label: "Add column after", icon: icons.insertRightTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addColumnAfter().run(), }, { - label: "Pick Column Color", - icon: icons.colorPicker, - action: ({ - editor, - triggerButton, - controlsContainer, - }: { - editor: Editor; - triggerButton: HTMLElement; - controlsContainer: Element; - }) => { - createColorPickerToolbox({ - triggerButton, - tippyOptions: { - appendTo: controlsContainer, - }, - onSelectColor: (color) => setCellsBackgroundColor(editor, color), - }); - }, + label: "Pick color", + icon: "", // No icon needed for color picker + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { - label: "Delete Column", + label: "Delete column", icon: icons.deleteColumn, action: ({ editor }: { editor: Editor }) => editor.chain().focus().deleteColumn().run(), }, @@ -135,35 +157,24 @@ const columnsToolboxItems: ToolboxItem[] = [ const rowsToolboxItems: ToolboxItem[] = [ { - label: "Add Row Above", + label: "Toggle row header", + icon: icons.toggleRowHeader, + action: ({ editor }: { editor: Editor }) => editor.chain().focus().toggleHeaderRow().run(), + }, + { + label: "Add row above", icon: icons.insertTopTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowBefore().run(), }, { - label: "Add Row Below", + label: "Add row below", icon: icons.insertBottomTableIcon, action: ({ editor }: { editor: Editor }) => editor.chain().focus().addRowAfter().run(), }, { - label: "Pick Row Color", - icon: icons.colorPicker, - action: ({ - editor, - triggerButton, - controlsContainer, - }: { - editor: Editor; - triggerButton: HTMLButtonElement; - controlsContainer: Element | "parent" | ((ref: Element) => Element) | undefined; - }) => { - createColorPickerToolbox({ - triggerButton, - tippyOptions: { - appendTo: controlsContainer, - }, - onSelectColor: (color) => setCellsBackgroundColor(editor, color), - }); - }, + label: "Pick color", + icon: "", + action: (args: any) => {}, // Placeholder action; actual color picking is handled in `createToolbox` }, { label: "Delete Row", @@ -176,37 +187,58 @@ function createToolbox({ triggerButton, items, tippyOptions, + onSelectColor, onClickItem, + colors, }: { triggerButton: Element | null; items: ToolboxItem[]; tippyOptions: any; onClickItem: (item: ToolboxItem) => void; + onSelectColor: (color: { backgroundColor: string; textColor: string }) => void; + colors: { [key: string]: { backgroundColor: string; textColor: string; icon?: string } }; }): Instance { // @ts-expect-error const toolbox = tippy(triggerButton, { content: h( "div", { className: "tableToolbox" }, - items.map((item) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick() { - onClickItem(item); + items.map((item, index) => { + if (item.label === "Pick color") { + return h("div", { className: "flex flex-col" }, [ + h("div", { className: "divider" }), + h("div", { className: "colorPickerLabel" }, item.label), + h( + "div", + { className: "colorPicker grid" }, + Object.entries(colors).map(([colorName, colorValue]) => + h("div", { + className: "colorPickerItem flex items-center justify-center", + style: `background-color: ${colorValue.backgroundColor}; + color: ${colorValue.textColor || "inherit"};`, + innerHTML: + colorValue.icon ?? `A`, + onClick: () => onSelectColor(colorValue), + }) + ) + ), + h("div", { className: "divider" }), + ]); + } else { + return h( + "div", + { + className: "toolboxItem", + itemType: "div", + onClick: () => onClickItem(item), }, - }, - [ - h("div", { - className: "iconContainer", - innerHTML: item.icon, - }), - h("div", { className: "label" }, item.label), - ] - ) - ) + [ + h("div", { className: "iconContainer", innerHTML: item.icon }), + h("div", { className: "label" }, item.label), + ] + ); + } + }) ), ...tippyOptions, }); @@ -214,71 +246,6 @@ function createToolbox({ return Array.isArray(toolbox) ? toolbox[0] : toolbox; } -function createColorPickerToolbox({ - triggerButton, - tippyOptions, - onSelectColor = () => {}, -}: { - triggerButton: HTMLElement; - tippyOptions: Partial; - onSelectColor?: (color: string) => void; -}) { - const items = { - Default: "rgb(var(--color-primary-100))", - Orange: "#FFE5D1", - Grey: "#F1F1F1", - Yellow: "#FEF3C7", - Green: "#DCFCE7", - Red: "#FFDDDD", - Blue: "#D9E4FF", - Pink: "#FFE8FA", - Purple: "#E8DAFB", - }; - - const colorPicker = tippy(triggerButton, { - ...defaultTippyOptions, - content: h( - "div", - { className: "tableColorPickerToolbox" }, - Object.entries(items).map(([key, value]) => - h( - "div", - { - className: "toolboxItem", - itemType: "button", - onClick: () => { - onSelectColor(value); - colorPicker.hide(); - }, - }, - [ - h("div", { - className: "colorContainer", - style: { - backgroundColor: value, - }, - }), - h( - "div", - { - className: "label", - }, - key - ), - ] - ) - ) - ), - onHidden: (instance) => { - instance.destroy(); - }, - showOnCreate: true, - ...tippyOptions, - }); - - return colorPicker; -} - export class TableView implements NodeView { node: ProseMirrorNode; cellMinWidth: number; @@ -347,10 +314,27 @@ export class TableView implements NodeView { this.rowsControl, this.columnsControl ); + const columnColors = { + Blue: { backgroundColor: "#D9E4FF", textColor: "#171717" }, + Orange: { backgroundColor: "#FFEDD5", textColor: "#171717" }, + Grey: { backgroundColor: "#F1F1F1", textColor: "#171717" }, + Yellow: { backgroundColor: "#FEF3C7", textColor: "#171717" }, + Green: { backgroundColor: "#DCFCE7", textColor: "#171717" }, + Red: { backgroundColor: "#FFDDDD", textColor: "#171717" }, + Pink: { backgroundColor: "#FFE8FA", textColor: "#171717" }, + Purple: { backgroundColor: "#E8DAFB", textColor: "#171717" }, + None: { + backgroundColor: "none", + textColor: "none", + icon: ``, + }, + }; this.columnsToolbox = createToolbox({ triggerButton: this.columnsControl.querySelector(".columnsControlDiv"), items: columnsToolboxItems, + colors: columnColors, + onSelectColor: (color) => setCellsBackgroundColor(this.editor, color), tippyOptions: { ...defaultTippyOptions, appendTo: this.controls, @@ -368,10 +352,12 @@ export class TableView implements NodeView { this.rowsToolbox = createToolbox({ triggerButton: this.rowsControl.firstElementChild, items: rowsToolboxItems, + colors: columnColors, tippyOptions: { ...defaultTippyOptions, appendTo: this.controls, }, + onSelectColor: (color) => setTableRowBackgroundColor(editor, color), onClickItem: (item) => { item.action({ editor: this.editor, @@ -383,8 +369,6 @@ export class TableView implements NodeView { }); } - // Table - this.colgroup = h( "colgroup", null, @@ -437,16 +421,19 @@ export class TableView implements NodeView { } updateControls() { - const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce((acc, curr) => { - if (curr.spec.hoveredCell !== undefined) { - acc["hoveredCell"] = curr.spec.hoveredCell; - } + const { hoveredTable: table, hoveredCell: cell } = Object.values(this.decorations).reduce( + (acc, curr) => { + if (curr.spec.hoveredCell !== undefined) { + acc["hoveredCell"] = curr.spec.hoveredCell; + } - if (curr.spec.hoveredTable !== undefined) { - acc["hoveredTable"] = curr.spec.hoveredTable; - } - return acc; - }, {} as Record) as any; + if (curr.spec.hoveredTable !== undefined) { + acc["hoveredTable"] = curr.spec.hoveredTable; + } + return acc; + }, + {} as Record + ) as any; if (table === undefined || cell === undefined) { return this.root.classList.add("controls--disabled"); @@ -457,12 +444,12 @@ export class TableView implements NodeView { const cellDom = this.editor.view.nodeDOM(cell.pos) as HTMLElement; - if (!this.table) { + if (!this.table || !cellDom) { return; } - const tableRect = this.table.getBoundingClientRect(); - const cellRect = cellDom.getBoundingClientRect(); + const tableRect = this.table?.getBoundingClientRect(); + const cellRect = cellDom?.getBoundingClientRect(); if (this.columnsControl) { this.columnsControl.style.left = `${cellRect.left - tableRect.left - this.table.parentElement!.scrollLeft}px`; diff --git a/packages/editor/core/src/ui/extensions/table/table/table.ts b/packages/editor/core/src/ui/extensions/table/table/table.ts index 5600fd82a..ef595eee2 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -107,10 +107,9 @@ export const Table = Node.create({ addCommands() { return { insertTable: - ({ rows = 3, cols = 3, withHeaderRow = true } = {}) => + ({ rows = 3, cols = 3, withHeaderRow = false } = {}) => ({ tr, dispatch, editor }) => { const node = createTable(editor.schema, rows, cols, withHeaderRow); - if (dispatch) { const offset = tr.selection.anchor + 1; diff --git a/packages/editor/core/src/ui/extensions/typography/index.ts b/packages/editor/core/src/ui/extensions/typography/index.ts new file mode 100644 index 000000000..78af3c46e --- /dev/null +++ b/packages/editor/core/src/ui/extensions/typography/index.ts @@ -0,0 +1,109 @@ +import { Extension } from "@tiptap/core"; +import { + TypographyOptions, + emDash, + ellipsis, + leftArrow, + rightArrow, + copyright, + trademark, + servicemark, + registeredTrademark, + oneHalf, + plusMinus, + notEqual, + laquo, + raquo, + multiplication, + superscriptTwo, + superscriptThree, + oneQuarter, + threeQuarters, + impliesArrowRight, +} from "src/ui/extensions/typography/inputRules"; + +export const CustomTypographyExtension = Extension.create({ + name: "typography", + + addInputRules() { + const rules = []; + + if (this.options.emDash !== false) { + rules.push(emDash(this.options.emDash)); + } + + if (this.options.impliesArrowRight !== false) { + rules.push(impliesArrowRight(this.options.impliesArrowRight)); + } + + if (this.options.ellipsis !== false) { + rules.push(ellipsis(this.options.ellipsis)); + } + + if (this.options.leftArrow !== false) { + rules.push(leftArrow(this.options.leftArrow)); + } + + if (this.options.rightArrow !== false) { + rules.push(rightArrow(this.options.rightArrow)); + } + + if (this.options.copyright !== false) { + rules.push(copyright(this.options.copyright)); + } + + if (this.options.trademark !== false) { + rules.push(trademark(this.options.trademark)); + } + + if (this.options.servicemark !== false) { + rules.push(servicemark(this.options.servicemark)); + } + + if (this.options.registeredTrademark !== false) { + rules.push(registeredTrademark(this.options.registeredTrademark)); + } + + if (this.options.oneHalf !== false) { + rules.push(oneHalf(this.options.oneHalf)); + } + + if (this.options.plusMinus !== false) { + rules.push(plusMinus(this.options.plusMinus)); + } + + if (this.options.notEqual !== false) { + rules.push(notEqual(this.options.notEqual)); + } + + if (this.options.laquo !== false) { + rules.push(laquo(this.options.laquo)); + } + + if (this.options.raquo !== false) { + rules.push(raquo(this.options.raquo)); + } + + if (this.options.multiplication !== false) { + rules.push(multiplication(this.options.multiplication)); + } + + if (this.options.superscriptTwo !== false) { + rules.push(superscriptTwo(this.options.superscriptTwo)); + } + + if (this.options.superscriptThree !== false) { + rules.push(superscriptThree(this.options.superscriptThree)); + } + + if (this.options.oneQuarter !== false) { + rules.push(oneQuarter(this.options.oneQuarter)); + } + + if (this.options.threeQuarters !== false) { + rules.push(threeQuarters(this.options.threeQuarters)); + } + + return rules; + }, +}); diff --git a/packages/editor/core/src/ui/extensions/typography/inputRules.ts b/packages/editor/core/src/ui/extensions/typography/inputRules.ts new file mode 100644 index 000000000..f528e9242 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/typography/inputRules.ts @@ -0,0 +1,137 @@ +import { textInputRule } from "@tiptap/core"; + +export interface TypographyOptions { + emDash: false | string; + ellipsis: false | string; + leftArrow: false | string; + rightArrow: false | string; + copyright: false | string; + trademark: false | string; + servicemark: false | string; + registeredTrademark: false | string; + oneHalf: false | string; + plusMinus: false | string; + notEqual: false | string; + laquo: false | string; + raquo: false | string; + multiplication: false | string; + superscriptTwo: false | string; + superscriptThree: false | string; + oneQuarter: false | string; + threeQuarters: false | string; + impliesArrowRight: false | string; +} + +export const emDash = (override?: string) => + textInputRule({ + find: /--$/, + replace: override ?? "—", + }); + +export const impliesArrowRight = (override?: string) => + textInputRule({ + find: /=>$/, + replace: override ?? "⇒", + }); + +export const leftArrow = (override?: string) => + textInputRule({ + find: /<-$/, + replace: override ?? "←", + }); + +export const rightArrow = (override?: string) => + textInputRule({ + find: /->$/, + replace: override ?? "→", + }); + +export const ellipsis = (override?: string) => + textInputRule({ + find: /\.\.\.$/, + replace: override ?? "…", + }); + +export const copyright = (override?: string) => + textInputRule({ + find: /\(c\)$/, + replace: override ?? "©", + }); + +export const trademark = (override?: string) => + textInputRule({ + find: /\(tm\)$/, + replace: override ?? "™", + }); + +export const servicemark = (override?: string) => + textInputRule({ + find: /\(sm\)$/, + replace: override ?? "℠", + }); + +export const registeredTrademark = (override?: string) => + textInputRule({ + find: /\(r\)$/, + replace: override ?? "®", + }); + +export const oneHalf = (override?: string) => + textInputRule({ + find: /(?:^|\s)(1\/2)\s$/, + replace: override ?? "½", + }); + +export const plusMinus = (override?: string) => + textInputRule({ + find: /\+\/-$/, + replace: override ?? "±", + }); + +export const notEqual = (override?: string) => + textInputRule({ + find: /!=$/, + replace: override ?? "≠", + }); + +export const laquo = (override?: string) => + textInputRule({ + find: /<<$/, + replace: override ?? "«", + }); + +export const raquo = (override?: string) => + textInputRule({ + find: />>$/, + replace: override ?? "»", + }); + +export const multiplication = (override?: string) => + textInputRule({ + find: /\d+\s?([*x])\s?\d+$/, + replace: override ?? "×", + }); + +export const superscriptTwo = (override?: string) => + textInputRule({ + find: /\^2$/, + replace: override ?? "²", + }); + +export const superscriptThree = (override?: string) => + textInputRule({ + find: /\^3$/, + replace: override ?? "³", + }); + +export const oneQuarter = (override?: string) => + textInputRule({ + find: /(?:^|\s)(1\/4)\s$/, + replace: override ?? "¼", + }); + +export const threeQuarters = (override?: string) => + textInputRule({ + find: /(?:^|\s)(3\/4)\s$/, + replace: override ?? "¾", + }); diff --git a/packages/editor/core/src/ui/props.tsx b/packages/editor/core/src/ui/props.tsx index 2aaeb4264..1846efe47 100644 --- a/packages/editor/core/src/ui/props.tsx +++ b/packages/editor/core/src/ui/props.tsx @@ -42,15 +42,6 @@ export function CoreEditorProps( return false; }, handleDrop: (view, event, _slice, moved) => { - if (typeof window !== "undefined") { - const selection: any = window?.getSelection(); - if (selection.rangeCount !== 0) { - const range = selection.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return; - } - } - } if (!moved && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0]) { event.preventDefault(); const file = event.dataTransfer.files[0]; diff --git a/packages/editor/document-editor/package.json b/packages/editor/document-editor/package.json index b33bc12fb..870d5edd9 100644 --- a/packages/editor/document-editor/package.json +++ b/packages/editor/document-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/document-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Pages Editor", "main": "./dist/index.mjs", "module": "./dist/index.mjs", @@ -37,7 +37,6 @@ "@tiptap/extension-placeholder": "^2.1.13", "@tiptap/pm": "^2.1.13", "@tiptap/suggestion": "^2.1.13", - "eslint-config-next": "13.2.4", "lucide-react": "^0.309.0", "react-popper": "^2.3.0", "tippy.js": "^6.3.7", @@ -47,7 +46,7 @@ "@types/node": "18.15.3", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "eslint": "8.36.0", + "eslint-config-custom": "*", "postcss": "^8.4.29", "tailwind-config-custom": "*", "tsconfig": "*", diff --git a/packages/editor/document-editor/src/ui/components/content-browser.tsx b/packages/editor/document-editor/src/ui/components/content-browser.tsx index 97231ea96..be70067a2 100644 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -15,7 +15,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => { const handleOnClick = (marking: IMarking) => { scrollSummary(editor, marking); if (setSidePeekVisible) setSidePeekVisible(false); - } + }; return (
diff --git a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx index 136d04e01..971915439 100644 --- a/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx +++ b/packages/editor/document-editor/src/ui/components/links/link-edit-view.tsx @@ -40,9 +40,11 @@ export const LinkEditView = ({ const [positionRef, setPositionRef] = useState({ from: from, to: to }); const [localUrl, setLocalUrl] = useState(viewProps.url); - const linkRemoved = useRef(); + const linkRemoved = useRef(); const getText = (from: number, to: number) => { + if (to >= editor.state.doc.content.size) return ""; + const text = editor.state.doc.textBetween(from, to, "\n"); return text; }; @@ -72,10 +74,12 @@ export const LinkEditView = ({ const url = isValidUrl(localUrl) ? localUrl : viewProps.url; + if (to >= editor.state.doc.content.size) return; + editor.view.dispatch(editor.state.tr.removeMark(from, to, editor.schema.marks.link)); editor.view.dispatch(editor.state.tr.addMark(from, to, editor.schema.marks.link.create({ href: url }))); }, - [localUrl] + [localUrl, editor, from, to, viewProps.url] ); const handleUpdateText = (text: string) => { diff --git a/packages/editor/document-editor/src/ui/components/summary-popover.tsx b/packages/editor/document-editor/src/ui/components/summary-popover.tsx index 6ad7cad83..41056c6ad 100644 --- a/packages/editor/document-editor/src/ui/components/summary-popover.tsx +++ b/packages/editor/document-editor/src/ui/components/summary-popover.tsx @@ -33,8 +33,9 @@ export const SummaryPopover: React.FC = (props) => { ))} diff --git a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx index be57a4a91..397e8c576 100644 --- a/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx +++ b/packages/editor/document-editor/src/ui/menu/fixed-menu.tsx @@ -48,34 +48,12 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(editor)]; - if (shouldShowImageItem()) { - items.push(ImageItem(editor, uploadFile, setIsSubmitting)); - } - + items.push(ImageItem(editor, uploadFile, setIsSubmitting)); return items; } const complexItems: BubbleMenuItem[] = getComplexItems(); - function shouldShowImageItem(): boolean { - if (typeof window !== "undefined") { - const selectionRange: any = window?.getSelection(); - const { selection } = props.editor.state; - - if (selectionRange.rangeCount !== 0) { - const range = selectionRange.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return false; - } - if (isCellSelection(selection)) { - return false; - } - } - return true; - } - return false; - } - return (
diff --git a/packages/editor/extensions/package.json b/packages/editor/extensions/package.json index 8481abdf3..f95aa4d7e 100644 --- a/packages/editor/extensions/package.json +++ b/packages/editor/extensions/package.json @@ -1,6 +1,6 @@ { "name": "@plane/editor-extensions", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Editor with extensions", "private": true, "main": "./dist/index.mjs", @@ -33,7 +33,6 @@ "@tiptap/pm": "^2.1.13", "@tiptap/react": "^2.1.13", "@tiptap/suggestion": "^2.1.13", - "eslint-config-next": "13.2.4", "lucide-react": "^0.294.0", "tippy.js": "^6.3.7" }, @@ -41,7 +40,7 @@ "@types/node": "18.15.3", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "eslint": "8.36.0", + "eslint-config-custom": "*", "postcss": "^8.4.29", "tailwind-config-custom": "*", "tsconfig": "*", diff --git a/packages/editor/extensions/src/extensions/drag-drop.tsx b/packages/editor/extensions/src/extensions/drag-drop.tsx index af99fec61..ce4088413 100644 --- a/packages/editor/extensions/src/extensions/drag-drop.tsx +++ b/packages/editor/extensions/src/extensions/drag-drop.tsx @@ -35,7 +35,7 @@ export interface DragHandleOptions { } function absoluteRect(node: Element) { - const data = node.getBoundingClientRect(); + const data = node?.getBoundingClientRect(); return { top: data.top, @@ -65,7 +65,7 @@ function nodeDOMAtCoords(coords: { x: number; y: number }) { } function nodePosAtDOM(node: Element, view: EditorView) { - const boundingRect = node.getBoundingClientRect(); + const boundingRect = node?.getBoundingClientRect(); if (node.nodeName === "IMG") { return view.posAtCoords({ diff --git a/packages/editor/lite-text-editor/package.json b/packages/editor/lite-text-editor/package.json index 71d70399d..c84cb7a9b 100644 --- a/packages/editor/lite-text-editor/package.json +++ b/packages/editor/lite-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/lite-text-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Package that powers Plane's Comment Editor", "private": true, "main": "./dist/index.mjs", @@ -36,10 +36,9 @@ "@types/node": "18.15.3", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "eslint": "^7.32.0", + "eslint-config-custom": "*", "postcss": "^8.4.29", "tailwind-config-custom": "*", - "eslint-config-custom": "*", "tsconfig": "*", "tsup": "^7.2.0", "typescript": "4.9.5" diff --git a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx index 71ad4e0e1..c6786698d 100644 --- a/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/menus/fixed-menu/index.tsx @@ -60,34 +60,13 @@ export const FixedMenu = (props: EditorBubbleMenuProps) => { function getComplexItems(): BubbleMenuItem[] { const items: BubbleMenuItem[] = [TableItem(props.editor)]; - if (shouldShowImageItem()) { - items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); - } + items.push(ImageItem(props.editor, props.uploadFile, props.setIsSubmitting)); return items; } const complexItems: BubbleMenuItem[] = getComplexItems(); - function shouldShowImageItem(): boolean { - if (typeof window !== "undefined") { - const selectionRange: any = window?.getSelection(); - const { selection } = props.editor.state; - - if (selectionRange.rangeCount !== 0) { - const range = selectionRange.getRangeAt(0); - if (findTableAncestor(range.startContainer)) { - return false; - } - if (isCellSelection(selection)) { - return false; - } - } - return true; - } - return false; - } - const handleAccessChange = (accessKey: string) => { props.commentAccessSpecifier?.onAccessChange(accessKey); }; diff --git a/packages/editor/rich-text-editor/package.json b/packages/editor/rich-text-editor/package.json index a85a8b998..794650678 100644 --- a/packages/editor/rich-text-editor/package.json +++ b/packages/editor/rich-text-editor/package.json @@ -1,6 +1,6 @@ { "name": "@plane/rich-text-editor", - "version": "0.15.1", + "version": "0.16.0", "description": "Rich Text Editor that powers Plane", "private": true, "main": "./dist/index.mjs", @@ -39,7 +39,7 @@ "@types/node": "18.15.3", "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", - "eslint": "^7.32.0", + "eslint-config-custom": "*", "postcss": "^8.4.29", "react": "^18.2.0", "tailwind-config-custom": "*", diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 43c3f8f34..4bcb340fd 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -15,6 +15,7 @@ import { EditorBubbleMenu } from "src/ui/menus/bubble-menu"; export type IRichTextEditor = { value: string; + initialValue?: string; dragDropEnabled?: boolean; uploadFile: UploadImage; restoreFile: RestoreImage; @@ -54,6 +55,7 @@ const RichTextEditor = ({ setShouldShowAlert, editorContentCustomClassNames, value, + initialValue, uploadFile, deleteFile, noBorder, @@ -97,6 +99,10 @@ const RichTextEditor = ({ customClassName, }); + React.useEffect(() => { + if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); + }, [editor, initialValue]); + if (!editor) return null; return ( diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 82be65376..9eae2e3f9 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -1,22 +1,43 @@ module.exports = { - extends: ["next", "turbo", "prettier"], + extends: [ + "next", + "turbo", + "prettier", + "plugin:@typescript-eslint/recommended", + ], parser: "@typescript-eslint/parser", - plugins: ["react", "@typescript-eslint"], + parserOptions: { + ecmaVersion: 2021, // Or the ECMAScript version you are using + sourceType: "module", // Or 'script' if you're using CommonJS or other modules + }, + plugins: ["react", "@typescript-eslint", "import"], settings: { next: { rootDir: ["web/", "space/", "packages/*/"], }, }, rules: { - "@next/next/no-html-link-for-pages": "off", - "react/jsx-key": "off", "prefer-const": "error", "no-irregular-whitespace": "error", "no-trailing-spaces": "error", "no-duplicate-imports": "error", "arrow-body-style": ["error", "as-needed"], - "react/self-closing-comp": ["error", { component: true, html: true }], + "@next/next/no-html-link-for-pages": "off", "@next/next/no-img-element": "off", - "@typescript-eslint/no-unused-vars": ["warn"], + "react/jsx-key": "error", + "react/self-closing-comp": ["error", { component: true, html: true }], + "react/jsx-boolean-value": "error", + "react/jsx-no-duplicate-props": "error", + "@typescript-eslint/no-unused-vars": ["error"], + "@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"], + }, + ], }, }; diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 6bfe67261..f7577ab87 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,21 +1,19 @@ { "name": "eslint-config-custom", "private": true, - "version": "0.15.1", + "version": "0.16.0", "main": "index.js", "license": "MIT", + "devDependencies": {}, "dependencies": { - "eslint": "^7.23.0", - "eslint-config-next": "13.0.0", - "eslint-config-prettier": "^8.3.0", - "eslint-config-turbo": "latest", - "eslint-plugin-react": "7.31.8" - }, - "devDependencies": { - "@typescript-eslint/eslint-plugin": "^6.13.2", - "typescript": "^4.7.4" - }, - "publishConfig": { - "access": "public" + "@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/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 50ede8674..d7e807b91 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.15.1", + "version": "0.16.0", "description": "common tailwind configuration across monorepo", "main": "index.js", "private": true, diff --git a/packages/tailwind-config-custom/tailwind.config.js b/packages/tailwind-config-custom/tailwind.config.js index 3465b8196..5d767e84f 100644 --- a/packages/tailwind-config-custom/tailwind.config.js +++ b/packages/tailwind-config-custom/tailwind.config.js @@ -198,6 +198,31 @@ module.exports = { 300: convertToRGB("--color-onboarding-border-300"), }, }, + toast: { + text: { + success: convertToRGB("--color-toast-success-text"), + error: convertToRGB("--color-toast-error-text"), + warning: convertToRGB("--color-toast-warning-text"), + info: convertToRGB("--color-toast-info-text"), + loading: convertToRGB("--color-toast-loading-text"), + secondary: convertToRGB("--color-toast-secondary-text"), + tertiary: convertToRGB("--color-toast-tertiary-text"), + }, + background: { + success: convertToRGB("--color-toast-success-background"), + error: convertToRGB("--color-toast-error-background"), + warning: convertToRGB("--color-toast-warning-background"), + info: convertToRGB("--color-toast-info-background"), + loading: convertToRGB("--color-toast-loading-background"), + }, + border: { + success: convertToRGB("--color-toast-success-border"), + error: convertToRGB("--color-toast-error-border"), + warning: convertToRGB("--color-toast-warning-border"), + info: convertToRGB("--color-toast-info-border"), + loading: convertToRGB("--color-toast-loading-border"), + }, + }, }, keyframes: { leftToaster: { diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index 42ce3fed5..e0829e87b 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.15.1", + "version": "0.16.0", "private": true, "files": [ "base.json", diff --git a/packages/types/package.json b/packages/types/package.json index 0e5c2eb16..9c9938845 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@plane/types", - "version": "0.15.1", + "version": "0.16.0", "private": true, "main": "./src/index.d.ts" } diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index 5d715385a..25b7427f5 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -1,11 +1,4 @@ -import type { - IUser, - TIssue, - IProjectLite, - IWorkspaceLite, - IIssueFilterOptions, - IUserLite, -} from "@plane/types"; +import type { TIssue, IIssueFilterOptions } from "@plane/types"; export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft"; @@ -30,10 +23,9 @@ export interface ICycle { is_favorite: boolean; issue: string; name: string; - owned_by: string; + owned_by_id: string; progress_snapshot: TProgressSnapshot; - project: string; - project_detail: IProjectLite; + project_id: string; status: TCycleGroups; sort_order: number; start_date: string | null; @@ -42,12 +34,11 @@ export interface ICycle { unstarted_issues: number; updated_at: Date; updated_by: string; - assignees: IUserLite[]; + assignee_ids: string[]; view_props: { filters: IIssueFilterOptions; }; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export type TProgressSnapshot = { diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard.ts similarity index 82% rename from packages/types/src/dashboard.d.ts rename to packages/types/src/dashboard.ts index 407b5cd79..be7d7b3be 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard.ts @@ -3,6 +3,15 @@ import { TIssue } from "./issues/issue"; import { TIssueRelationTypes } from "./issues/issue_relation"; import { TStateGroups } from "./state"; +enum EDurationFilters { + NONE = "none", + TODAY = "today", + THIS_WEEK = "this_week", + THIS_MONTH = "this_month", + THIS_YEAR = "this_year", + CUSTOM = "custom", +} + export type TWidgetKeys = | "overview_stats" | "assigned_issues" @@ -15,30 +24,27 @@ export type TWidgetKeys = export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed"; -export type TDurationFilterOptions = - | "none" - | "today" - | "this_week" - | "this_month" - | "this_year"; - // widget filters export type TAssignedIssuesWidgetFilters = { - duration?: TDurationFilterOptions; + custom_dates?: string[]; + duration?: EDurationFilters; tab?: TIssuesListTypes; }; export type TCreatedIssuesWidgetFilters = { - duration?: TDurationFilterOptions; + custom_dates?: string[]; + duration?: EDurationFilters; tab?: TIssuesListTypes; }; export type TIssuesByStateGroupsWidgetFilters = { - duration?: TDurationFilterOptions; + duration?: EDurationFilters; + custom_dates?: string[]; }; export type TIssuesByPriorityWidgetFilters = { - duration?: TDurationFilterOptions; + custom_dates?: string[]; + duration?: EDurationFilters; }; export type TWidgetFiltersFormData = @@ -97,6 +103,12 @@ export type TWidgetStatsRequestParams = | { target_date: string; widget_key: "issues_by_priority"; + } + | { + cursor: string; + per_page: number; + search?: string; + widget_key: "recent_collaborators"; }; export type TWidgetIssue = TIssue & { @@ -141,8 +153,17 @@ export type TRecentActivityWidgetResponse = IIssueActivity; export type TRecentProjectsWidgetResponse = string[]; export type TRecentCollaboratorsWidgetResponse = { - active_issue_count: number; - user_id: string; + 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; }; export type TWidgetStatsResponse = @@ -153,7 +174,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 new file mode 100644 index 000000000..259f13e9b --- /dev/null +++ b/packages/types/src/enums.ts @@ -0,0 +1,6 @@ +export enum EUserProjectRoles { + GUEST = 5, + VIEWER = 10, + MEMBER = 15, + ADMIN = 20, +} diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox/inbox-types.d.ts similarity index 65% rename from packages/types/src/inbox.d.ts rename to packages/types/src/inbox/inbox-types.d.ts index 4d666ae83..9db71c3ee 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox/inbox-types.d.ts @@ -1,5 +1,5 @@ -import { TIssue } from "./issues/base"; -import type { IProjectLite } from "./projects"; +import { TIssue } from "../issues/base"; +import type { IProjectLite } from "../projects"; export type TInboxIssueExtended = { completed_at: string | null; @@ -33,34 +33,6 @@ export interface IInbox { workspace: string; } -interface StatePending { - readonly status: -2; -} -interface StatusReject { - status: -1; -} - -interface StatusSnoozed { - status: 0; - snoozed_till: Date; -} - -interface StatusAccepted { - status: 1; -} - -interface StatusDuplicate { - status: 2; - duplicate_to: string; -} - -export type TInboxStatus = - | StatusReject - | StatusSnoozed - | StatusAccepted - | StatusDuplicate - | StatePending; - export interface IInboxFilterOptions { priority?: string[] | null; inbox_status?: number[] | null; diff --git a/packages/types/src/inbox/root.d.ts b/packages/types/src/inbox/root.d.ts index 2f10c088d..6fd21a4fe 100644 --- a/packages/types/src/inbox/root.d.ts +++ b/packages/types/src/inbox/root.d.ts @@ -1,2 +1,3 @@ -export * from "./inbox"; export * from "./inbox-issue"; +export * from "./inbox-types"; +export * from "./inbox"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 6e8ded942..bfebd92d0 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -4,7 +4,6 @@ export * from "./cycles"; export * from "./dashboard"; export * from "./projects"; export * from "./state"; -export * from "./invitation"; export * from "./issues"; export * from "./modules"; export * from "./views"; @@ -15,7 +14,6 @@ export * from "./estimate"; export * from "./importer"; // FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable -export * from "./inbox"; export * from "./inbox/root"; export * from "./analytics"; @@ -31,11 +29,3 @@ export * from "./auth"; export * from "./api_token"; export * from "./instance"; export * from "./app"; - -export type NestedKeyOf = { - [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object - ? ObjectType[Key] extends { pop: any; push: any } - ? `${Key}` - : `${Key}` | `${Key}.${NestedKeyOf}` - : `${Key}`; -}[keyof ObjectType & (string | number)]; diff --git a/packages/types/src/issues.d.ts b/packages/types/src/issues.d.ts index 1f4a35dd4..ebe537138 100644 --- a/packages/types/src/issues.d.ts +++ b/packages/types/src/issues.d.ts @@ -58,7 +58,6 @@ export interface IIssueLink { export interface ILinkDetails { created_at: Date; created_by: string; - created_by_detail: IUserLite; id: string; metadata: any; title: string; @@ -204,6 +203,8 @@ export interface ViewFlags { export type GroupByColumnTypes = | "project" + | "cycle" + | "module" | "state" | "state_detail.group" | "priority" diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 527abe630..42c95dc4e 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -1,4 +1,7 @@ import { TIssuePriorities } from "../issues"; +import { TIssueAttachment } from "./issue_attachment"; +import { TIssueLink } from "./issue_link"; +import { TIssueReaction } from "./issue_reaction"; // new issue structure types export type TIssue = { @@ -34,7 +37,12 @@ export type TIssue = { updated_by: string; is_draft: boolean; - is_subscribed: boolean; + is_subscribed?: boolean; + + 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; diff --git a/packages/types/src/issues/issue_attachment.d.ts b/packages/types/src/issues/issue_attachment.d.ts index 90daa08fa..7c3819e00 100644 --- a/packages/types/src/issues/issue_attachment.d.ts +++ b/packages/types/src/issues/issue_attachment.d.ts @@ -1,17 +1,15 @@ export type TIssueAttachment = { id: string; - created_at: string; - updated_at: string; attributes: { name: string; size: number; }; asset: string; - created_by: string; + issue_id: string; + + //need + updated_at: string; updated_by: string; - project: string; - workspace: string; - issue: string; }; export type TIssueAttachmentMap = { diff --git a/packages/types/src/issues/issue_link.d.ts b/packages/types/src/issues/issue_link.d.ts index 2c469e682..10f0d2792 100644 --- a/packages/types/src/issues/issue_link.d.ts +++ b/packages/types/src/issues/issue_link.d.ts @@ -4,11 +4,13 @@ export type TIssueLinkEditableFields = { }; export type TIssueLink = TIssueLinkEditableFields & { - created_at: Date; - created_by: string; - created_by_detail: IUserLite; + created_by_id: string; id: string; metadata: any; + issue_id: string; + + //need + created_at: Date; }; export type TIssueLinkMap = { diff --git a/packages/types/src/issues/issue_reaction.d.ts b/packages/types/src/issues/issue_reaction.d.ts index 88ef27426..a4eaee0a8 100644 --- a/packages/types/src/issues/issue_reaction.d.ts +++ b/packages/types/src/issues/issue_reaction.d.ts @@ -1,15 +1,8 @@ export type TIssueReaction = { - actor: string; - actor_detail: IUserLite; - created_at: Date; - created_by: string; + actor_id: string; id: string; - issue: string; - project: string; + issue_id: string; reaction: string; - updated_at: Date; - updated_by: string; - workspace: string; }; export type TIssueReactionMap = { diff --git a/packages/types/src/modules.d.ts b/packages/types/src/modules.d.ts index 0e49da7fe..c532a467c 100644 --- a/packages/types/src/modules.d.ts +++ b/packages/types/src/modules.d.ts @@ -1,16 +1,12 @@ -import type { - IUser, - IUserLite, - TIssue, - IProject, - IWorkspace, - IWorkspaceLite, - IProjectLite, - IIssueFilterOptions, - ILinkDetails, -} from "@plane/types"; +import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types"; -export type TModuleStatus = "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled"; +export type TModuleStatus = + | "backlog" + | "planned" + | "in-progress" + | "paused" + | "completed" + | "cancelled"; export interface IModule { backlog_issues: number; @@ -27,16 +23,12 @@ export interface IModule { labels: TLabelsDistribution[]; }; id: string; - lead: string | null; - lead_detail: IUserLite | null; + lead_id: string | null; link_module: ILinkDetails[]; - links_list: ModuleLink[]; - members: string[]; - members_detail: IUserLite[]; + member_ids: string[]; is_favorite: boolean; name: string; - project: string; - project_detail: IProjectLite; + project_id: string; sort_order: number; start_date: string | null; started_issues: number; @@ -49,8 +41,7 @@ export interface IModule { view_props: { filters: IIssueFilterOptions; }; - workspace: string; - workspace_detail: IWorkspaceLite; + workspace_id: string; } export interface ModuleIssueResponse { @@ -73,6 +64,10 @@ export type ModuleLink = { url: string; }; -export type SelectModuleType = (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined; +export type SelectModuleType = + | (IModule & { actionType: "edit" | "delete" | "create-issue" }) + | undefined; -export type SelectIssue = (TIssue & { actionType: "edit" | "delete" | "create" }) | undefined; +export type SelectIssue = + | (TIssue & { actionType: "edit" | "delete" | "create" }) + | undefined; diff --git a/packages/types/src/notifications.d.ts b/packages/types/src/notifications.d.ts index 8033c19a9..652e2776f 100644 --- a/packages/types/src/notifications.d.ts +++ b/packages/types/src/notifications.d.ts @@ -12,27 +12,27 @@ export interface PaginatedUserNotification { } export interface IUserNotification { - id: string; - created_at: Date; - updated_at: Date; + archived_at: string | null; + created_at: string; + created_by: null; data: Data; entity_identifier: string; entity_name: string; - title: string; + id: string; message: null; message_html: string; message_stripped: null; - sender: string; - read_at: Date | null; - archived_at: Date | null; - snoozed_till: Date | null; - created_by: null; - updated_by: null; - workspace: string; project: string; + read_at: Date | null; + receiver: string; + sender: string; + snoozed_till: Date | null; + title: string; triggered_by: string; triggered_by_details: IUserLite; - receiver: string; + updated_at: Date; + updated_by: null; + workspace: string; } export interface Data { diff --git a/packages/types/src/pages.d.ts b/packages/types/src/pages.d.ts index 29552b94c..c9b3fb623 100644 --- a/packages/types/src/pages.d.ts +++ b/packages/types/src/pages.d.ts @@ -1,5 +1,10 @@ // types -import { TIssue, IIssueLabel, IWorkspaceLite, IProjectLite } from "@plane/types"; +import { + TIssue, + IIssueLabel, + IWorkspaceLite, + IProjectLite, +} from "@plane/types"; export interface IPage { access: number; diff --git a/packages/types/src/projects.d.ts b/packages/types/src/projects.d.ts index 86b352482..a93734186 100644 --- a/packages/types/src/projects.d.ts +++ b/packages/types/src/projects.d.ts @@ -1,12 +1,26 @@ import { EUserProjectRoles } from "constants/project"; import type { + IProjectViewProps, IUser, IUserLite, + IUserMemberLite, IWorkspace, IWorkspaceLite, TStateGroups, } from "."; +export type TProjectLogoProps = { + in_use: "emoji" | "icon"; + emoji?: { + value?: string; + url?: string; + }; + icon?: { + name?: string; + color?: string; + }; +}; + export interface IProject { archive_in: number; close_in: number; @@ -21,24 +35,13 @@ export interface IProject { default_assignee: IUser | string | null; default_state: string | null; description: string; - emoji: string | null; - emoji_and_icon: - | string - | { - name: string; - color: string; - } - | null; estimate: string | null; - icon_prop: { - name: string; - color: string; - } | null; id: string; identifier: string; is_deployed: boolean; is_favorite: boolean; is_member: boolean; + logo_props: TProjectLogoProps; member_role: EUserProjectRoles | null; members: IProjectMemberLite[]; name: string; diff --git a/packages/types/src/state.d.ts b/packages/types/src/state.d.ts index 120b216da..7df658a88 100644 --- a/packages/types/src/state.d.ts +++ b/packages/types/src/state.d.ts @@ -1,6 +1,11 @@ import { IProject, IProjectLite, IWorkspaceLite } from "@plane/types"; -export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled"; +export type TStateGroups = + | "backlog" + | "unstarted" + | "started" + | "completed" + | "cancelled"; export interface IState { readonly id: string; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 81c8abcd5..5920f0b49 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -1,5 +1,9 @@ -import { EUserProjectRoles } from "constants/project"; -import { IIssueActivity, IIssueLite, TStateGroups } from "."; +import { + IIssueActivity, + TIssuePriorities, + TStateGroups, + EUserProjectRoles, +} from "."; export interface IUser { id: string; @@ -17,7 +21,6 @@ export interface IUser { is_onboarded: boolean; is_password_autoset: boolean; is_tour_completed: boolean; - is_password_autoset: boolean; mobile_number: string | null; role: string | null; onboarding_step: { @@ -80,7 +83,7 @@ export interface IUserActivity { } export interface IUserPriorityDistribution { - priority: string; + priority: TIssuePriorities; priority_count: number; } @@ -89,21 +92,6 @@ export interface IUserStateDistribution { state_count: number; } -export interface IUserWorkspaceDashboard { - assigned_issues_count: number; - completed_issues_count: number; - issue_activities: IUserActivity[]; - issues_due_week_count: number; - overdue_issues: IIssueLite[]; - completed_issues: { - week_in_month: number; - completed_count: number; - }[]; - pending_issues_count: number; - state_distribution: IUserStateDistribution[]; - upcoming_issues: IIssueLite[]; -} - export interface IUserActivityResponse { count: number; extra_stats: null; @@ -144,11 +132,7 @@ export interface IUserProfileProjectSegregation { assigned_issues: number; completed_issues: number; created_issues: number; - emoji: string | null; - icon_prop: null; id: string; - identifier: string; - name: string; pending_issues: number; }[]; user_data: { diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index 61cc7081b..c2c98def3 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -14,6 +14,8 @@ export type TIssueGroupByOptions = | "project" | "assignees" | "mentions" + | "cycle" + | "module" | null; export type TIssueOrderByOptions = @@ -30,6 +32,10 @@ export type TIssueOrderByOptions = | "-assignees__first_name" | "labels__name" | "-labels__name" + | "modules__name" + | "-modules__name" + | "cycle__name" + | "-cycle__name" | "target_date" | "-target_date" | "estimate_point" @@ -56,6 +62,8 @@ export type TIssueParams = | "created_by" | "subscriber" | "labels" + | "cycle" + | "module" | "start_date" | "target_date" | "project" @@ -75,6 +83,8 @@ export interface IIssueFilterOptions { labels?: string[] | null; priority?: string[] | null; project?: string[] | null; + cycle?: string[] | null; + module?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; @@ -109,6 +119,8 @@ export interface IIssueDisplayProperties { estimate?: boolean; created_on?: boolean; updated_on?: boolean; + modules?: boolean; + cycle?: boolean; } export type TIssueKanbanFilters = { diff --git a/packages/types/src/views.d.ts b/packages/types/src/views.d.ts index db30554a8..f9f7ee385 100644 --- a/packages/types/src/views.d.ts +++ b/packages/types/src/views.d.ts @@ -1,4 +1,8 @@ -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "./view-props"; +import { + IIssueDisplayFilterOptions, + IIssueDisplayProperties, + IIssueFilterOptions, +} from "./view-props"; export interface IProjectView { id: string; diff --git a/packages/ui/package.json b/packages/ui/package.json index 912fcfeb8..f80bcc6ae 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.15.1", + "version": "0.16.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", @@ -23,9 +23,11 @@ "@headlessui/react": "^1.7.17", "@popperjs/core": "^2.11.8", "clsx": "^2.0.0", + "emoji-picker-react": "^4.5.16", "react-color": "^2.19.3", "react-dom": "^18.2.0", "react-popper": "^2.3.0", + "sonner": "^1.4.2", "tailwind-merge": "^2.0.0" }, "devDependencies": { diff --git a/packages/ui/src/badge/helper.tsx b/packages/ui/src/badge/helper.tsx index 88e6fd8d3..b2e1beb48 100644 --- a/packages/ui/src/badge/helper.tsx +++ b/packages/ui/src/badge/helper.tsx @@ -122,14 +122,14 @@ export const badgeStyling: IBadgeStyling = { }; export const getBadgeStyling = (variant: TBadgeVariant, size: TBadgeSizes, disabled: boolean = false): string => { - let _variant: string = ``; + let tempVariant: string = ``; const currentVariant = badgeStyling[variant]; - _variant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover}`; + tempVariant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover}`; - let _size: string = ``; - if (size) _size = badgeSizeStyling[size]; - return `${_variant} ${_size}`; + let tempSize: string = ``; + if (size) tempSize = badgeSizeStyling[size]; + return `${tempVariant} ${tempSize}`; }; export const getIconStyling = (size: TBadgeSizes): string => { diff --git a/packages/ui/src/breadcrumbs/breadcrumbs.tsx b/packages/ui/src/breadcrumbs/breadcrumbs.tsx index 9a3b69bb8..253e8308e 100644 --- a/packages/ui/src/breadcrumbs/breadcrumbs.tsx +++ b/packages/ui/src/breadcrumbs/breadcrumbs.tsx @@ -29,13 +29,10 @@ const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => { {index > 0 && !isSmallScreen && (
-
)} -
0 ? 'hidden sm:flex' : 'flex'}`}> +
0 ? "hidden sm:flex" : "flex"}`}> {child}
@@ -46,7 +43,11 @@ const Breadcrumbs = ({ children, onBack }: BreadcrumbsProps) => { {isSmallScreen && childrenArray.length > 1 && ( <>
- {onBack && ...} + {onBack && ( + + ... + + )}
{childrenArray[childrenArray.length - 1]}
@@ -70,4 +71,4 @@ const BreadcrumbItem: React.FC = (props) => { Breadcrumbs.BreadcrumbItem = BreadcrumbItem; -export { Breadcrumbs, BreadcrumbItem }; \ No newline at end of file +export { Breadcrumbs, BreadcrumbItem }; diff --git a/packages/ui/src/button/helper.tsx b/packages/ui/src/button/helper.tsx index 9f7163d74..13a4e8040 100644 --- a/packages/ui/src/button/helper.tsx +++ b/packages/ui/src/button/helper.tsx @@ -100,16 +100,16 @@ export const buttonStyling: IButtonStyling = { }; export const getButtonStyling = (variant: TButtonVariant, size: TButtonSizes, disabled: boolean = false): string => { - let _variant: string = ``; + let tempVariant: string = ``; const currentVariant = buttonStyling[variant]; - _variant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover} ${ + tempVariant = `${currentVariant.default} ${disabled ? currentVariant.disabled : currentVariant.hover} ${ currentVariant.pressed }`; - let _size: string = ``; - if (size) _size = buttonSizeStyling[size]; - return `${_variant} ${_size}`; + let tempSize: string = ``; + if (size) tempSize = buttonSizeStyling[size]; + return `${tempVariant} ${tempSize}`; }; export const getIconStyling = (size: TButtonSizes): string => { diff --git a/packages/ui/src/control-link/control-link.tsx b/packages/ui/src/control-link/control-link.tsx index dbdbaf095..328dae4fa 100644 --- a/packages/ui/src/control-link/control-link.tsx +++ b/packages/ui/src/control-link/control-link.tsx @@ -5,13 +5,14 @@ export type TControlLink = React.AnchorHTMLAttributes & { onClick: () => void; children: React.ReactNode; target?: string; + disabled?: boolean; }; export const ControlLink: React.FC = (props) => { - const { href, onClick, children, target = "_self", ...rest } = props; + const { href, onClick, children, target = "_self", disabled = false, ...rest } = props; const LEFT_CLICK_EVENT_CODE = 0; - const _onClick = (event: React.MouseEvent) => { + const handleOnClick = (event: React.MouseEvent) => { const clickCondition = (event.metaKey || event.ctrlKey) && event.button === LEFT_CLICK_EVENT_CODE; if (!clickCondition) { event.preventDefault(); @@ -19,8 +20,10 @@ export const ControlLink: React.FC = (props) => { } }; + if (disabled) return <>{children}; + return ( - + {children} ); diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index 37aba932a..d1623dddf 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -27,6 +27,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { noBorder = false, noChevron = false, optionsClassName = "", + menuItemsClassName = "", verticalEllipsis = false, portalElement, menuButtonOnClick, @@ -70,7 +71,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { useOutsideClickDetector(dropdownRef, closeDropdown); let menuItems = ( - +
{ }; const MenuItem: React.FC = (props) => { - const { children, onClick, className = "" } = props; + const { children, disabled = false, onClick, className } = props; return ( - + {({ active, close }) => ( diff --git a/packages/ui/src/dropdowns/custom-select.tsx b/packages/ui/src/dropdowns/custom-select.tsx index 0fa183cb2..37608ea8d 100644 --- a/packages/ui/src/dropdowns/custom-select.tsx +++ b/packages/ui/src/dropdowns/custom-select.tsx @@ -122,7 +122,7 @@ const Option = (props: ICustomSelectItemProps) => { value={value} className={({ active }) => cn( - "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200", + "cursor-pointer select-none truncate rounded px-1 py-1.5 text-custom-text-200 flex items-center justify-between gap-2", { "bg-custom-background-80": active, }, @@ -131,10 +131,10 @@ const Option = (props: ICustomSelectItemProps) => { } > {({ selected }) => ( -
-
{children}
+ <> + {children} {selected && } -
+ )} ); diff --git a/packages/ui/src/dropdowns/helper.tsx b/packages/ui/src/dropdowns/helper.tsx index 930f332b9..93ac63b97 100644 --- a/packages/ui/src/dropdowns/helper.tsx +++ b/packages/ui/src/dropdowns/helper.tsx @@ -24,6 +24,7 @@ export interface ICustomMenuDropdownProps extends IDropdownProps { noBorder?: boolean; verticalEllipsis?: boolean; menuButtonOnClick?: (...args: any) => void; + menuItemsClassName?: string; onMenuClose?: () => void; closeOnSelect?: boolean; portalElement?: Element | null; @@ -64,6 +65,7 @@ export type ICustomSearchSelectProps = IDropdownProps & export interface ICustomMenuItemProps { children: React.ReactNode; + disabled?: boolean; onClick?: (args?: any) => void; className?: string; } diff --git a/packages/ui/src/emoji/emoji-icon-picker.tsx b/packages/ui/src/emoji/emoji-icon-picker.tsx new file mode 100644 index 000000000..42c367938 --- /dev/null +++ b/packages/ui/src/emoji/emoji-icon-picker.tsx @@ -0,0 +1,169 @@ +import React, { useState } from "react"; +import { usePopper } from "react-popper"; +import EmojiPicker, { EmojiClickData, Theme } from "emoji-picker-react"; +import { Popover, Tab } from "@headlessui/react"; +import { Placement } from "@popperjs/core"; +// components +import { IconsList } from "./icons-list"; +// helpers +import { cn } from "../../helpers"; + +export enum EmojiIconPickerTypes { + EMOJI = "emoji", + ICON = "icon", +} + +type TChangeHandlerProps = + | { + type: EmojiIconPickerTypes.EMOJI; + value: EmojiClickData; + } + | { + type: EmojiIconPickerTypes.ICON; + value: { + name: string; + color: string; + }; + }; + +export type TCustomEmojiPicker = { + buttonClassName?: string; + className?: string; + closeOnSelect?: boolean; + defaultIconColor?: string; + defaultOpen?: EmojiIconPickerTypes; + disabled?: boolean; + dropdownClassName?: string; + label: React.ReactNode; + onChange: (value: TChangeHandlerProps) => void; + placement?: Placement; + searchPlaceholder?: string; + theme?: Theme; +}; + +const TABS_LIST = [ + { + key: EmojiIconPickerTypes.EMOJI, + title: "Emojis", + }, + { + key: EmojiIconPickerTypes.ICON, + title: "Icons", + }, +]; + +export const CustomEmojiIconPicker: React.FC = (props) => { + const { + buttonClassName, + className, + closeOnSelect = true, + defaultIconColor = "#5f5f5f", + defaultOpen = EmojiIconPickerTypes.EMOJI, + disabled = false, + dropdownClassName, + label, + onChange, + placement = "bottom-start", + searchPlaceholder = "Search", + theme, + } = props; + // refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement, + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 20, + }, + }, + ], + }); + + return ( + + {({ close }) => ( + <> + + + + +
+ tab.key === defaultOpen)} + > + + {TABS_LIST.map((tab) => ( + + cn("py-1 text-sm rounded border border-custom-border-200", { + "bg-custom-background-80": selected, + "hover:bg-custom-background-90 focus:bg-custom-background-90": !selected, + }) + } + > + {tab.title} + + ))} + + + + { + onChange({ + type: EmojiIconPickerTypes.EMOJI, + value: val, + }); + if (closeOnSelect) close(); + }} + height="20rem" + width="100%" + theme={theme} + searchPlaceholder={searchPlaceholder} + previewConfig={{ + showPreview: false, + }} + /> + + + { + onChange({ + type: EmojiIconPickerTypes.ICON, + value: val, + }); + if (closeOnSelect) close(); + }} + /> + + + +
+
+ + )} +
+ ); +}; diff --git a/packages/ui/src/emoji/icons-list.tsx b/packages/ui/src/emoji/icons-list.tsx new file mode 100644 index 000000000..f55da881b --- /dev/null +++ b/packages/ui/src/emoji/icons-list.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useState } from "react"; +// components +import { Input } from "../form-fields"; +// helpers +import { cn } from "../../helpers"; +// constants +import { MATERIAL_ICONS_LIST } from "./icons"; + +type TIconsListProps = { + defaultColor: string; + onChange: (val: { name: string; color: string }) => void; +}; + +const DEFAULT_COLORS = ["#ff6b00", "#8cc1ff", "#fcbe1d", "#18904f", "#adf672", "#05c3ff", "#5f5f5f"]; + +export const IconsList: React.FC = (props) => { + const { defaultColor, onChange } = props; + // states + const [activeColor, setActiveColor] = useState(defaultColor); + const [showHexInput, setShowHexInput] = useState(false); + const [hexValue, setHexValue] = useState(""); + + useEffect(() => { + if (DEFAULT_COLORS.includes(defaultColor.toLowerCase())) setShowHexInput(false); + else { + setHexValue(defaultColor.slice(1, 7)); + setShowHexInput(true); + } + }, [defaultColor]); + + return ( + <> +
+ {showHexInput ? ( +
+ + HEX + # + { + const value = e.target.value; + setHexValue(value); + if (/^[0-9A-Fa-f]{6}$/.test(value)) setActiveColor(`#${value}`); + }} + className="flex-grow pl-0 text-xs text-custom-text-200" + mode="true-transparent" + autoFocus + /> +
+ ) : ( + DEFAULT_COLORS.map((curCol) => ( + + )) + )} + +
+
+ {MATERIAL_ICONS_LIST.map((icon) => ( + + ))} +
+ + ); +}; diff --git a/packages/ui/src/emoji/icons.ts b/packages/ui/src/emoji/icons.ts new file mode 100644 index 000000000..72aacf18b --- /dev/null +++ b/packages/ui/src/emoji/icons.ts @@ -0,0 +1,605 @@ +export const MATERIAL_ICONS_LIST = [ + { + name: "search", + }, + { + name: "home", + }, + { + name: "menu", + }, + { + name: "close", + }, + { + name: "settings", + }, + { + name: "done", + }, + { + name: "check_circle", + }, + { + name: "favorite", + }, + { + name: "add", + }, + { + name: "delete", + }, + { + name: "arrow_back", + }, + { + name: "star", + }, + { + name: "logout", + }, + { + name: "add_circle", + }, + { + name: "cancel", + }, + { + name: "arrow_drop_down", + }, + { + name: "more_vert", + }, + { + name: "check", + }, + { + name: "check_box", + }, + { + name: "toggle_on", + }, + { + name: "open_in_new", + }, + { + name: "refresh", + }, + { + name: "login", + }, + { + name: "radio_button_unchecked", + }, + { + name: "more_horiz", + }, + { + name: "apps", + }, + { + name: "radio_button_checked", + }, + { + name: "download", + }, + { + name: "remove", + }, + { + name: "toggle_off", + }, + { + name: "bolt", + }, + { + name: "arrow_upward", + }, + { + name: "filter_list", + }, + { + name: "delete_forever", + }, + { + name: "autorenew", + }, + { + name: "key", + }, + { + name: "sort", + }, + { + name: "sync", + }, + { + name: "add_box", + }, + { + name: "block", + }, + { + name: "restart_alt", + }, + { + name: "menu_open", + }, + { + name: "shopping_cart_checkout", + }, + { + name: "expand_circle_down", + }, + { + name: "backspace", + }, + { + name: "undo", + }, + { + name: "done_all", + }, + { + name: "do_not_disturb_on", + }, + { + name: "open_in_full", + }, + { + name: "double_arrow", + }, + { + name: "sync_alt", + }, + { + name: "zoom_in", + }, + { + name: "done_outline", + }, + { + name: "drag_indicator", + }, + { + name: "fullscreen", + }, + { + name: "star_half", + }, + { + name: "settings_accessibility", + }, + { + name: "reply", + }, + { + name: "exit_to_app", + }, + { + name: "unfold_more", + }, + { + name: "library_add", + }, + { + name: "cached", + }, + { + name: "select_check_box", + }, + { + name: "terminal", + }, + { + name: "change_circle", + }, + { + name: "disabled_by_default", + }, + { + name: "swap_horiz", + }, + { + name: "swap_vert", + }, + { + name: "app_registration", + }, + { + name: "download_for_offline", + }, + { + name: "close_fullscreen", + }, + { + name: "file_open", + }, + { + name: "minimize", + }, + { + name: "open_with", + }, + { + name: "dataset", + }, + { + name: "add_task", + }, + { + name: "start", + }, + { + name: "keyboard_voice", + }, + { + name: "create_new_folder", + }, + { + name: "forward", + }, + { + name: "download", + }, + { + name: "settings_applications", + }, + { + name: "compare_arrows", + }, + { + name: "redo", + }, + { + name: "zoom_out", + }, + { + name: "publish", + }, + { + name: "html", + }, + { + name: "token", + }, + { + name: "switch_access_shortcut", + }, + { + name: "fullscreen_exit", + }, + { + name: "sort_by_alpha", + }, + { + name: "delete_sweep", + }, + { + name: "indeterminate_check_box", + }, + { + name: "view_timeline", + }, + { + name: "settings_backup_restore", + }, + { + name: "arrow_drop_down_circle", + }, + { + name: "assistant_navigation", + }, + { + name: "sync_problem", + }, + { + name: "clear_all", + }, + { + name: "density_medium", + }, + { + name: "heart_plus", + }, + { + name: "filter_alt_off", + }, + { + name: "expand", + }, + { + name: "subdirectory_arrow_right", + }, + { + name: "download_done", + }, + { + name: "arrow_outward", + }, + { + name: "123", + }, + { + name: "swipe_left", + }, + { + name: "auto_mode", + }, + { + name: "saved_search", + }, + { + name: "place_item", + }, + { + name: "system_update_alt", + }, + { + name: "javascript", + }, + { + name: "search_off", + }, + { + name: "output", + }, + { + name: "select_all", + }, + { + name: "fit_screen", + }, + { + name: "swipe_up", + }, + { + name: "dynamic_form", + }, + { + name: "hide_source", + }, + { + name: "swipe_right", + }, + { + name: "switch_access_shortcut_add", + }, + { + name: "browse_gallery", + }, + { + name: "css", + }, + { + name: "density_small", + }, + { + name: "assistant_direction", + }, + { + name: "check_small", + }, + { + name: "youtube_searched_for", + }, + { + name: "move_up", + }, + { + name: "swap_horizontal_circle", + }, + { + name: "data_thresholding", + }, + { + name: "install_mobile", + }, + { + name: "move_down", + }, + { + name: "dataset_linked", + }, + { + name: "keyboard_command_key", + }, + { + name: "view_kanban", + }, + { + name: "swipe_down", + }, + { + name: "key_off", + }, + { + name: "transcribe", + }, + { + name: "send_time_extension", + }, + { + name: "swipe_down_alt", + }, + { + name: "swipe_left_alt", + }, + { + name: "swipe_right_alt", + }, + { + name: "swipe_up_alt", + }, + { + name: "keyboard_option_key", + }, + { + name: "cycle", + }, + { + name: "rebase", + }, + { + name: "rebase_edit", + }, + { + name: "empty_dashboard", + }, + { + name: "magic_exchange", + }, + { + name: "acute", + }, + { + name: "point_scan", + }, + { + name: "step_into", + }, + { + name: "cheer", + }, + { + name: "emoticon", + }, + { + name: "explosion", + }, + { + name: "water_bottle", + }, + { + name: "weather_hail", + }, + { + name: "syringe", + }, + { + name: "pill", + }, + { + name: "genetics", + }, + { + name: "allergy", + }, + { + name: "medical_mask", + }, + { + name: "body_fat", + }, + { + name: "barefoot", + }, + { + name: "infrared", + }, + { + name: "wrist", + }, + { + name: "metabolism", + }, + { + name: "conditions", + }, + { + name: "taunt", + }, + { + name: "altitude", + }, + { + name: "tibia", + }, + { + name: "footprint", + }, + { + name: "eyeglasses", + }, + { + name: "man_3", + }, + { + name: "woman_2", + }, + { + name: "rheumatology", + }, + { + name: "tornado", + }, + { + name: "landslide", + }, + { + name: "foggy", + }, + { + name: "severe_cold", + }, + { + name: "tsunami", + }, + { + name: "vape_free", + }, + { + name: "sign_language", + }, + { + name: "emoji_symbols", + }, + { + name: "clear_night", + }, + { + name: "emoji_food_beverage", + }, + { + name: "hive", + }, + { + name: "thunderstorm", + }, + { + name: "communication", + }, + { + name: "rocket", + }, + { + name: "pets", + }, + { + name: "public", + }, + { + name: "quiz", + }, + { + name: "mood", + }, + { + name: "gavel", + }, + { + name: "eco", + }, + { + name: "diamond", + }, + { + name: "forest", + }, + { + name: "rainy", + }, + { + name: "skull", + }, +]; diff --git a/packages/ui/src/emoji/index.ts b/packages/ui/src/emoji/index.ts new file mode 100644 index 000000000..973454139 --- /dev/null +++ b/packages/ui/src/emoji/index.ts @@ -0,0 +1 @@ +export * from "./emoji-icon-picker"; diff --git a/packages/ui/src/form-fields/input.tsx b/packages/ui/src/form-fields/input.tsx index 6688d6778..f73467621 100644 --- a/packages/ui/src/form-fields/input.tsx +++ b/packages/ui/src/form-fields/input.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +// helpers +import { cn } from "../../helpers"; export interface InputProps extends React.InputHTMLAttributes { mode?: "primary" | "transparent" | "true-transparent"; @@ -16,17 +18,20 @@ const Input = React.forwardRef((props, ref) => { ref={ref} type={type} name={name} - className={`block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none ${ - mode === "primary" - ? "rounded-md border-[0.5px] border-custom-border-200" - : mode === "transparent" - ? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary" - : mode === "true-transparent" - ? "rounded border-none bg-transparent ring-0" - : "" - } ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${ - inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : "" - } ${className}`} + className={cn( + `block rounded-md bg-transparent text-sm placeholder-custom-text-400 focus:outline-none ${ + mode === "primary" + ? "rounded-md border-[0.5px] border-custom-border-200" + : mode === "transparent" + ? "rounded border-none bg-transparent ring-0 transition-all focus:ring-1 focus:ring-custom-primary" + : mode === "true-transparent" + ? "rounded border-none bg-transparent ring-0" + : "" + } ${hasError ? "border-red-500" : ""} ${hasError && mode === "primary" ? "bg-red-500/20" : ""} ${ + inputSize === "sm" ? "px-3 py-2" : inputSize === "md" ? "p-3" : "" + }`, + className + )} {...rest} /> ); diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dbca8bd64..7529a5030 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -2,7 +2,7 @@ export * from "./avatar"; export * from "./badge"; export * from "./breadcrumbs"; export * from "./button"; -export * from "./control-link"; +export * from "./emoji"; export * from "./dropdowns"; export * from "./form-fields"; export * from "./icons"; @@ -11,3 +11,5 @@ export * from "./spinners"; export * from "./tabs"; export * from "./tooltip"; export * from "./loader"; +export * from "./control-link"; +export * from "./toast"; diff --git a/packages/ui/src/spinners/circular-bar-spinner.tsx b/packages/ui/src/spinners/circular-bar-spinner.tsx new file mode 100644 index 000000000..3be8af43a --- /dev/null +++ b/packages/ui/src/spinners/circular-bar-spinner.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; + +interface ICircularBarSpinner extends React.SVGAttributes { + height?: string; + width?: string; + className?: string | undefined; +} + +export const CircularBarSpinner: React.FC = ({ + height = "16px", + width = "16px", + className = "", +}) => ( +
+ + + + + + + + + + + + +
+); diff --git a/packages/ui/src/spinners/index.ts b/packages/ui/src/spinners/index.ts index 768568172..a871a9b77 100644 --- a/packages/ui/src/spinners/index.ts +++ b/packages/ui/src/spinners/index.ts @@ -1 +1,2 @@ export * from "./circular-spinner"; +export * from "./circular-bar-spinner"; diff --git a/packages/ui/src/toast/index.tsx b/packages/ui/src/toast/index.tsx new file mode 100644 index 000000000..755326275 --- /dev/null +++ b/packages/ui/src/toast/index.tsx @@ -0,0 +1,206 @@ +import * as React from "react"; +import { Toaster, toast } from "sonner"; +// icons +import { AlertTriangle, CheckCircle2, X, XCircle } from "lucide-react"; +// spinner +import { CircularBarSpinner } from "../spinners"; +// helper +import { cn } from "../../helpers"; + +export enum TOAST_TYPE { + SUCCESS = "success", + ERROR = "error", + INFO = "info", + WARNING = "warning", + LOADING = "loading", +} + +type SetToastProps = + | { + type: TOAST_TYPE.LOADING; + title?: string; + } + | { + id?: string | number; + type: Exclude; + title: string; + message?: string; + }; + +type PromiseToastCallback = (data: ToastData) => string; + +type PromiseToastData = { + title: string; + message?: PromiseToastCallback; +}; + +type PromiseToastOptions = { + loading?: string; + success: PromiseToastData; + error: PromiseToastData; +}; + +type ToastContentProps = { + toastId: string | number; + icon?: React.ReactNode; + textColorClassName: string; + backgroundColorClassName: string; + borderColorClassName: string; +}; + +type ToastProps = { + theme: "light" | "dark" | "system"; +}; + +export const Toast = (props: ToastProps) => { + const { theme } = props; + return ; +}; + +export const setToast = (props: SetToastProps) => { + const renderToastContent = ({ + toastId, + icon, + textColorClassName, + backgroundColorClassName, + borderColorClassName, + }: ToastContentProps) => + props.type === TOAST_TYPE.LOADING ? ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + className={cn("w-[350px] h-[67.3px] rounded-lg border shadow-sm p-2", backgroundColorClassName, borderColorClassName)} + > +
+ {icon &&
{icon}
} +
+
{props.title ?? "Loading..."}
+
+ toast.dismiss(toastId)} + /> +
+
+
+
+ ) : ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + className={cn( + "relative flex flex-col w-[350px] rounded-lg border shadow-sm p-2", + backgroundColorClassName, + borderColorClassName + )} + > + toast.dismiss(toastId)} + /> +
+ {icon &&
{icon}
} +
+
{props.title}
+ {props.message &&
{props.message}
} +
+
+
+ ); + + switch (props.type) { + case TOAST_TYPE.SUCCESS: + return toast.custom( + (toastId) => + renderToastContent({ + toastId, + icon: , + textColorClassName: "text-toast-text-success", + backgroundColorClassName: "bg-toast-background-success", + borderColorClassName: "border-toast-border-success", + }), + props.id ? { id: props.id } : {} + ); + case TOAST_TYPE.ERROR: + return toast.custom( + (toastId) => + renderToastContent({ + toastId, + icon: , + textColorClassName: "text-toast-text-error", + backgroundColorClassName: "bg-toast-background-error", + borderColorClassName: "border-toast-border-error", + }), + props.id ? { id: props.id } : {} + ); + case TOAST_TYPE.WARNING: + return toast.custom( + (toastId) => + renderToastContent({ + toastId, + icon: , + textColorClassName: "text-toast-text-warning", + backgroundColorClassName: "bg-toast-background-warning", + borderColorClassName: "border-toast-border-warning", + }), + props.id ? { id: props.id } : {} + ); + case TOAST_TYPE.INFO: + return toast.custom( + (toastId) => + renderToastContent({ + toastId, + textColorClassName: "text-toast-text-info", + backgroundColorClassName: "bg-toast-background-info", + borderColorClassName: "border-toast-border-info", + }), + props.id ? { id: props.id } : {} + ); + + case TOAST_TYPE.LOADING: + return toast.custom((toastId) => + renderToastContent({ + toastId, + icon: , + textColorClassName: "text-toast-text-loading", + backgroundColorClassName: "bg-toast-background-loading", + borderColorClassName: "border-toast-border-loading", + }) + ); + } +}; + +export const setPromiseToast = ( + promise: Promise, + options: PromiseToastOptions +): void => { + const tId = setToast({ type: TOAST_TYPE.LOADING, title: options.loading }); + + promise + .then((data: ToastData) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + id: tId, + title: options.success.title, + message: options.success.message?.(data), + }); + }) + .catch((data: ToastData) => { + setToast({ + type: TOAST_TYPE.ERROR, + id: tId, + title: options.error.title, + message: options.error.message?.(data), + }); + }); +}; diff --git a/space/components/common/index.ts b/space/components/common/index.ts index f1c0b088e..36cc3c898 100644 --- a/space/components/common/index.ts +++ b/space/components/common/index.ts @@ -1 +1,2 @@ export * from "./latest-feature-block"; +export * from "./project-logo"; diff --git a/space/components/common/project-logo.tsx b/space/components/common/project-logo.tsx new file mode 100644 index 000000000..3d5887b28 --- /dev/null +++ b/space/components/common/project-logo.tsx @@ -0,0 +1,34 @@ +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TProjectLogoProps } from "@plane/types"; + +type Props = { + className?: string; + logo: TProjectLogoProps; +}; + +export const ProjectLogo: React.FC = (props) => { + const { className, logo } = props; + + if (logo.in_use === "icon" && logo.icon) + return ( + + {logo.icon.name} + + ); + + if (logo.in_use === "emoji" && logo.emoji) + return ( + + {logo.emoji.value?.split("-").map((emoji) => String.fromCodePoint(parseInt(emoji, 10)))} + + ); + + return ; +}; diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 0bc493b16..feb11ed13 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -1,15 +1,12 @@ import { useEffect } from "react"; - import Link from "next/link"; import { useRouter } from "next/router"; - -// mobx import { observer } from "mobx-react-lite"; // components -// import { NavbarSearch } from "./search"; import { NavbarIssueBoardView } from "./issue-board-view"; import { NavbarTheme } from "./theme"; import { IssueFiltersDropdown } from "components/issues/filters"; +import { ProjectLogo } from "components/common"; // ui import { Avatar, Button } from "@plane/ui"; import { Briefcase } from "lucide-react"; @@ -19,18 +16,6 @@ import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; import { TIssueBoardKeys } from "types/issue"; -const renderEmoji = (emoji: string | { name: string; color: string }) => { - if (!emoji) return; - - if (typeof emoji === "object") - return ( - - {emoji.name} - - ); - else return isNaN(parseInt(emoji)) ? emoji : String.fromCodePoint(parseInt(emoji)); -}; - const IssueNavbar = observer(() => { const { project: projectStore, @@ -123,27 +108,15 @@ const IssueNavbar = observer(() => {
{/* project detail */}
-
- {projectStore.project ? ( - projectStore.project?.emoji ? ( - - {renderEmoji(projectStore.project.emoji)} - - ) : projectStore.project?.icon_prop ? ( -
- {renderEmoji(projectStore.project.icon_prop)} -
- ) : ( - - {projectStore.project?.name.charAt(0)} - - ) - ) : ( - - - - )} -
+ {projectStore.project ? ( + + + + ) : ( + + + + )}
{projectStore?.project?.name || `...`}
diff --git a/space/components/ui/dropdown.tsx b/space/components/ui/dropdown.tsx index 09d27da42..75399619b 100644 --- a/space/components/ui/dropdown.tsx +++ b/space/components/ui/dropdown.tsx @@ -67,13 +67,13 @@ const DropdownList: React.FC = (props) => { const DropdownItem: React.FC = (props) => { const { item } = props; - const { display, children, as: as_, href, onClick, isSelected } = item; + const { display, children, as: itemAs, href, onClick, isSelected } = item; const [open, setOpen] = useState(false); return (
- {(!as_ || as_ === "button" || as_ === "div") && ( + {(!itemAs || itemAs === "button" || itemAs === "div") && ( )} - {as_ === "link" && {display}} + {itemAs === "link" && {display}} {children && setOpen(false)} items={children} />}
diff --git a/space/lib/mobx/store-provider.tsx b/space/lib/mobx/store-provider.tsx index c6fde14ae..e12f2823a 100644 --- a/space/lib/mobx/store-provider.tsx +++ b/space/lib/mobx/store-provider.tsx @@ -9,10 +9,10 @@ let rootStore: RootStore = new RootStore(); export const MobxStoreContext = createContext(rootStore); const initializeStore = () => { - const _rootStore: RootStore = rootStore ?? new RootStore(); - if (typeof window === "undefined") return _rootStore; - if (!rootStore) rootStore = _rootStore; - return _rootStore; + const singletonRootStore: RootStore = rootStore ?? new RootStore(); + if (typeof window === "undefined") return singletonRootStore; + if (!rootStore) rootStore = singletonRootStore; + return singletonRootStore; }; export const MobxStoreProvider = ({ children }: any) => { diff --git a/space/package.json b/space/package.json index 9ee7279cd..7018cd241 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.15.1", + "version": "0.16.0", "private": true, "scripts": { "dev": "turbo run develop", @@ -49,9 +49,7 @@ "@types/react-dom": "^18.2.17", "@types/uuid": "^9.0.1", "@typescript-eslint/eslint-plugin": "^5.48.2", - "eslint": "8.34.0", "eslint-config-custom": "*", - "eslint-config-next": "13.2.1", "tailwind-config-custom": "*", "tsconfig": "*" } diff --git a/space/types/project.ts b/space/types/project.ts index e0e1bba9e..7e81d366c 100644 --- a/space/types/project.ts +++ b/space/types/project.ts @@ -1,3 +1,5 @@ +import { TProjectLogoProps } from "@plane/types"; + export interface IWorkspace { id: string; name: string; @@ -9,10 +11,8 @@ export interface IProject { identifier: string; name: string; description: string; - icon: string; cover_image: string | null; - icon_prop: string | null; - emoji: string | null; + logo_props: TProjectLogoProps; } export interface IProjectSettings { diff --git a/turbo.json b/turbo.json index bd5ee34b5..9302a7183 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,7 @@ "NEXT_PUBLIC_DEPLOY_WITH_NGINX", "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", + "NEXT_PUBLIC_POSTHOG_DEBUG", "JITSU_TRACKER_ACCESS_KEY", "JITSU_TRACKER_HOST" ], diff --git a/web/.eslintrc.js b/web/.eslintrc.js index c8df60750..eb05b2af8 100644 --- a/web/.eslintrc.js +++ b/web/.eslintrc.js @@ -1,4 +1,103 @@ 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: "@headlessui/**", + // group: "external", + // position: "after", + // }, + // { + // pattern: "lucide-react", + // group: "external", + // position: "after", + // }, + // { + // pattern: "@plane/ui", + // group: "external", + // position: "after", + // }, + // { + // pattern: "components/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "constants/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "contexts/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "helpers/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "hooks/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "layouts/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "lib/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "services/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "store/**", + // group: "internal", + // position: "before", + // }, + // { + // pattern: "@plane/types", + // group: "internal", + // position: "after", + // }, + // { + // pattern: "lib/types", + // group: "internal", + // position: "after", + // }, + // ], + // pathGroupsExcludedImportTypes: ["builtin", "internal", "react"], + // alphabetize: { + // order: "asc", + // caseInsensitive: true, + // }, + // }, + // ], + }, }; diff --git a/web/components/account/deactivate-account-modal.tsx b/web/components/account/deactivate-account-modal.tsx index 701db6ad9..34129cebe 100644 --- a/web/components/account/deactivate-account-modal.tsx +++ b/web/components/account/deactivate-account-modal.tsx @@ -1,15 +1,13 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; import { useTheme } from "next-themes"; +import { mutate } from "swr"; import { Dialog, Transition } from "@headlessui/react"; import { Trash2 } from "lucide-react"; -import { mutate } from "swr"; // hooks -import { useUser } from "hooks/store"; // ui -import { Button } from "@plane/ui"; -// hooks -import useToast from "hooks/use-toast"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { useUser } from "hooks/store"; type Props = { isOpen: boolean; @@ -26,7 +24,6 @@ export const DeactivateAccountModal: React.FC = (props) => { const router = useRouter(); - const { setToastAlert } = useToast(); const { setTheme } = useTheme(); const handleClose = () => { @@ -39,8 +36,8 @@ export const DeactivateAccountModal: React.FC = (props) => { await deactivateAccount() .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Account deactivated successfully.", }); @@ -50,8 +47,8 @@ export const DeactivateAccountModal: React.FC = (props) => { handleClose(); }) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error, }) @@ -89,8 +86,11 @@ export const DeactivateAccountModal: React.FC = (props) => {
-
-
diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index 98719df63..f42398850 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -1,23 +1,22 @@ import React, { useState } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { Eye, EyeOff, XCircle } from "lucide-react"; // services +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +import { ESignInSteps, ForgotPasswordPopover } from "components/account"; +import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker"; +import { checkEmailValidity } from "helpers/string.helper"; +import { useApplication, useEventTracker } from "hooks/store"; import { AuthService } from "services/auth.service"; // hooks -import useToast from "hooks/use-toast"; -import { useApplication, useEventTracker } from "hooks/store"; // components -import { ESignInSteps, ForgotPasswordPopover } from "components/account"; // ui -import { Button, Input } from "@plane/ui"; // helpers -import { checkEmailValidity } from "helpers/string.helper"; // types import { IPasswordSignInData } from "@plane/types"; // constants -import { FORGOT_PASSWORD, SIGN_IN_WITH_PASSWORD } from "constants/event-tracker"; type Props = { email: string; @@ -43,8 +42,6 @@ export const SignInPasswordForm: React.FC = observer((props) => { // states const [isSendingUniqueCode, setIsSendingUniqueCode] = useState(false); const [showPassword, setShowPassword] = useState(false); - // toast alert - const { setToastAlert } = useToast(); const { config: { envConfig }, } = useApplication(); @@ -83,8 +80,8 @@ export const SignInPasswordForm: React.FC = observer((props) => { await onSubmit(); }) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) @@ -107,8 +104,8 @@ export const SignInPasswordForm: React.FC = observer((props) => { .generateUniqueCode({ email: emailFormValue }) .then(() => handleStepChange(ESignInSteps.USE_UNIQUE_CODE_FROM_PASSWORD)) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 62f63caea..835e018dc 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -1,11 +1,7 @@ import React, { useEffect, useState } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; // hooks -import { useApplication, useEventTracker } from "hooks/store"; -import useSignInRedirection from "hooks/use-sign-in-redirection"; -// components -import { LatestFeatureBlock } from "components/common"; import { SignInEmailForm, SignInUniqueCodeForm, @@ -13,8 +9,12 @@ import { OAuthOptions, SignInOptionalSetPasswordForm, } from "components/account"; -// constants +import { LatestFeatureBlock } from "components/common"; import { NAVIGATE_TO_SIGNUP } from "constants/event-tracker"; +import { useApplication, useEventTracker } from "hooks/store"; +import useSignInRedirection from "hooks/use-sign-in-redirection"; +// components +// constants export enum ESignInSteps { EMAIL = "EMAIL", diff --git a/web/components/account/sign-in-forms/unique-code.tsx b/web/components/account/sign-in-forms/unique-code.tsx index 55dbe86e2..6929ef0fe 100644 --- a/web/components/account/sign-in-forms/unique-code.tsx +++ b/web/components/account/sign-in-forms/unique-code.tsx @@ -2,20 +2,21 @@ import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; // services +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; + +import { CODE_VERIFIED } from "constants/event-tracker"; +import { checkEmailValidity } from "helpers/string.helper"; +import { useEventTracker } from "hooks/store"; + +import useTimer from "hooks/use-timer"; import { AuthService } from "services/auth.service"; import { UserService } from "services/user.service"; // hooks -import useToast from "hooks/use-toast"; -import useTimer from "hooks/use-timer"; -import { useEventTracker } from "hooks/store"; // ui -import { Button, Input } from "@plane/ui"; // helpers -import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; // constants -import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -42,8 +43,6 @@ export const SignInUniqueCodeForm: React.FC = (props) => { const { email, onSubmit, handleEmailClear, submitButtonText } = props; // states const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); - // toast alert - const { setToastAlert } = useToast(); // store hooks const { captureEvent } = useEventTracker(); // timer @@ -84,8 +83,8 @@ export const SignInUniqueCodeForm: React.FC = (props) => { captureEvent(CODE_VERIFIED, { state: "FAILED", }); - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }); @@ -101,8 +100,8 @@ export const SignInUniqueCodeForm: React.FC = (props) => { .generateUniqueCode(payload) .then(() => { setResendCodeTimer(30); - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "A new unique code has been sent to your email.", }); @@ -113,8 +112,8 @@ export const SignInUniqueCodeForm: React.FC = (props) => { }); }) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) diff --git a/web/components/account/sign-up-forms/email.tsx b/web/components/account/sign-up-forms/email.tsx index 0d5861b4e..22dba892f 100644 --- a/web/components/account/sign-up-forms/email.tsx +++ b/web/components/account/sign-up-forms/email.tsx @@ -1,15 +1,13 @@ import React from "react"; +import { observer } from "mobx-react-lite"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; -import { observer } from "mobx-react-lite"; // services -import { AuthService } from "services/auth.service"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { Button, Input } from "@plane/ui"; -// helpers +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; import { checkEmailValidity } from "helpers/string.helper"; +import { AuthService } from "services/auth.service"; +// ui +// helpers // types import { IEmailCheckData } from "@plane/types"; @@ -27,7 +25,6 @@ const authService = new AuthService(); export const SignUpEmailForm: React.FC = observer((props) => { const { onSubmit, updateEmail } = props; // hooks - const { setToastAlert } = useToast(); const { control, formState: { errors, isSubmitting, isValid }, @@ -52,8 +49,8 @@ export const SignUpEmailForm: React.FC = observer((props) => { .emailCheck(payload) .then(() => onSubmit()) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) diff --git a/web/components/account/sign-up-forms/optional-set-password.tsx b/web/components/account/sign-up-forms/optional-set-password.tsx index b49adabbb..93f774248 100644 --- a/web/components/account/sign-up-forms/optional-set-password.tsx +++ b/web/components/account/sign-up-forms/optional-set-password.tsx @@ -1,19 +1,19 @@ import React, { useState } from "react"; import { Controller, useForm } from "react-hook-form"; // services +import { Eye, EyeOff } from "lucide-react"; +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +import { ESignUpSteps } from "components/account"; +import { PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker"; +import { checkEmailValidity } from "helpers/string.helper"; +import { useEventTracker } from "hooks/store"; import { AuthService } from "services/auth.service"; // hooks -import useToast from "hooks/use-toast"; -import { useEventTracker } from "hooks/store"; // ui -import { Button, Input } from "@plane/ui"; // helpers -import { checkEmailValidity } from "helpers/string.helper"; +// components // constants -import { ESignUpSteps } from "components/account"; -import { PASSWORD_CREATE_SELECTED, PASSWORD_CREATE_SKIPPED, SETUP_PASSWORD } from "constants/event-tracker"; // icons -import { Eye, EyeOff } from "lucide-react"; type Props = { email: string; @@ -41,8 +41,6 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { const [showPassword, setShowPassword] = useState(false); // store hooks const { captureEvent } = useEventTracker(); - // toast alert - const { setToastAlert } = useToast(); // form info const { control, @@ -65,8 +63,8 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { await authService .setPassword(payload) .then(async () => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Password created successfully.", }); @@ -81,8 +79,8 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => { state: "FAILED", first_time: true, }); - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }); @@ -164,7 +162,7 @@ export const SignUpOptionalSetPasswordForm: React.FC = (props) => {
)} /> -

+

This password will continue to be your account{"'"}s password.

diff --git a/web/components/account/sign-up-forms/password.tsx b/web/components/account/sign-up-forms/password.tsx index 293e03ef8..7fab81fbe 100644 --- a/web/components/account/sign-up-forms/password.tsx +++ b/web/components/account/sign-up-forms/password.tsx @@ -1,16 +1,14 @@ import React, { useState } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { Eye, EyeOff, XCircle } from "lucide-react"; // services -import { AuthService } from "services/auth.service"; -// hooks -import useToast from "hooks/use-toast"; // ui -import { Button, Input } from "@plane/ui"; +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { checkEmailValidity } from "helpers/string.helper"; +import { AuthService } from "services/auth.service"; // types import { IPasswordSignInData } from "@plane/types"; @@ -34,8 +32,6 @@ export const SignUpPasswordForm: React.FC = observer((props) => { const { onSubmit } = props; // states const [showPassword, setShowPassword] = useState(false); - // toast alert - const { setToastAlert } = useToast(); // form info const { control, @@ -59,8 +55,8 @@ export const SignUpPasswordForm: React.FC = observer((props) => { .passwordSignIn(payload) .then(async () => await onSubmit()) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) @@ -138,7 +134,7 @@ export const SignUpPasswordForm: React.FC = observer((props) => {
)} /> -

+

This password will continue to be your account{"'"}s password.

diff --git a/web/components/account/sign-up-forms/root.tsx b/web/components/account/sign-up-forms/root.tsx index 8eeb5e99f..455112e9e 100644 --- a/web/components/account/sign-up-forms/root.tsx +++ b/web/components/account/sign-up-forms/root.tsx @@ -1,9 +1,7 @@ import React, { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useEventTracker } from "hooks/store"; -import useSignInRedirection from "hooks/use-sign-in-redirection"; -// components +import Link from "next/link"; import { OAuthOptions, SignUpEmailForm, @@ -11,9 +9,11 @@ import { SignUpPasswordForm, SignUpUniqueCodeForm, } from "components/account"; -import Link from "next/link"; -// constants import { NAVIGATE_TO_SIGNIN } from "constants/event-tracker"; +import { useApplication, useEventTracker } from "hooks/store"; +import useSignInRedirection from "hooks/use-sign-in-redirection"; +// components +// constants export enum ESignUpSteps { EMAIL = "EMAIL", diff --git a/web/components/account/sign-up-forms/unique-code.tsx b/web/components/account/sign-up-forms/unique-code.tsx index 1b54ef9eb..28581aed4 100644 --- a/web/components/account/sign-up-forms/unique-code.tsx +++ b/web/components/account/sign-up-forms/unique-code.tsx @@ -3,20 +3,20 @@ import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; // services +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; + +import { CODE_VERIFIED } from "constants/event-tracker"; +import { checkEmailValidity } from "helpers/string.helper"; +import { useEventTracker } from "hooks/store"; +import useTimer from "hooks/use-timer"; import { AuthService } from "services/auth.service"; import { UserService } from "services/user.service"; // hooks -import useToast from "hooks/use-toast"; -import useTimer from "hooks/use-timer"; -import { useEventTracker } from "hooks/store"; // ui -import { Button, Input } from "@plane/ui"; // helpers -import { checkEmailValidity } from "helpers/string.helper"; // types import { IEmailCheckData, IMagicSignInData } from "@plane/types"; // constants -import { CODE_VERIFIED } from "constants/event-tracker"; type Props = { email: string; @@ -44,8 +44,6 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { const [isRequestingNewCode, setIsRequestingNewCode] = useState(false); // store hooks const { captureEvent } = useEventTracker(); - // toast alert - const { setToastAlert } = useToast(); // timer const { timer: resendTimerCode, setTimer: setResendCodeTimer } = useTimer(30); // form info @@ -84,8 +82,8 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { captureEvent(CODE_VERIFIED, { state: "FAILED", }); - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }); @@ -101,8 +99,8 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { .generateUniqueCode(payload) .then(() => { setResendCodeTimer(30); - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "A new unique code has been sent to your email.", }); @@ -112,8 +110,8 @@ export const SignUpUniqueCodeForm: React.FC = (props) => { }); }) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) diff --git a/web/components/analytics/custom-analytics/custom-analytics.tsx b/web/components/analytics/custom-analytics/custom-analytics.tsx index 0c3ec8925..1159689c6 100644 --- a/web/components/analytics/custom-analytics/custom-analytics.tsx +++ b/web/components/analytics/custom-analytics/custom-analytics.tsx @@ -1,17 +1,17 @@ -import { useRouter } from "next/router"; -import useSWR from "swr"; -import { useForm } from "react-hook-form"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import { useForm } from "react-hook-form"; +import useSWR from "swr"; // services -import { AnalyticsService } from "services/analytics.service"; // components import { CustomAnalyticsSelectBar, CustomAnalyticsMainContent, CustomAnalyticsSidebar } from "components/analytics"; // types -import { IAnalyticsParams } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; import { cn } from "helpers/common.helper"; import { useApplication } from "hooks/store"; +import { AnalyticsService } from "services/analytics.service"; +import { IAnalyticsParams } from "@plane/types"; type Props = { additionalParams?: Partial; diff --git a/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx b/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx index ec7c40195..b90e9994f 100644 --- a/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx +++ b/web/components/analytics/custom-analytics/graph/custom-tooltip.tsx @@ -60,8 +60,8 @@ export const CustomTooltip: React.FC = ({ datum, analytics, params }) => ? "capitalize" : "" : params.x_axis === "priority" || params.x_axis === "state__group" - ? "capitalize" - : "" + ? "capitalize" + : "" }`} > {params.segment === "assignees__id" ? renderAssigneeName(tooltipValue.toString()) : tooltipValue}: diff --git a/web/components/analytics/custom-analytics/graph/index.tsx b/web/components/analytics/custom-analytics/graph/index.tsx index 51b4089c4..0e70fd898 100644 --- a/web/components/analytics/custom-analytics/graph/index.tsx +++ b/web/components/analytics/custom-analytics/graph/index.tsx @@ -1,15 +1,15 @@ // nivo import { BarDatum } from "@nivo/bar"; // components -import { CustomTooltip } from "./custom-tooltip"; import { Tooltip } from "@plane/ui"; // ui import { BarGraph } from "components/ui"; // helpers -import { findStringWithMostCharacters } from "helpers/array.helper"; import { generateBarColor, generateDisplayName } from "helpers/analytics.helper"; +import { findStringWithMostCharacters } from "helpers/array.helper"; // types import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types"; +import { CustomTooltip } from "./custom-tooltip"; type Props = { analytics: IAnalyticsResponse; @@ -101,8 +101,8 @@ export const AnalyticsGraph: React.FC = ({ analytics, barGraphData, param ? generateDisplayName(datum.value, analytics, params, "x_axis")[0].toUpperCase() : "?" : datum.value && datum.value !== "None" - ? `${datum.value}`.toUpperCase()[0] - : "?"} + ? `${datum.value}`.toUpperCase()[0] + : "?"} diff --git a/web/components/analytics/custom-analytics/main-content.tsx b/web/components/analytics/custom-analytics/main-content.tsx index 3c199f807..e13b9cdd1 100644 --- a/web/components/analytics/custom-analytics/main-content.tsx +++ b/web/components/analytics/custom-analytics/main-content.tsx @@ -2,15 +2,15 @@ import { useRouter } from "next/router"; import { mutate } from "swr"; // components +import { Button, Loader } from "@plane/ui"; import { AnalyticsGraph, AnalyticsTable } from "components/analytics"; // ui -import { Button, Loader } from "@plane/ui"; // helpers +import { ANALYTICS } from "constants/fetch-keys"; import { convertResponseToBarGraphData } from "helpers/analytics.helper"; // types import { IAnalyticsParams, IAnalyticsResponse } from "@plane/types"; // fetch-keys -import { ANALYTICS } from "constants/fetch-keys"; type Props = { analytics: IAnalyticsResponse | undefined; @@ -33,7 +33,7 @@ export const CustomAnalyticsMainContent: React.FC = (props) => { {!error ? ( analytics ? ( analytics.total > 0 ? ( -
+
= observer((props) => { return (
{!isProjectLevel && (
diff --git a/web/components/analytics/custom-analytics/select/project.tsx b/web/components/analytics/custom-analytics/select/project.tsx index 3c08e1574..61c3acb09 100644 --- a/web/components/analytics/custom-analytics/select/project.tsx +++ b/web/components/analytics/custom-analytics/select/project.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react-lite"; // hooks +import { CustomSearchSelect } from "@plane/ui"; import { useProject } from "hooks/store"; // ui -import { CustomSearchSelect } from "@plane/ui"; type Props = { value: string[] | undefined; diff --git a/web/components/analytics/custom-analytics/select/segment.tsx b/web/components/analytics/custom-analytics/select/segment.tsx index 055665d9e..de94eac62 100644 --- a/web/components/analytics/custom-analytics/select/segment.tsx +++ b/web/components/analytics/custom-analytics/select/segment.tsx @@ -3,9 +3,9 @@ import { useRouter } from "next/router"; // ui import { CustomSelect } from "@plane/ui"; // types +import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; import { IAnalyticsParams, TXAxisValues } from "@plane/types"; // constants -import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; type Props = { value: TXAxisValues | null | undefined; diff --git a/web/components/analytics/custom-analytics/select/x-axis.tsx b/web/components/analytics/custom-analytics/select/x-axis.tsx index 74ee99a77..9daecaaa0 100644 --- a/web/components/analytics/custom-analytics/select/x-axis.tsx +++ b/web/components/analytics/custom-analytics/select/x-axis.tsx @@ -3,9 +3,9 @@ import { useRouter } from "next/router"; // ui import { CustomSelect } from "@plane/ui"; // types +import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; import { IAnalyticsParams, TXAxisValues } from "@plane/types"; // constants -import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics"; type Props = { value: TXAxisValues; diff --git a/web/components/analytics/custom-analytics/select/y-axis.tsx b/web/components/analytics/custom-analytics/select/y-axis.tsx index 9f66c6b54..92e4fd2e5 100644 --- a/web/components/analytics/custom-analytics/select/y-axis.tsx +++ b/web/components/analytics/custom-analytics/select/y-axis.tsx @@ -1,9 +1,9 @@ // ui import { CustomSelect } from "@plane/ui"; // types +import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics"; import { TYAxisValues } from "@plane/types"; // constants -import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics"; type Props = { value: TYAxisValues; diff --git a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx index f7ba07b75..31812cb00 100644 --- a/web/components/analytics/custom-analytics/sidebar/projects-list.tsx +++ b/web/components/analytics/custom-analytics/sidebar/projects-list.tsx @@ -1,11 +1,11 @@ import { observer } from "mobx-react-lite"; // hooks -import { useProject } from "hooks/store"; // icons import { Contrast, LayoutGrid, Users } from "lucide-react"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; import { truncateText } from "helpers/string.helper"; +import { useProject } from "hooks/store"; +import { ProjectLogo } from "components/project"; type Props = { projectIds: string[]; @@ -19,7 +19,7 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro return (

Selected Projects

-
+
{projectIds.map((projectId) => { const project = getProjectById(projectId); @@ -28,21 +28,15 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro return (
- {project.emoji ? ( - {renderEmoji(project.emoji)} - ) : project.icon_prop ? ( -
{renderEmoji(project.icon_prop)}
- ) : ( - - {project?.name.charAt(0)} - - )} +
+ +

{truncateText(project.name, 20)}

({project.identifier})
-
+
diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx index ee677fe91..26f97e8f9 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar-header.tsx @@ -1,12 +1,13 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks -import { useCycle, useMember, useModule, useProject } from "hooks/store"; -// helpers -import { renderEmoji } from "helpers/emoji.helper"; -import { renderFormattedDate } from "helpers/date-time.helper"; -// constants import { NETWORK_CHOICES } from "constants/project"; +import { renderFormattedDate } from "helpers/date-time.helper"; +import { useCycle, useMember, useModule, useProject } from "hooks/store"; +// components +import { ProjectLogo } from "components/project"; +// helpers +// constants export const CustomAnalyticsSidebarHeader = observer(() => { const router = useRouter(); @@ -20,7 +21,8 @@ export const CustomAnalyticsSidebarHeader = observer(() => { const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const moduleDetails = moduleId ? getModuleById(moduleId.toString()) : undefined; const projectDetails = projectId ? getProjectById(projectId.toString()) : undefined; - const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; + const moduleLeadDetails = moduleDetails && moduleDetails.lead_id ? getUserDetails(moduleDetails.lead_id) : undefined; return ( <> @@ -57,7 +59,7 @@ export const CustomAnalyticsSidebarHeader = observer(() => {
Lead
- {moduleDetails.lead_detail?.display_name} + {moduleLeadDetails && {moduleLeadDetails?.display_name}}
Start Date
@@ -80,15 +82,9 @@ export const CustomAnalyticsSidebarHeader = observer(() => { ) : (
- {projectDetails?.emoji ? ( -
{renderEmoji(projectDetails.emoji)}
- ) : projectDetails?.icon_prop ? ( -
- {renderEmoji(projectDetails.icon_prop)} -
- ) : ( - - {projectDetails?.name.charAt(0)} + {projectDetails && ( + + )}

{projectDetails?.name}

diff --git a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx index c2e12dc3c..7a7c52377 100644 --- a/web/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -1,25 +1,24 @@ -import { useEffect, } from "react"; -import { useRouter } from "next/router"; +import { useEffect } from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { mutate } from "swr"; // services -import { AnalyticsService } from "services/analytics.service"; // hooks -import { useCycle, useModule, useProject, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; // components -import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; // ui -import { Button, LayersIcon } from "@plane/ui"; -// icons import { CalendarDays, Download, RefreshCw } from "lucide-react"; +import { Button, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui"; +// icons +import { CustomAnalyticsSidebarHeader, CustomAnalyticsSidebarProjectsList } from "components/analytics"; // helpers -import { renderFormattedDate } from "helpers/date-time.helper"; // types -import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; // fetch-keys import { ANALYTICS } from "constants/fetch-keys"; import { cn } from "helpers/common.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; +import { useCycle, useModule, useProject, useUser, useWorkspace } from "hooks/store"; +import { AnalyticsService } from "services/analytics.service"; +import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IWorkspace } from "@plane/types"; type Props = { analytics: IAnalyticsResponse | undefined; @@ -34,11 +33,11 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { // router const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - // toast alert - const { setToastAlert } = useToast(); // store hooks const { currentUser } = useUser(); const { workspaceProjectIds, getProjectById } = useProject(); + const { getWorkspaceById } = useWorkspace(); + const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchModuleDetails, getModuleById } = useModule(); @@ -70,11 +69,14 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { if (cycleDetails || moduleDetails) { const details = cycleDetails || moduleDetails; - eventPayload.workspaceId = details?.workspace_detail?.id; - eventPayload.workspaceName = details?.workspace_detail?.name; - eventPayload.projectId = details?.project_detail.id; - eventPayload.projectIdentifier = details?.project_detail.identifier; - eventPayload.projectName = details?.project_detail.name; + const currentProjectDetails = getProjectById(details?.project_id || ""); + const currentWorkspaceDetails = getWorkspaceById(details?.workspace_id || ""); + + eventPayload.workspaceId = details?.workspace_id; + eventPayload.workspaceName = currentWorkspaceDetails?.name; + eventPayload.projectId = details?.project_id; + eventPayload.projectIdentifier = currentProjectDetails?.identifier; + eventPayload.projectName = currentProjectDetails?.name; } if (cycleDetails) { @@ -102,8 +104,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { analyticsService .exportAnalytics(workspaceSlug.toString(), data) .then((res) => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: res.message, }); @@ -111,8 +113,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { trackExportAnalytics(); }) .catch(() => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "There was some error in exporting the analytics. Please try again.", }) @@ -138,14 +140,18 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { const selectedProjects = params.project && params.project.length > 0 ? params.project : workspaceProjectIds; - return ( -
- {analytics ? analytics.total : "..."}
Issues
+ {analytics ? analytics.total : "..."} +
Issues
{isProjectLevel && (
@@ -154,8 +160,8 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => { (cycleId ? cycleDetails?.created_at : moduleId - ? moduleDetails?.created_at - : projectDetails?.created_at) ?? "" + ? moduleDetails?.created_at + : projectDetails?.created_at) ?? "" )}
)} @@ -170,10 +176,10 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => {
-
+
diff --git a/web/components/automation/auto-close-automation.tsx b/web/components/automation/auto-close-automation.tsx index 8d6662c11..000f0bbf6 100644 --- a/web/components/automation/auto-close-automation.tsx +++ b/web/components/automation/auto-close-automation.tsx @@ -1,16 +1,16 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; // hooks +import { ArchiveX } from "lucide-react"; +import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui"; +import { SelectMonthModal } from "components/automation"; +import { EUserProjectRoles, PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { useProject, useProjectState, useUser } from "hooks/store"; // component -import { SelectMonthModal } from "components/automation"; -import { CustomSelect, CustomSearchSelect, ToggleSwitch, StateGroupIcon, DoubleCircleIcon, Loader } from "@plane/ui"; // icons -import { ArchiveX } from "lucide-react"; // types import { IProject } from "@plane/types"; // constants -import { EUserProjectRoles, PROJECT_AUTOMATION_MONTHS } from "constants/project"; type Props = { handleChange: (formData: Partial) => Promise; @@ -74,7 +74,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => {

Auto-close issues

- Plane will automatically close issue that haven{"'"}t been completed or cancelled. + Plane will automatically close issue that haven{"'"}t been completed or canceled.

@@ -100,7 +100,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { { handleChange({ close_in: val }); @@ -119,7 +119,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { className="flex w-full select-none items-center rounded px-1 py-1.5 text-custom-text-200 hover:bg-custom-background-80" onClick={() => setmonthModal(true)} > - Customize Time Range + Customize time range diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index 1d306bb04..01d07f64a 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -72,7 +72,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen,
- Customise Time Range + Customize time range
diff --git a/web/components/breadcrumbs/index.tsx b/web/components/breadcrumbs/index.tsx index 16fa1e333..de93cdec3 100644 --- a/web/components/breadcrumbs/index.tsx +++ b/web/components/breadcrumbs/index.tsx @@ -1,6 +1,6 @@ import * as React from "react"; -import { useRouter } from "next/router"; import Link from "next/link"; +import { useRouter } from "next/router"; // icons import { MoveLeft } from "lucide-react"; diff --git a/web/components/command-palette/actions/help-actions.tsx b/web/components/command-palette/actions/help-actions.tsx index 4aaaab33a..34317846a 100644 --- a/web/components/command-palette/actions/help-actions.tsx +++ b/web/components/command-palette/actions/help-actions.tsx @@ -1,9 +1,9 @@ import { Command } from "cmdk"; import { FileText, GithubIcon, MessageSquare, Rocket } from "lucide-react"; // hooks +import { DiscordIcon } from "@plane/ui"; import { useApplication } from "hooks/store"; // ui -import { DiscordIcon } from "@plane/ui"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/actions/issue-actions/actions-list.tsx b/web/components/command-palette/actions/issue-actions/actions-list.tsx index 55f72c85d..98059af39 100644 --- a/web/components/command-palette/actions/issue-actions/actions-list.tsx +++ b/web/components/command-palette/actions/issue-actions/actions-list.tsx @@ -1,18 +1,16 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { LinkIcon, Signal, Trash2, UserMinus2, UserPlus2 } from "lucide-react"; // hooks -import { useApplication, useUser, useIssues } from "hooks/store"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; -// helpers +import { DoubleCircleIcon, UserGroupIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { EIssuesStoreType } from "constants/issue"; import { copyTextToClipboard } from "helpers/string.helper"; +import { useApplication, useUser, useIssues } from "hooks/store"; +// ui +// helpers // types import { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; type Props = { closePalette: () => void; @@ -37,8 +35,6 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { } = useApplication(); const { currentUser } = useUser(); - const { setToastAlert } = useToast(); - const handleUpdateIssue = async (formData: Partial) => { if (!workspaceSlug || !projectId || !issueDetails) return; @@ -71,14 +67,14 @@ export const CommandPaletteIssueActions: React.FC = observer((props) => { const url = new URL(window.location.href); copyTextToClipboard(url.href) .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard", }); }) .catch(() => { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Some error occurred", }); }); diff --git a/web/components/command-palette/actions/issue-actions/change-assignee.tsx b/web/components/command-palette/actions/issue-actions/change-assignee.tsx index 96fba41f6..18b11e129 100644 --- a/web/components/command-palette/actions/issue-actions/change-assignee.tsx +++ b/web/components/command-palette/actions/issue-actions/change-assignee.tsx @@ -1,14 +1,14 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { Check } from "lucide-react"; // mobx store +import { Avatar } from "@plane/ui"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues, useMember } from "hooks/store"; // ui -import { Avatar } from "@plane/ui"; // types import { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/actions/issue-actions/change-priority.tsx b/web/components/command-palette/actions/issue-actions/change-priority.tsx index 8d1c48261..d07866833 100644 --- a/web/components/command-palette/actions/issue-actions/change-priority.tsx +++ b/web/components/command-palette/actions/issue-actions/change-priority.tsx @@ -1,15 +1,15 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { Check } from "lucide-react"; // mobx store +import { PriorityIcon } from "@plane/ui"; +import { EIssuesStoreType, ISSUE_PRIORITIES } from "constants/issue"; import { useIssues } from "hooks/store"; // ui -import { PriorityIcon } from "@plane/ui"; // types import { TIssue, TIssuePriorities } from "@plane/types"; // constants -import { EIssuesStoreType, ISSUE_PRIORITIES } from "constants/issue"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/actions/issue-actions/change-state.tsx b/web/components/command-palette/actions/issue-actions/change-state.tsx index 7841a4a1e..d208facc9 100644 --- a/web/components/command-palette/actions/issue-actions/change-state.tsx +++ b/web/components/command-palette/actions/issue-actions/change-state.tsx @@ -1,15 +1,15 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { Command } from "cmdk"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { Check } from "lucide-react"; +import { Spinner, StateGroupIcon } from "@plane/ui"; +import { EIssuesStoreType } from "constants/issue"; import { useProjectState, useIssues } from "hooks/store"; // ui -import { Spinner, StateGroupIcon } from "@plane/ui"; // icons -import { Check } from "lucide-react"; // types import { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/actions/project-actions.tsx b/web/components/command-palette/actions/project-actions.tsx index bdd08a0d8..d1589cb92 100644 --- a/web/components/command-palette/actions/project-actions.tsx +++ b/web/components/command-palette/actions/project-actions.tsx @@ -1,9 +1,9 @@ import { Command } from "cmdk"; import { ContrastIcon, FileText } from "lucide-react"; // hooks +import { DiceIcon, PhotoFilterIcon } from "@plane/ui"; import { useApplication, useEventTracker } from "hooks/store"; // ui -import { DiceIcon, PhotoFilterIcon } from "@plane/ui"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/actions/search-results.tsx b/web/components/command-palette/actions/search-results.tsx index 769a26be7..5398d889d 100644 --- a/web/components/command-palette/actions/search-results.tsx +++ b/web/components/command-palette/actions/search-results.tsx @@ -1,5 +1,5 @@ -import { useRouter } from "next/router"; import { Command } from "cmdk"; +import { useRouter } from "next/router"; // helpers import { commandGroups } from "components/command-palette"; // types diff --git a/web/components/command-palette/actions/theme-actions.tsx b/web/components/command-palette/actions/theme-actions.tsx index 976a63c87..fe4a9fa20 100644 --- a/web/components/command-palette/actions/theme-actions.tsx +++ b/web/components/command-palette/actions/theme-actions.tsx @@ -1,13 +1,14 @@ import React, { FC, useEffect, useState } from "react"; import { Command } from "cmdk"; +import { observer } from "mobx-react-lite"; import { useTheme } from "next-themes"; import { Settings } from "lucide-react"; -import { observer } from "mobx-react-lite"; // hooks -import { useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; -// constants +import { TOAST_TYPE, setToast } from "@plane/ui"; import { THEME_OPTIONS } from "constants/themes"; +import { useUser } from "hooks/store"; +// ui +// constants type Props = { closePalette: () => void; @@ -21,15 +22,14 @@ export const CommandPaletteThemeActions: FC = observer((props) => { const { updateCurrentUserTheme } = useUser(); // hooks const { setTheme } = useTheme(); - const { setToastAlert } = useToast(); const updateUserTheme = async (newTheme: string) => { setTheme(newTheme); return updateCurrentUserTheme(newTheme).catch(() => { - setToastAlert({ + setToast({ + type: TOAST_TYPE.ERROR, title: "Failed to save user theme settings!", - type: "error", }); }); }; diff --git a/web/components/command-palette/actions/workspace-settings-actions.tsx b/web/components/command-palette/actions/workspace-settings-actions.tsx index 1f05234f4..5a2b2cd69 100644 --- a/web/components/command-palette/actions/workspace-settings-actions.tsx +++ b/web/components/command-palette/actions/workspace-settings-actions.tsx @@ -1,10 +1,10 @@ -import { useRouter } from "next/router"; import { Command } from "cmdk"; // hooks -import { useUser } from "hooks/store"; import Link from "next/link"; +import { useRouter } from "next/router"; // constants import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "constants/workspace"; +import { useUser } from "hooks/store"; type Props = { closePalette: () => void; diff --git a/web/components/command-palette/command-modal.tsx b/web/components/command-palette/command-modal.tsx index dbf349f9d..747075181 100644 --- a/web/components/command-palette/command-modal.tsx +++ b/web/components/command-palette/command-modal.tsx @@ -1,18 +1,12 @@ import React, { useEffect, useState } from "react"; +import { Command } from "cmdk"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import useSWR from "swr"; -import { Command } from "cmdk"; import { Dialog, Transition } from "@headlessui/react"; -import { observer } from "mobx-react-lite"; import { FolderPlus, Search, Settings } from "lucide-react"; // hooks -import { useApplication, useEventTracker, useProject } from "hooks/store"; -// services -import { WorkspaceService } from "services/workspace.service"; -import { IssueService } from "services/issue"; -// hooks -import useDebounce from "hooks/use-debounce"; -// components +import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; import { CommandPaletteThemeActions, ChangeIssueAssignee, @@ -24,11 +18,17 @@ import { CommandPaletteWorkspaceSettingsActions, CommandPaletteSearchResults, } from "components/command-palette"; -import { LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; +import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { useApplication, useEventTracker, useProject } from "hooks/store"; +// services +import useDebounce from "hooks/use-debounce"; +import { IssueService } from "services/issue"; +import { WorkspaceService } from "services/workspace.service"; +// hooks +// components // types import { IWorkspaceSearchResults } from "@plane/types"; // fetch-keys -import { ISSUE_DETAILS } from "constants/fetch-keys"; // services const workspaceService = new WorkspaceService(); @@ -154,237 +154,239 @@ export const CommandModal: React.FC = observer(() => {
-
- - -
- { - if (value.toLowerCase().includes(search.toLowerCase())) return 1; - return 0; - }} - onKeyDown={(e) => { - // when search is empty and page is undefined - // when user tries to close the modal with esc - if (e.key === "Escape" && !page && !searchTerm) closePalette(); +
+
+ + +
+ { + if (value.toLowerCase().includes(search.toLowerCase())) return 1; + return 0; + }} + onKeyDown={(e) => { + // when search is empty and page is undefined + // when user tries to close the modal with esc + if (e.key === "Escape" && !page && !searchTerm) closePalette(); - // Escape goes to previous page - // Backspace goes to previous page when search is empty - if (e.key === "Escape" || (e.key === "Backspace" && !searchTerm)) { - e.preventDefault(); - setPages((pages) => pages.slice(0, -1)); - setPlaceholder("Type a command or search..."); - } - }} - > -
pages.slice(0, -1)); + setPlaceholder("Type a command or search..."); + } + }} > - {issueDetails && ( -
- {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name} -
- )} - {projectId && ( - -
- - setIsWorkspaceLevel((prevData) => !prevData)} - /> +
+ {issueDetails && ( +
+ {projectDetails?.identifier}-{issueDetails.sequence_id} {issueDetails.name}
- - )} -
-
-
+ )} + {projectId && ( + +
+ + setIsWorkspaceLevel((prevData) => !prevData)} + /> +
+
+ )} +
+
+
- - {searchTerm !== "" && ( -
- Search results for{" "} - - {'"'} - {searchTerm} - {'"'} - {" "} - in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: -
- )} + + {searchTerm !== "" && ( +
+ Search results for{" "} + + {'"'} + {searchTerm} + {'"'} + {" "} + in {!projectId || isWorkspaceLevel ? "workspace" : "project"}: +
+ )} - {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( -
No results found.
- )} + {!isLoading && resultsCount === 0 && searchTerm !== "" && debouncedSearchTerm !== "" && ( +
No results found.
+ )} - {(isLoading || isSearching) && ( - - - - - - - - - )} + {(isLoading || isSearching) && ( + + + + + + + + + )} - {debouncedSearchTerm !== "" && ( - - )} + {debouncedSearchTerm !== "" && ( + + )} - {!page && ( - <> - {/* issue actions */} - {issueId && ( - setPages(newPages)} - setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} - setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} - /> - )} - - { - closePalette(); - setTrackElement("Command Palette"); - toggleCreateIssueModal(true); - }} - className="focus:bg-custom-background-80" - > -
- - Create new issue -
- C -
-
- - {workspaceSlug && ( - + {!page && ( + <> + {/* issue actions */} + {issueId && ( + setPages(newPages)} + setPlaceholder={(newPlaceholder) => setPlaceholder(newPlaceholder)} + setSearchTerm={(newSearchTerm) => setSearchTerm(newSearchTerm)} + /> + )} + { closePalette(); - setTrackElement("Command palette"); - toggleCreateProjectModal(true); + setTrackElement("Command Palette"); + toggleCreateIssueModal(true); + }} + className="focus:bg-custom-background-80" + > +
+ + Create new issue +
+ C +
+
+ + {workspaceSlug && ( + + { + closePalette(); + setTrackElement("Command palette"); + toggleCreateProjectModal(true); + }} + className="focus:outline-none" + > +
+ + Create new project +
+ P +
+
+ )} + + {/* project actions */} + {projectId && } + + + { + setPlaceholder("Search workspace settings..."); + setSearchTerm(""); + setPages([...pages, "settings"]); }} className="focus:outline-none" >
- - Create new project + + Search settings... +
+
+
+ + +
+ + Create new workspace +
+
+ { + setPlaceholder("Change interface theme..."); + setSearchTerm(""); + setPages([...pages, "change-interface-theme"]); + }} + className="focus:outline-none" + > +
+ + Change interface theme...
- P
- )} - {/* project actions */} - {projectId && } + {/* help options */} + + + )} - - { - setPlaceholder("Search workspace settings..."); - setSearchTerm(""); - setPages([...pages, "settings"]); - }} - className="focus:outline-none" - > -
- - Search settings... -
-
-
- - -
- - Create new workspace -
-
- { - setPlaceholder("Change interface theme..."); - setSearchTerm(""); - setPages([...pages, "change-interface-theme"]); - }} - className="focus:outline-none" - > -
- - Change interface theme... -
-
-
+ {/* workspace settings actions */} + {page === "settings" && workspaceSlug && ( + + )} - {/* help options */} - - - )} + {/* issue details page actions */} + {page === "change-issue-state" && issueDetails && ( + + )} + {page === "change-issue-priority" && issueDetails && ( + + )} + {page === "change-issue-assignee" && issueDetails && ( + + )} - {/* workspace settings actions */} - {page === "settings" && workspaceSlug && ( - - )} - - {/* issue details page actions */} - {page === "change-issue-state" && issueDetails && ( - - )} - {page === "change-issue-priority" && issueDetails && ( - - )} - {page === "change-issue-assignee" && issueDetails && ( - - )} - - {/* theme actions */} - {page === "change-interface-theme" && ( - { - closePalette(); - setPages((pages) => pages.slice(0, -1)); - }} - /> - )} -
- -
- - + {/* theme actions */} + {page === "change-interface-theme" && ( + { + closePalette(); + setPages((pages) => pages.slice(0, -1)); + }} + /> + )} + +
+
+
+
+
diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 396003589..ab2743afd 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -1,26 +1,28 @@ import React, { useCallback, useEffect, FC } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import useSWR from "swr"; -import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; -// components +import { TOAST_TYPE, setToast } from "@plane/ui"; + import { CommandModal, ShortcutsModal } from "components/command-palette"; +// ui +// components import { BulkDeleteIssuesModal } from "components/core"; import { CycleCreateUpdateModal } from "components/cycles"; import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; import { CreateUpdateModuleModal } from "components/modules"; +import { CreateUpdatePageModal } from "components/pages"; import { CreateProjectModal } from "components/project"; import { CreateUpdateProjectViewModal } from "components/views"; -import { CreateUpdatePageModal } from "components/pages"; // helpers -import { copyTextToClipboard } from "helpers/string.helper"; // services -import { IssueService } from "services/issue"; // fetch keys import { ISSUE_DETAILS } from "constants/fetch-keys"; import { EIssuesStoreType } from "constants/issue"; +import { copyTextToClipboard } from "helpers/string.helper"; +import { useApplication, useEventTracker, useIssues, useUser } from "hooks/store"; +import { IssueService } from "services/issue"; // services const issueService = new IssueService(); @@ -63,8 +65,6 @@ export const CommandPalette: FC = observer(() => { createIssueStoreType, } = commandPalette; - const { setToastAlert } = useToast(); - const { data: issueDetails } = useSWR( workspaceSlug && projectId && issueId ? ISSUE_DETAILS(issueId as string) : null, workspaceSlug && projectId && issueId @@ -78,18 +78,18 @@ export const CommandPalette: FC = observer(() => { const url = new URL(window.location.href); copyTextToClipboard(url.href) .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard", }); }) .catch(() => { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Some error occurred", }); }); - }, [setToastAlert, issueId]); + }, [issueId]); const handleKeyDown = useCallback( (e: KeyboardEvent) => { diff --git a/web/components/command-palette/helpers.tsx b/web/components/command-palette/helpers.tsx index 44fc55bbe..2d6a38c71 100644 --- a/web/components/command-palette/helpers.tsx +++ b/web/components/command-palette/helpers.tsx @@ -1,6 +1,6 @@ // types -import { ContrastIcon, DiceIcon, LayersIcon, PhotoFilterIcon } from "@plane/ui"; import { Briefcase, FileText, LayoutGrid } from "lucide-react"; +import { ContrastIcon, DiceIcon, LayersIcon, PhotoFilterIcon } from "@plane/ui"; import { IWorkspaceDefaultSearchResult, IWorkspaceIssueSearchResult, diff --git a/web/components/command-palette/shortcuts-modal/modal.tsx b/web/components/command-palette/shortcuts-modal/modal.tsx index 3054bdb28..97a9c9891 100644 --- a/web/components/command-palette/shortcuts-modal/modal.tsx +++ b/web/components/command-palette/shortcuts-modal/modal.tsx @@ -2,9 +2,9 @@ import { FC, useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; import { Search, X } from "lucide-react"; // components +import { Input } from "@plane/ui"; import { ShortcutCommandsList } from "components/command-palette"; // ui -import { Input } from "@plane/ui"; type Props = { isOpen: boolean; diff --git a/web/components/common/breadcrumb-link.tsx b/web/components/common/breadcrumb-link.tsx index e5f1dbce6..dfa437231 100644 --- a/web/components/common/breadcrumb-link.tsx +++ b/web/components/common/breadcrumb-link.tsx @@ -1,5 +1,5 @@ -import { Tooltip } from "@plane/ui"; import Link from "next/link"; +import { Tooltip } from "@plane/ui"; type Props = { label?: string; diff --git a/web/components/common/product-updates-modal.tsx b/web/components/common/product-updates-modal.tsx index cd0a5b9ff..20b8b815e 100644 --- a/web/components/common/product-updates-modal.tsx +++ b/web/components/common/product-updates-modal.tsx @@ -3,14 +3,14 @@ import useSWR from "swr"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // services -import { WorkspaceService } from "services/workspace.service"; // components -import { MarkdownRenderer } from "components/ui"; -import { Loader } from "@plane/ui"; -// icons import { X } from "lucide-react"; +import { Loader } from "@plane/ui"; +import { MarkdownRenderer } from "components/ui"; +// icons // helpers import { renderFormattedDate } from "helpers/date-time.helper"; +import { WorkspaceService } from "services/workspace.service"; type Props = { isOpen: boolean; diff --git a/web/components/core/activity.tsx b/web/components/core/activity.tsx index 72a67883e..020e88ccc 100644 --- a/web/components/core/activity.tsx +++ b/web/components/core/activity.tsx @@ -1,10 +1,8 @@ -import { useRouter } from "next/router"; import { useEffect } from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // store hooks -import { useEstimate, useLabel } from "hooks/store"; // icons -import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui"; import { TagIcon, CopyPlus, @@ -20,9 +18,11 @@ import { MessageSquareIcon, UsersIcon, } from "lucide-react"; +import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; +import { useEstimate, useLabel } from "hooks/store"; // types import { IIssueActivity } from "@plane/types"; diff --git a/web/components/core/filters/date-filter-modal.tsx b/web/components/core/filters/date-filter-modal.tsx index 9b460bf28..3d7e78ba1 100644 --- a/web/components/core/filters/date-filter-modal.tsx +++ b/web/components/core/filters/date-filter-modal.tsx @@ -1,15 +1,14 @@ import { Fragment } from "react"; +import { DayPicker } from "react-day-picker"; import { Controller, useForm } from "react-hook-form"; -import DatePicker from "react-datepicker"; import { Dialog, Transition } from "@headlessui/react"; +import { X } from "lucide-react"; // components -import { DateFilterSelect } from "./date-filter-select"; // ui import { Button } from "@plane/ui"; -// icons -import { X } from "lucide-react"; // helpers import { renderFormattedPayloadDate, renderFormattedDate } from "helpers/date-time.helper"; +import { DateFilterSelect } from "./date-filter-select"; type Props = { title: string; @@ -38,7 +37,8 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o const handleFormSubmit = (formData: TFormValues) => { const { filterType, date1, date2 } = formData; - if (filterType === "range") onSelect([`${renderFormattedPayloadDate(date1)};after`, `${renderFormattedPayloadDate(date2)};before`]); + if (filterType === "range") + onSelect([`${renderFormattedPayloadDate(date1)};after`, `${renderFormattedPayloadDate(date2)};before`]); else onSelect([`${renderFormattedPayloadDate(date1)};${filterType}`]); handleClose(); @@ -46,9 +46,6 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o const isInvalid = watch("filterType") === "range" ? new Date(watch("date1")) > new Date(watch("date2")) : false; - const nextDay = new Date(watch("date1")); - nextDay.setDate(nextDay.getDate() + 1); - return ( @@ -91,12 +88,13 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o control={control} name="date1" render={({ field: { value, onChange } }) => ( - onChange(val)} - dateFormat="dd-MM-yyyy" - calendarClassName="h-full" - inline + onChange(date)} + mode="single" + disabled={[{ after: new Date(watch("date2")) }]} + className="border border-custom-border-200 p-3 rounded-md" /> )} /> @@ -105,13 +103,13 @@ export const DateFilterModal: React.FC = ({ title, handleClose, isOpen, o control={control} name="date2" render={({ field: { value, onChange } }) => ( - onChange(date)} + mode="single" + disabled={[{ before: new Date(watch("date1")) }]} + className="border border-custom-border-200 p-3 rounded-md" /> )} /> diff --git a/web/components/core/filters/date-filter-select.tsx b/web/components/core/filters/date-filter-select.tsx index 2585e2f95..47207e0cc 100644 --- a/web/components/core/filters/date-filter-select.tsx +++ b/web/components/core/filters/date-filter-select.tsx @@ -1,10 +1,7 @@ import React from "react"; - +import { CalendarDays } from "lucide-react"; // ui import { CustomSelect, CalendarAfterIcon, CalendarBeforeIcon } from "@plane/ui"; -// icons -import { CalendarDays } from "lucide-react"; -// fetch-keys type Props = { title: string; @@ -22,17 +19,17 @@ const dueDateRange: DueDate[] = [ { name: "before", value: "before", - icon: , + icon: , }, { name: "after", value: "after", - icon: , + icon: , }, { name: "range", value: "range", - icon: , + icon: , }, ]; @@ -51,10 +48,10 @@ export const DateFilterSelect: React.FC = ({ title, value, onChange }) => > {dueDateRange.map((option, index) => ( - <> +
{option.icon} {title} {option.name} - +
))} diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index b2e4c4c9f..3db409bb0 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -1,22 +1,22 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; +import { observer } from "mobx-react-lite"; import Image from "next/image"; import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import useSWR from "swr"; import { useDropzone } from "react-dropzone"; -import { Tab, Popover } from "@headlessui/react"; import { Control, Controller } from "react-hook-form"; +import useSWR from "swr"; +import { Tab, Popover } from "@headlessui/react"; // hooks +import { Button, Input, Loader } from "@plane/ui"; +import { MAX_FILE_SIZE } from "constants/common"; import { useApplication, useWorkspace } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; // services +import useOutsideClickDetector from "hooks/use-outside-click-detector"; import { FileService } from "services/file.service"; // hooks -import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { Button, Input, Loader } from "@plane/ui"; // constants -import { MAX_FILE_SIZE } from "constants/common"; const tabOptions = [ { @@ -187,7 +187,7 @@ export const ImagePickerPopover: React.FC = observer((props) => { ); })} - + {(unsplashImages || !unsplashError) && (
diff --git a/web/components/core/index.ts b/web/components/core/index.ts index 4f99f3606..f68ff5f3c 100644 --- a/web/components/core/index.ts +++ b/web/components/core/index.ts @@ -4,3 +4,4 @@ export * from "./sidebar"; export * from "./theme"; export * from "./activity"; export * from "./image-picker-popover"; +export * from "./page-title"; diff --git a/web/components/core/modals/bulk-delete-issues-modal.tsx b/web/components/core/modals/bulk-delete-issues-modal.tsx index f5eab83ef..94d665fa7 100644 --- a/web/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal.tsx @@ -1,27 +1,26 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { SubmitHandler, useForm } from "react-hook-form"; +import useSWR from "swr"; import { Combobox, Dialog, Transition } from "@headlessui/react"; // services -import { IssueService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -// ui -import { Button, LayersIcon } from "@plane/ui"; -// icons import { Search } from "lucide-react"; +import { Button, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui"; + +import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; +import { EIssuesStoreType } from "constants/issue"; +import { useIssues, useProject } from "hooks/store"; +import { IssueService } from "services/issue"; +// ui +// icons // types import { IUser, TIssue } from "@plane/types"; // fetch keys -import { PROJECT_ISSUES_LIST } from "constants/fetch-keys"; // store hooks -import { useIssues, useProject } from "hooks/store"; // components import { BulkDeleteIssuesModalItem } from "./bulk-delete-issues-modal-item"; // constants -import { EIssuesStoreType } from "constants/issue"; type FormInput = { delete_issue_ids: string[]; @@ -49,12 +48,12 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { const [query, setQuery] = useState(""); // fetching project issues. const { data: issues } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, - workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null + workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, + workspaceSlug && projectId && isOpen + ? () => issueService.getIssues(workspaceSlug as string, projectId as string) + : null ); - const { setToastAlert } = useToast(); - const { handleSubmit, watch, @@ -77,8 +76,8 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { if (!workspaceSlug || !projectId) return; if (!data.delete_issue_ids || data.delete_issue_ids.length === 0) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Please select at least one issue.", }); @@ -89,16 +88,16 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { await removeBulkIssues(workspaceSlug as string, projectId as string, data.delete_issue_ids) .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Issues deleted successfully!", }); handleClose(); }) .catch(() => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Something went wrong. Please try again.", }) diff --git a/web/components/core/modals/existing-issues-list-modal.tsx b/web/components/core/modals/existing-issues-list-modal.tsx index c4fa25c6d..3e3c2871c 100644 --- a/web/components/core/modals/existing-issues-list-modal.tsx +++ b/web/components/core/modals/existing-issues-list-modal.tsx @@ -2,12 +2,12 @@ import React, { useEffect, useState } from "react"; import { Combobox, Dialog, Transition } from "@headlessui/react"; import { Rocket, Search, X } from "lucide-react"; // services -import { ProjectService } from "services/project"; -// hooks -import useToast from "hooks/use-toast"; +import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; + import useDebounce from "hooks/use-debounce"; + +import { ProjectService } from "services/project"; // ui -import { Button, LayersIcon, Loader, ToggleSwitch, Tooltip } from "@plane/ui"; // types import { ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; @@ -43,8 +43,6 @@ export const ExistingIssuesListModal: React.FC = (props) => { const debouncedSearchTerm: string = useDebounce(searchTerm, 500); - const { setToastAlert } = useToast(); - const handleClose = () => { onClose(); setSearchTerm(""); @@ -54,8 +52,8 @@ export const ExistingIssuesListModal: React.FC = (props) => { const onSubmit = async () => { if (selectedIssues.length === 0) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Please select at least one issue.", }); @@ -68,12 +66,6 @@ export const ExistingIssuesListModal: React.FC = (props) => { await handleOnSubmit(selectedIssues).finally(() => setIsSubmitting(false)); handleClose(); - - setToastAlert({ - title: "Success", - type: "success", - message: `Issue${selectedIssues.length > 1 ? "s" : ""} added successfully`, - }); }; useEffect(() => { @@ -184,7 +176,10 @@ export const ExistingIssuesListModal: React.FC = (props) => { )}
- + {searchTerm !== "" && (
Search results for{" "} diff --git a/web/components/core/modals/gpt-assistant-popover.tsx b/web/components/core/modals/gpt-assistant-popover.tsx index 590015e12..ecb4aec52 100644 --- a/web/components/core/modals/gpt-assistant-popover.tsx +++ b/web/components/core/modals/gpt-assistant-popover.tsx @@ -1,17 +1,16 @@ import React, { useEffect, useState, useRef, Fragment } from "react"; +import { Placement } from "@popperjs/core"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; // services -import { AIService } from "services/ai.service"; -// hooks -import useToast from "hooks/use-toast"; import { usePopper } from "react-popper"; -// ui -import { Button, Input } from "@plane/ui"; -// components import { RichReadOnlyEditorWithRef } from "@plane/rich-text-editor"; import { Popover, Transition } from "@headlessui/react"; +// hooks +// ui +import { Button, Input, TOAST_TYPE, setToast } from "@plane/ui"; +// components // types -import { Placement } from "@popperjs/core"; +import { AIService } from "services/ai.service"; type Props = { isOpen: boolean; @@ -44,8 +43,6 @@ export const GptAssistantPopover: React.FC = (props) => { // router const router = useRouter(); const { workspaceSlug } = router.query; - // toast alert - const { setToastAlert } = useToast(); // popper const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "auto", @@ -78,8 +75,8 @@ export const GptAssistantPopover: React.FC = (props) => { ? error || "You have reached the maximum number of requests of 50 requests per month per user." : error || "Some error occurred. Please try again."; - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: errorMessage, }); @@ -104,8 +101,8 @@ export const GptAssistantPopover: React.FC = (props) => { }; const handleInvalidTask = () => { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Please enter some task to get AI assistance.", }); @@ -195,7 +192,7 @@ export const GptAssistantPopover: React.FC = (props) => { > = (props) => {
)} {response !== "" && ( -
+
Response: ${response}

`} diff --git a/web/components/core/modals/link-modal.tsx b/web/components/core/modals/link-modal.tsx index 1c1372e8d..70324b4b7 100644 --- a/web/components/core/modals/link-modal.tsx +++ b/web/components/core/modals/link-modal.tsx @@ -159,8 +159,8 @@ export const LinkModal: FC = (props) => { ? "Updating Link..." : "Update Link" : isSubmitting - ? "Adding Link..." - : "Add Link"} + ? "Adding Link..." + : "Add Link"}
diff --git a/web/components/core/modals/user-image-upload-modal.tsx b/web/components/core/modals/user-image-upload-modal.tsx index 6debc2c15..7f41b8225 100644 --- a/web/components/core/modals/user-image-upload-modal.tsx +++ b/web/components/core/modals/user-image-upload-modal.tsx @@ -3,17 +3,16 @@ import { observer } from "mobx-react-lite"; import { useDropzone } from "react-dropzone"; import { Transition, Dialog } from "@headlessui/react"; // hooks +import { UserCircle2 } from "lucide-react"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; + +import { MAX_FILE_SIZE } from "constants/common"; import { useApplication } from "hooks/store"; // services import { FileService } from "services/file.service"; -// hooks -import useToast from "hooks/use-toast"; // ui -import { Button } from "@plane/ui"; // icons -import { UserCircle2 } from "lucide-react"; // constants -import { MAX_FILE_SIZE } from "constants/common"; type Props = { handleDelete?: () => void; @@ -32,8 +31,6 @@ export const UserImageUploadModal: React.FC = observer((props) => { // states const [image, setImage] = useState(null); const [isImageUploading, setIsImageUploading] = useState(false); - // toast alert - const { setToastAlert } = useToast(); // store hooks const { config: { envConfig }, @@ -76,8 +73,8 @@ export const UserImageUploadModal: React.FC = observer((props) => { if (value) fileService.deleteUserFile(value); }) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) diff --git a/web/components/core/modals/workspace-image-upload-modal.tsx b/web/components/core/modals/workspace-image-upload-modal.tsx index e04ccf820..9c1a8363b 100644 --- a/web/components/core/modals/workspace-image-upload-modal.tsx +++ b/web/components/core/modals/workspace-image-upload-modal.tsx @@ -1,20 +1,18 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { useDropzone } from "react-dropzone"; import { Transition, Dialog } from "@headlessui/react"; // hooks +import { UserCircle2 } from "lucide-react"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { MAX_FILE_SIZE } from "constants/common"; import { useApplication, useWorkspace } from "hooks/store"; // services import { FileService } from "services/file.service"; -// hooks -import useToast from "hooks/use-toast"; // ui -import { Button } from "@plane/ui"; // icons -import { UserCircle2 } from "lucide-react"; // constants -import { MAX_FILE_SIZE } from "constants/common"; type Props = { handleRemove?: () => void; @@ -37,8 +35,6 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; - const { setToastAlert } = useToast(); - const { config: { envConfig }, } = useApplication(); @@ -83,8 +79,8 @@ export const WorkspaceImageUploadModal: React.FC = observer((props) => { if (value && currentWorkspace) fileService.deleteFile(currentWorkspace.id, value); }) .catch((err) => - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) diff --git a/web/components/core/page-title.tsx b/web/components/core/page-title.tsx new file mode 100644 index 000000000..f9f4e94b2 --- /dev/null +++ b/web/components/core/page-title.tsx @@ -0,0 +1,18 @@ +import Head from "next/head"; + +type PageHeadTitleProps = { + title?: string; + description?: string; +}; + +export const PageHead: React.FC = (props) => { + const { title } = props; + + if (!title) return null; + + return ( + + {title} + + ); +}; diff --git a/web/components/core/render-if-visible-HOC.tsx b/web/components/core/render-if-visible-HOC.tsx index 24ae19fe7..f0e9f59b4 100644 --- a/web/components/core/render-if-visible-HOC.tsx +++ b/web/components/core/render-if-visible-HOC.tsx @@ -1,10 +1,10 @@ -import { cn } from "helpers/common.helper"; import React, { useState, useRef, useEffect, ReactNode, MutableRefObject } from "react"; +import { cn } from "helpers/common.helper"; type Props = { defaultHeight?: string; verticalOffset?: number; - horizonatlOffset?: number; + horizontalOffset?: number; root?: MutableRefObject; children: ReactNode; as?: keyof JSX.IntrinsicElements; @@ -20,7 +20,7 @@ const RenderIfVisible: React.FC = (props) => { defaultHeight = "300px", root, verticalOffset = 50, - horizonatlOffset = 0, + horizontalOffset = 0, as = "div", children, classNames = "", @@ -52,17 +52,18 @@ const RenderIfVisible: React.FC = (props) => { }, { root: root?.current, - rootMargin: `${verticalOffset}% ${horizonatlOffset}% ${verticalOffset}% ${horizonatlOffset}%`, + rootMargin: `${verticalOffset}% ${horizontalOffset}% ${verticalOffset}% ${horizontalOffset}%`, } ); observer.observe(intersectionRef.current); return () => { if (intersectionRef.current) { + // eslint-disable-next-line react-hooks/exhaustive-deps observer.unobserve(intersectionRef.current); } }; } - }, [root?.current, intersectionRef, children, changingReference]); + }, [intersectionRef, children, changingReference, root, verticalOffset, horizontalOffset]); //Set height after render useEffect(() => { diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index 52b1e9de1..3e068e4f0 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -1,13 +1,14 @@ -// ui -import { ExternalLinkIcon, Tooltip } from "@plane/ui"; +import { observer } from "mobx-react"; // icons import { Pencil, Trash2, LinkIcon } from "lucide-react"; +// ui +import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; // helpers import { calculateTimeAgo } from "helpers/date-time.helper"; +// hooks +import { useMember } from "hooks/store"; // types import { ILinkDetails, UserAuth } from "@plane/types"; -// hooks -import useToast from "hooks/use-toast"; type Props = { links: ILinkDetails[]; @@ -16,87 +17,91 @@ type Props = { userAuth: UserAuth; }; -export const LinksList: React.FC = ({ links, handleDeleteLink, handleEditLink, userAuth }) => { - // toast - const { setToastAlert } = useToast(); +export const LinksList: React.FC = observer(({ links, handleDeleteLink, handleEditLink, userAuth }) => { + const { getUserDetails } = useMember(); const isNotAllowed = userAuth.isGuest || userAuth.isViewer; const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); - setToastAlert({ - message: "The URL has been successfully copied to your clipboard", - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Copied to clipboard", + message: "The URL has been successfully copied to your clipboard", }); }; return ( <> - {links.map((link) => ( -
-
-
- - - - - copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} - > - {link.title && link.title !== "" ? link.title : link.url} + {links.map((link) => { + const createdByDetails = getUserDetails(link.created_by); + return ( +
+
+
+ + - -
- - {!isNotAllowed && ( -
- - - - - + + copyToClipboard(link.title && link.title !== "" ? link.title : link.url)} + > + {link.title && link.title !== "" ? link.title : link.url} + +
- )} + + {!isNotAllowed && ( +
+ + + + + +
+ )} +
+
+

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

+
-
-

- Added {calculateTimeAgo(link.created_at)} -
- by{" "} - {link.created_by_detail.is_bot - ? link.created_by_detail.first_name + " Bot" - : link.created_by_detail.display_name} -

-
-
- ))} + ); + })} ); -}; +}); diff --git a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx index cb433de05..880cf8146 100644 --- a/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx +++ b/web/components/core/sidebar/sidebar-menu-hamburger-toggle.tsx @@ -1,21 +1,21 @@ import { FC } from "react"; +import { observer } from "mobx-react"; import { Menu } from "lucide-react"; import { useApplication } from "hooks/store"; -import { observer } from "mobx-react"; type Props = { onClick?: () => void; -} +}; export const SidebarHamburgerToggle: FC = observer((props) => { - const { onClick } = props + const { onClick } = props; const { theme: themeStore } = useApplication(); return (
{ - if (onClick) onClick() - else themeStore.toggleMobileSidebar() + if (onClick) onClick(); + else themeStore.toggleSidebar(); }} > diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index 8fd021403..157fd2c79 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -4,14 +4,14 @@ import Image from "next/image"; // headless ui import { Tab } from "@headlessui/react"; // hooks +import { Avatar, StateGroupIcon } from "@plane/ui"; +import { SingleProgressStats } from "components/core"; import useLocalStorage from "hooks/use-local-storage"; // images import emptyLabel from "public/empty-state/empty_label.svg"; import emptyMembers from "public/empty-state/empty_members.svg"; // components -import { SingleProgressStats } from "components/core"; // ui -import { Avatar, StateGroupIcon } from "@plane/ui"; // types import { IModule, @@ -125,7 +125,10 @@ export const SidebarProgressStats: React.FC = ({ - + {distribution?.assignees.length > 0 ? ( distribution.assignees.map((assignee, index) => { if (assignee.assignee_id) @@ -182,7 +185,10 @@ export const SidebarProgressStats: React.FC = ({
)} - + {distribution?.labels.length > 0 ? ( distribution.labels.map((label, index) => ( = ({
)} - + {Object.keys(groupedIssues).map((group, index) => ( { const handleValueChange = (val: string | undefined, onChange: any) => { let hex = val; - // prepend a hashtag if it doesn't exist if (val && val[0] !== "#") hex = `#${val}`; @@ -94,7 +93,7 @@ export const CustomThemeSelector: React.FC = observer(() => { placeholder="#0d101b" className="w-full" style={{ - backgroundColor: value, + backgroundColor: watch("background"), color: watch("text"), }} hasError={Boolean(errors?.background)} @@ -120,8 +119,8 @@ export const CustomThemeSelector: React.FC = observer(() => { placeholder="#c5c5c5" className="w-full" style={{ - backgroundColor: watch("background"), - color: value, + backgroundColor: watch("text"), + color: watch("background"), }} hasError={Boolean(errors?.text)} /> @@ -146,7 +145,7 @@ export const CustomThemeSelector: React.FC = observer(() => { placeholder="#3f76ff" className="w-full" style={{ - backgroundColor: value, + backgroundColor: watch("primary"), color: watch("text"), }} hasError={Boolean(errors?.primary)} @@ -172,7 +171,7 @@ export const CustomThemeSelector: React.FC = observer(() => { placeholder="#0d101b" className="w-full" style={{ - backgroundColor: value, + backgroundColor: watch("sidebarBackground"), color: watch("sidebarText"), }} hasError={Boolean(errors?.sidebarBackground)} @@ -200,8 +199,8 @@ export const CustomThemeSelector: React.FC = observer(() => { placeholder="#c5c5c5" className="w-full" style={{ - backgroundColor: watch("sidebarBackground"), - color: value, + backgroundColor: watch("sidebarText"), + color: watch("sidebarBackground"), }} hasError={Boolean(errors?.sidebarText)} /> diff --git a/web/components/core/theme/theme-switch.tsx b/web/components/core/theme/theme-switch.tsx index bcd847a28..428e6930b 100644 --- a/web/components/core/theme/theme-switch.tsx +++ b/web/components/core/theme/theme-switch.tsx @@ -1,8 +1,8 @@ import { FC } from "react"; // constants +import { CustomSelect } from "@plane/ui"; import { THEME_OPTIONS, I_THEME_OPTION } from "constants/themes"; // ui -import { CustomSelect } from "@plane/ui"; type Props = { value: I_THEME_OPTION | null; diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 5d4bcd768..d9309d4b5 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -1,11 +1,10 @@ import { MouseEvent } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; -import useSWR from "swr"; +import Link from "next/link"; import { useTheme } from "next-themes"; +import useSWR from "swr"; // hooks import { useCycle, useIssues, useMember, useProject, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; // ui import { SingleProgressStats } from "components/core"; import { @@ -18,6 +17,7 @@ import { PriorityIcon, Avatar, CycleGroupIcon, + setPromiseToast, } from "@plane/ui"; // components import ProgressChart from "components/core/sidebar/progress-chart"; @@ -60,8 +60,6 @@ export const ActiveCycleDetails: React.FC = observer((props } = useCycle(); const { currentProjectDetails } = useProject(); const { getUserDetails } = useMember(); - // toast alert - const { setToastAlert } = useToast(); const { isLoading } = useSWR( workspaceSlug && projectId ? `PROJECT_ACTIVE_CYCLE_${projectId}` : null, @@ -69,7 +67,7 @@ export const ActiveCycleDetails: React.FC = observer((props ); const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null; - const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by) : undefined; + const cycleOwnerDetails = activeCycle ? getUserDetails(activeCycle.owned_by_id) : undefined; const { data: activeCycleIssues } = useSWR( workspaceSlug && projectId && currentProjectActiveCycleId @@ -119,12 +117,18 @@ export const ActiveCycleDetails: React.FC = observer((props e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id).catch(() => { - setToastAlert({ - type: "error", + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", - }); + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, }); }; @@ -132,12 +136,22 @@ export const ActiveCycleDetails: React.FC = observer((props e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), activeCycle.id).catch(() => { - setToastAlert({ - type: "error", + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + activeCycle.id + ); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", - }); + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, }); }; @@ -169,7 +183,7 @@ export const ActiveCycleDetails: React.FC = observer((props - + {`${daysLeft} ${daysLeft > 1 ? "days" : "day"} left`} {activeCycle.is_favorite ? ( @@ -222,12 +236,13 @@ export const ActiveCycleDetails: React.FC = observer((props {cycleOwnerDetails?.display_name}
- {activeCycle.assignees.length > 0 && ( + {activeCycle.assignee_ids.length > 0 && (
- {activeCycle.assignees.map((assignee) => ( - - ))} + {activeCycle.assignee_ids.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })}
)} @@ -288,9 +303,9 @@ export const ActiveCycleDetails: React.FC = observer((props
-
+
High Priority Issues
-
+
{activeCycleIssues ? ( activeCycleIssues.length > 0 ? ( activeCycleIssues.map((issue: any) => ( @@ -310,21 +325,21 @@ export const ActiveCycleDetails: React.FC = observer((props {currentProjectDetails?.identifier}-{issue.sequence_id} - + {truncateText(issue.name, 30)}
-
+
{}} projectId={projectId?.toString() ?? ""} - disabled={true} + disabled buttonVariant="background-with-text" /> {issue.target_date && ( -
+
{renderFormattedDateWithoutYear(issue.target_date)}
@@ -334,7 +349,7 @@ export const ActiveCycleDetails: React.FC = observer((props )) ) : ( -
+
There are no high priority issues present in this cycle.
) @@ -347,7 +362,7 @@ export const ActiveCycleDetails: React.FC = observer((props )}
-
+
diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle-stats.tsx index 1ffe19260..7d935c347 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle-stats.tsx @@ -1,11 +1,11 @@ import React, { Fragment } from "react"; import { Tab } from "@headlessui/react"; // hooks +import { Avatar } from "@plane/ui"; +import { SingleProgressStats } from "components/core"; import useLocalStorage from "hooks/use-local-storage"; // components -import { SingleProgressStats } from "components/core"; // ui -import { Avatar } from "@plane/ui"; // types import { ICycle } from "@plane/types"; @@ -69,7 +69,10 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { {cycle && cycle.total_issues > 0 ? ( - + {cycle.distribution?.assignees?.map((assignee, index) => { if (assignee.assignee_id) return ( @@ -104,7 +107,11 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ); })} - + + {cycle.distribution?.labels?.map((label, index) => ( { const [analyticsModal, setAnalyticsModal] = useState(false); @@ -100,6 +100,7 @@ export const CycleMobileHeader = () => { > {layouts.map((layout, index) => ( { handleLayoutChange(ISSUE_LAYOUTS[index].key); }} @@ -152,6 +153,7 @@ export const CycleMobileHeader = () => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["cycle"]} />
diff --git a/web/components/cycles/cycle-peek-overview.tsx b/web/components/cycles/cycle-peek-overview.tsx index fbfb46b50..b7e778c10 100644 --- a/web/components/cycles/cycle-peek-overview.tsx +++ b/web/components/cycles/cycle-peek-overview.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks import { useCycle } from "hooks/store"; // components diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/cycles-board-card.tsx index e96b01858..2eecb1ae9 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/cycles-board-card.tsx @@ -1,23 +1,32 @@ import { FC, MouseEvent, useState } from "react"; -import { useRouter } from "next/router"; -import Link from "next/link"; import { observer } from "mobx-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; // hooks -import { useEventTracker, useCycle, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; // components +import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; +import { + Avatar, + AvatarGroup, + CustomMenu, + Tooltip, + LayersIcon, + CycleGroupIcon, + TOAST_TYPE, + setToast, + setPromiseToast, +} from "@plane/ui"; import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; // ui -import { Avatar, AvatarGroup, CustomMenu, Tooltip, LayersIcon, CycleGroupIcon } from "@plane/ui"; // icons -import { Info, LinkIcon, Pencil, Star, Trash2 } from "lucide-react"; // helpers +import { CYCLE_STATUS } from "constants/cycle"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; +import { EUserWorkspaceRoles } from "constants/workspace"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; // constants -import { CYCLE_STATUS } from "constants/cycle"; -import { EUserWorkspaceRoles } from "constants/workspace"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; //.types import { TCycleGroups } from "@plane/types"; @@ -40,8 +49,7 @@ export const CyclesBoardCard: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { addCycleToFavorites, removeCycleFromFavorites, getCycleById } = useCycle(); - // toast alert - const { setToastAlert } = useToast(); + const { getUserDetails } = useMember(); // computed const cycleDetails = getCycleById(cycleId); @@ -80,8 +88,8 @@ export const CyclesBoardCard: FC = observer((props) => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Cycle link copied to clipboard.", }); @@ -92,42 +100,56 @@ export const CyclesBoardCard: FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) - .then(() => { + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( + () => { captureEvent(CYCLE_FAVORITED, { cycle_id: cycleId, element: "Grid layout", state: "SUCCESS", }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", - }); - }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, + }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) - .then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "Grid layout", - state: "SUCCESS", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", - }); + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + cycleId + ).then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "Grid layout", + state: "SUCCESS", }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, + }); }; const handleEditCycle = (e: MouseEvent) => { @@ -212,13 +234,14 @@ export const CyclesBoardCard: FC = observer((props) => { {issueCount}
- {cycleDetails.assignees.length > 0 && ( - + {cycleDetails.assignee_ids.length > 0 && ( +
- {cycleDetails.assignees.map((assignee) => ( - - ))} + {cycleDetails.assignee_ids.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })}
diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx index 34e973614..00c98e57c 100644 --- a/web/components/cycles/cycles-board.tsx +++ b/web/components/cycles/cycles-board.tsx @@ -2,12 +2,12 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useTheme } from "next-themes"; // hooks -import { useUser } from "hooks/store"; // components import { CyclePeekOverview, CyclesBoardCard } from "components/cycles"; import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; // constants import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state"; +import { useUser } from "hooks/store"; export interface ICyclesBoard { cycleIds: string[]; @@ -39,7 +39,7 @@ export const CyclesBoard: FC = observer((props) => { peekCycle ? "lg:grid-cols-1 xl:grid-cols-2 3xl:grid-cols-3" : "lg:grid-cols-2 xl:grid-cols-3 3xl:grid-cols-4" - } auto-rows-max transition-all `} + } auto-rows-max transition-all vertical-scrollbar scrollbar-lg`} > {cycleIds.map((cycleId) => ( diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/cycles-list-item.tsx index ed2b26c53..9bf1866ff 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/cycles-list-item.tsx @@ -1,25 +1,34 @@ import { FC, MouseEvent, useState } from "react"; +import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; -import { observer } from "mobx-react"; // hooks -import { useEventTracker, useCycle, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; -// components -import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; -// ui -import { CustomMenu, Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar } from "@plane/ui"; -// icons import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; -// helpers +import { + CustomMenu, + Tooltip, + CircularProgressIndicator, + CycleGroupIcon, + AvatarGroup, + Avatar, + TOAST_TYPE, + setToast, + setPromiseToast, +} from "@plane/ui"; +import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +import { CYCLE_STATUS } from "constants/cycle"; +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; +import { EUserWorkspaceRoles } from "constants/workspace"; import { findHowManyDaysLeft, renderFormattedDate } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; +// components +// ui +// icons +// helpers // constants -import { CYCLE_STATUS } from "constants/cycle"; -import { EUserWorkspaceRoles } from "constants/workspace"; // types import { TCycleGroups } from "@plane/types"; -import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; type TCyclesListItem = { cycleId: string; @@ -44,8 +53,7 @@ export const CyclesListItem: FC = observer((props) => { membership: { currentProjectRole }, } = useUser(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); - // toast alert - const { setToastAlert } = useToast(); + const { getUserDetails } = useMember(); const handleCopyText = (e: MouseEvent) => { e.preventDefault(); @@ -53,8 +61,8 @@ export const CyclesListItem: FC = observer((props) => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Cycle link copied to clipboard.", }); @@ -65,42 +73,56 @@ export const CyclesListItem: FC = observer((props) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) - .then(() => { + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( + () => { captureEvent(CYCLE_FAVORITED, { cycle_id: cycleId, element: "List layout", state: "SUCCESS", }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", - }); - }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, + }); }; const handleRemoveFromFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; - removeCycleFromFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId) - .then(() => { - captureEvent(CYCLE_UNFAVORITED, { - cycle_id: cycleId, - element: "List layout", - state: "SUCCESS", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Couldn't add the cycle to favorites. Please try again.", - }); + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + cycleId + ).then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, + }); }; const handleEditCycle = (e: MouseEvent) => { @@ -205,7 +227,7 @@ export const CyclesListItem: FC = observer((props) => {
-
@@ -230,13 +252,14 @@ export const CyclesListItem: FC = observer((props) => {
- +
- {cycleDetails.assignees.length > 0 ? ( + {cycleDetails.assignee_ids?.length > 0 ? ( - {cycleDetails.assignees.map((assignee) => ( - - ))} + {cycleDetails.assignee_ids?.map((assigne_id) => { + const member = getUserDetails(assigne_id); + return ; + })} ) : ( diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx index 838d88a30..99cf1f2b1 100644 --- a/web/components/cycles/cycles-list.tsx +++ b/web/components/cycles/cycles-list.tsx @@ -2,14 +2,14 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useTheme } from "next-themes"; // hooks -import { useUser } from "hooks/store"; // components +import { Loader } from "@plane/ui"; import { CyclePeekOverview, CyclesListItem } from "components/cycles"; import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; // ui -import { Loader } from "@plane/ui"; // constants import { CYCLE_EMPTY_STATE_DETAILS } from "constants/empty-state"; +import { useUser } from "hooks/store"; export interface ICyclesList { cycleIds: string[]; @@ -37,7 +37,7 @@ export const CyclesList: FC = observer((props) => { {cycleIds.length > 0 ? (
-
+
{cycleIds.map((cycleId) => ( = observer((props) => { filter === "completed" ? currentProjectCompletedCycleIds : filter === "draft" - ? currentProjectDraftCycleIds - : filter === "upcoming" - ? currentProjectUpcomingCycleIds - : currentProjectCycleIds; + ? currentProjectDraftCycleIds + : filter === "upcoming" + ? currentProjectUpcomingCycleIds + : currentProjectCycleIds; if (loader || !cyclesList) return ( diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index 5dc0306ab..fd7b1f356 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -1,17 +1,16 @@ import { Fragment, useState } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; -import { observer } from "mobx-react-lite"; import { AlertTriangle } from "lucide-react"; // hooks +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +import { CYCLE_DELETED } from "constants/event-tracker"; import { useEventTracker, useCycle } from "hooks/store"; -import useToast from "hooks/use-toast"; // components -import { Button } from "@plane/ui"; // types import { ICycle } from "@plane/types"; // constants -import { CYCLE_DELETED } from "constants/event-tracker"; interface ICycleDelete { cycle: ICycle; @@ -31,8 +30,6 @@ export const CycleDeleteModal: React.FC = observer((props) => { // store hooks const { captureCycleEvent } = useEventTracker(); const { deleteCycle } = useCycle(); - // toast alert - const { setToastAlert } = useToast(); const formSubmit = async () => { if (!cycle) return; @@ -41,8 +38,8 @@ export const CycleDeleteModal: React.FC = observer((props) => { try { await deleteCycle(workspaceSlug, projectId, cycle.id) .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Cycle deleted successfully.", }); @@ -62,8 +59,8 @@ export const CycleDeleteModal: React.FC = observer((props) => { handleClose(); } catch (error) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Warning!", message: "Something went wrong please try again later.", }); diff --git a/web/components/cycles/form.tsx b/web/components/cycles/form.tsx index dfe2a878e..4e2f55ef9 100644 --- a/web/components/cycles/form.tsx +++ b/web/components/cycles/form.tsx @@ -1,9 +1,9 @@ import { useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; // components -import { DateDropdown, ProjectDropdown } from "components/dropdowns"; -// ui import { Button, Input, TextArea } from "@plane/ui"; +import { DateRangeDropdown, ProjectDropdown } from "components/dropdowns"; +// ui // helpers import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types @@ -32,11 +32,10 @@ export const CycleForm: React.FC = (props) => { formState: { errors, isSubmitting, dirtyFields }, handleSubmit, control, - watch, reset, } = useForm({ defaultValues: { - project: projectId, + project_id: projectId, name: data?.name || "", description: data?.description || "", start_date: data?.start_date || null, @@ -51,23 +50,14 @@ export const CycleForm: React.FC = (props) => { }); }, [data, reset]); - const startDate = watch("start_date"); - const endDate = watch("end_date"); - - const minDate = startDate ? new Date(startDate) : new Date(); - minDate.setDate(minDate.getDate() + 1); - - const maxDate = endDate ? new Date(endDate) : null; - maxDate?.setDate(maxDate.getDate() - 1); - return ( -
handleFormSubmit(formData,dirtyFields))}> + handleFormSubmit(formData, dirtyFields))}>
{!status && ( ( = (props) => {
-
- ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Start date" - minDate={new Date()} - maxDate={maxDate ?? undefined} - tabIndex={3} - /> -
- )} - /> -
( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="End date" - minDate={minDate} - tabIndex={4} - /> -
+ name="start_date" + render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => ( + ( + { + onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null); + onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + hideIcon={{ + to: true, + }} + tabIndex={3} + /> + )} + /> )} />
@@ -172,10 +160,10 @@ export const CycleForm: React.FC = (props) => {
- -
diff --git a/web/components/cycles/gantt-chart/blocks.tsx b/web/components/cycles/gantt-chart/blocks.tsx index beb239d87..e9fdd50de 100644 --- a/web/components/cycles/gantt-chart/blocks.tsx +++ b/web/components/cycles/gantt-chart/blocks.tsx @@ -1,11 +1,11 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react"; +import { useRouter } from "next/router"; // hooks -import { useApplication, useCycle } from "hooks/store"; // ui import { Tooltip, ContrastIcon } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; +import { useApplication, useCycle } from "hooks/store"; type Props = { cycleId: string; @@ -33,14 +33,14 @@ export const CycleGanttBlock: React.FC = observer((props) => { cycleStatus === "current" ? "#09a953" : cycleStatus === "upcoming" - ? "#f7ae59" - : cycleStatus === "completed" - ? "#3f76ff" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "", + ? "#f7ae59" + : cycleStatus === "completed" + ? "#3f76ff" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "", }} - onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} >
= observer((props) => { return (
router.push(`/${workspaceSlug}/projects/${cycleDetails?.project}/cycles/${cycleDetails?.id}`)} + onClick={() => router.push(`/${workspaceSlug}/projects/${cycleDetails?.project_id}/cycles/${cycleDetails?.id}`)} > = observer((props) => { cycleStatus === "current" ? "#09a953" : cycleStatus === "upcoming" - ? "#f7ae59" - : cycleStatus === "completed" - ? "#3f76ff" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "" + ? "#f7ae59" + : cycleStatus === "completed" + ? "#3f76ff" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "" }`} />
{cycleDetails?.name}
diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 421a73a4a..521273c51 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -1,15 +1,15 @@ import { FC } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { CycleGanttBlock } from "components/cycles"; +import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; +import { EUserProjectRoles } from "constants/project"; import { useCycle, useUser } from "hooks/store"; // components -import { GanttChartRoot, IBlockUpdateData, CycleGanttSidebar } from "components/gantt-chart"; -import { CycleGanttBlock } from "components/cycles"; // types import { ICycle } from "@plane/types"; // constants -import { EUserProjectRoles } from "constants/project"; type Props = { workspaceSlug: string; @@ -33,7 +33,7 @@ export const CyclesListGanttChartView: FC = observer((props) => { const payload: any = { ...data }; if (data.sort_order) payload.sort_order = data.sort_order.newSortOrder; - await updateCycleDetails(workspaceSlug.toString(), cycle.project, cycle.id, payload); + await updateCycleDetails(workspaceSlug.toString(), cycle.project_id, cycle.id, payload); }; const blockFormat = (blocks: (ICycle | null)[]) => { diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index e8f19d6a1..2d1640ec9 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -1,17 +1,18 @@ import React, { useEffect, useState } from "react"; import { Dialog, Transition } from "@headlessui/react"; // services +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { CycleForm } from "components/cycles"; +import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker"; +import { useEventTracker, useCycle, useProject } from "hooks/store"; +import useLocalStorage from "hooks/use-local-storage"; import { CycleService } from "services/cycle.service"; // hooks -import { useEventTracker, useCycle, useProject } from "hooks/store"; -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; // components -import { CycleForm } from "components/cycles"; +// ui // types import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; // constants -import { CYCLE_CREATED, CYCLE_UPDATED } from "constants/event-tracker"; type CycleModalProps = { isOpen: boolean; @@ -32,19 +33,17 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const { captureCycleEvent } = useEventTracker(); const { workspaceProjectIds } = useProject(); const { createCycle, updateCycleDetails } = useCycle(); - // toast alert - const { setToastAlert } = useToast(); const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await createCycle(workspaceSlug, selectedProjectId, payload) .then((res) => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Cycle created successfully.", }); @@ -54,8 +53,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { }); }) .catch((err) => { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err.detail ?? "Error in creating cycle. Please try again.", }); @@ -69,7 +68,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const handleUpdateCycle = async (cycleId: string, payload: Partial, dirtyFields: any) => { if (!workspaceSlug || !projectId) return; - const selectedProjectId = payload.project ?? projectId.toString(); + const selectedProjectId = payload.project_id ?? projectId.toString(); await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload) .then((res) => { const changed_properties = Object.keys(dirtyFields); @@ -77,8 +76,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { eventName: CYCLE_UPDATED, payload: { ...res, changed_properties: changed_properties, state: "SUCCESS" }, }); - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Cycle updated successfully.", }); @@ -88,8 +87,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { eventName: CYCLE_UPDATED, payload: { ...payload, state: "FAILED" }, }); - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: err.detail ?? "Error in updating cycle. Please try again.", }); @@ -138,8 +137,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { } handleClose(); } else - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.", }); @@ -155,8 +154,8 @@ export const CycleCreateUpdateModal: React.FC = (props) => { // if data is present, set active project to the project of the // issue. This has more priority than the project in the url. - if (data && data.project) { - setActiveProject(data.project); + if (data && data.project_id) { + setActiveProject(data.project_id); return; } diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index c825feb37..06db83e0d 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -1,47 +1,31 @@ -import React, { useEffect, useRef, useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { useForm } from "react-hook-form"; -import { Disclosure, Popover, Transition } from "@headlessui/react"; +import React, { useEffect, useState } from "react"; import isEmpty from "lodash/isEmpty"; -// services -import { CycleService } from "services/cycle.service"; -// hooks -import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; -import useToast from "hooks/use-toast"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import { Controller, useForm } from "react-hook-form"; +import { Disclosure, Transition } from "@headlessui/react"; +// icons +import { ChevronDown, LinkIcon, Trash2, UserCircle2, AlertCircle, ChevronRight, CalendarClock } from "lucide-react"; +// ui +import { Avatar, CustomMenu, Loader, LayersIcon, TOAST_TYPE, setToast } from "@plane/ui"; // components import { SidebarProgressStats } from "components/core"; import ProgressChart from "components/core/sidebar/progress-chart"; import { CycleDeleteModal } from "components/cycles/delete-modal"; -// ui -import { CustomRangeDatePicker } from "components/ui"; -import { Avatar, CustomMenu, Loader, LayersIcon } from "@plane/ui"; -// icons -import { - ChevronDown, - LinkIcon, - Trash2, - UserCircle2, - AlertCircle, - ChevronRight, - CalendarCheck2, - CalendarClock, -} from "lucide-react"; +import { DateRangeDropdown } from "components/dropdowns"; +// constants +import { CYCLE_STATUS } from "constants/cycle"; +import { CYCLE_UPDATED } from "constants/event-tracker"; +import { EUserWorkspaceRoles } from "constants/workspace"; // helpers +import { findHowManyDaysLeft, renderFormattedPayloadDate } from "helpers/date-time.helper"; import { copyUrlToClipboard } from "helpers/string.helper"; -import { - findHowManyDaysLeft, - isDateGreaterThanToday, - renderFormattedPayloadDate, - renderFormattedDate, -} from "helpers/date-time.helper"; +// hooks +import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; +// services +import { CycleService } from "services/cycle.service"; // types import { ICycle } from "@plane/types"; -// constants -import { EUserWorkspaceRoles } from "constants/workspace"; -import { CYCLE_UPDATED } from "constants/event-tracker"; -// fetch-keys -import { CYCLE_STATUS } from "constants/cycle"; type Props = { cycleId: string; @@ -61,9 +45,6 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const { cycleId, handleClose } = props; // states const [cycleDeleteModal, setCycleDeleteModal] = useState(false); - // refs - const startDateButtonRef = useRef(null); - const endDateButtonRef = useRef(null); // router const router = useRouter(); const { workspaceSlug, projectId, peekCycle } = router.query; @@ -74,13 +55,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { } = useUser(); const { getCycleById, updateCycleDetails } = useCycle(); const { getUserDetails } = useMember(); - + // derived values const cycleDetails = getCycleById(cycleId); - const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by) : undefined; - - const { setToastAlert } = useToast(); - - const { setValue, reset, watch } = useForm({ + const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined; + // form info + const { control, reset } = useForm({ defaultValues, }); @@ -115,15 +94,15 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { const handleCopyText = () => { copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`) .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Link Copied!", message: "Cycle link copied to clipboard.", }); }) .catch(() => { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Some error occurred", }); }); @@ -145,160 +124,38 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { } }; - const handleStartDateChange = async (date: string) => { - setValue("start_date", date); + const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => { + if (!startDate || !endDate) return; - if (!watch("end_date") || watch("end_date") === "") endDateButtonRef.current?.click(); + let isDateValid = false; - if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("end_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create cycle in past date. Please enter a valid date.", - }); - reset({ ...cycleDetails }); - return; - } + const payload = { + start_date: renderFormattedPayloadDate(startDate), + end_date: renderFormattedPayloadDate(endDate), + }; - if (cycleDetails?.start_date && cycleDetails?.end_date) { - const isDateValidForExistingCycle = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, - cycle_id: cycleDetails.id, - }); - - if (isDateValidForExistingCycle) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "start_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - } - - reset({ ...cycleDetails }); - return; - } - - const isDateValid = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, + if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date) + isDateValid = await dateChecker({ + ...payload, + cycle_id: cycleDetails.id, }); + else isDateValid = await dateChecker(payload); - if (isDateValid) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "start_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - reset({ ...cycleDetails }); - } - } - }; - - const handleEndDateChange = async (date: string) => { - setValue("end_date", date); - - if (!watch("start_date") || watch("start_date") === "") startDateButtonRef.current?.click(); - - if (watch("start_date") && watch("end_date") && watch("start_date") !== "" && watch("start_date") !== "") { - if (!isDateGreaterThanToday(`${watch("end_date")}`)) { - setToastAlert({ - type: "error", - title: "Error!", - message: "Unable to create cycle in past date. Please enter a valid date.", - }); - reset({ ...cycleDetails }); - return; - } - - if (cycleDetails?.start_date && cycleDetails?.end_date) { - const isDateValidForExistingCycle = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, - cycle_id: cycleDetails.id, - }); - - if (isDateValidForExistingCycle) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "end_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - } - reset({ ...cycleDetails }); - return; - } - - const isDateValid = await dateChecker({ - start_date: `${watch("start_date")}`, - end_date: `${watch("end_date")}`, + if (isDateValid) { + submitChanges(payload, "date_range"); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Cycle updated successfully.", }); - - if (isDateValid) { - submitChanges( - { - start_date: renderFormattedPayloadDate(`${watch("start_date")}`), - end_date: renderFormattedPayloadDate(`${watch("end_date")}`), - }, - "end_date" - ); - setToastAlert({ - type: "success", - title: "Success!", - message: "Cycle updated successfully.", - }); - } else { - setToastAlert({ - type: "error", - title: "Error!", - message: - "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", - }); - reset({ ...cycleDetails }); - } + } else { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: + "You already have a cycle on the given dates, if you want to create a draft cycle, you can do that by removing both the dates.", + }); + reset({ ...cycleDetails }); } }; @@ -351,9 +208,6 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ); - const endDate = new Date(watch("end_date") ?? cycleDetails.end_date ?? ""); - const startDate = new Date(watch("start_date") ?? cycleDetails.start_date ?? ""); - const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus); const issueCount = @@ -440,125 +294,52 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
-
+
- Start date + Date range
-
- - {({ close }) => ( - <> - - - {renderFormattedDate(startDate) ?? "No date selected"} - - - - - - { - if (val) { - setTrackElement("CYCLE_PAGE_SIDEBAR_START_DATE_BUTTON"); - handleStartDateChange(val); - close(); - } - }} - startDate={watch("start_date") ?? watch("end_date") ?? null} - endDate={watch("end_date") ?? watch("start_date") ?? null} - maxDate={new Date(`${watch("end_date")}`)} - selectsStart={watch("end_date") ? true : false} - /> - - - +
+ ( + ( + { + onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null); + onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null); + handleDateChange(val?.from, val?.to); + }} + placeholder={{ + from: "Start date", + to: "End date", + }} + required={cycleDetails.status !== "draft"} + /> + )} + /> )} - + />
-
- - Target date -
-
- - {({ close }) => ( - <> - - - {renderFormattedDate(endDate) ?? "No date selected"} - - - - - - { - if (val) { - setTrackElement("CYCLE_PAGE_SIDEBAR_END_DATE_BUTTON"); - handleEndDateChange(val); - close(); - } - }} - startDate={watch("start_date") ?? watch("end_date") ?? null} - endDate={watch("end_date") ?? watch("start_date") ?? null} - minDate={new Date(`${watch("start_date")}`)} - selectsEnd={watch("start_date") ? true : false} - /> - - - - )} - -
-
- -
-
+
Lead
-
+
{cycleOwnerDetails?.display_name} @@ -567,11 +348,11 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {
-
+
Issues
-
+
{issueCount}
diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index 5956e4a1e..fbf20925b 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -1,15 +1,16 @@ import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; -import { observer } from "mobx-react-lite"; // hooks -import useToast from "hooks/use-toast"; +// ui +import { AlertCircle, Search, X } from "lucide-react"; +//icons +import { ContrastIcon, TransferIcon, TOAST_TYPE, setToast } from "@plane/ui"; +import { EIssuesStoreType } from "constants/issue"; import { useCycle, useIssues } from "hooks/store"; //icons -import { ContrastIcon, TransferIcon } from "@plane/ui"; -import { AlertCircle, Search, X } from "lucide-react"; // constants -import { EIssuesStoreType } from "constants/issue"; type Props = { isOpen: boolean; @@ -30,23 +31,21 @@ export const TransferIssuesModal: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; - const { setToastAlert } = useToast(); - const transferIssue = async (payload: any) => { if (!workspaceSlug || !projectId || !cycleId) return; // TODO: import transferIssuesFromCycle from store await transferIssuesFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), payload) .then(() => { - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Issues transferred successfully", message: "Issues have been transferred successfully", }); }) .catch(() => { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Issues cannot be transfer. Please try again.", }); @@ -56,7 +55,7 @@ export const TransferIssuesModal: React.FC = observer((props) => { const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => { const cycleDetails = getCycleById(optionId); - return cycleDetails?.name.toLowerCase().includes(query.toLowerCase()); + return cycleDetails?.name?.toLowerCase().includes(query?.toLowerCase()); }); // useEffect(() => { diff --git a/web/components/cycles/transfer-issues.tsx b/web/components/cycles/transfer-issues.tsx index 5ec23cd70..921e67e4e 100644 --- a/web/components/cycles/transfer-issues.tsx +++ b/web/components/cycles/transfer-issues.tsx @@ -1,26 +1,28 @@ import React from "react"; +import isEmpty from "lodash/isEmpty"; import { useRouter } from "next/router"; import useSWR from "swr"; // component +import { AlertCircle } from "lucide-react"; import { Button, TransferIcon } from "@plane/ui"; // icon -import { AlertCircle } from "lucide-react"; // services +import { CYCLE_DETAILS } from "constants/fetch-keys"; import { CycleService } from "services/cycle.service"; // fetch-key -import { CYCLE_DETAILS } from "constants/fetch-keys"; type Props = { handleClick: () => void; + disabled?: boolean; }; const cycleService = new CycleService(); export const TransferIssues: React.FC = (props) => { - const { handleClick } = props; + const { handleClick, disabled = false } = props; const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; @@ -43,9 +45,14 @@ export const TransferIssues: React.FC = (props) => { Completed cycles are not editable.
- {transferableIssuesCount > 0 && ( + {isEmpty(cycleDetails?.progress_snapshot) && transferableIssuesCount > 0 && (
-
diff --git a/web/components/dashboard/home-dashboard-widgets.tsx b/web/components/dashboard/home-dashboard-widgets.tsx index 2e2f9ef88..ab96ef90f 100644 --- a/web/components/dashboard/home-dashboard-widgets.tsx +++ b/web/components/dashboard/home-dashboard-widgets.tsx @@ -1,7 +1,5 @@ import { observer } from "mobx-react-lite"; // hooks -import { useApplication, useDashboard } from "hooks/store"; -// components import { AssignedIssuesWidget, CreatedIssuesWidget, @@ -13,6 +11,8 @@ import { RecentProjectsWidget, WidgetProps, } from "components/dashboard"; +import { useApplication, useDashboard } from "hooks/store"; +// components // types import { TWidgetKeys } from "@plane/types"; diff --git a/web/components/dashboard/project-empty-state.tsx b/web/components/dashboard/project-empty-state.tsx index bb7f82f34..32236e233 100644 --- a/web/components/dashboard/project-empty-state.tsx +++ b/web/components/dashboard/project-empty-state.tsx @@ -1,13 +1,13 @@ -import Image from "next/image"; import { observer } from "mobx-react-lite"; +import Image from "next/image"; // hooks +import { Button } from "@plane/ui"; +import { EUserWorkspaceRoles } from "constants/workspace"; import { useApplication, useEventTracker, useUser } from "hooks/store"; // ui -import { Button } from "@plane/ui"; // assets import ProjectEmptyStateImage from "public/empty-state/dashboard/project.svg"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; export const DashboardProjectEmptyState = observer(() => { // store hooks diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index ed6bac324..1e031cacd 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { Tab } from "@headlessui/react"; // hooks import { useDashboard } from "hooks/store"; @@ -17,7 +17,7 @@ import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashbo // types import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types"; // constants -import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard"; +import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard"; const WIDGET_KEY = "assigned_issues"; @@ -30,8 +30,9 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none"; + const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); + const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -43,7 +44,10 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { filters, }); - const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter); + const filterDates = getCustomDates( + filters.duration ?? selectedDurationFilter, + filters.custom_dates ?? selectedCustomDates + ); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, issue_type: filters.tab ?? selectedTab, @@ -53,7 +57,7 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { }; useEffect(() => { - const filterDates = getCustomDates(selectedDurationFilter); + const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, @@ -67,6 +71,7 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { const filterParams = getRedirectionFilters(selectedTab); const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); if (!widgetDetails || !widgetStats) return ; @@ -80,34 +85,38 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { Assigned to you { - if (val === selectedDurationFilter) return; - - // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ duration: val, tab: "pending" }); - return; - } - // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { + onChange={(val, customDates) => { + if (val === "custom" && customDates) { handleUpdateFilters({ duration: val, - tab: "upcoming", + custom_dates: customDates, }); return; } - handleUpdateFilters({ duration: val }); + if (val === selectedDurationFilter) return; + + let newTab = selectedTab; + // switch to pending tab if target date is changed to none + if (val === "none" && selectedTab !== "completed") newTab = "pending"; + // switch to upcoming tab if target date is changed to other than none + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; + + handleUpdateFilters({ + duration: val, + tab: newTab, + }); }} />
tab.key === selectedTab)} + selectedIndex={selectedTabIndex} onChange={(i) => { - const selectedTab = tabsList[i]; - handleUpdateFilters({ tab: selectedTab?.key ?? "pending" }); + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); }} className="h-full flex flex-col" > @@ -115,18 +124,21 @@ export const AssignedIssuesWidget: React.FC = observer((props) => {
- {tabsList.map((tab) => ( - - - - ))} + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })}
diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx index 4ef5708c8..d36260f21 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { Tab } from "@headlessui/react"; // hooks import { useDashboard } from "hooks/store"; @@ -17,7 +17,7 @@ import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashbo // types import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types"; // constants -import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard"; +import { EDurationFilters, FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard"; const WIDGET_KEY = "created_issues"; @@ -30,8 +30,9 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none"; + const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); + const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -43,7 +44,10 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { filters, }); - const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter); + const filterDates = getCustomDates( + filters.duration ?? selectedDurationFilter, + filters.custom_dates ?? selectedCustomDates + ); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, issue_type: filters.tab ?? selectedTab, @@ -52,7 +56,7 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { }; useEffect(() => { - const filterDates = getCustomDates(selectedDurationFilter); + const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, @@ -64,6 +68,7 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { const filterParams = getRedirectionFilters(selectedTab); const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; + const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); if (!widgetDetails || !widgetStats) return ; @@ -77,34 +82,38 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { Created by you { - if (val === selectedDurationFilter) return; - - // switch to pending tab if target date is changed to none - if (val === "none" && selectedTab !== "completed") { - handleUpdateFilters({ duration: val, tab: "pending" }); - return; - } - // switch to upcoming tab if target date is changed to other than none - if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") { + onChange={(val, customDates) => { + if (val === "custom" && customDates) { handleUpdateFilters({ duration: val, - tab: "upcoming", + custom_dates: customDates, }); return; } - handleUpdateFilters({ duration: val }); + if (val === selectedDurationFilter) return; + + let newTab = selectedTab; + // switch to pending tab if target date is changed to none + if (val === "none" && selectedTab !== "completed") newTab = "pending"; + // switch to upcoming tab if target date is changed to other than none + if (val !== "none" && selectedDurationFilter === "none" && selectedTab !== "completed") newTab = "upcoming"; + + handleUpdateFilters({ + duration: val, + tab: newTab, + }); }} />
tab.key === selectedTab)} + selectedIndex={selectedTabIndex} onChange={(i) => { - const selectedTab = tabsList[i]; - handleUpdateFilters({ tab: selectedTab.key ?? "pending" }); + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); }} className="h-full flex flex-col" > @@ -112,18 +121,21 @@ export const CreatedIssuesWidget: React.FC = observer((props) => {
- {tabsList.map((tab) => ( - - - - ))} + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })}
diff --git a/web/components/dashboard/widgets/dropdowns/duration-filter.tsx b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx index 4844ea406..feef7ceca 100644 --- a/web/components/dashboard/widgets/dropdowns/duration-filter.tsx +++ b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx @@ -1,36 +1,56 @@ +import { useState } from "react"; import { ChevronDown } from "lucide-react"; -// ui +// components import { CustomMenu } from "@plane/ui"; -// types -import { TDurationFilterOptions } from "@plane/types"; +import { DateFilterModal } from "components/core"; +// ui +// helpers +import { getDurationFilterDropdownLabel } from "helpers/dashboard.helper"; // constants -import { DURATION_FILTER_OPTIONS } from "constants/dashboard"; +import { DURATION_FILTER_OPTIONS, EDurationFilters } from "constants/dashboard"; type Props = { - onChange: (value: TDurationFilterOptions) => void; - value: TDurationFilterOptions; + customDates?: string[]; + onChange: (value: EDurationFilters, customDates?: string[]) => void; + value: EDurationFilters; }; export const DurationFilterDropdown: React.FC = (props) => { - const { onChange, value } = props; + const { customDates, onChange, value } = props; + // states + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); return ( - - {DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label} - -
- } - placement="bottom-end" - closeOnSelect - > + <> + setIsDateFilterModalOpen(false)} + onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)} + title="Due date" + /> + + {getDurationFilterDropdownLabel(value, customDates ?? [])} + +
+ } + placement="bottom-end" + closeOnSelect + > {DURATION_FILTER_OPTIONS.map((option) => ( - onChange(option.key)}> + { + if (option.key === "custom") setIsDateFilterModalOpen(true); + else onChange(option.key); + }} + > {option.label} ))} - + + ); }; diff --git a/web/components/dashboard/widgets/empty-states/assigned-issues.tsx b/web/components/dashboard/widgets/empty-states/assigned-issues.tsx index f60d8efe6..0cfad7dc9 100644 --- a/web/components/dashboard/widgets/empty-states/assigned-issues.tsx +++ b/web/components/dashboard/widgets/empty-states/assigned-issues.tsx @@ -1,9 +1,9 @@ import Image from "next/image"; import { useTheme } from "next-themes"; // types +import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard"; import { TIssuesListTypes } from "@plane/types"; // constants -import { ASSIGNED_ISSUES_EMPTY_STATES } from "constants/dashboard"; type Props = { type: TIssuesListTypes; diff --git a/web/components/dashboard/widgets/empty-states/created-issues.tsx b/web/components/dashboard/widgets/empty-states/created-issues.tsx index fe93d4404..2c59342fc 100644 --- a/web/components/dashboard/widgets/empty-states/created-issues.tsx +++ b/web/components/dashboard/widgets/empty-states/created-issues.tsx @@ -1,9 +1,9 @@ import Image from "next/image"; import { useTheme } from "next-themes"; // types +import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard"; import { TIssuesListTypes } from "@plane/types"; // constants -import { CREATED_ISSUES_EMPTY_STATES } from "constants/dashboard"; type Props = { type: TIssuesListTypes; diff --git a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx index fe003e167..a5279f715 100644 --- a/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx +++ b/web/components/dashboard/widgets/issue-panels/issue-list-item.tsx @@ -1,11 +1,11 @@ -import { observer } from "mobx-react-lite"; import isToday from "date-fns/isToday"; +import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail, useMember, useProject } from "hooks/store"; // ui import { Avatar, AvatarGroup, ControlLink, PriorityIcon } from "@plane/ui"; // helpers import { findTotalDaysInRange, renderFormattedDate } from "helpers/date-time.helper"; +import { useIssueDetail, useMember, useProject } from "hooks/store"; // types import { TIssue, TWidgetIssue } from "@plane/types"; @@ -179,7 +179,7 @@ export const CreatedUpcomingIssueListItem: React.FC = observ : "-"}
- {issue.assignee_ids.length > 0 ? ( + {issue.assignee_ids && issue.assignee_ids?.length > 0 ? ( {issue.assignee_ids?.map((assigneeId) => { const userDetails = getUserDetails(assigneeId); diff --git a/web/components/dashboard/widgets/issue-panels/issues-list.tsx b/web/components/dashboard/widgets/issue-panels/issues-list.tsx index cf3f32232..c429f3599 100644 --- a/web/components/dashboard/widgets/issue-panels/issues-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/issues-list.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; // hooks -import { useIssueDetail } from "hooks/store"; // components +import { Loader, getButtonStyling } from "@plane/ui"; import { AssignedCompletedIssueListItem, AssignedIssuesEmptyState, @@ -14,24 +14,23 @@ import { IssueListItemProps, } from "components/dashboard/widgets"; // ui -import { getButtonStyling } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; import { getRedirectionFilters } from "helpers/dashboard.helper"; +import { useIssueDetail } from "hooks/store"; // types -import { TIssue, TIssuesListTypes } from "@plane/types"; +import { TAssignedIssuesWidgetResponse, TCreatedIssuesWidgetResponse, TIssue, TIssuesListTypes } from "@plane/types"; export type WidgetIssuesListProps = { isLoading: boolean; - issues: TIssue[]; tab: TIssuesListTypes; - totalIssues: number; type: "assigned" | "created"; + widgetStats: TAssignedIssuesWidgetResponse | TCreatedIssuesWidgetResponse; workspaceSlug: string; }; export const WidgetIssuesList: React.FC = (props) => { - const { isLoading, issues, tab, totalIssues, type, workspaceSlug } = props; + const { isLoading, tab, type, widgetStats, workspaceSlug } = props; // store hooks const { setPeekIssue } = useIssueDetail(); @@ -59,12 +58,19 @@ export const WidgetIssuesList: React.FC = (props) => { }, }; + const issuesList = widgetStats.issues; + return ( <>
{isLoading ? ( - <> - ) : issues.length > 0 ? ( + + + + + + + ) : issuesList.length > 0 ? ( <>
= (props) => { > Issues - {totalIssues} + {widgetStats.count}
{["upcoming", "pending"].includes(tab) &&
Due date
} @@ -84,7 +90,7 @@ export const WidgetIssuesList: React.FC = (props) => { {type === "created" &&
Assigned to
}
- {issues.map((issue) => { + {issuesList.map((issue) => { const IssueListItem = ISSUE_LIST_ITEM[type][tab]; if (!IssueListItem) return null; @@ -107,7 +113,7 @@ export const WidgetIssuesList: React.FC = (props) => {
)}
- {issues.length > 0 && ( + {!isLoading && issuesList.length > 0 && ( = observer((props) => { className={cn( "relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500", { - "text-custom-text-100 bg-custom-background-100": selectedTab === tab.key, + "text-custom-text-100": selectedTab === tab.key, "hover:text-custom-text-300": selectedTab !== tab.key, } )} diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx index 91e321b05..becf32285 100644 --- a/web/components/dashboard/widgets/issues-by-priority.tsx +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -1,82 +1,37 @@ -import { useEffect, useState } from "react"; -import Link from "next/link"; +import { useEffect } from "react"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { useRouter } from "next/router"; // hooks import { useDashboard } from "hooks/store"; // components -import { MarimekkoGraph } from "components/ui"; import { DurationFilterDropdown, IssuesByPriorityEmptyState, WidgetLoader, WidgetProps, } from "components/dashboard/widgets"; -// ui -import { PriorityIcon } from "@plane/ui"; // helpers import { getCustomDates } from "helpers/dashboard.helper"; // types import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types"; // constants -import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard"; -import { ISSUE_PRIORITIES } from "constants/issue"; - -const TEXT_COLORS = { - urgent: "#F4A9AA", - high: "#AB4800", - medium: "#AB6400", - low: "#1F2D5C", - none: "#60646C", -}; - -const CustomBar = (props: any) => { - const { bar, workspaceSlug } = props; - // states - const [isMouseOver, setIsMouseOver] = useState(false); - - return ( - - setIsMouseOver(true)} - onMouseLeave={() => setIsMouseOver(false)} - > - - - {bar?.id} - - - - ); -}; +import { IssuesByPriorityGraph } from "components/graphs"; +import { EDurationFilters } from "constants/dashboard"; const WIDGET_KEY = "issues_by_priority"; export const IssuesByPriorityWidget: React.FC = observer((props) => { const { dashboardId, workspaceSlug } = props; + // router + const router = useRouter(); // store hooks const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - const selectedDuration = widgetDetails?.widget_filters.duration ?? "none"; + const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; + const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -86,7 +41,10 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => filters, }); - const filterDates = getCustomDates(filters.duration ?? selectedDuration); + const filterDates = getCustomDates( + filters.duration ?? selectedDuration, + filters.custom_dates ?? selectedCustomDates + ); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -94,7 +52,7 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => }; useEffect(() => { - const filterDates = getCustomDates(selectedDuration); + const filterDates = getCustomDates(selectedDuration, selectedCustomDates); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -105,34 +63,13 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => if (!widgetDetails || !widgetStats) return ; const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0); - const chartData = widgetStats - .filter((i) => i.count !== 0) - .map((item) => ({ - priority: item?.priority, - percentage: (item?.count / totalCount) * 100, - urgent: item?.priority === "urgent" ? 1 : 0, - high: item?.priority === "high" ? 1 : 0, - medium: item?.priority === "medium" ? 1 : 0, - low: item?.priority === "low" ? 1 : 0, - none: item?.priority === "none" ? 1 : 0, - })); - - const CustomBarsLayer = (props: any) => { - const { bars } = props; - - return ( - - {bars - ?.filter((b: any) => b?.value === 1) // render only bars with value 1 - .map((bar: any) => ( - - ))} - - ); - }; + const chartData = widgetStats.map((item) => ({ + priority: item?.priority, + priority_count: item?.count, + })); return ( -
+
= observer((props) => Assigned by priority + onChange={(val, customDates) => handleUpdateFilters({ duration: val, + ...(val === "custom" ? { custom_dates: customDates } : {}), }) } />
{totalCount > 0 ? ( -
-
- +
+ ({ - id: p.key, - value: p.key, - }))} - axisBottom={null} - axisLeft={null} - height="119px" - margin={{ - top: 11, - right: 0, - bottom: 0, - left: 0, + onBarClick={(datum) => { + router.push( + `/${workspaceSlug}/workspace-views/assigned?priority=${`${datum.data.priority}`.toLowerCase()}` + ); }} - defs={PRIORITY_GRAPH_GRADIENTS} - fill={ISSUE_PRIORITIES.map((p) => ({ - match: { - id: p.key, - }, - id: `gradient${p.title}`, - }))} - tooltip={() => <>} - enableGridX={false} - enableGridY={false} - layers={[CustomBarsLayer]} /> -
- {chartData.map((item) => ( -

- - {item.percentage.toFixed(0)}% -

- ))} -
) : ( -
+
)} diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx index a0eb6c70f..6857f7ef3 100644 --- a/web/components/dashboard/widgets/issues-by-state-group.tsx +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -1,24 +1,24 @@ import { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; // hooks -import { useDashboard } from "hooks/store"; -// components -import { PieGraph } from "components/ui"; import { DurationFilterDropdown, IssuesByStateGroupEmptyState, WidgetLoader, WidgetProps, } from "components/dashboard/widgets"; -// helpers +import { PieGraph } from "components/ui"; +import { STATE_GROUPS } from "constants/state"; import { getCustomDates } from "helpers/dashboard.helper"; +import { useDashboard } from "hooks/store"; +// components +// helpers // types import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types"; // constants -import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard"; -import { STATE_GROUPS } from "constants/state"; +import { EDurationFilters, STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard"; const WIDGET_KEY = "issues_by_state_groups"; @@ -34,7 +34,8 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - const selectedDuration = widgetDetails?.widget_filters.duration ?? "none"; + const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; + const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; const handleUpdateFilters = async (filters: Partial) => { if (!widgetDetails) return; @@ -44,7 +45,10 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) filters, }); - const filterDates = getCustomDates(filters.duration ?? selectedDuration); + const filterDates = getCustomDates( + filters.duration ?? selectedDuration, + filters.custom_dates ?? selectedCustomDates + ); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -53,7 +57,7 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) // fetch widget stats useEffect(() => { - const filterDates = getCustomDates(selectedDuration); + const filterDates = getCustomDates(selectedDuration, selectedCustomDates); fetchWidgetStats(workspaceSlug, dashboardId, { widget_key: WIDGET_KEY, ...(filterDates.trim() !== "" ? { target_date: filterDates } : {}), @@ -139,10 +143,12 @@ export const IssuesByStateGroupWidget: React.FC = observer((props) Assigned by state + onChange={(val, customDates) => handleUpdateFilters({ duration: val, + ...(val === "custom" ? { custom_dates: customDates } : {}), }) } /> diff --git a/web/components/dashboard/widgets/loaders/loader.tsx b/web/components/dashboard/widgets/loaders/loader.tsx index 141bb5533..ae4038b38 100644 --- a/web/components/dashboard/widgets/loaders/loader.tsx +++ b/web/components/dashboard/widgets/loaders/loader.tsx @@ -1,13 +1,13 @@ // components +import { TWidgetKeys } from "@plane/types"; import { AssignedIssuesWidgetLoader } from "./assigned-issues"; import { IssuesByPriorityWidgetLoader } from "./issues-by-priority"; import { IssuesByStateGroupWidgetLoader } from "./issues-by-state-group"; import { OverviewStatsWidgetLoader } from "./overview-stats"; import { RecentActivityWidgetLoader } from "./recent-activity"; -import { RecentProjectsWidgetLoader } from "./recent-projects"; import { RecentCollaboratorsWidgetLoader } from "./recent-collaborators"; +import { RecentProjectsWidgetLoader } from "./recent-projects"; // types -import { TWidgetKeys } from "@plane/types"; type Props = { widgetKey: TWidgetKeys; diff --git a/web/components/dashboard/widgets/loaders/recent-collaborators.tsx b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx index d838967af..dc2163128 100644 --- a/web/components/dashboard/widgets/loaders/recent-collaborators.tsx +++ b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx @@ -2,17 +2,16 @@ import { Loader } from "@plane/ui"; export const RecentCollaboratorsWidgetLoader = () => ( - - -
- {Array.from({ length: 8 }).map((_, index) => ( -
+ <> + {Array.from({ length: 8 }).map((_, index) => ( + +
- ))} -
- + + ))} + ); diff --git a/web/components/dashboard/widgets/overview-stats.tsx b/web/components/dashboard/widgets/overview-stats.tsx index 31bdee587..bfea5bf40 100644 --- a/web/components/dashboard/widgets/overview-stats.tsx +++ b/web/components/dashboard/widgets/overview-stats.tsx @@ -2,14 +2,14 @@ import { useEffect } from "react"; import { observer } from "mobx-react-lite"; import Link from "next/link"; // hooks +import { WidgetLoader } from "components/dashboard/widgets"; +import { cn } from "helpers/common.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; import { useDashboard } from "hooks/store"; // components -import { WidgetLoader } from "components/dashboard/widgets"; // helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; // types import { TOverviewStatsWidgetResponse } from "@plane/types"; -import { cn } from "helpers/common.helper"; export type WidgetProps = { dashboardId: string; @@ -37,7 +37,7 @@ export const OverviewStatsWidget: React.FC = observer((props) => { key: "overdue", title: "Issues overdue", count: widgetStats?.pending_issues_count, - link: `/${workspaceSlug}/workspace-views/assigned/?target_date=${today};before`, + link: `/${workspaceSlug}/workspace-views/assigned/?state_group=backlog,unstarted,started&target_date=${today};before`, }, { key: "created", @@ -74,6 +74,7 @@ export const OverviewStatsWidget: React.FC = observer((props) => { > {STATS_LIST.map((stat, index) => (
= observer((props) => { // derived values const { fetchWidgetStats, getWidgetStats } = useDashboard(); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`; useEffect(() => { fetchWidgetStats(workspaceSlug, dashboardId, { @@ -34,12 +36,12 @@ export const RecentActivityWidget: React.FC = observer((props) => { if (!widgetStats) return ; return ( -
- +
+ Your issue activities {widgetStats.length > 0 ? ( -
+
{widgetStats.map((activity) => (
@@ -47,7 +49,7 @@ export const RecentActivityWidget: React.FC = observer((props) => { activity.new_value === "restore" ? ( ) : ( -
+
) @@ -83,9 +85,18 @@ export const RecentActivityWidget: React.FC = observer((props) => {
))} + + View all +
) : ( -
+
)} diff --git a/web/components/dashboard/widgets/recent-collaborators.tsx b/web/components/dashboard/widgets/recent-collaborators.tsx index 2fafbb9ac..438f87c45 100644 --- a/web/components/dashboard/widgets/recent-collaborators.tsx +++ b/web/components/dashboard/widgets/recent-collaborators.tsx @@ -1,12 +1,12 @@ import { useEffect } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; // hooks +import { Avatar } from "@plane/ui"; +import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets"; import { useDashboard, useMember, useUser } from "hooks/store"; // components -import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets"; // ui -import { Avatar } from "@plane/ui"; // types import { TRecentCollaboratorsWidgetResponse } from "@plane/types"; diff --git a/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx new file mode 100644 index 000000000..cfe7dd5ca --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx @@ -0,0 +1,120 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; +import Link from "next/link"; +import useSWR from "swr"; +// store hooks +import { Avatar } from "@plane/ui"; +import { useDashboard, useMember, useUser } from "hooks/store"; +// components +import { TRecentCollaboratorsWidgetResponse } from "@plane/types"; +import { WidgetLoader } from "../loaders"; +// ui +// types + +type CollaboratorListItemProps = { + issueCount: number; + userId: string; + workspaceSlug: string; +}; + +const CollaboratorListItem: React.FC = observer((props) => { + const { issueCount, userId, workspaceSlug } = props; + // store hooks + const { currentUser } = useUser(); + const { getUserDetails } = useMember(); + // derived values + const userDetails = getUserDetails(userId); + const isCurrentUser = userId === currentUser?.id; + + if (!userDetails) return null; + + return ( + +
+ +
+
+ {isCurrentUser ? "You" : userDetails?.display_name} +
+

+ {issueCount} active issue{issueCount > 1 ? "s" : ""} +

+ + ); +}); + +type CollaboratorsListProps = { + cursor: string; + dashboardId: string; + perPage: number; + searchQuery?: string; + updateIsLoading?: (isLoading: boolean) => void; + updateResultsCount: (count: number) => void; + updateTotalPages: (count: number) => void; + workspaceSlug: string; +}; + +const WIDGET_KEY = "recent_collaborators"; + +export const CollaboratorsList: React.FC = (props) => { + const { + cursor, + dashboardId, + perPage, + searchQuery = "", + updateIsLoading, + updateResultsCount, + updateTotalPages, + workspaceSlug, + } = props; + // store hooks + const { fetchWidgetStats } = useDashboard(); + + const { data: widgetStats } = useSWR( + workspaceSlug && dashboardId && cursor + ? `WIDGET_STATS_${workspaceSlug}_${dashboardId}_${cursor}_${searchQuery}` + : null, + workspaceSlug && dashboardId && cursor + ? () => + fetchWidgetStats(workspaceSlug, dashboardId, { + cursor, + per_page: perPage, + search: searchQuery, + widget_key: WIDGET_KEY, + }) + : null + ) as { + data: TRecentCollaboratorsWidgetResponse | undefined; + }; + + useEffect(() => { + updateIsLoading?.(true); + + if (!widgetStats) return; + + updateIsLoading?.(false); + updateTotalPages(widgetStats.total_pages); + updateResultsCount(widgetStats.results.length); + }, [updateIsLoading, updateResultsCount, updateTotalPages, widgetStats]); + + if (!widgetStats) return ; + + return ( + <> + {widgetStats?.results.map((user) => ( + + ))} + + ); +}; diff --git a/web/components/dashboard/widgets/recent-collaborators/default-list.tsx b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx new file mode 100644 index 000000000..a27534bbf --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx @@ -0,0 +1,59 @@ +import { useState } from "react"; +// components +import { Button } from "@plane/ui"; +import { CollaboratorsList } from "./collaborators-list"; +// ui + +type Props = { + dashboardId: string; + perPage: number; + workspaceSlug: string; +}; + +export const DefaultCollaboratorsList: React.FC = (props) => { + const { dashboardId, perPage, workspaceSlug } = props; + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const collaboratorsPages: JSX.Element[] = []; + for (let i = 0; i < pageCount; i++) + collaboratorsPages.push( + + ); + + return ( + <> +
+ {collaboratorsPages} +
+ {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} + + ); +}; diff --git a/web/components/dashboard/widgets/recent-collaborators/index.ts b/web/components/dashboard/widgets/recent-collaborators/index.ts new file mode 100644 index 000000000..1efe34c51 --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/components/dashboard/widgets/recent-collaborators/root.tsx b/web/components/dashboard/widgets/recent-collaborators/root.tsx new file mode 100644 index 000000000..d65b15db7 --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/root.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import { Search } from "lucide-react"; +// types +import { WidgetProps } from "components/dashboard/widgets"; +// components +import { DefaultCollaboratorsList } from "./default-list"; +import { SearchedCollaboratorsList } from "./search-list"; + +const PER_PAGE = 8; + +export const RecentCollaboratorsWidget: React.FC = (props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [searchQuery, setSearchQuery] = useState(""); + + return ( +
+
+
+

Most active members

+

+ Top eight active members in your project by last activity +

+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ {searchQuery.trim() !== "" ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/web/components/dashboard/widgets/recent-collaborators/search-list.tsx b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx new file mode 100644 index 000000000..32baa72ad --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx @@ -0,0 +1,80 @@ +import { useState } from "react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +// components +// ui +import { Button } from "@plane/ui"; +// assets +import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators-1.svg"; +import LightImage from "public/empty-state/dashboard/light/recent-collaborators-1.svg"; +import { CollaboratorsList } from "./collaborators-list"; + +type Props = { + dashboardId: string; + perPage: number; + searchQuery: string; + workspaceSlug: string; +}; + +export const SearchedCollaboratorsList: React.FC = (props) => { + const { dashboardId, perPage, searchQuery, workspaceSlug } = props; + // states + const [pageCount, setPageCount] = useState(1); + const [totalPages, setTotalPages] = useState(0); + const [resultsCount, setResultsCount] = useState(0); + const [isLoading, setIsLoading] = useState(true); + // next-themes + const { resolvedTheme } = useTheme(); + + const handleLoadMore = () => setPageCount((prev) => prev + 1); + + const updateTotalPages = (count: number) => setTotalPages(count); + + const updateResultsCount = (count: number) => setResultsCount(count); + + const collaboratorsPages: JSX.Element[] = []; + for (let i = 0; i < pageCount; i++) + collaboratorsPages.push( + + ); + + const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage; + + return ( + <> +
+ {collaboratorsPages} +
+ {!isLoading && totalPages === 0 && ( +
+
+ Recent collaborators +
+

No matching member

+
+ )} + {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} + + ); +}; diff --git a/web/components/dashboard/widgets/recent-projects.tsx b/web/components/dashboard/widgets/recent-projects.tsx index eb7a5e4e5..22e561ac8 100644 --- a/web/components/dashboard/widgets/recent-projects.tsx +++ b/web/components/dashboard/widgets/recent-projects.tsx @@ -1,20 +1,20 @@ import { useEffect } from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { Plus } from "lucide-react"; // hooks +import { Avatar, AvatarGroup } from "@plane/ui"; +import { WidgetLoader, WidgetProps } from "components/dashboard/widgets"; +import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard"; +import { EUserWorkspaceRoles } from "constants/workspace"; import { useApplication, useEventTracker, useDashboard, useProject, useUser } from "hooks/store"; // components -import { WidgetLoader, WidgetProps } from "components/dashboard/widgets"; // ui -import { Avatar, AvatarGroup } from "@plane/ui"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; // types import { TRecentProjectsWidgetResponse } from "@plane/types"; +import { ProjectLogo } from "components/project"; // constants -import { EUserWorkspaceRoles } from "constants/workspace"; -import { PROJECT_BACKGROUND_COLORS } from "constants/dashboard"; const WIDGET_KEY = "recent_projects"; @@ -38,17 +38,9 @@ const ProjectListItem: React.FC = observer((props) => {
- {projectDetails.emoji ? ( - - {renderEmoji(projectDetails.emoji)} - - ) : projectDetails.icon_prop ? ( -
{renderEmoji(projectDetails.icon_prop)}
- ) : ( - - {projectDetails.name.charAt(0)} - - )} +
+ +
@@ -96,7 +88,7 @@ export const RecentProjectsWidget: React.FC = observer((props) => { href={`/${workspaceSlug}/projects`} className="text-lg font-semibold text-custom-text-300 mx-7 hover:underline" > - Your projects + Recent projects
{canCreateProject && ( diff --git a/web/components/dropdowns/buttons.tsx b/web/components/dropdowns/buttons.tsx index 93d8c187c..d5d08a115 100644 --- a/web/components/dropdowns/buttons.tsx +++ b/web/components/dropdowns/buttons.tsx @@ -1,10 +1,10 @@ // helpers +import { Tooltip } from "@plane/ui"; import { cn } from "helpers/common.helper"; // types +import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants"; import { TButtonVariants } from "./types"; // constants -import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS } from "./constants"; -import { Tooltip } from "@plane/ui"; export type DropdownButtonProps = { children: React.ReactNode; @@ -31,8 +31,8 @@ export const DropdownButton: React.FC = (props) => { const ButtonToRender: React.FC = BORDER_BUTTON_VARIANTS.includes(variant) ? BorderButton : BACKGROUND_BUTTON_VARIANTS.includes(variant) - ? BackgroundButton - : TransparentButton; + ? BackgroundButton + : TransparentButton; return ( void; - onClose?: () => void; - projectId: string; - value: string | null; -}; - -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - -export const CycleDropdown: React.FC = observer((props) => { - const { - button, - buttonClassName, - buttonContainerClassName, - buttonVariant, - className = "", - disabled = false, - dropdownArrow = false, - dropdownArrowClassName = "", - hideIcon = false, - onChange, - onClose, - placeholder = "Cycle", - placement, - projectId, - showTooltip = false, - tabIndex, - value, - } = props; - // states - const [query, setQuery] = useState(""); - const [isOpen, setIsOpen] = useState(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { - router: { workspaceSlug }, - } = useApplication(); - const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); - const cycleIds = getProjectCycleIds(projectId); - - const options: DropdownOptions = cycleIds?.map((cycleId) => { - const cycleDetails = getCycleById(cycleId); - - return { - value: cycleId, - query: `${cycleDetails?.name}`, - content: ( -
- - {cycleDetails?.name} -
- ), - }; - }); - options?.unshift({ - value: null, - query: "No cycle", - content: ( -
- - No cycle -
- ), - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const selectedCycle = value ? getCycleById(value) : null; - - const onOpen = () => { - if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId); - }; - - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const dropdownOnChange = (val: string | null) => { - onChange(val); - handleClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matches found

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/cycle/cycle-options.tsx b/web/components/dropdowns/cycle/cycle-options.tsx new file mode 100644 index 000000000..3d63ae3ec --- /dev/null +++ b/web/components/dropdowns/cycle/cycle-options.tsx @@ -0,0 +1,162 @@ +import { FC, useEffect, useRef, useState } from "react"; +import { Placement } from "@popperjs/core"; +import { observer } from "mobx-react"; +import { usePopper } from "react-popper"; +// components +import { Combobox } from "@headlessui/react"; +// icon +import { Check, Search } from "lucide-react"; +// ui +import { ContrastIcon, CycleGroupIcon } from "@plane/ui"; +// store hooks +import { useApplication, useCycle } from "hooks/store"; +// types +import { TCycleGroups } from "@plane/types"; + +type DropdownOptions = + | { + value: string | null; + query: string; + content: JSX.Element; + }[] + | undefined; + +type CycleOptionsProps = { + projectId: string; + referenceElement: HTMLButtonElement | null; + placement: Placement | undefined; + isOpen: boolean; +}; + +export const CycleOptions: FC = observer((props) => { + const { projectId, isOpen, referenceElement, placement } = props; + + //state hooks + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + const inputRef = useRef(null); + + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getProjectCycleIds, fetchAllCycles, getCycleById } = useCycle(); + + useEffect(() => { + if (isOpen) { + onOpen(); + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const cycleIds = (getProjectCycleIds(projectId) ?? [])?.filter((cycleId) => { + const cycleDetails = getCycleById(cycleId); + return cycleDetails?.status ? (cycleDetails?.status.toLowerCase() != "completed" ? true : false) : true; + }); + + const onOpen = () => { + if (workspaceSlug && !cycleIds) fetchAllCycles(workspaceSlug, projectId); + }; + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + const options: DropdownOptions = cycleIds?.map((cycleId) => { + const cycleDetails = getCycleById(cycleId); + const cycleStatus = cycleDetails?.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft"; + + return { + value: cycleId, + query: `${cycleDetails?.name}`, + content: ( +
+ + {cycleDetails?.name} +
+ ), + }; + }); + options?.unshift({ + value: null, + query: "No cycle", + content: ( +
+ + No cycle +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matches found

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/cycle/index.tsx b/web/components/dropdowns/cycle/index.tsx new file mode 100644 index 000000000..8c08cd67d --- /dev/null +++ b/web/components/dropdowns/cycle/index.tsx @@ -0,0 +1,150 @@ +import { Fragment, ReactNode, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Combobox } from "@headlessui/react"; +import { ChevronDown } from "lucide-react"; +// hooks +import { ContrastIcon } from "@plane/ui"; +import { cn } from "helpers/common.helper"; +import { useCycle } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { DropdownButton } from "../buttons"; +// icons +// helpers +// types +import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; +import { TDropdownProps } from "../types"; +// constants +import { CycleOptions } from "./cycle-options"; + +type Props = TDropdownProps & { + button?: ReactNode; + dropdownArrow?: boolean; + dropdownArrowClassName?: string; + onChange: (val: string | null) => void; + onClose?: () => void; + projectId: string; + value: string | null; +}; + +export const CycleDropdown: React.FC = observer((props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + onChange, + onClose, + placeholder = "Cycle", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + + const [isOpen, setIsOpen] = useState(false); + const { getCycleNameById } = useCycle(); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + const selectedName = value ? getCycleNameById(value) : null; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose && onClose(); + }; + + const toggleDropdown = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const dropdownOnChange = (val: string | null) => { + onChange(val); + handleClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + + )} + + ); +}); diff --git a/web/components/dropdowns/date-range.tsx b/web/components/dropdowns/date-range.tsx new file mode 100644 index 000000000..421ab41e6 --- /dev/null +++ b/web/components/dropdowns/date-range.tsx @@ -0,0 +1,261 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Placement } from "@popperjs/core"; +import { DateRange, DayPicker, Matcher } from "react-day-picker"; +import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; +import { ArrowRight, CalendarDays } from "lucide-react"; +// hooks +// components +// ui +import { Button } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import { DropdownButton } from "./buttons"; +// types +import { TButtonVariants } from "./types"; + +type Props = { + applyButtonText?: string; + bothRequired?: boolean; + buttonClassName?: string; + buttonContainerClassName?: string; + buttonFromDateClassName?: string; + buttonToDateClassName?: string; + buttonVariant: TButtonVariants; + cancelButtonText?: string; + className?: string; + disabled?: boolean; + hideIcon?: { + from?: boolean; + to?: boolean; + }; + icon?: React.ReactNode; + minDate?: Date; + maxDate?: Date; + onSelect: (range: DateRange | undefined) => void; + placeholder?: { + from?: string; + to?: string; + }; + placement?: Placement; + required?: boolean; + showTooltip?: boolean; + tabIndex?: number; + value: { + from: Date | undefined; + to: Date | undefined; + }; +}; + +export const DateRangeDropdown: React.FC = (props) => { + const { + applyButtonText = "Apply changes", + bothRequired = true, + buttonClassName, + buttonContainerClassName, + buttonFromDateClassName, + buttonToDateClassName, + buttonVariant, + cancelButtonText = "Cancel", + className, + disabled = false, + hideIcon = { + from: true, + to: true, + }, + icon = , + minDate, + maxDate, + onSelect, + placeholder = { + from: "Add date", + to: "Add date", + }, + placement, + required = false, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [isOpen, setIsOpen] = useState(false); + const [dateRange, setDateRange] = useState(value); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + const [popperElement, setPopperElement] = useState(null); + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const onOpen = () => { + if (referenceElement) referenceElement.focus(); + }; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + setDateRange({ + from: value.from, + to: value.to, + }); + if (referenceElement) referenceElement.blur(); + }; + + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + const disabledDays: Matcher[] = []; + if (minDate) disabledDays.push({ before: minDate }); + if (maxDate) disabledDays.push({ after: maxDate }); + + useEffect(() => { + setDateRange(value); + }, [value]); + + return ( + { + if (e.key === "Enter") { + if (!isOpen) handleKeyDown(e); + } else handleKeyDown(e); + }} + > + + + + {isOpen && ( + +
+ { + // if both the dates are not required, immediately call onSelect + if (!bothRequired) onSelect(val); + setDateRange({ + from: val?.from ?? undefined, + to: val?.to ?? undefined, + }); + }} + mode="range" + disabled={disabledDays} + showOutsideDays + initialFocus + footer={ + bothRequired && ( +
+
+ + +
+ ) + } + /> +
+ + )} + + ); +}; diff --git a/web/components/dropdowns/date.tsx b/web/components/dropdowns/date.tsx index 2603b3eb2..049bf2250 100644 --- a/web/components/dropdowns/date.tsx +++ b/web/components/dropdowns/date.tsx @@ -1,20 +1,20 @@ import React, { useRef, useState } from "react"; -import { Combobox } from "@headlessui/react"; -import DatePicker from "react-datepicker"; +import { DayPicker, Matcher } from "react-day-picker"; import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; import { CalendarDays, X } from "lucide-react"; // hooks +import { cn } from "helpers/common.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { DropdownButton } from "./buttons"; // helpers -import { renderFormattedDate } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; // types +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; import { TDropdownProps } from "./types"; // constants -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; type Props = TDropdownProps & { clearIconClassName?: string; @@ -50,6 +50,7 @@ export const DateDropdown: React.FC = (props) => { tabIndex, value, } = props; + // states const [isOpen, setIsOpen] = useState(false); // refs const dropdownRef = useRef(null); @@ -85,6 +86,7 @@ export const DateDropdown: React.FC = (props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: Date | null) => { @@ -102,18 +104,25 @@ export const DateDropdown: React.FC = (props) => { useOutsideClickDetector(dropdownRef, handleClose); + const disabledDays: Matcher[] = []; + if (minDate) disabledDays.push({ before: minDate }); + if (maxDate) disabledDays.push({ after: maxDate }); + return ( { + if (e.key === "Enter") { + if (!isOpen) handleKeyDown(e); + } else handleKeyDown(e); + }} disabled={disabled} >
diff --git a/web/components/dropdowns/index.ts b/web/components/dropdowns/index.ts index 036ed9f75..64b7efe80 100644 --- a/web/components/dropdowns/index.ts +++ b/web/components/dropdowns/index.ts @@ -1,5 +1,6 @@ export * from "./member"; export * from "./cycle"; +export * from "./date-range"; export * from "./date"; export * from "./estimate"; export * from "./module"; diff --git a/web/components/dropdowns/member/avatar.tsx b/web/components/dropdowns/member/avatar.tsx index 067d609c5..0f841b9e1 100644 --- a/web/components/dropdowns/member/avatar.tsx +++ b/web/components/dropdowns/member/avatar.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react-lite"; // hooks +import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui"; import { useMember } from "hooks/store"; // ui -import { Avatar, AvatarGroup, UserGroupIcon } from "@plane/ui"; type AvatarProps = { showTooltip: boolean; diff --git a/web/components/dropdowns/member/index.ts b/web/components/dropdowns/member/index.ts deleted file mode 100644 index a9f7e09c8..000000000 --- a/web/components/dropdowns/member/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./project-member"; -export * from "./workspace-member"; diff --git a/web/components/dropdowns/member/index.tsx b/web/components/dropdowns/member/index.tsx new file mode 100644 index 000000000..0e9e36e21 --- /dev/null +++ b/web/components/dropdowns/member/index.tsx @@ -0,0 +1,157 @@ +import { Fragment, useRef, useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Combobox } from "@headlessui/react"; +import { ChevronDown } from "lucide-react"; +// hooks +import { cn } from "helpers/common.helper"; +import { useMember } from "hooks/store"; +import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { DropdownButton } from "../buttons"; +import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; +import { ButtonAvatars } from "./avatar"; +// helpers +// types +import { MemberOptions } from "./member-options"; +import { MemberDropdownProps } from "./types"; +// constants + +type Props = { + projectId?: string; + onClose?: () => void; +} & MemberDropdownProps; + +export const MemberDropdown: React.FC = observer((props) => { + const { + button, + buttonClassName, + buttonContainerClassName, + buttonVariant, + className = "", + disabled = false, + dropdownArrow = false, + dropdownArrowClassName = "", + hideIcon = false, + multiple, + onChange, + onClose, + placeholder = "Members", + placement, + projectId, + showTooltip = false, + tabIndex, + value, + } = props; + // states + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + // popper-js refs + const [referenceElement, setReferenceElement] = useState(null); + + const { getUserDetails } = useMember(); + + const comboboxProps: any = { + value, + onChange, + disabled, + }; + if (multiple) comboboxProps.multiple = true; + + const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); + onClose && onClose(); + }; + + const toggleDropdown = () => { + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const dropdownOnChange = (val: string & string[]) => { + onChange(val); + if (!multiple) handleClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + return ( + + + {button ? ( + + ) : ( + + )} + + {isOpen && ( + + )} + + ); +}); diff --git a/web/components/dropdowns/member/member-options.tsx b/web/components/dropdowns/member/member-options.tsx new file mode 100644 index 000000000..d91c6e0b1 --- /dev/null +++ b/web/components/dropdowns/member/member-options.tsx @@ -0,0 +1,142 @@ +import { useEffect, useRef, useState } from "react"; +import { Placement } from "@popperjs/core"; +import { observer } from "mobx-react"; +import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; +//components +import { Check, Search } from "lucide-react"; +import { Avatar } from "@plane/ui"; +//store +import { useApplication, useMember, useUser } from "hooks/store"; +//hooks +//icon +//types + +interface Props { + projectId?: string; + referenceElement: HTMLButtonElement | null; + placement: Placement | undefined; + isOpen: boolean; +} + +export const MemberOptions = observer((props: Props) => { + const { projectId, referenceElement, placement, isOpen } = props; + + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + const inputRef = useRef(null); + + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { + getUserDetails, + project: { getProjectMemberIds, fetchProjectMembers }, + workspace: { workspaceMemberIds }, + } = useMember(); + const { currentUser } = useUser(); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + useEffect(() => { + if (isOpen) { + onOpen(); + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + + const memberIds = projectId ? getProjectMemberIds(projectId) : workspaceMemberIds; + const onOpen = () => { + if (!memberIds && workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId); + }; + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + const options = memberIds?.map((userId) => { + const userDetails = getUserDetails(userId); + + return { + value: userId, + query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, + content: ( +
+ + {currentUser?.id === userId ? "You" : userDetails?.display_name} +
+ ), + }; + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ + active ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/member/project-member.tsx b/web/components/dropdowns/member/project-member.tsx deleted file mode 100644 index d1f285aa5..000000000 --- a/web/components/dropdowns/member/project-member.tsx +++ /dev/null @@ -1,253 +0,0 @@ -import { Fragment, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; -// hooks -import { useApplication, useMember, useUser } from "hooks/store"; -import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { ButtonAvatars } from "./avatar"; -import { DropdownButton } from "../buttons"; -// icons -import { Avatar } from "@plane/ui"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { MemberDropdownProps } from "./types"; -// constants -import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; - -type Props = { - projectId: string; - onClose?: () => void; -} & MemberDropdownProps; - -export const ProjectMemberDropdown: React.FC = observer((props) => { - const { - button, - buttonClassName, - buttonContainerClassName, - buttonVariant, - className = "", - disabled = false, - dropdownArrow = false, - dropdownArrowClassName = "", - hideIcon = false, - multiple, - onChange, - onClose, - placeholder = "Members", - placement, - projectId, - showTooltip = false, - tabIndex, - value, - } = props; - // states - const [query, setQuery] = useState(""); - const [isOpen, setIsOpen] = useState(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { - router: { workspaceSlug }, - } = useApplication(); - const { currentUser } = useUser(); - const { - getUserDetails, - project: { getProjectMemberIds, fetchProjectMembers }, - } = useMember(); - const projectMemberIds = getProjectMemberIds(projectId); - - const options = projectMemberIds?.map((userId) => { - const userDetails = getUserDetails(userId); - - return { - value: userId, - query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, - content: ( -
- - {currentUser?.id === userId ? "You" : userDetails?.display_name} -
- ), - }; - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const comboboxProps: any = { - value, - onChange, - disabled, - }; - if (multiple) comboboxProps.multiple = true; - - const onOpen = () => { - if (!projectMemberIds && workspaceSlug) fetchProjectMembers(workspaceSlug, projectId); - }; - - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - if (!isOpen) onOpen(); - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const dropdownOnChange = (val: string & string[]) => { - onChange(val); - if (!multiple) handleClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/member/workspace-member.tsx b/web/components/dropdowns/member/workspace-member.tsx deleted file mode 100644 index 7a2628cca..000000000 --- a/web/components/dropdowns/member/workspace-member.tsx +++ /dev/null @@ -1,238 +0,0 @@ -import { Fragment, useEffect, useRef, useState } from "react"; -import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; -// hooks -import { useMember, useUser } from "hooks/store"; -import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// components -import { ButtonAvatars } from "./avatar"; -import { DropdownButton } from "../buttons"; -// icons -import { Avatar } from "@plane/ui"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { MemberDropdownProps } from "./types"; -// constants -import { BUTTON_VARIANTS_WITH_TEXT } from "../constants"; - -export const WorkspaceMemberDropdown: React.FC = observer((props) => { - const { - button, - buttonClassName, - buttonContainerClassName, - buttonVariant, - className = "", - disabled = false, - dropdownArrow = false, - dropdownArrowClassName = "", - hideIcon = false, - multiple, - onChange, - onClose, - placeholder = "Members", - placement, - showTooltip = false, - tabIndex, - value, - } = props; - // states - const [query, setQuery] = useState(""); - const [isOpen, setIsOpen] = useState(false); - // refs - const dropdownRef = useRef(null); - const inputRef = useRef(null); - // popper-js refs - const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { currentUser } = useUser(); - const { - getUserDetails, - workspace: { workspaceMemberIds }, - } = useMember(); - - const options = workspaceMemberIds?.map((userId) => { - const userDetails = getUserDetails(userId); - - return { - value: userId, - query: `${userDetails?.display_name} ${userDetails?.first_name} ${userDetails?.last_name}`, - content: ( -
- - {currentUser?.id === userId ? "You" : userDetails?.display_name} -
- ), - }; - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const comboboxProps: any = { - value, - onChange, - disabled, - }; - if (multiple) comboboxProps.multiple = true; - - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - onClose && onClose(); - }; - - const toggleDropdown = () => { - setIsOpen((prevIsOpen) => !prevIsOpen); - }; - - const dropdownOnChange = (val: string & string[]) => { - onChange(val); - if (!multiple) handleClose(); - }; - - const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); - - const handleOnClick = (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - toggleDropdown(); - }; - - useOutsideClickDetector(dropdownRef, handleClose); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - return ( - - - {button ? ( - - ) : ( - - )} - - {isOpen && ( - -
-
- - setQuery(e.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `w-full truncate flex items-center justify-between gap-2 rounded px-1 py-1.5 cursor-pointer select-none ${ - active ? "bg-custom-background-80" : "" - } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
- )} -
- ); -}); diff --git a/web/components/dropdowns/module.tsx b/web/components/dropdowns/module/index.tsx similarity index 55% rename from web/components/dropdowns/module.tsx rename to web/components/dropdowns/module/index.tsx index c05eeb97e..882604712 100644 --- a/web/components/dropdowns/module.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -1,22 +1,22 @@ import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search, X } from "lucide-react"; +import { ChevronDown, X } from "lucide-react"; // hooks -import { useApplication, useModule } from "hooks/store"; +import { DiceIcon, Tooltip } from "@plane/ui"; +import { cn } from "helpers/common.helper"; +import { useModule } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { DropdownButton } from "./buttons"; +import { DropdownButton } from "../buttons"; // icons -import { DiceIcon, Tooltip } from "@plane/ui"; // helpers -import { cn } from "helpers/common.helper"; // types -import { TDropdownProps } from "./types"; +import { BUTTON_VARIANTS_WITHOUT_TEXT } from "../constants"; +import { TDropdownProps } from "../types"; // constants -import { BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; +import { ModuleOptions } from "./module-options"; type Props = TDropdownProps & { button?: ReactNode; @@ -38,14 +38,6 @@ type Props = TDropdownProps & { } ); -type DropdownOptions = - | { - value: string | null; - query: string; - content: JSX.Element; - }[] - | undefined; - type ButtonContentProps = { disabled: boolean; dropdownArrow: boolean; @@ -77,25 +69,29 @@ const ButtonContent: React.FC = (props) => { return ( <> {showCount ? ( - <> +
{!hideIcon && } - - {value.length > 0 ? `${value.length} Module${value.length === 1 ? "" : "s"}` : placeholder} - - +
+ {value.length > 0 + ? value.length === 1 + ? `${getModuleById(value[0])?.name || "module"}` + : `${value.length} Module${value.length === 1 ? "" : "s"}` + : placeholder} +
+
) : value.length > 0 ? ( -
+
{value.map((moduleId) => { const moduleDetails = getModuleById(moduleId); return (
{!hideIcon && } {!hideText && ( - {moduleDetails?.name} + {moduleDetails?.name} )} {!disabled && ( @@ -162,64 +158,14 @@ export const ModuleDropdown: React.FC = observer((props) => { value, } = props; // states - const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); // refs const dropdownRef = useRef(null); const inputRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); - const [popperElement, setPopperElement] = useState(null); - // popper-js init - const { styles, attributes } = usePopper(referenceElement, popperElement, { - placement: placement ?? "bottom-start", - modifiers: [ - { - name: "preventOverflow", - options: { - padding: 12, - }, - }, - ], - }); - // store hooks - const { - router: { workspaceSlug }, - } = useApplication(); - const { getProjectModuleIds, fetchModules, getModuleById } = useModule(); - const moduleIds = getProjectModuleIds(projectId); - const options: DropdownOptions = moduleIds?.map((moduleId) => { - const moduleDetails = getModuleById(moduleId); - return { - value: moduleId, - query: `${moduleDetails?.name}`, - content: ( -
- - {moduleDetails?.name} -
- ), - }; - }); - if (!multiple) - options?.unshift({ - value: null, - query: "No module", - content: ( -
- - No module -
- ), - }); - - const filteredOptions = - query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - - const onOpen = () => { - if (!moduleIds && workspaceSlug) fetchModules(workspaceSlug, projectId); - }; + const { getModuleNameById } = useModule(); const handleClose = () => { if (!isOpen) return; @@ -228,8 +174,8 @@ export const ModuleDropdown: React.FC = observer((props) => { }; const toggleDropdown = () => { - if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: string & string[]) => { @@ -274,7 +220,10 @@ export const ModuleDropdown: React.FC = observer((props) => { )} {isOpen && ( - -
-
- - 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-100": selected, - "text-custom-text-200": !selected, - } - ) - } - > - {({ selected }) => ( - <> - {option.content} - {selected && } - - )} - - )) - ) : ( -

No matching results

- ) - ) : ( -

Loading...

- )} -
-
-
+ )} ); diff --git a/web/components/dropdowns/module/module-options.tsx b/web/components/dropdowns/module/module-options.tsx new file mode 100644 index 000000000..8f6a66468 --- /dev/null +++ b/web/components/dropdowns/module/module-options.tsx @@ -0,0 +1,163 @@ +import { useEffect, useRef, useState } from "react"; +import { Placement } from "@popperjs/core"; +import { observer } from "mobx-react"; +import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; +//components +import { Check, Search } from "lucide-react"; +import { DiceIcon } from "@plane/ui"; +//store +import { cn } from "helpers/common.helper"; +import { useApplication, useModule } from "hooks/store"; +//hooks +//icon +//types + +type DropdownOptions = + | { + value: string | null; + query: string; + content: JSX.Element; + }[] + | undefined; + +interface Props { + projectId: string; + referenceElement: HTMLButtonElement | null; + placement: Placement | undefined; + isOpen: boolean; + multiple: boolean; +} + +export const ModuleOptions = observer((props: Props) => { + const { projectId, isOpen, referenceElement, placement, multiple } = props; + + const [query, setQuery] = useState(""); + const [popperElement, setPopperElement] = useState(null); + const inputRef = useRef(null); + + // store hooks + const { + router: { workspaceSlug }, + } = useApplication(); + const { getProjectModuleIds, fetchModules, getModuleById } = useModule(); + + useEffect(() => { + if (isOpen) { + onOpen(); + inputRef.current && inputRef.current.focus(); + } + }, [isOpen]); + + // popper-js init + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: placement ?? "bottom-start", + modifiers: [ + { + name: "preventOverflow", + options: { + padding: 12, + }, + }, + ], + }); + + const moduleIds = getProjectModuleIds(projectId); + + const onOpen = () => { + if (workspaceSlug && !moduleIds) fetchModules(workspaceSlug, projectId); + }; + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + const options: DropdownOptions = moduleIds?.map((moduleId) => { + const moduleDetails = getModuleById(moduleId); + return { + value: moduleId, + query: `${moduleDetails?.name}`, + content: ( +
+ + {moduleDetails?.name} +
+ ), + }; + }); + if (!multiple) + options?.unshift({ + value: null, + query: "No module", + content: ( +
+ + No module +
+ ), + }); + + const filteredOptions = + query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); + + return ( + +
+
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} + /> +
+
+ {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-100": selected, + "text-custom-text-200": !selected, + } + ) + } + > + {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( +

No matching results

+ ) + ) : ( +

Loading...

+ )} +
+
+
+ ); +}); diff --git a/web/components/dropdowns/priority.tsx b/web/components/dropdowns/priority.tsx index d519ad9f1..2409971f3 100644 --- a/web/components/dropdowns/priority.tsx +++ b/web/components/dropdowns/priority.tsx @@ -1,21 +1,21 @@ import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; -import { Combobox } from "@headlessui/react"; -import { usePopper } from "react-popper"; -import { Check, ChevronDown, Search } from "lucide-react"; import { useTheme } from "next-themes"; +import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; +import { Check, ChevronDown, Search } from "lucide-react"; // hooks +import { PriorityIcon, Tooltip } from "@plane/ui"; +import { ISSUE_PRIORITIES } from "constants/issue"; +import { cn } from "helpers/common.helper"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons -import { PriorityIcon, Tooltip } from "@plane/ui"; // helpers -import { cn } from "helpers/common.helper"; // types import { TIssuePriorities } from "@plane/types"; +import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS, BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; import { TDropdownProps } from "./types"; // constants -import { ISSUE_PRIORITIES } from "constants/issue"; -import { BACKGROUND_BUTTON_VARIANTS, BORDER_BUTTON_VARIANTS, BUTTON_VARIANTS_WITHOUT_TEXT } from "./constants"; type Props = TDropdownProps & { button?: ReactNode; @@ -58,7 +58,7 @@ const BorderButton = (props: ButtonProps) => { high: "bg-orange-500/20 text-orange-950 border-orange-500", medium: "bg-yellow-500/20 text-yellow-950 border-yellow-500", low: "bg-custom-primary-100/20 text-custom-primary-950 border-custom-primary-100", - none: "bg-custom-background-80 border-custom-border-300", + none: "hover:bg-custom-background-80 border-custom-border-300", }; return ( @@ -197,7 +197,7 @@ const TransparentButton = (props: ButtonProps) => { high: "text-orange-950", medium: "text-yellow-950", low: "text-blue-950", - none: "", + none: "hover:text-custom-text-300", }; return ( @@ -314,6 +314,7 @@ export const PriorityDropdown: React.FC = (props) => { const toggleDropdown = () => { setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: TIssuePriorities) => { @@ -329,13 +330,20 @@ export const PriorityDropdown: React.FC = (props) => { toggleDropdown(); }; + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + useOutsideClickDetector(dropdownRef, handleClose); const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) ? BorderButton : BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant) - ? BackgroundButton - : TransparentButton; + ? BackgroundButton + : TransparentButton; useEffect(() => { if (isOpen && inputRef.current) { @@ -417,6 +425,7 @@ export const PriorityDropdown: React.FC = (props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/dropdowns/project.tsx b/web/components/dropdowns/project.tsx index f6fb9205e..719b89802 100644 --- a/web/components/dropdowns/project.tsx +++ b/web/components/dropdowns/project.tsx @@ -1,21 +1,21 @@ import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; import { Check, ChevronDown, Search } from "lucide-react"; // hooks +import { cn } from "helpers/common.helper"; import { useProject } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { DropdownButton } from "./buttons"; +import { ProjectLogo } from "components/project"; // helpers -import { cn } from "helpers/common.helper"; -import { renderEmoji } from "helpers/emoji.helper"; // types +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; import { TDropdownProps } from "./types"; // constants -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; type Props = TDropdownProps & { button?: ReactNode; @@ -77,13 +77,11 @@ export const ProjectDropdown: React.FC = observer((props) => { query: `${projectDetails?.name}`, content: (
- - {projectDetails?.emoji - ? renderEmoji(projectDetails?.emoji) - : projectDetails?.icon_prop - ? renderEmoji(projectDetails?.icon_prop) - : null} - + {projectDetails && ( + + + + )} {projectDetails?.name}
), @@ -169,13 +167,9 @@ export const ProjectDropdown: React.FC = observer((props) => { showTooltip={showTooltip} variant={buttonVariant} > - {!hideIcon && ( - - {selectedProject?.emoji - ? renderEmoji(selectedProject?.emoji) - : selectedProject?.icon_prop - ? renderEmoji(selectedProject?.icon_prop) - : null} + {!hideIcon && selectedProject && ( + + )} {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( diff --git a/web/components/dropdowns/state.tsx b/web/components/dropdowns/state.tsx index fa068fdd0..f34ef576c 100644 --- a/web/components/dropdowns/state.tsx +++ b/web/components/dropdowns/state.tsx @@ -1,22 +1,22 @@ import { Fragment, ReactNode, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Combobox } from "@headlessui/react"; import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; import { Check, ChevronDown, Search } from "lucide-react"; // hooks +import { StateGroupIcon } from "@plane/ui"; +import { cn } from "helpers/common.helper"; import { useApplication, useProjectState } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components import { DropdownButton } from "./buttons"; // icons -import { StateGroupIcon } from "@plane/ui"; // helpers -import { cn } from "helpers/common.helper"; // types +import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; import { TDropdownProps } from "./types"; // constants -import { BUTTON_VARIANTS_WITH_TEXT } from "./constants"; type Props = TDropdownProps & { button?: ReactNode; @@ -104,6 +104,7 @@ export const StateDropdown: React.FC = observer((props) => { const toggleDropdown = () => { if (!isOpen) onOpen(); setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); }; const dropdownOnChange = (val: string) => { @@ -119,6 +120,13 @@ export const StateDropdown: React.FC = observer((props) => { toggleDropdown(); }; + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + useOutsideClickDetector(dropdownRef, handleClose); useEffect(() => { @@ -205,6 +213,7 @@ export const StateDropdown: React.FC = observer((props) => { onChange={(e) => setQuery(e.target.value)} placeholder="Search" displayValue={(assigned: any) => assigned?.name} + onKeyDown={searchInputKeyDown} />
diff --git a/web/components/emoji-icon-picker/emojis.json b/web/components/emoji-icon-picker/emojis.json deleted file mode 100644 index 73b9b800f..000000000 --- a/web/components/emoji-icon-picker/emojis.json +++ /dev/null @@ -1,1090 +0,0 @@ -[ - "8986", - "8987", - "9193", - "9194", - "9195", - "9196", - "9197", - "9198", - "9199", - "9200", - "9201", - "9202", - "9203", - "9208", - "9209", - "9210", - "9410", - "9748", - "9749", - "9757", - "9800", - "9801", - "9802", - "9803", - "9804", - "9805", - "9806", - "9807", - "9808", - "9809", - "9810", - "9811", - "9823", - "9855", - "9875", - "9889", - "9898", - "9899", - "9917", - "9918", - "9924", - "9925", - "9934", - "9935", - "9937", - "9939", - "9940", - "9961", - "9962", - "9968", - "9969", - "9970", - "9971", - "9972", - "9973", - "9975", - "9976", - "9977", - "9978", - "9981", - "9986", - "9989", - "9992", - "9993", - "9994", - "9995", - "9996", - "9997", - "9999", - "10002", - "10004", - "10006", - "10013", - "10017", - "10024", - "10035", - "10036", - "10052", - "10055", - "10060", - "10062", - "10067", - "10068", - "10069", - "10071", - "10083", - "10084", - "10133", - "10134", - "10135", - "10145", - "10160", - "10175", - "10548", - "10549", - "11013", - "11014", - "11015", - "11035", - "11036", - "11088", - "11093", - "12336", - "12349", - "12951", - "12953", - "126980", - "127183", - "127344", - "127345", - "127358", - "127359", - "127374", - "127377", - "127378", - "127379", - "127380", - "127381", - "127382", - "127383", - "127384", - "127385", - "127386", - "127489", - "127490", - "127514", - "127535", - "127538", - "127539", - "127540", - "127541", - "127542", - "127543", - "127544", - "127545", - "127546", - "127568", - "127569", - "127744", - "127745", - "127746", - "127747", - "127748", - "127749", - "127750", - "127751", - "127752", - "127753", - "127754", - "127755", - "127756", - "127757", - "127758", - "127759", - "127760", - "127761", - "127762", - "127763", - "127764", - "127765", - "127766", - "127767", - "127768", - "127769", - "127770", - "127771", - "127772", - "127773", - "127774", - "127775", - "127776", - "127777", - "127780", - "127781", - "127782", - "127783", - "127784", - "127785", - "127786", - "127787", - "127788", - "127789", - "127790", - "127791", - "127792", - "127793", - "127794", - "127795", - "127796", - "127797", - "127798", - "127799", - "127800", - "127801", - "127802", - "127803", - "127804", - "127805", - "127806", - "127807", - "127808", - "127809", - "127810", - "127811", - "127812", - "127813", - "127814", - "127815", - "127816", - "127817", - "127818", - "127819", - "127820", - "127821", - "127822", - "127823", - "127824", - "127825", - "127826", - "127827", - "127828", - "127829", - "127830", - "127831", - "127832", - "127833", - "127834", - "127835", - "127836", - "127837", - "127838", - "127839", - "127840", - "127841", - "127842", - "127843", - "127844", - "127845", - "127846", - "127847", - "127848", - "127849", - "127850", - "127851", - "127852", - "127853", - "127854", - "127855", - "127856", - "127857", - "127858", - "127859", - "127860", - "127861", - "127862", - "127863", - "127864", - "127865", - "127866", - "127867", - "127868", - "127869", - "127870", - "127871", - "127872", - "127873", - "127874", - "127875", - "127876", - "127877", - "127878", - "127879", - "127880", - "127881", - "127882", - "127883", - "127884", - "127885", - "127886", - "127887", - "127888", - "127889", - "127890", - "127891", - "127894", - "127895", - "127897", - "127898", - "127899", - "127902", - "127903", - "127904", - "127905", - "127906", - "127907", - "127908", - "127909", - "127910", - "127911", - "127912", - "127913", - "127914", - "127915", - "127916", - "127917", - "127918", - "127919", - "127920", - "127921", - "127922", - "127923", - "127924", - "127925", - "127926", - "127927", - "127928", - "127929", - "127930", - "127931", - "127932", - "127933", - "127934", - "127935", - "127936", - "127937", - "127938", - "127939", - "127940", - "127941", - "127942", - "127943", - "127944", - "127945", - "127946", - "127947", - "127948", - "127949", - "127950", - "127951", - "127952", - "127953", - "127954", - "127955", - "127956", - "127957", - "127958", - "127959", - "127960", - "127961", - "127962", - "127963", - "127964", - "127965", - "127966", - "127967", - "127968", - "127969", - "127970", - "127971", - "127972", - "127973", - "127974", - "127975", - "127976", - "127977", - "127978", - "127979", - "127980", - "127981", - "127982", - "127983", - "127984", - "127987", - "127988", - "127989", - "127991", - "127992", - "127993", - "127994", - "127995", - "127996", - "127997", - "127998", - "127999", - "128000", - "128001", - "128002", - "128003", - "128004", - "128005", - "128006", - "128007", - "128008", - "128009", - "128010", - "128011", - "128012", - "128013", - "128014", - "128015", - "128016", - "128017", - "128018", - "128019", - "128020", - "128021", - "128022", - "128023", - "128024", - "128025", - "128026", - "128027", - "128028", - "128029", - "128030", - "128031", - "128032", - "128033", - "128034", - "128035", - "128036", - "128037", - "128038", - "128039", - "128040", - "128041", - "128042", - "128043", - "128044", - "128045", - "128046", - "128047", - "128048", - "128049", - "128050", - "128051", - "128052", - "128053", - "128054", - "128055", - "128056", - "128057", - "128058", - "128059", - "128060", - "128061", - "128062", - "128063", - "128064", - "128065", - "128066", - "128067", - "128068", - "128069", - "128070", - "128071", - "128072", - "128073", - "128074", - "128075", - "128076", - "128077", - "128078", - "128079", - "128080", - "128081", - "128082", - "128083", - "128084", - "128085", - "128086", - "128087", - "128088", - "128089", - "128090", - "128091", - "128092", - "128093", - "128094", - "128095", - "128096", - "128097", - "128098", - "128099", - "128100", - "128101", - "128102", - "128103", - "128104", - "128105", - "128106", - "128107", - "128108", - "128109", - "128110", - "128111", - "128112", - "128113", - "128114", - "128115", - "128116", - "128117", - "128118", - "128119", - "128120", - "128121", - "128122", - "128123", - "128124", - "128125", - "128126", - "128127", - "128128", - "128129", - "128130", - "128131", - "128132", - "128133", - "128134", - "128135", - "128136", - "128137", - "128138", - "128139", - "128140", - "128141", - "128142", - "128143", - "128144", - "128145", - "128146", - "128147", - "128148", - "128149", - "128150", - "128151", - "128152", - "128153", - "128154", - "128155", - "128156", - "128157", - "128158", - "128159", - "128160", - "128161", - "128162", - "128163", - "128164", - "128165", - "128166", - "128167", - "128168", - "128169", - "128170", - "128171", - "128172", - "128173", - "128174", - "128175", - "128176", - "128177", - "128178", - "128179", - "128180", - "128181", - "128182", - "128183", - "128184", - "128185", - "128186", - "128187", - "128188", - "128189", - "128190", - "128191", - "128192", - "128193", - "128194", - "128195", - "128196", - "128197", - "128198", - "128199", - "128200", - "128201", - "128202", - "128203", - "128204", - "128205", - "128206", - "128207", - "128208", - "128209", - "128210", - "128211", - "128212", - "128213", - "128214", - "128215", - "128216", - "128217", - "128218", - "128219", - "128220", - "128221", - "128222", - "128223", - "128224", - "128225", - "128226", - "128227", - "128228", - "128229", - "128230", - "128231", - "128232", - "128233", - "128234", - "128235", - "128236", - "128237", - "128238", - "128239", - "128240", - "128241", - "128242", - "128243", - "128244", - "128245", - "128246", - "128247", - "128248", - "128249", - "128250", - "128251", - "128252", - "128253", - "128255", - "128256", - "128257", - "128258", - "128259", - "128260", - "128261", - "128262", - "128263", - "128264", - "128265", - "128266", - "128267", - "128268", - "128269", - "128270", - "128271", - "128272", - "128273", - "128274", - "128275", - "128276", - "128277", - "128278", - "128279", - "128280", - "128281", - "128282", - "128283", - "128284", - "128285", - "128286", - "128287", - "128288", - "128289", - "128290", - "128291", - "128292", - "128293", - "128294", - "128295", - "128296", - "128297", - "128298", - "128299", - "128300", - "128301", - "128302", - "128303", - "128304", - "128305", - "128306", - "128307", - "128308", - "128309", - "128310", - "128311", - "128312", - "128313", - "128314", - "128315", - "128316", - "128317", - "128329", - "128330", - "128331", - "128332", - "128333", - "128334", - "128336", - "128337", - "128338", - "128339", - "128340", - "128341", - "128342", - "128343", - "128344", - "128345", - "128346", - "128347", - "128348", - "128349", - "128350", - "128351", - "128352", - "128353", - "128354", - "128355", - "128356", - "128357", - "128358", - "128359", - "128367", - "128368", - "128371", - "128372", - "128373", - "128374", - "128375", - "128376", - "128377", - "128378", - "128391", - "128394", - "128395", - "128396", - "128397", - "128400", - "128405", - "128406", - "128420", - "128421", - "128424", - "128433", - "128434", - "128444", - "128450", - "128451", - "128452", - "128465", - "128466", - "128467", - "128476", - "128477", - "128478", - "128481", - "128483", - "128488", - "128495", - "128499", - "128506", - "128507", - "128508", - "128509", - "128510", - "128511", - "128512", - "128513", - "128514", - "128515", - "128516", - "128517", - "128518", - "128519", - "128520", - "128521", - "128522", - "128523", - "128524", - "128525", - "128526", - "128527", - "128528", - "128529", - "128530", - "128531", - "128532", - "128533", - "128534", - "128535", - "128536", - "128537", - "128538", - "128539", - "128540", - "128541", - "128542", - "128543", - "128544", - "128545", - "128546", - "128547", - "128548", - "128549", - "128550", - "128551", - "128552", - "128553", - "128554", - "128555", - "128556", - "128557", - "128558", - "128559", - "128560", - "128561", - "128562", - "128563", - "128564", - "128565", - "128566", - "128567", - "128568", - "128569", - "128570", - "128571", - "128572", - "128573", - "128574", - "128575", - "128576", - "128577", - "128578", - "128579", - "128580", - "128581", - "128582", - "128583", - "128584", - "128585", - "128586", - "128587", - "128588", - "128589", - "128590", - "128591", - "128640", - "128641", - "128642", - "128643", - "128644", - "128645", - "128646", - "128647", - "128648", - "128649", - "128650", - "128651", - "128652", - "128653", - "128654", - "128655", - "128656", - "128657", - "128658", - "128659", - "128660", - "128661", - "128662", - "128663", - "128664", - "128665", - "128666", - "128667", - "128668", - "128669", - "128670", - "128671", - "128672", - "128673", - "128674", - "128675", - "128676", - "128677", - "128678", - "128679", - "128680", - "128681", - "128682", - "128683", - "128684", - "128685", - "128686", - "128687", - "128688", - "128689", - "128690", - "128691", - "128692", - "128693", - "128694", - "128695", - "128696", - "128697", - "128698", - "128699", - "128700", - "128701", - "128702", - "128703", - "128704", - "128705", - "128706", - "128707", - "128708", - "128709", - "128715", - "128716", - "128717", - "128718", - "128719", - "128720", - "128721", - "128722", - "128736", - "128737", - "128738", - "128739", - "128740", - "128741", - "128745", - "128747", - "128748", - "128752", - "128755", - "128756", - "128757", - "128758", - "128759", - "128760", - "128761", - "128762", - "129296", - "129297", - "129298", - "129299", - "129300", - "129301", - "129302", - "129303", - "129304", - "129305", - "129306", - "129307", - "129308", - "129309", - "129310", - "129311", - "129312", - "129313", - "129314", - "129315", - "129316", - "129317", - "129318", - "129319", - "129320", - "129321", - "129322", - "129323", - "129324", - "129325", - "129326", - "129327", - "129328", - "129329", - "129330", - "129331", - "129332", - "129333", - "129334", - "129335", - "129336", - "129337", - "129338", - "129340", - "129341", - "129342", - "129344", - "129345", - "129346", - "129347", - "129348", - "129349", - "129351", - "129352", - "129353", - "129354", - "129355", - "129356", - "129357", - "129358", - "129359", - "129360", - "129361", - "129362", - "129363", - "129364", - "129365", - "129366", - "129367", - "129368", - "129369", - "129370", - "129371", - "129372", - "129373", - "129374", - "129375", - "129376", - "129377", - "129378", - "129379", - "129380", - "129381", - "129382", - "129383", - "129384", - "129385", - "129386", - "129387", - "129408", - "129409", - "129410", - "129411", - "129412", - "129413", - "129414", - "129415", - "129416", - "129417", - "129418", - "129419", - "129420", - "129421", - "129422", - "129423", - "129424", - "129425", - "129426", - "129427", - "129428", - "129429", - "129430", - "129431", - "129472", - "129488", - "129489", - "129490", - "129491", - "129492", - "129493", - "129494", - "129495", - "129496", - "129497", - "129498", - "129499", - "129500", - "129501", - "129502", - "129503", - "129504", - "129505", - "129506", - "129507", - "129508", - "129509", - "129510" -] diff --git a/web/components/emoji-icon-picker/helpers.ts b/web/components/emoji-icon-picker/helpers.ts deleted file mode 100644 index ab59a7b07..000000000 --- a/web/components/emoji-icon-picker/helpers.ts +++ /dev/null @@ -1,26 +0,0 @@ -export const saveRecentEmoji = (emoji: string) => { - const recentEmojis = localStorage.getItem("recentEmojis"); - if (recentEmojis) { - const recentEmojisArray = recentEmojis.split(","); - if (recentEmojisArray.includes(emoji)) { - const index = recentEmojisArray.indexOf(emoji); - recentEmojisArray.splice(index, 1); - } - recentEmojisArray.unshift(emoji); - if (recentEmojisArray.length > 18) { - recentEmojisArray.pop(); - } - localStorage.setItem("recentEmojis", recentEmojisArray.join(",")); - } else { - localStorage.setItem("recentEmojis", emoji); - } -}; - -export const getRecentEmojis = () => { - const recentEmojis = localStorage.getItem("recentEmojis"); - if (recentEmojis) { - const recentEmojisArray = recentEmojis.split(","); - return recentEmojisArray; - } - return []; -}; diff --git a/web/components/emoji-icon-picker/icons.json b/web/components/emoji-icon-picker/icons.json deleted file mode 100644 index f844f22d4..000000000 --- a/web/components/emoji-icon-picker/icons.json +++ /dev/null @@ -1,607 +0,0 @@ -{ - "material_rounded": [ - { - "name": "search" - }, - { - "name": "home" - }, - { - "name": "menu" - }, - { - "name": "close" - }, - { - "name": "settings" - }, - { - "name": "done" - }, - { - "name": "check_circle" - }, - { - "name": "favorite" - }, - { - "name": "add" - }, - { - "name": "delete" - }, - { - "name": "arrow_back" - }, - { - "name": "star" - }, - { - "name": "logout" - }, - { - "name": "add_circle" - }, - { - "name": "cancel" - }, - { - "name": "arrow_drop_down" - }, - { - "name": "more_vert" - }, - { - "name": "check" - }, - { - "name": "check_box" - }, - { - "name": "toggle_on" - }, - { - "name": "open_in_new" - }, - { - "name": "refresh" - }, - { - "name": "login" - }, - { - "name": "radio_button_unchecked" - }, - { - "name": "more_horiz" - }, - { - "name": "apps" - }, - { - "name": "radio_button_checked" - }, - { - "name": "download" - }, - { - "name": "remove" - }, - { - "name": "toggle_off" - }, - { - "name": "bolt" - }, - { - "name": "arrow_upward" - }, - { - "name": "filter_list" - }, - { - "name": "delete_forever" - }, - { - "name": "autorenew" - }, - { - "name": "key" - }, - { - "name": "sort" - }, - { - "name": "sync" - }, - { - "name": "add_box" - }, - { - "name": "block" - }, - { - "name": "restart_alt" - }, - { - "name": "menu_open" - }, - { - "name": "shopping_cart_checkout" - }, - { - "name": "expand_circle_down" - }, - { - "name": "backspace" - }, - { - "name": "undo" - }, - { - "name": "done_all" - }, - { - "name": "do_not_disturb_on" - }, - { - "name": "open_in_full" - }, - { - "name": "double_arrow" - }, - { - "name": "sync_alt" - }, - { - "name": "zoom_in" - }, - { - "name": "done_outline" - }, - { - "name": "drag_indicator" - }, - { - "name": "fullscreen" - }, - { - "name": "star_half" - }, - { - "name": "settings_accessibility" - }, - { - "name": "reply" - }, - { - "name": "exit_to_app" - }, - { - "name": "unfold_more" - }, - { - "name": "library_add" - }, - { - "name": "cached" - }, - { - "name": "select_check_box" - }, - { - "name": "terminal" - }, - { - "name": "change_circle" - }, - { - "name": "disabled_by_default" - }, - { - "name": "swap_horiz" - }, - { - "name": "swap_vert" - }, - { - "name": "app_registration" - }, - { - "name": "download_for_offline" - }, - { - "name": "close_fullscreen" - }, - { - "name": "file_open" - }, - { - "name": "minimize" - }, - { - "name": "open_with" - }, - { - "name": "dataset" - }, - { - "name": "add_task" - }, - { - "name": "start" - }, - { - "name": "keyboard_voice" - }, - { - "name": "create_new_folder" - }, - { - "name": "forward" - }, - { - "name": "download" - }, - { - "name": "settings_applications" - }, - { - "name": "compare_arrows" - }, - { - "name": "redo" - }, - { - "name": "zoom_out" - }, - { - "name": "publish" - }, - { - "name": "html" - }, - { - "name": "token" - }, - { - "name": "switch_access_shortcut" - }, - { - "name": "fullscreen_exit" - }, - { - "name": "sort_by_alpha" - }, - { - "name": "delete_sweep" - }, - { - "name": "indeterminate_check_box" - }, - { - "name": "view_timeline" - }, - { - "name": "settings_backup_restore" - }, - { - "name": "arrow_drop_down_circle" - }, - { - "name": "assistant_navigation" - }, - { - "name": "sync_problem" - }, - { - "name": "clear_all" - }, - { - "name": "density_medium" - }, - { - "name": "heart_plus" - }, - { - "name": "filter_alt_off" - }, - { - "name": "expand" - }, - { - "name": "subdirectory_arrow_right" - }, - { - "name": "download_done" - }, - { - "name": "arrow_outward" - }, - { - "name": "123" - }, - { - "name": "swipe_left" - }, - { - "name": "auto_mode" - }, - { - "name": "saved_search" - }, - { - "name": "place_item" - }, - { - "name": "system_update_alt" - }, - { - "name": "javascript" - }, - { - "name": "search_off" - }, - { - "name": "output" - }, - { - "name": "select_all" - }, - { - "name": "fit_screen" - }, - { - "name": "swipe_up" - }, - { - "name": "dynamic_form" - }, - { - "name": "hide_source" - }, - { - "name": "swipe_right" - }, - { - "name": "switch_access_shortcut_add" - }, - { - "name": "browse_gallery" - }, - { - "name": "css" - }, - { - "name": "density_small" - }, - { - "name": "assistant_direction" - }, - { - "name": "check_small" - }, - { - "name": "youtube_searched_for" - }, - { - "name": "move_up" - }, - { - "name": "swap_horizontal_circle" - }, - { - "name": "data_thresholding" - }, - { - "name": "install_mobile" - }, - { - "name": "move_down" - }, - { - "name": "dataset_linked" - }, - { - "name": "keyboard_command_key" - }, - { - "name": "view_kanban" - }, - { - "name": "swipe_down" - }, - { - "name": "key_off" - }, - { - "name": "transcribe" - }, - { - "name": "send_time_extension" - }, - { - "name": "swipe_down_alt" - }, - { - "name": "swipe_left_alt" - }, - { - "name": "swipe_right_alt" - }, - { - "name": "swipe_up_alt" - }, - { - "name": "keyboard_option_key" - }, - { - "name": "cycle" - }, - { - "name": "rebase" - }, - { - "name": "rebase_edit" - }, - { - "name": "empty_dashboard" - }, - { - "name": "magic_exchange" - }, - { - "name": "acute" - }, - { - "name": "point_scan" - }, - { - "name": "step_into" - }, - { - "name": "cheer" - }, - { - "name": "emoticon" - }, - { - "name": "explosion" - }, - { - "name": "water_bottle" - }, - { - "name": "weather_hail" - }, - { - "name": "syringe" - }, - { - "name": "pill" - }, - { - "name": "genetics" - }, - { - "name": "allergy" - }, - { - "name": "medical_mask" - }, - { - "name": "body_fat" - }, - { - "name": "barefoot" - }, - { - "name": "infrared" - }, - { - "name": "wrist" - }, - { - "name": "metabolism" - }, - { - "name": "conditions" - }, - { - "name": "taunt" - }, - { - "name": "altitude" - }, - { - "name": "tibia" - }, - { - "name": "footprint" - }, - { - "name": "eyeglasses" - }, - { - "name": "man_3" - }, - { - "name": "woman_2" - }, - { - "name": "rheumatology" - }, - { - "name": "tornado" - }, - { - "name": "landslide" - }, - { - "name": "foggy" - }, - { - "name": "severe_cold" - }, - { - "name": "tsunami" - }, - { - "name": "vape_free" - }, - { - "name": "sign_language" - }, - { - "name": "emoji_symbols" - }, - { - "name": "clear_night" - }, - { - "name": "emoji_food_beverage" - }, - { - "name": "hive" - }, - { - "name": "thunderstorm" - }, - { - "name": "communication" - }, - { - "name": "rocket" - }, - { - "name": "pets" - }, - { - "name": "public" - }, - { - "name": "quiz" - }, - { - "name": "mood" - }, - { - "name": "gavel" - }, - { - "name": "eco" - }, - { - "name": "diamond" - }, - { - "name": "forest" - }, - { - "name": "rainy" - }, - { - "name": "skull" - } - ] -} diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx deleted file mode 100644 index 0c72b986a..000000000 --- a/web/components/emoji-icon-picker/index.tsx +++ /dev/null @@ -1,204 +0,0 @@ -import React, { useEffect, useState, useRef } from "react"; -// headless ui -import { Tab, Transition, Popover } from "@headlessui/react"; -// react colors -import { TwitterPicker } from "react-color"; -// hooks -import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; -import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// types -import { Props } from "./types"; -// emojis -import emojis from "./emojis.json"; -import icons from "./icons.json"; -// helpers -import { getRecentEmojis, saveRecentEmoji } from "./helpers"; -import { getRandomEmoji, renderEmoji } from "helpers/emoji.helper"; - -const tabOptions = [ - { - key: "emoji", - title: "Emoji", - }, - { - key: "icon", - title: "Icon", - }, -]; - -const EmojiIconPicker: React.FC = (props) => { - const { label, value, onChange, onIconColorChange, disabled = false } = props; - // states - const [isOpen, setIsOpen] = useState(false); - const [openColorPicker, setOpenColorPicker] = useState(false); - const [activeColor, setActiveColor] = useState("rgb(var(--color-text-200))"); - const [recentEmojis, setRecentEmojis] = useState([]); - - const buttonRef = useRef(null); - const emojiPickerRef = useRef(null); - - useEffect(() => { - setRecentEmojis(getRecentEmojis()); - }, []); - - useEffect(() => { - if (!value || value?.length === 0) onChange(getRandomEmoji()); - }, [value, onChange]); - - useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); - useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef); - - return ( - - setIsOpen((prev) => !prev)} - className="outline-none" - disabled={disabled} - > - {label} - - - -
- - - {tabOptions.map((tab) => ( - - {({ selected }) => ( - - )} - - ))} - - - - {recentEmojis.length > 0 && ( -
-

Recent

-
- {recentEmojis.map((emoji) => ( - - ))} -
-
- )} -
-
-
- {emojis.map((emoji) => ( - - ))} -
-
-
-
- -
-
- {["#FF6B00", "#8CC1FF", "#FCBE1D", "#18904F", "#ADF672", "#05C3FF", "#000000"].map((curCol) => ( - setActiveColor(curCol)} - /> - ))} - -
-
- { - setActiveColor(color.hex); - if (onIconColorChange) onIconColorChange(color.hex); - }} - triangle="hide" - width="205px" - /> -
-
-
-
- {icons.material_rounded.map((icon, index) => ( - - ))} -
-
-
-
-
-
-
-
-
- ); -}; - -export default EmojiIconPicker; diff --git a/web/components/emoji-icon-picker/types.d.ts b/web/components/emoji-icon-picker/types.d.ts deleted file mode 100644 index 8a0b54342..000000000 --- a/web/components/emoji-icon-picker/types.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -export type Props = { - label: React.ReactNode; - value: any; - onChange: ( - data: - | string - | { - name: string; - color: string; - } - ) => void; - onIconColorChange?: (data: any) => void; - disabled?: boolean; - tabIndex?: number; -}; diff --git a/web/components/empty-state/comic-box-button.tsx b/web/components/empty-state/comic-box-button.tsx index 607d74a91..0bf546a2f 100644 --- a/web/components/empty-state/comic-box-button.tsx +++ b/web/components/empty-state/comic-box-button.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; +import { usePopper } from "react-popper"; import { Popover } from "@headlessui/react"; // popper -import { usePopper } from "react-popper"; // helper import { getButtonStyling } from "@plane/ui"; diff --git a/web/components/empty-state/empty-state.tsx b/web/components/empty-state/empty-state.tsx index 4a5aeca02..9d77a81d0 100644 --- a/web/components/empty-state/empty-state.tsx +++ b/web/components/empty-state/empty-state.tsx @@ -1,11 +1,11 @@ import React from "react"; import Image from "next/image"; // components -import { ComicBoxButton } from "./comic-box-button"; // ui import { Button, getButtonStyling } from "@plane/ui"; // helper import { cn } from "helpers/common.helper"; +import { ComicBoxButton } from "./comic-box-button"; type Props = { title: string; diff --git a/web/components/estimates/create-update-estimate-modal.tsx b/web/components/estimates/create-update-estimate-modal.tsx index 0a607e88d..3be83e319 100644 --- a/web/components/estimates/create-update-estimate-modal.tsx +++ b/web/components/estimates/create-update-estimate-modal.tsx @@ -1,15 +1,14 @@ import React, { useEffect } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Controller, useForm } from "react-hook-form"; import { Dialog, Transition } from "@headlessui/react"; -import { observer } from "mobx-react-lite"; // store hooks -import { useEstimate } from "hooks/store"; -import useToast from "hooks/use-toast"; -// ui -import { Button, Input, TextArea } from "@plane/ui"; -// helpers +import { Button, Input, TextArea, TOAST_TYPE, setToast } from "@plane/ui"; import { checkDuplicates } from "helpers/array.helper"; +import { useEstimate } from "hooks/store"; +// ui +// helpers // types import { IEstimate, IEstimateFormData } from "@plane/types"; @@ -40,8 +39,6 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { // store hooks const { createEstimate, updateEstimate } = useEstimate(); // form info - // toast alert - const { setToastAlert } = useToast(); const { formState: { errors, isSubmitting }, handleSubmit, @@ -67,8 +64,8 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { const error = err?.error; const errorString = Array.isArray(error) ? error[0] : error; - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: errorString ?? err.status === 400 @@ -89,8 +86,8 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { const error = err?.error; const errorString = Array.isArray(error) ? error[0] : error; - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: errorString ?? "Estimate could not be updated. Please try again.", }); @@ -99,8 +96,8 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { const onSubmit = async (formData: FormValues) => { if (!formData.name || formData.name === "") { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Estimate title cannot be empty.", }); @@ -115,8 +112,8 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { formData.value5 === "" || formData.value6 === "" ) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Estimate point cannot be empty.", }); @@ -131,8 +128,8 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { formData.value5.length > 20 || formData.value6.length > 20 ) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Estimate point cannot have more than 20 characters.", }); @@ -149,8 +146,8 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { formData.value6, ]) ) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Estimate points cannot have duplicate values.", }); @@ -272,7 +269,7 @@ export const CreateUpdateEstimateModal: React.FC = observer((props) => { {Array(6) .fill(0) .map((_, i) => ( -
+
{i + 1} diff --git a/web/components/estimates/delete-estimate-modal.tsx b/web/components/estimates/delete-estimate-modal.tsx index 8055ddb90..f8bc2a65b 100644 --- a/web/components/estimates/delete-estimate-modal.tsx +++ b/web/components/estimates/delete-estimate-modal.tsx @@ -1,15 +1,14 @@ import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; -import { observer } from "mobx-react-lite"; import { AlertTriangle } from "lucide-react"; // store hooks +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; import { useEstimate } from "hooks/store"; -import useToast from "hooks/use-toast"; // types import { IEstimate } from "@plane/types"; // ui -import { Button } from "@plane/ui"; type Props = { isOpen: boolean; @@ -26,12 +25,11 @@ export const DeleteEstimateModal: React.FC = observer((props) => { const { workspaceSlug, projectId } = router.query; // store hooks const { deleteEstimate } = useEstimate(); - // toast alert - const { setToastAlert } = useToast(); const handleEstimateDelete = () => { if (!workspaceSlug || !projectId) return; + // eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain const estimateId = data?.id!; deleteEstimate(workspaceSlug.toString(), projectId.toString(), estimateId) @@ -43,8 +41,8 @@ export const DeleteEstimateModal: React.FC = observer((props) => { const error = err?.error; const errorString = Array.isArray(error) ? error[0] : error; - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: errorString ?? "Estimate could not be deleted. Please try again", }); diff --git a/web/components/estimates/estimate-list-item.tsx b/web/components/estimates/estimate-list-item.tsx index b6effa711..c63c4b208 100644 --- a/web/components/estimates/estimate-list-item.tsx +++ b/web/components/estimates/estimate-list-item.tsx @@ -1,15 +1,14 @@ import React from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks -import { useProject } from "hooks/store"; -import useToast from "hooks/use-toast"; -// ui -import { Button, CustomMenu } from "@plane/ui"; -//icons import { Pencil, Trash2 } from "lucide-react"; -// helpers +import { Button, CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { orderArrayBy } from "helpers/array.helper"; +import { useProject } from "hooks/store"; +// ui +//icons +// helpers // types import { IEstimate } from "@plane/types"; @@ -26,8 +25,6 @@ export const EstimateListItem: React.FC = observer((props) => { const { workspaceSlug, projectId } = router.query; // store hooks const { currentProjectDetails, updateProject } = useProject(); - // hooks - const { setToastAlert } = useToast(); const handleUseEstimate = async () => { if (!workspaceSlug || !projectId) return; @@ -38,8 +35,8 @@ export const EstimateListItem: React.FC = observer((props) => { const error = err?.error; const errorString = Array.isArray(error) ? error[0] : error; - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: errorString ?? "Estimate points could not be used. Please try again.", }); diff --git a/web/components/estimates/estimates-list.tsx b/web/components/estimates/estimates-list.tsx index 1dabc6181..8e447d6ac 100644 --- a/web/components/estimates/estimates-list.tsx +++ b/web/components/estimates/estimates-list.tsx @@ -1,21 +1,20 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { useTheme } from "next-themes"; // store hooks -import { useEstimate, useProject, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; -// components -import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates"; +import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; +import { CreateUpdateEstimateModal, DeleteEstimateModal, EstimateListItem } from "components/estimates"; +import { PROJECT_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state"; +import { orderArrayBy } from "helpers/array.helper"; +import { useEstimate, useProject, useUser } from "hooks/store"; +// components // ui -import { Button, Loader } from "@plane/ui"; // types import { IEstimate } from "@plane/types"; // helpers -import { orderArrayBy } from "helpers/array.helper"; // constants -import { PROJECT_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state"; export const EstimatesList: React.FC = observer(() => { // states @@ -31,8 +30,6 @@ export const EstimatesList: React.FC = observer(() => { const { updateProject, currentProjectDetails } = useProject(); const { projectEstimates, getProjectEstimateById } = useEstimate(); const { currentUser } = useUser(); - // toast alert - const { setToastAlert } = useToast(); const editEstimate = (estimate: IEstimate) => { setEstimateFormOpen(true); @@ -50,8 +47,8 @@ export const EstimatesList: React.FC = observer(() => { const error = err?.error; const errorString = Array.isArray(error) ? error[0] : error; - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: errorString ?? "Estimate could not be disabled. Please try again", }); diff --git a/web/components/exporter/export-modal.tsx b/web/components/exporter/export-modal.tsx index b1f529775..16f8d4640 100644 --- a/web/components/exporter/export-modal.tsx +++ b/web/components/exporter/export-modal.tsx @@ -1,15 +1,14 @@ import React, { useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { Dialog, Transition } from "@headlessui/react"; // hooks +import { Button, CustomSearchSelect, TOAST_TYPE, setToast } from "@plane/ui"; + import { useProject } from "hooks/store"; // services import { ProjectExportService } from "services/project"; -// hooks -import useToast from "hooks/use-toast"; // ui -import { Button, CustomSearchSelect } from "@plane/ui"; // types import { IUser, IImporterService } from "@plane/types"; @@ -34,8 +33,6 @@ export const Exporter: React.FC = observer((props) => { const { workspaceSlug } = router.query; // store hooks const { workspaceProjectIds, getProjectById } = useProject(); - // toast alert - const { setToastAlert } = useToast(); const options = workspaceProjectIds?.map((projectId) => { const projectDetails = getProjectById(projectId); @@ -71,8 +68,8 @@ export const Exporter: React.FC = observer((props) => { mutateServices(); router.push(`/${workspaceSlug}/settings/exports`); setExportLoading(false); - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Export Successful", message: `You will be able to download the exported ${ provider === "csv" ? "CSV" : provider === "xlsx" ? "Excel" : provider === "json" ? "JSON" : "" @@ -81,8 +78,8 @@ export const Exporter: React.FC = observer((props) => { }) .catch(() => { setExportLoading(false); - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Export was unsuccessful. Please try again.", }); diff --git a/web/components/exporter/guide.tsx b/web/components/exporter/guide.tsx index ed6a39220..381b168bd 100644 --- a/web/components/exporter/guide.tsx +++ b/web/components/exporter/guide.tsx @@ -1,30 +1,29 @@ import { useState } from "react"; -import Link from "next/link"; +import { observer } from "mobx-react-lite"; import Image from "next/image"; +import Link from "next/link"; import { useRouter } from "next/router"; import { useTheme } from "next-themes"; import useSWR, { mutate } from "swr"; -import { observer } from "mobx-react-lite"; // hooks +import { MoveLeft, MoveRight, RefreshCw } from "lucide-react"; +import { Button } from "@plane/ui"; +import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; +import { Exporter, SingleExport } from "components/exporter"; +import { ImportExportSettingsLoader } from "components/ui"; +import { WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state"; +import { EXPORT_SERVICES_LIST } from "constants/fetch-keys"; +import { EXPORTERS_LIST } from "constants/workspace"; import { useUser } from "hooks/store"; import useUserAuth from "hooks/use-user-auth"; // services import { IntegrationService } from "services/integrations"; // components -import { Exporter, SingleExport } from "components/exporter"; -import { EmptyState, getEmptyStateImagePath } from "components/empty-state"; // ui -import { Button } from "@plane/ui"; -import { ImportExportSettingsLoader } from "components/ui"; // icons -import { MoveLeft, MoveRight, RefreshCw } from "lucide-react"; // fetch-keys -import { EXPORT_SERVICES_LIST } from "constants/fetch-keys"; // constants -import { EXPORTERS_LIST } from "constants/workspace"; - -import { WORKSPACE_SETTINGS_EMPTY_STATE_DETAILS } from "constants/empty-state"; // services const integrationService = new IntegrationService(); diff --git a/web/components/exporter/single-export.tsx b/web/components/exporter/single-export.tsx index 34e41fc35..4fdcb4a15 100644 --- a/web/components/exporter/single-export.tsx +++ b/web/components/exporter/single-export.tsx @@ -38,12 +38,12 @@ export const SingleExport: FC = ({ service, refreshing }) => { service.status === "completed" ? "bg-green-500/20 text-green-500" : service.status === "processing" - ? "bg-yellow-500/20 text-yellow-500" - : service.status === "failed" - ? "bg-red-500/20 text-red-500" - : service.status === "expired" - ? "bg-orange-500/20 text-orange-500" - : "" + ? "bg-yellow-500/20 text-yellow-500" + : service.status === "failed" + ? "bg-red-500/20 text-red-500" + : service.status === "expired" + ? "bg-orange-500/20 text-orange-500" + : "" }`} > {refreshing ? "Refreshing..." : service.status} diff --git a/web/components/gantt-chart/blocks/block.tsx b/web/components/gantt-chart/blocks/block.tsx new file mode 100644 index 000000000..3305c9846 --- /dev/null +++ b/web/components/gantt-chart/blocks/block.tsx @@ -0,0 +1,106 @@ +import { observer } from "mobx-react"; +// hooks +// components +// helpers +import { cn } from "helpers/common.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { useIssueDetail } from "hooks/store"; +// types +// constants +import { BLOCK_HEIGHT } from "../constants"; +import { ChartAddBlock, ChartDraggable } from "../helpers"; +import { useGanttChart } from "../hooks"; +import { IBlockUpdateData, IGanttBlock } from "../types"; + +type Props = { + block: IGanttBlock; + blockToRender: (data: any) => React.ReactNode; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + enableBlockLeftResize: boolean; + enableBlockRightResize: boolean; + enableBlockMove: boolean; + enableAddBlock: boolean; + ganttContainerRef: React.RefObject; +}; + +export const GanttChartBlock: React.FC = observer((props) => { + const { + block, + blockToRender, + blockUpdateHandler, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + enableAddBlock, + ganttContainerRef, + } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { peekIssue } = useIssueDetail(); + + const isBlockVisibleOnChart = block.start_date && block.target_date; + + const handleChartBlockPosition = ( + block: IGanttBlock, + totalBlockShifts: number, + dragDirection: "left" | "right" | "move" + ) => { + if (!block.start_date || !block.target_date) return; + + const originalStartDate = new Date(block.start_date); + const updatedStartDate = new Date(originalStartDate); + + const originalTargetDate = new Date(block.target_date); + const updatedTargetDate = new Date(originalTargetDate); + + // update the start date on left resize + if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); + // update the target date on right resize + else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); + // update both the dates on x-axis move + else if (dragDirection === "move") { + updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts); + updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); + } + + // call the block update handler with the updated dates + blockUpdateHandler(block.data, { + start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, + target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, + }); + }; + + return ( +
+
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + > + {isBlockVisibleOnChart ? ( + handleChartBlockPosition(block, ...args)} + enableBlockLeftResize={enableBlockLeftResize} + enableBlockRightResize={enableBlockRightResize} + enableBlockMove={enableBlockMove} + ganttContainerRef={ganttContainerRef} + /> + ) : ( + enableAddBlock && + )} +
+
+ ); +}); diff --git a/web/components/gantt-chart/blocks/blocks-list.tsx b/web/components/gantt-chart/blocks/blocks-list.tsx index 15a3e5295..8eb1d8772 100644 --- a/web/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/components/gantt-chart/blocks/blocks-list.tsx @@ -1,16 +1,10 @@ -import { observer } from "mobx-react"; import { FC } from "react"; -// hooks -import { useIssueDetail } from "hooks/store"; -import { useChart } from "../hooks"; -// helpers -import { ChartAddBlock, ChartDraggable } from "components/gantt-chart"; -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types +// components +import { HEADER_HEIGHT } from "../constants"; import { IBlockUpdateData, IGanttBlock } from "../types"; +import { GanttChartBlock } from "./block"; +// types // constants -import { BLOCK_HEIGHT, HEADER_HEIGHT } from "../constants"; export type GanttChartBlocksProps = { itemsContainerWidth: number; @@ -21,10 +15,11 @@ export type GanttChartBlocksProps = { enableBlockRightResize: boolean; enableBlockMove: boolean; enableAddBlock: boolean; + ganttContainerRef: React.RefObject; showAllBlocks: boolean; }; -export const GanttChartBlocksList: FC = observer((props) => { +export const GanttChartBlocksList: FC = (props) => { const { itemsContainerWidth, blocks, @@ -34,52 +29,9 @@ export const GanttChartBlocksList: FC = observer((props) enableBlockRightResize, enableBlockMove, enableAddBlock, + ganttContainerRef, showAllBlocks, } = props; - // store hooks - const { peekIssue } = useIssueDetail(); - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleChartBlockPosition = ( - block: IGanttBlock, - totalBlockShifts: number, - dragDirection: "left" | "right" | "move" - ) => { - if (!block.start_date || !block.target_date) return; - - const originalStartDate = new Date(block.start_date); - const updatedStartDate = new Date(originalStartDate); - - const originalTargetDate = new Date(block.target_date); - const updatedTargetDate = new Date(originalTargetDate); - - // update the start date on left resize - if (dragDirection === "left") updatedStartDate.setDate(originalStartDate.getDate() - totalBlockShifts); - // update the target date on right resize - else if (dragDirection === "right") updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - // update both the dates on x-axis move - else if (dragDirection === "move") { - updatedStartDate.setDate(originalStartDate.getDate() + totalBlockShifts); - updatedTargetDate.setDate(originalTargetDate.getDate() + totalBlockShifts); - } - - // call the block update handler with the updated dates - blockUpdateHandler(block.data, { - start_date: renderFormattedPayloadDate(updatedStartDate) ?? undefined, - target_date: renderFormattedPayloadDate(updatedTargetDate) ?? undefined, - }); - }; return (
= observer((props) // hide the block if it doesn't have start and target dates and showAllBlocks is false if (!showAllBlocks && !(block.start_date && block.target_date)) return; - const isBlockVisibleOnChart = block.start_date && block.target_date; - return ( -
-
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - > - {isBlockVisibleOnChart ? ( - handleChartBlockPosition(block, ...args)} - enableBlockLeftResize={enableBlockLeftResize} - enableBlockRightResize={enableBlockRightResize} - enableBlockMove={enableBlockMove} - /> - ) : ( - enableAddBlock && - )} -
-
+ ); })}
); -}); +}; diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index 6dcfdc36f..fe35c9e52 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -1,10 +1,13 @@ +import { observer } from "mobx-react"; import { Expand, Shrink } from "lucide-react"; // hooks -import { useChart } from "../hooks"; // helpers +import { VIEWS_LIST } from "components/gantt-chart/data"; import { cn } from "helpers/common.helper"; // types +import { useGanttChart } from "../hooks/use-gantt-chart"; import { IGanttBlock, TGanttViews } from "../types"; +// constants type Props = { blocks: IGanttBlock[] | null; @@ -16,10 +19,10 @@ type Props = { toggleFullScreenMode: () => void; }; -export const GanttChartHeader: React.FC = (props) => { +export const GanttChartHeader: React.FC = observer((props) => { const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, title, toggleFullScreenMode } = props; // chart hook - const { currentView, allViews } = useChart(); + const { currentView } = useGanttChart(); return (
@@ -29,7 +32,7 @@ export const GanttChartHeader: React.FC = (props) => {
- {allViews?.map((chartView: any) => ( + {VIEWS_LIST.map((chartView: any) => (
= (props) => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index 7a35adbb6..8c00fde1b 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -1,3 +1,6 @@ +import { useRef } from "react"; +import { observer } from "mobx-react"; +// hooks // components import { BiWeekChartView, @@ -12,10 +15,10 @@ import { TGanttViews, WeekChartView, YearChartView, - useChart, } from "components/gantt-chart"; // helpers import { cn } from "helpers/common.helper"; +import { useGanttChart } from "../hooks/use-gantt-chart"; type Props = { blocks: IGanttBlock[] | null; @@ -36,7 +39,7 @@ type Props = { quickAdd?: React.JSX.Element | undefined; }; -export const GanttChartMainContent: React.FC = (props) => { +export const GanttChartMainContent: React.FC = observer((props) => { const { blocks, blockToRender, @@ -55,13 +58,15 @@ export const GanttChartMainContent: React.FC = (props) => { updateCurrentViewRenderPayload, quickAdd, } = props; + // refs + const ganttContainerRef = useRef(null); // chart hook - const { currentView, currentViewData, updateScrollLeft } = useChart(); + const { currentView, currentViewData } = useGanttChart(); // handling scroll functionality const onScroll = (e: React.UIEvent) => { const { clientWidth, scrollLeft, scrollWidth } = e.currentTarget; - updateScrollLeft(scrollLeft); + // updateScrollLeft(scrollLeft); const approxRangeLeft = scrollLeft >= clientWidth + 1000 ? 1000 : scrollLeft - clientWidth; const approxRangeRight = scrollWidth - (scrollLeft + clientWidth); @@ -90,11 +95,12 @@ export const GanttChartMainContent: React.FC = (props) => { // DO NOT REMOVE THE ID id="gantt-container" className={cn( - "h-full w-full overflow-auto horizontal-scroll-enable flex border-t-[0.5px] border-custom-border-200", + "h-full w-full overflow-auto vertical-scrollbar horizontal-scrollbar scrollbar-lg flex border-t-[0.5px] border-custom-border-200", { "mb-8": bottomSpacing, } )} + ref={ganttContainerRef} onScroll={onScroll} > = (props) => { enableBlockRightResize={enableBlockRightResize} enableBlockMove={enableBlockMove} enableAddBlock={enableAddBlock} + ganttContainerRef={ganttContainerRef} showAllBlocks={showAllBlocks} /> )}
); -}; +}); diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index 877c15901..ab04c2ec1 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -1,20 +1,23 @@ import { FC, useEffect, useState } from "react"; +import { observer } from "mobx-react"; +// hooks // components -import { GanttChartHeader, useChart, GanttChartMainContent } from "components/gantt-chart"; +import { GanttChartHeader, GanttChartMainContent } from "components/gantt-chart"; // views +// helpers +import { cn } from "helpers/common.helper"; +// types +// data +import { SIDEBAR_WIDTH } from "../constants"; +import { currentViewDataWithView } from "../data"; +// constants +import { useGanttChart } from "../hooks/use-gantt-chart"; +import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; import { generateMonthChart, getNumberOfDaysBetweenTwoDatesInMonth, getMonthChartItemPositionWidthInMonth, } from "../views"; -// helpers -import { cn } from "helpers/common.helper"; -// types -import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; -// data -import { currentViewDataWithView } from "../data"; -// constants -import { SIDEBAR_WIDTH } from "../constants"; type ChartViewRootProps = { border: boolean; @@ -34,7 +37,7 @@ type ChartViewRootProps = { quickAdd?: React.JSX.Element | undefined; }; -export const ChartViewRoot: FC = (props) => { +export const ChartViewRoot: FC = observer((props) => { const { border, title, @@ -57,7 +60,8 @@ export const ChartViewRoot: FC = (props) => { const [fullScreenMode, setFullScreenMode] = useState(false); const [chartBlocks, setChartBlocks] = useState(null); // hooks - const { currentView, currentViewData, renderView, dispatch } = useChart(); + const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } = + useGanttChart(); // rendering the block structure const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => @@ -87,36 +91,20 @@ export const ChartViewRoot: FC = (props) => { // updating the prevData, currentData and nextData if (currentRender.payload.length > 0) { + updateCurrentViewData(currentRender.state); + if (side === "left") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: selectedCurrentView, - currentViewData: currentRender.state, - renderView: [...currentRender.payload, ...renderView], - }, - }); + updateCurrentView(selectedCurrentView); + updateRenderView([...currentRender.payload, ...renderView]); updatingCurrentLeftScrollPosition(currentRender.scrollWidth); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else if (side === "right") { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...renderView, ...currentRender.payload], - }, - }); + updateCurrentView(view); + updateRenderView([...renderView, ...currentRender.payload]); setItemsContainerWidth(itemsContainerWidth + currentRender.scrollWidth); } else { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - currentView: view, - currentViewData: currentRender.state, - renderView: [...currentRender.payload], - }, - }); + updateCurrentView(view); + updateRenderView(currentRender.payload); setItemsContainerWidth(currentRender.scrollWidth); setTimeout(() => { handleScrollToCurrentSelectedDate(currentRender.state, currentRender.state.data.currentDate); @@ -206,4 +194,4 @@ export const ChartViewRoot: FC = (props) => { />
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/bi-week.tsx b/web/components/gantt-chart/chart/views/bi-week.tsx index 6e53d5390..f0ad084e9 100644 --- a/web/components/gantt-chart/chart/views/bi-week.tsx +++ b/web/components/gantt-chart/chart/views/bi-week.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "components/gantt-chart"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const BiWeekChartView: FC = () => { +export const BiWeekChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const BiWeekChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/day.tsx b/web/components/gantt-chart/chart/views/day.tsx index a50b7748a..84b2edac4 100644 --- a/web/components/gantt-chart/chart/views/day.tsx +++ b/web/components/gantt-chart/chart/views/day.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const DayChartView: FC = () => { +export const DayChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const DayChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/hours.tsx b/web/components/gantt-chart/chart/views/hours.tsx index e1fd02e3f..bd1a7b6dd 100644 --- a/web/components/gantt-chart/chart/views/hours.tsx +++ b/web/components/gantt-chart/chart/views/hours.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "components/gantt-chart"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const HourChartView: FC = () => { +export const HourChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const HourChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/month.tsx b/web/components/gantt-chart/chart/views/month.tsx index c559e9688..b09bcc671 100644 --- a/web/components/gantt-chart/chart/views/month.tsx +++ b/web/components/gantt-chart/chart/views/month.tsx @@ -1,22 +1,23 @@ import { FC } from "react"; +import { observer } from "mobx-react"; // hooks -import { useChart } from "components/gantt-chart"; +import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants"; +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; // helpers import { cn } from "helpers/common.helper"; // types import { IMonthBlock } from "../../views"; // constants -import { HEADER_HEIGHT, SIDEBAR_WIDTH } from "components/gantt-chart/constants"; -export const MonthChartView: FC = () => { +export const MonthChartView: FC = observer(() => { // chart hook - const { currentViewData, renderView } = useChart(); + const { currentViewData, renderView } = useGanttChart(); const monthBlocks: IMonthBlock[] = renderView; return ( -
+
{monthBlocks?.map((block, rootIndex) => ( -
+
= () => { ))}
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/quarter.tsx b/web/components/gantt-chart/chart/views/quarter.tsx index ffbc1cbfe..b8adc4b3a 100644 --- a/web/components/gantt-chart/chart/views/quarter.tsx +++ b/web/components/gantt-chart/chart/views/quarter.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const QuarterChartView: FC = () => { +export const QuarterChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -46,4 +47,4 @@ export const QuarterChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/week.tsx b/web/components/gantt-chart/chart/views/week.tsx index 8170affa4..981fc9236 100644 --- a/web/components/gantt-chart/chart/views/week.tsx +++ b/web/components/gantt-chart/chart/views/week.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const WeekChartView: FC = () => { +export const WeekChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -50,4 +51,4 @@ export const WeekChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/chart/views/year.tsx b/web/components/gantt-chart/chart/views/year.tsx index 9dbeedece..659126ac3 100644 --- a/web/components/gantt-chart/chart/views/year.tsx +++ b/web/components/gantt-chart/chart/views/year.tsx @@ -1,10 +1,11 @@ import { FC } from "react"; -// context -import { useChart } from "../../hooks"; +import { observer } from "mobx-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks/use-gantt-chart"; -export const YearChartView: FC = () => { +export const YearChartView: FC = observer(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { currentView, currentViewData, renderView, dispatch, allViews } = useChart(); + const { currentView, currentViewData, renderView } = useGanttChart(); return ( <> @@ -46,4 +47,4 @@ export const YearChartView: FC = () => {
); -}; +}); diff --git a/web/components/gantt-chart/contexts/index.tsx b/web/components/gantt-chart/contexts/index.tsx index 84e7a19b5..752645f66 100644 --- a/web/components/gantt-chart/contexts/index.tsx +++ b/web/components/gantt-chart/contexts/index.tsx @@ -1,57 +1,23 @@ -import React, { createContext, useState } from "react"; -// types -import { ChartContextData, ChartContextActionPayload, ChartContextReducer } from "../types"; -// data -import { allViewsWithData, currentViewDataWithView } from "../data"; +import React, { FC, createContext } from "react"; +// mobx store +import { GanttStore } from "store/issue/issue_gantt_view.store"; -export const ChartContext = createContext(undefined); +let ganttViewStore = new GanttStore(); -const chartReducer = (state: ChartContextData, action: ChartContextActionPayload): ChartContextData => { - switch (action.type) { - case "CURRENT_VIEW": - return { ...state, currentView: action.payload }; - case "CURRENT_VIEW_DATA": - return { ...state, currentViewData: action.payload }; - case "RENDER_VIEW": - return { ...state, currentViewData: action.payload }; - case "PARTIAL_UPDATE": - return { ...state, ...action.payload }; - default: - return state; - } +export const GanttStoreContext = createContext(ganttViewStore); + +const initializeStore = () => { + const newGanttViewStore = ganttViewStore ?? new GanttStore(); + if (typeof window === "undefined") return newGanttViewStore; + if (!ganttViewStore) ganttViewStore = newGanttViewStore; + return newGanttViewStore; }; -const initialView = "month"; - -export const ChartContextProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - // states; - const [state, dispatch] = useState({ - currentView: initialView, - currentViewData: currentViewDataWithView(initialView), - renderView: [], - allViews: allViewsWithData, - activeBlock: null, - }); - const [scrollLeft, setScrollLeft] = useState(0); - - const handleDispatch = (action: ChartContextActionPayload): ChartContextData => { - const newState = chartReducer(state, action); - dispatch(() => newState); - return newState; - }; - - const updateScrollLeft = (scrollLeft: number) => setScrollLeft(scrollLeft); - - return ( - - {children} - - ); +type GanttStoreProviderProps = { + children: React.ReactNode; +}; + +export const GanttStoreProvider: FC = ({ children }) => { + const store = initializeStore(); + return {children}; }; diff --git a/web/components/gantt-chart/data/index.ts b/web/components/gantt-chart/data/index.ts index 58ac6e4b2..cc15c5d9e 100644 --- a/web/components/gantt-chart/data/index.ts +++ b/web/components/gantt-chart/data/index.ts @@ -1,5 +1,5 @@ // types -import { WeekMonthDataType, ChartDataType } from "../types"; +import { WeekMonthDataType, ChartDataType, TGanttViews } from "../types"; // constants export const weeks: WeekMonthDataType[] = [ @@ -53,7 +53,7 @@ export const datePreview = (date: Date, includeTime: boolean = false) => { }; // context data -export const allViewsWithData: ChartDataType[] = [ +export const VIEWS_LIST: ChartDataType[] = [ // { // key: "hours", // title: "Hours", @@ -133,7 +133,5 @@ export const allViewsWithData: ChartDataType[] = [ // }, ]; -export const currentViewDataWithView = (view: string = "month") => { - const currentView: ChartDataType | undefined = allViewsWithData.find((_viewData) => _viewData.key === view); - return currentView; -}; +export const currentViewDataWithView = (view: TGanttViews = "month") => + VIEWS_LIST.find((_viewData) => _viewData.key === view); diff --git a/web/components/gantt-chart/helpers/add-block.tsx b/web/components/gantt-chart/helpers/add-block.tsx index bfeddffa2..d12c9f20e 100644 --- a/web/components/gantt-chart/helpers/add-block.tsx +++ b/web/components/gantt-chart/helpers/add-block.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef, useState } from "react"; import { addDays } from "date-fns"; +import { observer } from "mobx-react"; import { Plus } from "lucide-react"; -// hooks -import { useChart } from "../hooks"; // ui import { Tooltip } from "@plane/ui"; // helpers import { renderFormattedDate, renderFormattedPayloadDate } from "helpers/date-time.helper"; // types +import { useGanttChart } from "../hooks/use-gantt-chart"; import { IBlockUpdateData, IGanttBlock } from "../types"; type Props = { @@ -15,7 +15,7 @@ type Props = { blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; }; -export const ChartAddBlock: React.FC = (props) => { +export const ChartAddBlock: React.FC = observer((props) => { const { block, blockUpdateHandler } = props; // states const [isButtonVisible, setIsButtonVisible] = useState(false); @@ -24,7 +24,7 @@ export const ChartAddBlock: React.FC = (props) => { // refs const containerRef = useRef(null); // chart hook - const { currentViewData } = useChart(); + const { currentViewData } = useGanttChart(); const handleButtonClick = () => { if (!currentViewData) return; @@ -88,4 +88,4 @@ export const ChartAddBlock: React.FC = (props) => { )}
); -}; +}); diff --git a/web/components/gantt-chart/helpers/block-structure.ts b/web/components/gantt-chart/helpers/block-structure.ts deleted file mode 100644 index 0f18b43cc..000000000 --- a/web/components/gantt-chart/helpers/block-structure.ts +++ /dev/null @@ -1,12 +0,0 @@ -// types -import { TIssue } from "@plane/types"; -import { IGanttBlock } from "components/gantt-chart"; - -export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] => - blocks?.map((block) => ({ - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: block.start_date ? new Date(block.start_date) : null, - target_date: block.target_date ? new Date(block.target_date) : null, - })); diff --git a/web/components/gantt-chart/helpers/draggable.tsx b/web/components/gantt-chart/helpers/draggable.tsx index ac1602346..54590c372 100644 --- a/web/components/gantt-chart/helpers/draggable.tsx +++ b/web/components/gantt-chart/helpers/draggable.tsx @@ -1,11 +1,13 @@ import React, { useEffect, useRef, useState } from "react"; +import { observer } from "mobx-react"; import { ArrowRight } from "lucide-react"; // hooks -import { IGanttBlock, useChart } from "components/gantt-chart"; +import { IGanttBlock } from "components/gantt-chart"; // helpers import { cn } from "helpers/common.helper"; // constants import { SIDEBAR_WIDTH } from "../constants"; +import { useGanttChart } from "../hooks/use-gantt-chart"; type Props = { block: IGanttBlock; @@ -14,19 +16,29 @@ type Props = { enableBlockLeftResize: boolean; enableBlockRightResize: boolean; enableBlockMove: boolean; + ganttContainerRef: React.RefObject; }; -export const ChartDraggable: React.FC = (props) => { - const { block, blockToRender, handleBlock, enableBlockLeftResize, enableBlockRightResize, enableBlockMove } = props; +export const ChartDraggable: React.FC = observer((props) => { + const { + block, + blockToRender, + handleBlock, + enableBlockLeftResize, + enableBlockRightResize, + enableBlockMove, + ganttContainerRef, + } = props; // states const [isLeftResizing, setIsLeftResizing] = useState(false); const [isRightResizing, setIsRightResizing] = useState(false); const [isMoving, setIsMoving] = useState(false); const [isHidden, setIsHidden] = useState(true); + const [scrollLeft, setScrollLeft] = useState(0); // refs const resizableRef = useRef(null); // chart hook - const { currentViewData, scrollLeft } = useChart(); + const { currentViewData } = useGanttChart(); // check if cursor reaches either end while resizing/dragging const checkScrollEnd = (e: MouseEvent): number => { const SCROLL_THRESHOLD = 70; @@ -212,6 +224,17 @@ export const ChartDraggable: React.FC = (props) => { block.position?.width && scrollLeft > block.position.marginLeft + block.position.width; + useEffect(() => { + const ganttContainer = ganttContainerRef.current; + if (!ganttContainer) return; + + const handleScroll = () => setScrollLeft(ganttContainer.scrollLeft); + ganttContainer.addEventListener("scroll", handleScroll); + return () => { + ganttContainer.removeEventListener("scroll", handleScroll); + }; + }, [ganttContainerRef]); + useEffect(() => { const intersectionRoot = document.querySelector("#gantt-container") as HTMLDivElement; const resizableBlock = resizableRef.current; @@ -234,7 +257,7 @@ export const ChartDraggable: React.FC = (props) => { return () => { observer.unobserve(resizableBlock); }; - }, [block.data.name]); + }, []); return ( <> @@ -312,4 +335,4 @@ export const ChartDraggable: React.FC = (props) => {
); -}; +}); diff --git a/web/components/gantt-chart/helpers/index.ts b/web/components/gantt-chart/helpers/index.ts index 1b51dc374..c96d42eec 100644 --- a/web/components/gantt-chart/helpers/index.ts +++ b/web/components/gantt-chart/helpers/index.ts @@ -1,3 +1,2 @@ export * from "./add-block"; -export * from "./block-structure"; export * from "./draggable"; diff --git a/web/components/gantt-chart/hooks/index.ts b/web/components/gantt-chart/hooks/index.ts new file mode 100644 index 000000000..009650675 --- /dev/null +++ b/web/components/gantt-chart/hooks/index.ts @@ -0,0 +1 @@ +export * from "./use-gantt-chart"; diff --git a/web/components/gantt-chart/hooks/index.tsx b/web/components/gantt-chart/hooks/index.tsx deleted file mode 100644 index 5fb9bee3f..000000000 --- a/web/components/gantt-chart/hooks/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { useContext } from "react"; -// types -import { ChartContextReducer } from "../types"; -// context -import { ChartContext } from "../contexts"; - -export const useChart = (): ChartContextReducer => { - const context = useContext(ChartContext); - - if (!context) throw new Error("useChart must be used within a GanttChart"); - - return context; -}; diff --git a/web/components/gantt-chart/hooks/use-gantt-chart.ts b/web/components/gantt-chart/hooks/use-gantt-chart.ts new file mode 100644 index 000000000..23e025e90 --- /dev/null +++ b/web/components/gantt-chart/hooks/use-gantt-chart.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +// mobx store +import { GanttStoreContext } from "components/gantt-chart/contexts"; +// types +import { IGanttStore } from "store/issue/issue_gantt_view.store"; + +export const useGanttChart = (): IGanttStore => { + const context = useContext(GanttStoreContext); + if (context === undefined) throw new Error("useGanttChart must be used within GanttStoreProvider"); + return context; +}; diff --git a/web/components/gantt-chart/index.ts b/web/components/gantt-chart/index.ts index 54a2cc597..78297ffcd 100644 --- a/web/components/gantt-chart/index.ts +++ b/web/components/gantt-chart/index.ts @@ -3,5 +3,5 @@ export * from "./chart"; export * from "./helpers"; export * from "./hooks"; export * from "./root"; -export * from "./types"; export * from "./sidebar"; +export * from "./types"; diff --git a/web/components/gantt-chart/root.tsx b/web/components/gantt-chart/root.tsx index ac132500b..4df5d9931 100644 --- a/web/components/gantt-chart/root.tsx +++ b/web/components/gantt-chart/root.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; // components import { ChartViewRoot, IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; // context -import { ChartContextProvider } from "./contexts"; +import { GanttStoreProvider } from "components/gantt-chart/contexts"; type GanttChartRootProps = { border?: boolean; @@ -42,7 +42,7 @@ export const GanttChartRoot: FC = (props) => { } = props; return ( - + = (props) => { showAllBlocks={showAllBlocks} quickAdd={quickAdd} /> - + ); }; diff --git a/web/components/gantt-chart/sidebar/cycles.tsx b/web/components/gantt-chart/sidebar/cycles.tsx deleted file mode 100644 index 384869a40..000000000 --- a/web/components/gantt-chart/sidebar/cycles.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -// ui -import { Loader } from "@plane/ui"; -// components -import { CycleGanttSidebarBlock } from "components/cycles"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; -// constants -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; -}; - -export const CycleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- ); -}; diff --git a/web/components/gantt-chart/sidebar/cycles/block.tsx b/web/components/gantt-chart/sidebar/cycles/block.tsx new file mode 100644 index 000000000..6e780c479 --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/block.tsx @@ -0,0 +1,72 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { CycleGanttSidebarBlock } from "components/cycles"; +import { BLOCK_HEIGHT } from "components/gantt-chart/constants"; +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +// helpers +import { IGanttBlock } from "components/gantt-chart/types"; +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +// constants + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const CyclesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/cycles/index.ts b/web/components/gantt-chart/sidebar/cycles/index.ts new file mode 100644 index 000000000..01acaeffb --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/cycles/sidebar.tsx b/web/components/gantt-chart/sidebar/cycles/sidebar.tsx new file mode 100644 index 000000000..e47b2304e --- /dev/null +++ b/web/components/gantt-chart/sidebar/cycles/sidebar.tsx @@ -0,0 +1,100 @@ +import { DragDropContext, Draggable, DropResult, Droppable } from "@hello-pangea/dnd"; +// ui +import { Loader } from "@plane/ui"; +// components +import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; +import { CyclesSidebarBlock } from "./block"; +// types + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; +}; + +export const CycleGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/issues.tsx b/web/components/gantt-chart/sidebar/issues.tsx deleted file mode 100644 index 52e30ded5..000000000 --- a/web/components/gantt-chart/sidebar/issues.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { observer } from "mobx-react"; -import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -import { useIssueDetail } from "hooks/store"; -// ui -import { Loader } from "@plane/ui"; -// components -import { IssueGanttSidebarBlock } from "components/issues"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; - showAllBlocks?: boolean; -}; - -export const IssueGanttSidebar: React.FC = observer((props: Props) => { - const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; - - const { activeBlock, dispatch } = useChart(); - const { peekIssue } = useIssueDetail(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - <> - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const isBlockVisibleOnSidebar = block.start_date && block.target_date; - - // hide the block if it doesn't have start and target dates and showAllBlocks is false - if (!showAllBlocks && !isBlockVisibleOnSidebar) return; - - const duration = - !block.start_date || !block.target_date - ? null - : findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration && ( -
- - {duration} day{duration > 1 ? "s" : ""} - -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- - ); -}); diff --git a/web/components/gantt-chart/sidebar/issues/block.tsx b/web/components/gantt-chart/sidebar/issues/block.tsx new file mode 100644 index 000000000..92fc32664 --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/block.tsx @@ -0,0 +1,77 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { IssueGanttSidebarBlock } from "components/issues"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +import { useIssueDetail } from "hooks/store"; +// types +// constants +import { BLOCK_HEIGHT } from "../../constants"; +import { IGanttBlock } from "../../types"; + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const IssuesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { peekIssue } = useIssueDetail(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration && ( +
+ + {duration} day{duration > 1 ? "s" : ""} + +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/issues/index.ts b/web/components/gantt-chart/sidebar/issues/index.ts new file mode 100644 index 000000000..01acaeffb --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/components/gantt-chart/sidebar/issues/sidebar.tsx new file mode 100644 index 000000000..e82e40f5d --- /dev/null +++ b/web/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -0,0 +1,107 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; +// components +// ui +import { Loader } from "@plane/ui"; +// types +import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types"; +import { IssuesSidebarBlock } from "./block"; + +type Props = { + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; + showAllBlocks?: boolean; +}; + +export const IssueGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder, showAllBlocks = false } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => { + const isBlockVisibleOnSidebar = block.start_date && block.target_date; + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!showAllBlocks && !isBlockVisibleOnSidebar) return; + + return ( + + {(provided, snapshot) => ( + + )} + + ); + }) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/modules.tsx b/web/components/gantt-chart/sidebar/modules.tsx deleted file mode 100644 index bdf8ca571..000000000 --- a/web/components/gantt-chart/sidebar/modules.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; -// ui -import { Loader } from "@plane/ui"; -// components -import { ModuleGanttSidebarBlock } from "components/modules"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -import { cn } from "helpers/common.helper"; -// types -import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; -// constants -import { BLOCK_HEIGHT } from "../constants"; - -type Props = { - title: string; - blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; - enableReorder: boolean; -}; - -export const ModuleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; - - const handleOrderChange = (result: DropResult) => { - if (!blocks) return; - - const { source, destination } = result; - - // return if dropped outside the list - if (!destination) return; - - // return if dropped on the same index - if (source.index === destination.index) return; - - let updatedSortOrder = blocks[source.index].sort_order; - - // update the sort order to the lowest if dropped at the top - if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; - // update the sort order to the highest if dropped at the bottom - else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; - // update the sort order to the average of the two adjacent blocks if dropped in between - else { - const destinationSortingOrder = blocks[destination.index].sort_order; - const relativeDestinationSortingOrder = - source.index < destination.index - ? blocks[destination.index + 1].sort_order - : blocks[destination.index - 1].sort_order; - - updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; - } - - // extract the element from the source index and insert it at the destination index without updating the entire array - const removedElement = blocks.splice(source.index, 1)[0]; - blocks.splice(destination.index, 0, removedElement); - - // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(removedElement.data, { - sort_order: { - destinationIndex: destination.index, - newSortOrder: updatedSortOrder, - sourceIndex: source.index, - }, - }); - }; - - return ( - - - {(droppableProvided) => ( -
- <> - {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration !== undefined && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) - ) : ( - - - - - - - )} - {droppableProvided.placeholder} - -
- )} -
-
- ); -}; diff --git a/web/components/gantt-chart/sidebar/modules/block.tsx b/web/components/gantt-chart/sidebar/modules/block.tsx new file mode 100644 index 000000000..41647644f --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/block.tsx @@ -0,0 +1,72 @@ +import { DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; +import { observer } from "mobx-react"; +import { MoreVertical } from "lucide-react"; +// hooks +import { BLOCK_HEIGHT } from "components/gantt-chart/constants"; +import { useGanttChart } from "components/gantt-chart/hooks"; +// components +import { IGanttBlock } from "components/gantt-chart/types"; +import { ModuleGanttSidebarBlock } from "components/modules"; +// helpers +import { cn } from "helpers/common.helper"; +import { findTotalDaysInRange } from "helpers/date-time.helper"; +// types +// constants + +type Props = { + block: IGanttBlock; + enableReorder: boolean; + provided: DraggableProvided; + snapshot: DraggableStateSnapshot; +}; + +export const ModulesSidebarBlock: React.FC = observer((props) => { + const { block, enableReorder, provided, snapshot } = props; + // store hooks + const { updateActiveBlockId, isBlockActive } = useGanttChart(); + + const duration = findTotalDaysInRange(block.start_date, block.target_date); + + return ( +
updateActiveBlockId(block.id)} + onMouseLeave={() => updateActiveBlockId(null)} + ref={provided.innerRef} + {...provided.draggableProps} + > +
+ {enableReorder && ( + + )} +
+
+ +
+ {duration !== undefined && ( +
+ {duration} day{duration > 1 ? "s" : ""} +
+ )} +
+
+
+ ); +}); diff --git a/web/components/gantt-chart/sidebar/modules/index.ts b/web/components/gantt-chart/sidebar/modules/index.ts new file mode 100644 index 000000000..01acaeffb --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/index.ts @@ -0,0 +1 @@ +export * from "./sidebar"; diff --git a/web/components/gantt-chart/sidebar/modules/sidebar.tsx b/web/components/gantt-chart/sidebar/modules/sidebar.tsx new file mode 100644 index 000000000..a4bcbd5ec --- /dev/null +++ b/web/components/gantt-chart/sidebar/modules/sidebar.tsx @@ -0,0 +1,100 @@ +import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; +// ui +import { Loader } from "@plane/ui"; +// components +import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; +import { ModulesSidebarBlock } from "./block"; +// types + +type Props = { + title: string; + blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + blocks: IGanttBlock[] | null; + enableReorder: boolean; +}; + +export const ModuleGanttSidebar: React.FC = (props) => { + const { blockUpdateHandler, blocks, enableReorder } = props; + + const handleOrderChange = (result: DropResult) => { + if (!blocks) return; + + const { source, destination } = result; + + // return if dropped outside the list + if (!destination) return; + + // return if dropped on the same index + if (source.index === destination.index) return; + + let updatedSortOrder = blocks[source.index].sort_order; + + // update the sort order to the lowest if dropped at the top + if (destination.index === 0) updatedSortOrder = blocks[0].sort_order - 1000; + // update the sort order to the highest if dropped at the bottom + else if (destination.index === blocks.length - 1) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + // update the sort order to the average of the two adjacent blocks if dropped in between + else { + const destinationSortingOrder = blocks[destination.index].sort_order; + const relativeDestinationSortingOrder = + source.index < destination.index + ? blocks[destination.index + 1].sort_order + : blocks[destination.index - 1].sort_order; + + updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; + } + + // extract the element from the source index and insert it at the destination index without updating the entire array + const removedElement = blocks.splice(source.index, 1)[0]; + blocks.splice(destination.index, 0, removedElement); + + // call the block update handler with the updated sort order, new and old index + blockUpdateHandler(removedElement.data, { + sort_order: { + destinationIndex: destination.index, + newSortOrder: updatedSortOrder, + sourceIndex: source.index, + }, + }); + }; + + return ( + + + {(droppableProvided) => ( +
+ <> + {blocks ? ( + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) + ) : ( + + + + + + + )} + {droppableProvided.placeholder} + +
+ )} +
+
+ ); +}; diff --git a/web/components/gantt-chart/sidebar/project-views.tsx b/web/components/gantt-chart/sidebar/project-views.tsx index a27c4dded..92a677b19 100644 --- a/web/components/gantt-chart/sidebar/project-views.tsx +++ b/web/components/gantt-chart/sidebar/project-views.tsx @@ -1,17 +1,10 @@ import { DragDropContext, Draggable, Droppable, DropResult } from "@hello-pangea/dnd"; -import { MoreVertical } from "lucide-react"; -// hooks -import { useChart } from "components/gantt-chart/hooks"; // ui import { Loader } from "@plane/ui"; // components -import { IssueGanttSidebarBlock } from "components/issues"; -// helpers -import { findTotalDaysInRange } from "helpers/date-time.helper"; -// types import { IBlockUpdateData, IGanttBlock } from "components/gantt-chart/types"; -// constants -import { BLOCK_HEIGHT } from "../constants"; +import { IssuesSidebarBlock } from "./issues/block"; +// types type Props = { title: string; @@ -23,18 +16,6 @@ type Props = { export const ProjectViewGanttSidebar: React.FC = (props) => { const { blockUpdateHandler, blocks, enableReorder } = props; - // chart hook - const { activeBlock, dispatch } = useChart(); - - // update the active block on hover - const updateActiveBlock = (block: IGanttBlock | null) => { - dispatch({ - type: "PARTIAL_UPDATE", - payload: { - activeBlock: block, - }, - }); - }; const handleOrderChange = (result: DropResult) => { if (!blocks) return; @@ -89,59 +70,23 @@ export const ProjectViewGanttSidebar: React.FC = (props) => { > <> {blocks ? ( - blocks.map((block, index) => { - const duration = findTotalDaysInRange(block.start_date, block.target_date); - - return ( - - {(provided, snapshot) => ( -
updateActiveBlock(block)} - onMouseLeave={() => updateActiveBlock(null)} - ref={provided.innerRef} - {...provided.draggableProps} - > -
- {enableReorder && ( - - )} -
-
- -
- {duration !== undefined && ( -
- {duration} day{duration > 1 ? "s" : ""} -
- )} -
-
-
- )} -
- ); - }) + blocks.map((block, index) => ( + + {(provided, snapshot) => ( + + )} + + )) ) : ( diff --git a/web/components/gantt-chart/types/index.ts b/web/components/gantt-chart/types/index.ts index 1360f9f45..6268e4363 100644 --- a/web/components/gantt-chart/types/index.ts +++ b/web/components/gantt-chart/types/index.ts @@ -1,10 +1,3 @@ -// context types -export type allViewsType = { - key: string; - title: string; - data: Object | null; -}; - export interface IGanttBlock { data: any; id: string; @@ -29,34 +22,6 @@ export interface IBlockUpdateData { export type TGanttViews = "hours" | "day" | "week" | "bi_week" | "month" | "quarter" | "year"; -export interface ChartContextData { - allViews: allViewsType[]; - currentView: TGanttViews; - currentViewData: ChartDataType | undefined; - renderView: any; - activeBlock: IGanttBlock | null; -} - -export type ChartContextActionPayload = - | { - type: "CURRENT_VIEW"; - payload: TGanttViews; - } - | { - type: "CURRENT_VIEW_DATA" | "RENDER_VIEW"; - payload: ChartDataType | undefined; - } - | { - type: "PARTIAL_UPDATE"; - payload: Partial; - }; - -export interface ChartContextReducer extends ChartContextData { - scrollLeft: number; - updateScrollLeft: (scrollLeft: number) => void; - dispatch: (action: ChartContextActionPayload) => void; -} - // chart render types export interface WeekMonthDataType { key: number; diff --git a/web/components/gantt-chart/views/bi-week-view.ts b/web/components/gantt-chart/views/bi-week-view.ts index 14c0aad15..6ace4bcc4 100644 --- a/web/components/gantt-chart/views/bi-week-view.ts +++ b/web/components/gantt-chart/views/bi-week-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType } from "../types"; // data -import { weeks, months } from "../data"; // helpers import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; diff --git a/web/components/gantt-chart/views/day-view.ts b/web/components/gantt-chart/views/day-view.ts index 0801b7bb1..e8da6801c 100644 --- a/web/components/gantt-chart/views/day-view.ts +++ b/web/components/gantt-chart/views/day-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType } from "../types"; // data -import { weeks, months } from "../data"; export const getWeekNumberByDate = (date: Date) => { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); diff --git a/web/components/gantt-chart/views/helpers.ts b/web/components/gantt-chart/views/helpers.ts index 94b614286..4bd295ce3 100644 --- a/web/components/gantt-chart/views/helpers.ts +++ b/web/components/gantt-chart/views/helpers.ts @@ -56,8 +56,8 @@ export const getAllDatesInWeekByWeekNumber = (weekNumber: number, year: number) const startDate = new Date(firstDayOfYear.getTime()); startDate.setDate(startDate.getDate() + 7 * (weekNumber - 1)); - var datesInWeek = []; - for (var i = 0; i < 7; i++) { + const datesInWeek = []; + for (let i = 0; i < 7; i++) { const currentDate = new Date(startDate.getTime()); currentDate.setDate(currentDate.getDate() + i); datesInWeek.push(currentDate); diff --git a/web/components/gantt-chart/views/hours-view.ts b/web/components/gantt-chart/views/hours-view.ts index 0801b7bb1..e8da6801c 100644 --- a/web/components/gantt-chart/views/hours-view.ts +++ b/web/components/gantt-chart/views/hours-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType } from "../types"; // data -import { weeks, months } from "../data"; export const getWeekNumberByDate = (date: Date) => { const firstDayOfYear = new Date(date.getFullYear(), 0, 1); diff --git a/web/components/gantt-chart/views/month-view.ts b/web/components/gantt-chart/views/month-view.ts index 13d054da1..1e7e6d878 100644 --- a/web/components/gantt-chart/views/month-view.ts +++ b/web/components/gantt-chart/views/month-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType, IGanttBlock } from "../types"; // data -import { weeks, months } from "../data"; // helpers import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; @@ -178,7 +178,7 @@ export const getMonthChartItemPositionWidthInMonth = (chartData: ChartDataType, const positionDaysDifference: number = Math.abs(Math.floor(positionTimeDifference / (1000 * 60 * 60 * 24))); scrollPosition = positionDaysDifference * chartData.data.width; - var diffMonths = (itemStartDate.getFullYear() - startDate.getFullYear()) * 12; + let diffMonths = (itemStartDate.getFullYear() - startDate.getFullYear()) * 12; diffMonths -= startDate.getMonth(); diffMonths += itemStartDate.getMonth(); diff --git a/web/components/gantt-chart/views/quater-view.ts b/web/components/gantt-chart/views/quater-view.ts index ed25974a3..9d45a43a1 100644 --- a/web/components/gantt-chart/views/quater-view.ts +++ b/web/components/gantt-chart/views/quater-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType } from "../types"; // data -import { weeks, months } from "../data"; // helpers import { getDatesBetweenTwoDates, getWeeksByMonthAndYear } from "./helpers"; diff --git a/web/components/gantt-chart/views/week-view.ts b/web/components/gantt-chart/views/week-view.ts index a65eb70b9..bd4ae383d 100644 --- a/web/components/gantt-chart/views/week-view.ts +++ b/web/components/gantt-chart/views/week-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType } from "../types"; // data -import { weeks, months } from "../data"; // helpers import { generateDate, getWeekNumberByDate, getNumberOfDaysInMonth, getDatesBetweenTwoDates } from "./helpers"; diff --git a/web/components/gantt-chart/views/year-view.ts b/web/components/gantt-chart/views/year-view.ts index 82d397e97..69ff9dae8 100644 --- a/web/components/gantt-chart/views/year-view.ts +++ b/web/components/gantt-chart/views/year-view.ts @@ -1,7 +1,7 @@ // types +import { weeks, months } from "../data"; import { ChartDataType } from "../types"; // data -import { weeks, months } from "../data"; // helpers import { getDatesBetweenTwoDates, getWeeksByMonthAndYear } from "./helpers"; diff --git a/web/components/graphs/index.ts b/web/components/graphs/index.ts new file mode 100644 index 000000000..305c3944e --- /dev/null +++ b/web/components/graphs/index.ts @@ -0,0 +1 @@ +export * from "./issues-by-priority"; diff --git a/web/components/graphs/issues-by-priority.tsx b/web/components/graphs/issues-by-priority.tsx new file mode 100644 index 000000000..9dfe56891 --- /dev/null +++ b/web/components/graphs/issues-by-priority.tsx @@ -0,0 +1,103 @@ +import { ComputedDatum } from "@nivo/bar"; +import { Theme } from "@nivo/core"; +// components +import { BarGraph } from "components/ui"; +// helpers +import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard"; +import { ISSUE_PRIORITIES } from "constants/issue"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// types +import { TIssuePriorities } from "@plane/types"; +// constants + +type Props = { + borderRadius?: number; + data: { + priority: TIssuePriorities; + priority_count: number; + }[]; + height?: number; + onBarClick?: ( + datum: ComputedDatum & { + color: string; + } + ) => void; + padding?: number; + theme?: Theme; +}; + +const PRIORITY_TEXT_COLORS = { + urgent: "#CE2C31", + high: "#AB4800", + medium: "#AB6400", + low: "#1F2D5C", + none: "#60646C", +}; + +export const IssuesByPriorityGraph: React.FC = (props) => { + const { borderRadius = 8, data, height = 300, onBarClick, padding = 0.05, theme } = props; + + const chartData = data.map((priority) => ({ + priority: capitalizeFirstLetter(priority.priority), + value: priority.priority_count, + })); + + return ( + p.priority_count)} + axisBottom={{ + tickPadding: 8, + tickSize: 0, + }} + tooltip={(datum) => ( +
+ + {datum.data.priority}: + {datum.value} +
+ )} + colors={({ data }) => `url(#gradient${data.priority})`} + defs={PRIORITY_GRAPH_GRADIENTS} + fill={ISSUE_PRIORITIES.map((p) => ({ + match: { + id: p.key, + }, + id: `gradient${p.title}`, + }))} + onClick={(datum) => { + if (onBarClick) onBarClick(datum); + }} + theme={{ + axis: { + domain: { + line: { + stroke: "transparent", + }, + }, + ticks: { + text: { + fontSize: 13, + }, + }, + }, + grid: { + line: { + stroke: "transparent", + }, + }, + ...theme, + }} + /> + ); +}; diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 05030c500..468900110 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -1,8 +1,19 @@ import { useCallback, useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import Link from "next/link"; +import { useRouter } from "next/router"; // hooks +import { ArrowRight, Plus, PanelRight } from "lucide-react"; +import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { CycleMobileHeader } from "components/cycles/cycle-mobile-header"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; +import { cn } from "helpers/common.helper"; +import { truncateText } from "helpers/string.helper"; import { useApplication, useEventTracker, @@ -16,24 +27,13 @@ import { } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; // components -import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; -import { ProjectAnalyticsModal } from "components/analytics"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; // ui -import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // icons -import { ArrowRight, Plus, PanelRight } from "lucide-react"; // helpers -import { truncateText } from "helpers/string.helper"; -import { renderEmoji } from "helpers/emoji.helper"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { ProjectLogo } from "components/project"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; -import { EUserProjectRoles } from "constants/project"; -import { cn } from "helpers/common.helper"; -import { CycleMobileHeader } from "components/cycles/cycle-mobile-header"; const CycleDropdownOption: React.FC<{ cycleId: string }> = ({ cycleId }) => { // router @@ -149,7 +149,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} cycleDetails={cycleDetails ?? undefined} /> -
+
@@ -163,19 +163,20 @@ export const CycleIssuesHeader: React.FC = observer(() => { label={currentProjectDetails?.name ?? "Project"} href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } /> - ... + + ... + } /> @@ -239,6 +240,7 @@ export const CycleIssuesHeader: React.FC = observer(() => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["cycle"]} /> @@ -282,5 +284,3 @@ export const CycleIssuesHeader: React.FC = observer(() => { ); }); - - diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index 496fabecd..22637147f 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -1,24 +1,25 @@ import { FC, useCallback } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { List, Plus } from "lucide-react"; // hooks -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu } from "@plane/ui"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; -import { EUserProjectRoles } from "constants/project"; // components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; -import { TCycleLayout } from "@plane/types"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { CYCLE_VIEW_LAYOUTS } from "constants/cycle"; +import { EUserProjectRoles } from "constants/project"; +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; +import { TCycleLayout } from "@plane/types"; +import { ProjectLogo } from "components/project"; export const CyclesHeader: FC = observer(() => { // router const router = useRouter(); + const { workspaceSlug } = router.query; // store hooks const { commandPalette: { toggleCreateCycleModal }, @@ -32,9 +33,6 @@ export const CyclesHeader: FC = observer(() => { const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const { workspaceSlug } = router.query as { - workspaceSlug: string; - }; const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); const handleCurrentLayout = useCallback( @@ -58,13 +56,9 @@ export const CyclesHeader: FC = observer(() => { label={currentProjectDetails?.name ?? "Project"} href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } @@ -73,7 +67,9 @@ export const CyclesHeader: FC = observer(() => { /> } />} + link={ + } /> + } />
@@ -110,6 +106,7 @@ export const CyclesHeader: FC = observer(() => { > {CYCLE_VIEW_LAYOUTS.map((layout) => ( { // handleLayoutChange(ISSUE_LAYOUTS[index].key); handleCurrentLayout(layout.key as TCycleLayout); diff --git a/web/components/headers/global-issues.tsx b/web/components/headers/global-issues.tsx index cca1a972b..effe60fe4 100644 --- a/web/components/headers/global-issues.tsx +++ b/web/components/headers/global-issues.tsx @@ -1,23 +1,23 @@ import { useCallback, useState } from "react"; +import { observer } from "mobx-react-lite"; import Link from "next/link"; import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; // hooks -import { useLabel, useMember, useUser, useIssues } from "hooks/store"; -// components -import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "components/issues"; -import { CreateUpdateWorkspaceViewModal } from "components/workspace"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; -// ui -import { Breadcrumbs, Button, LayersIcon, PhotoFilterIcon, Tooltip } from "@plane/ui"; -// icons import { List, PlusIcon, Sheet } from "lucide-react"; +import { Breadcrumbs, Button, LayersIcon, PhotoFilterIcon, Tooltip } from "@plane/ui"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection } from "components/issues"; +// components +import { CreateUpdateWorkspaceViewModal } from "components/workspace"; +// ui +// icons // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // constants import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; import { EUserWorkspaceRoles } from "constants/workspace"; +import { useLabel, useMember, useUser, useIssues } from "hooks/store"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; const GLOBAL_VIEW_LAYOUTS = [ { key: "list", title: "List", link: "/workspace-views", icon: List }, @@ -107,7 +107,7 @@ export const GlobalIssuesHeader: React.FC = observer((props) => { return ( <> setCreateViewModal(false)} /> -
+
diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index d51c0f432..b42b8774a 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -1,8 +1,19 @@ import { useCallback, useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import Link from "next/link"; +import { useRouter } from "next/router"; // hooks +import { ArrowRight, PanelRight, Plus } from "lucide-react"; +import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; +import { ModuleMobileHeader } from "components/modules/module-mobile-header"; +import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; +import { cn } from "helpers/common.helper"; +import { truncateText } from "helpers/string.helper"; import { useApplication, useEventTracker, @@ -16,24 +27,13 @@ import { } from "hooks/store"; import useLocalStorage from "hooks/use-local-storage"; // components -import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; -import { ProjectAnalyticsModal } from "components/analytics"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; // ui -import { Breadcrumbs, Button, CustomMenu, DiceIcon, LayersIcon } from "@plane/ui"; // icons -import { ArrowRight, PanelRight, Plus } from "lucide-react"; // helpers -import { truncateText } from "helpers/string.helper"; -import { renderEmoji } from "helpers/emoji.helper"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { ProjectLogo } from "components/project"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; -import { EUserProjectRoles } from "constants/project"; -import { cn } from "helpers/common.helper"; -import { ModuleMobileHeader } from "components/modules/module-mobile-header"; const ModuleDropdownOption: React.FC<{ moduleId: string }> = ({ moduleId }) => { // router @@ -64,11 +64,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const [analyticsModal, setAnalyticsModal] = useState(false); // router const router = useRouter(); - const { workspaceSlug, projectId, moduleId } = router.query as { - workspaceSlug: string; - projectId: string; - moduleId: string; - }; + const { workspaceSlug, projectId, moduleId } = router.query; // store hooks const { issuesFilter: { issueFilters, updateFilters }, @@ -100,7 +96,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const handleLayoutChange = useCallback( (layout: TIssueLayouts) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + { layout: layout }, + moduleId?.toString() + ); }, [workspaceSlug, projectId, moduleId, updateFilters] ); @@ -119,7 +121,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { else newValues.push(value); } - updateFilters(workspaceSlug, projectId, EIssueFilterType.FILTERS, { [key]: newValues }, moduleId); + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { [key]: newValues }, + moduleId?.toString() + ); }, [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] ); @@ -127,7 +135,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const handleDisplayFilters = useCallback( (updatedDisplayFilter: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter, moduleId); + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_FILTERS, + updatedDisplayFilter, + moduleId?.toString() + ); }, [workspaceSlug, projectId, moduleId, updateFilters] ); @@ -135,7 +149,13 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const handleDisplayProperties = useCallback( (property: Partial) => { if (!workspaceSlug || !projectId) return; - updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_PROPERTIES, property, moduleId); + updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.DISPLAY_PROPERTIES, + property, + moduleId?.toString() + ); }, [workspaceSlug, projectId, moduleId, updateFilters] ); @@ -152,7 +172,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} moduleDetails={moduleDetails ?? undefined} /> -
+
@@ -166,19 +186,20 @@ export const ModuleIssuesHeader: React.FC = observer(() => { label={currentProjectDetails?.name ?? "Project"} href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } /> - ... + + ... + } /> @@ -243,13 +264,19 @@ export const ModuleIssuesHeader: React.FC = observer(() => { handleDisplayFiltersUpdate={handleDisplayFilters} displayProperties={issueFilters?.displayProperties ?? {}} handleDisplayPropertiesUpdate={handleDisplayProperties} + ignoreGroupedFilters={["module"]} />
{canUserCreateIssue && ( <> -
diff --git a/web/components/headers/modules-list.tsx b/web/components/headers/modules-list.tsx index 9ad34678a..a1233ae52 100644 --- a/web/components/headers/modules-list.tsx +++ b/web/components/headers/modules-list.tsx @@ -1,19 +1,19 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +// icons import { GanttChartSquare, LayoutGrid, List, Plus } from "lucide-react"; -// hooks -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; -import useLocalStorage from "hooks/use-local-storage"; // ui import { Breadcrumbs, Button, Tooltip, DiceIcon, CustomMenu } from "@plane/ui"; -// helper -import { renderEmoji } from "helpers/emoji.helper"; +// components +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // constants import { MODULE_VIEW_LAYOUTS } from "constants/module"; import { EUserProjectRoles } from "constants/project"; -// components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; +// hooks +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; +import useLocalStorage from "hooks/use-local-storage"; +import { ProjectLogo } from "components/project"; export const ModulesListHeader: React.FC = observer(() => { // router @@ -45,13 +45,9 @@ export const ModulesListHeader: React.FC = observer(() => { href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } @@ -71,14 +67,16 @@ export const ModulesListHeader: React.FC = observer(() => { @@ -106,7 +104,13 @@ export const ModulesListHeader: React.FC = observer(() => { // placement="bottom-start" customButton={ - {modulesView === 'gantt_chart' ? : modulesView === 'grid' ? : } + {modulesView === "gantt_chart" ? ( + + ) : modulesView === "grid" ? ( + + ) : ( + + )} Layout } @@ -115,6 +119,7 @@ export const ModulesListHeader: React.FC = observer(() => { > {MODULE_VIEW_LAYOUTS.map((layout) => ( setModulesView(layout.key)} className="flex items-center gap-2" > @@ -127,5 +132,3 @@ export const ModulesListHeader: React.FC = observer(() => {
); }); - - diff --git a/web/components/headers/page-details.tsx b/web/components/headers/page-details.tsx index e2a427db7..2c05d95fa 100644 --- a/web/components/headers/page-details.tsx +++ b/web/components/headers/page-details.tsx @@ -1,16 +1,16 @@ import { FC } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { FileText, Plus } from "lucide-react"; // hooks -import { useApplication, usePage, useProject } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; -// components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +// components +import { useApplication, usePage, useProject } from "hooks/store"; +import { ProjectLogo } from "components/project"; export interface IPagesHeaderProps { showButton?: boolean; @@ -42,13 +42,9 @@ export const PageDetailsHeader: FC = observer((props) => { href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } diff --git a/web/components/headers/pages.tsx b/web/components/headers/pages.tsx index 1984971d6..e45d1a9fe 100644 --- a/web/components/headers/pages.tsx +++ b/web/components/headers/pages.tsx @@ -1,17 +1,17 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { FileText, Plus } from "lucide-react"; // hooks -import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; // ui import { Breadcrumbs, Button } from "@plane/ui"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; -// constants -import { EUserProjectRoles } from "constants/project"; -// components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { EUserProjectRoles } from "constants/project"; +// constants +// components +import { useApplication, useEventTracker, useProject, useUser } from "hooks/store"; +import { ProjectLogo } from "components/project"; export const PagesHeader = observer(() => { // router @@ -43,13 +43,9 @@ export const PagesHeader = observer(() => { href={`/${workspaceSlug}/projects/${currentProjectDetails?.id}/issues`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } diff --git a/web/components/headers/profile-settings.tsx b/web/components/headers/profile-settings.tsx index 24c69f093..5c419f05b 100644 --- a/web/components/headers/profile-settings.tsx +++ b/web/components/headers/profile-settings.tsx @@ -1,7 +1,7 @@ import { FC } from "react"; // ui -import { Breadcrumbs } from "@plane/ui"; import { Settings } from "lucide-react"; +import { Breadcrumbs } from "@plane/ui"; import { BreadcrumbLink } from "components/common"; interface IProfileSettingHeader { diff --git a/web/components/headers/project-archived-issue-details.tsx b/web/components/headers/project-archived-issue-details.tsx index 3b3e05f1a..86dae643d 100644 --- a/web/components/headers/project-archived-issue-details.tsx +++ b/web/components/headers/project-archived-issue-details.tsx @@ -1,22 +1,23 @@ import { FC } from "react"; -import useSWR from "swr"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import useSWR from "swr"; // hooks -import { useProject } from "hooks/store"; -// ui import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { useProject } from "hooks/store"; +// components +import { ProjectLogo } from "components/project"; +// ui // types +import { IssueArchiveService } from "services/issue"; import { TIssue } from "@plane/types"; // constants -import { ISSUE_DETAILS } from "constants/fetch-keys"; // services -import { IssueArchiveService } from "services/issue"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; // components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; const issueArchiveService = new IssueArchiveService(); @@ -52,13 +53,9 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { href={`/${workspaceSlug}/projects`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } @@ -71,7 +68,7 @@ export const ProjectArchivedIssueDetailsHeader: FC = observer(() => { link={ } /> } diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index b7ca78ede..db208aa21 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -1,21 +1,21 @@ import { FC } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { ArrowLeft } from "lucide-react"; // hooks -import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // ui import { Breadcrumbs, LayersIcon } from "@plane/ui"; // components -import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "components/issues"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; +import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store"; // types import type { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; +import { ProjectLogo } from "components/project"; export const ProjectArchivedIssuesHeader: FC = observer(() => { // router @@ -91,13 +91,9 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { href={`/${workspaceSlug}/projects`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } @@ -109,7 +105,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { type="text" link={ } /> } diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 139ec0257..4f2929621 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -1,18 +1,18 @@ import { FC, useCallback } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks -import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store"; // components -import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; -// ui import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; +// ui // helper -import { renderEmoji } from "helpers/emoji.helper"; import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { useIssues, useLabel, useMember, useProject, useProjectState } from "hooks/store"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { ProjectLogo } from "components/project"; export const ProjectDraftIssueHeader: FC = observer(() => { // router @@ -86,13 +86,9 @@ export const ProjectDraftIssueHeader: FC = observer(() => { href={`/${workspaceSlug}/projects`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } diff --git a/web/components/headers/project-inbox.tsx b/web/components/headers/project-inbox.tsx index b5260edd7..0e1bdcd1e 100644 --- a/web/components/headers/project-inbox.tsx +++ b/web/components/headers/project-inbox.tsx @@ -1,17 +1,17 @@ import { FC, useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; import { Plus } from "lucide-react"; // hooks -import { useProject } from "hooks/store"; // ui import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // components -import { CreateInboxIssueModal } from "components/inbox"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { CreateInboxIssueModal } from "components/inbox"; // helper -import { renderEmoji } from "helpers/emoji.helper"; +import { useProject } from "hooks/store"; +import { ProjectLogo } from "components/project"; export const ProjectInboxHeader: FC = observer(() => { // states @@ -35,13 +35,9 @@ export const ProjectInboxHeader: FC = observer(() => { href={`/${workspaceSlug}/projects`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } diff --git a/web/components/headers/project-issue-details.tsx b/web/components/headers/project-issue-details.tsx index 3732f2598..b9343a15c 100644 --- a/web/components/headers/project-issue-details.tsx +++ b/web/components/headers/project-issue-details.tsx @@ -1,22 +1,22 @@ import { FC } from "react"; -import useSWR from "swr"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; +import useSWR from "swr"; // hooks +import { PanelRight } from "lucide-react"; +import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { cn } from "helpers/common.helper"; import { useApplication, useProject } from "hooks/store"; // ui -import { Breadcrumbs, LayersIcon } from "@plane/ui"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; // services import { IssueService } from "services/issue"; +import { ProjectLogo } from "components/project"; // constants -import { ISSUE_DETAILS } from "constants/fetch-keys"; // components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; -import { PanelRight } from "lucide-react"; -import { cn } from "helpers/common.helper"; // services const issueService = new IssueService(); @@ -51,13 +51,9 @@ export const ProjectIssueDetailsHeader: FC = observer(() => { href={`/${workspaceSlug}/projects`} label={currentProjectDetails?.name ?? "Project"} icon={ - currentProjectDetails?.emoji ? ( - renderEmoji(currentProjectDetails.emoji) - ) : currentProjectDetails?.icon_prop ? ( - renderEmoji(currentProjectDetails.icon_prop) - ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) } @@ -91,7 +87,9 @@ export const ProjectIssueDetailsHeader: FC = observer(() => {
); diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 5c44a84d6..19eaf4f4f 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -1,9 +1,16 @@ import { useCallback, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import { Briefcase, Circle, ExternalLink, Plus, Inbox } from "lucide-react"; +import { useRouter } from "next/router"; +import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks +import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; +import { ProjectAnalyticsModal } from "components/analytics"; +import { BreadcrumbLink } from "components/common"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; +import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; +import { IssuesMobileHeader } from "components/issues/issues-mobile-header"; +import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; import { useApplication, useEventTracker, @@ -11,25 +18,16 @@ import { useProject, useProjectState, useUser, - useInbox, useMember, } from "hooks/store"; +import { useIssues } from "hooks/store/use-issues"; // components -import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues"; -import { ProjectAnalyticsModal } from "components/analytics"; -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; -import { BreadcrumbLink } from "components/common"; // ui -import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; // types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { ProjectLogo } from "components/project"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue"; // helper -import { renderEmoji } from "helpers/emoji.helper"; -import { EUserProjectRoles } from "constants/project"; -import { useIssues } from "hooks/store/use-issues"; -import { IssuesMobileHeader } from "components/issues/issues-mobile-header"; export const ProjectIssuesHeader: React.FC = observer(() => { // states @@ -54,7 +52,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const { currentProjectDetails } = useProject(); const { projectStates } = useProjectState(); const { projectLabels } = useLabel(); - const { getInboxesByProjectId, getInboxById } = useInbox(); const activeLayout = issueFilters?.displayFilters?.layout; @@ -101,9 +98,6 @@ export const ProjectIssuesHeader: React.FC = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const inboxesMap = currentProjectDetails?.inbox_view ? getInboxesByProjectId(currentProjectDetails.id) : undefined; - const inboxDetails = inboxesMap && inboxesMap.length > 0 ? getInboxById(inboxesMap[0]) : undefined; - const deployUrl = process.env.NEXT_PUBLIC_DEPLOY_URL; const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); @@ -115,7 +109,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => { onClose={() => setAnalyticsModal(false)} projectDetails={currentProjectDetails ?? undefined} /> -
+
@@ -129,17 +123,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { label={currentProjectDetails?.name ?? "Project"} icon={ currentProjectDetails ? ( - currentProjectDetails?.emoji ? ( - - {renderEmoji(currentProjectDetails.emoji)} - - ) : currentProjectDetails?.icon_prop ? ( -
- {renderEmoji(currentProjectDetails.icon_prop)} -
- ) : ( - - {currentProjectDetails?.name.charAt(0)} + currentProjectDetails && ( + + ) ) : ( @@ -154,7 +140,9 @@ export const ProjectIssuesHeader: React.FC = observer(() => { } />} + link={ + } /> + } />
@@ -201,24 +189,15 @@ export const ProjectIssuesHeader: React.FC = observer(() => { />
- {currentProjectDetails?.inbox_view && inboxDetails && ( - - - - - - - )} + {canUserCreateIssue && ( <> - + return ( +
+
+ +
+ + } + /> + +
+ + {type} + +
+ } + customButtonClassName="flex flex-grow justify-center text-custom-text-200 text-sm" + closeOnSelect + > + <> + {tabsList.map((tab) => ( + + + {tab.label} + + + ))} + + +
-
) + ); }); - - diff --git a/web/components/headers/workspace-active-cycles.tsx b/web/components/headers/workspace-active-cycles.tsx index 195b89471..a33161de9 100644 --- a/web/components/headers/workspace-active-cycles.tsx +++ b/web/components/headers/workspace-active-cycles.tsx @@ -1,10 +1,10 @@ import { observer } from "mobx-react-lite"; // ui +import { Crown } from "lucide-react"; import { Breadcrumbs, ContrastIcon } from "@plane/ui"; import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // icons -import { Crown } from "lucide-react"; export const WorkspaceActiveCycleHeader = observer(() => (
diff --git a/web/components/headers/workspace-analytics.tsx b/web/components/headers/workspace-analytics.tsx index a6ad67f05..2bede32ba 100644 --- a/web/components/headers/workspace-analytics.tsx +++ b/web/components/headers/workspace-analytics.tsx @@ -1,14 +1,14 @@ +import { useEffect } from "react"; +import { observer } from "mobx-react"; import { useRouter } from "next/router"; import { BarChart2, PanelRight } from "lucide-react"; // ui import { Breadcrumbs } from "@plane/ui"; // components -import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { BreadcrumbLink } from "components/common"; -import { useApplication } from "hooks/store"; -import { observer } from "mobx-react"; +import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { cn } from "helpers/common.helper"; -import { useEffect } from "react"; +import { useApplication } from "hooks/store"; export const WorkspaceAnalyticsHeader = observer(() => { const router = useRouter(); @@ -47,11 +47,21 @@ export const WorkspaceAnalyticsHeader = observer(() => { } /> - {analytics_tab === 'custom' && - - } + )}
diff --git a/web/components/headers/workspace-dashboard.tsx b/web/components/headers/workspace-dashboard.tsx index 6b85577f6..e7ae3c726 100644 --- a/web/components/headers/workspace-dashboard.tsx +++ b/web/components/headers/workspace-dashboard.tsx @@ -1,17 +1,17 @@ -import { LayoutGrid, Zap } from "lucide-react"; import Image from "next/image"; import { useTheme } from "next-themes"; +import { LayoutGrid, Zap } from "lucide-react"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; // hooks -import { useEventTracker } from "hooks/store"; // components -import { BreadcrumbLink } from "components/common"; import { Breadcrumbs } from "@plane/ui"; +import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; // constants import { CHANGELOG_REDIRECTED, GITHUB_REDIRECTED } from "constants/event-tracker"; +import { useEventTracker } from "hooks/store"; export const WorkspaceDashboardHeader = () => { // hooks diff --git a/web/components/headers/workspace-settings.tsx b/web/components/headers/workspace-settings.tsx index 5ced55204..faf1a45d1 100644 --- a/web/components/headers/workspace-settings.tsx +++ b/web/components/headers/workspace-settings.tsx @@ -1,14 +1,14 @@ import { FC } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // ui -import { Breadcrumbs, CustomMenu } from "@plane/ui"; import { Settings } from "lucide-react"; +import { Breadcrumbs, CustomMenu } from "@plane/ui"; // hooks -import { observer } from "mobx-react-lite"; // components +import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; import { WORKSPACE_SETTINGS_LINKS } from "constants/workspace"; -import { BreadcrumbLink } from "components/common"; export interface IWorkspaceSettingHeader { title: string; diff --git a/web/components/icons/priority-icon.tsx b/web/components/icons/priority-icon.tsx index b23f56eab..36ea67b3d 100644 --- a/web/components/icons/priority-icon.tsx +++ b/web/components/icons/priority-icon.tsx @@ -14,12 +14,12 @@ export const PriorityIcon: React.FC = ({ priority, className = "" }) => { {priority === "urgent" ? "error" : priority === "high" - ? "signal_cellular_alt" - : priority === "medium" - ? "signal_cellular_alt_2_bar" - : priority === "low" - ? "signal_cellular_alt_1_bar" - : "block"} + ? "signal_cellular_alt" + : priority === "medium" + ? "signal_cellular_alt_2_bar" + : priority === "low" + ? "signal_cellular_alt_1_bar" + : "block"} ); }; diff --git a/web/components/icons/state/state-group-icon.tsx b/web/components/icons/state/state-group-icon.tsx index 15debf5f2..ae9e5f1a9 100644 --- a/web/components/icons/state/state-group-icon.tsx +++ b/web/components/icons/state/state-group-icon.tsx @@ -7,9 +7,9 @@ import { StateGroupUnstartedIcon, } from "components/icons"; // types +import { STATE_GROUPS } from "constants/state"; import { TStateGroups } from "@plane/types"; // constants -import { STATE_GROUPS } from "constants/state"; type Props = { className?: string; diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 26f58131e..7cc19bec3 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -2,12 +2,12 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { Inbox } from "lucide-react"; // hooks -import { useInboxIssues } from "hooks/store"; -// components +import { Loader } from "@plane/ui"; import { InboxIssueActionsHeader } from "components/inbox"; import { InboxIssueDetailRoot } from "components/issues/issue-detail/inbox"; +import { useInboxIssues } from "hooks/store"; +// components // ui -import { Loader } from "@plane/ui"; type TInboxContentRoot = { workspaceSlug: string; diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 998ad268c..661bc2d72 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -1,11 +1,12 @@ import { FC, useCallback, useEffect, useMemo, useState } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; -import DatePicker from "react-datepicker"; +import { useRouter } from "next/router"; +import { DayPicker } from "react-day-picker"; import { Popover } from "@headlessui/react"; -// hooks -import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "hooks/store"; -import useToast from "hooks/use-toast"; +// icons +import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // components import { AcceptIssueModal, @@ -13,14 +14,12 @@ import { DeleteInboxIssueModal, SelectDuplicateInboxIssueModal, } from "components/inbox"; -// ui -import { Button } from "@plane/ui"; -// icons -import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react"; -// types -import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types"; -import { EUserProjectRoles } from "constants/project"; import { ISSUE_DELETED } from "constants/event-tracker"; +import { EUserProjectRoles } from "constants/project"; +// hooks +import { useUser, useInboxIssues, useIssueDetail, useWorkspace, useEventTracker } from "hooks/store"; +// types +import type { TInboxDetailedStatus } from "@plane/types"; type TInboxIssueActionsHeader = { workspaceSlug: string; @@ -30,7 +29,7 @@ type TInboxIssueActionsHeader = { }; type TInboxIssueOperations = { - updateInboxIssueStatus: (data: TInboxStatus) => Promise; + updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise; removeInboxIssue: () => Promise; }; @@ -51,7 +50,6 @@ export const InboxIssueActionsHeader: FC = observer((p currentUser, membership: { currentProjectRole }, } = useUser(); - const { setToastAlert } = useToast(); // states const [date, setDate] = useState(new Date()); @@ -74,8 +72,8 @@ export const InboxIssueActionsHeader: FC = observer((p if (!workspaceSlug || !projectId || !inboxId || !inboxIssueId) throw new Error("Missing required parameters"); await updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data); } catch (error) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Something went wrong while updating inbox status. Please try again.", }); @@ -92,14 +90,14 @@ export const InboxIssueActionsHeader: FC = observer((p id: inboxIssueId, state: "SUCCESS", element: "Inbox page", - } + }, }); router.push({ pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, }); } catch (error) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Something went wrong while deleting inbox issue. Please try again.", }); @@ -122,7 +120,6 @@ export const InboxIssueActionsHeader: FC = observer((p inboxIssueId, updateInboxIssueStatus, removeInboxIssue, - setToastAlert, captureIssueEvent, router, ] @@ -131,6 +128,8 @@ export const InboxIssueActionsHeader: FC = observer((p const handleInboxIssueNavigation = useCallback( (direction: "next" | "prev") => { if (!inboxIssues || !inboxIssueId) return; + const activeElement = document.activeElement as HTMLElement; + if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return; const nextIssueIndex = direction === "next" ? (currentIssueIndex + 1) % inboxIssues.length @@ -233,7 +232,7 @@ export const InboxIssueActionsHeader: FC = observer((p )} {inboxIssueId && ( -
+
+ +
+
+ + +
+
+ + + ); +}; diff --git a/web/components/issues/attachment/attachment-detail.tsx b/web/components/issues/attachment/attachment-detail.tsx index 0d345a619..8ff2b9305 100644 --- a/web/components/issues/attachment/attachment-detail.tsx +++ b/web/components/issues/attachment/attachment-detail.tsx @@ -2,17 +2,17 @@ import { FC, useState } from "react"; import Link from "next/link"; import { AlertCircle, X } from "lucide-react"; // hooks -import { useIssueDetail, useMember } from "hooks/store"; // ui import { Tooltip } from "@plane/ui"; // components -import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal"; // icons import { getFileIcon } from "components/icons"; // helper -import { truncateText } from "helpers/string.helper"; -import { renderFormattedDate } from "helpers/date-time.helper"; import { convertBytesToSize, getFileExtension, getFileName } from "helpers/attachment.helper"; +import { renderFormattedDate } from "helpers/date-time.helper"; +import { truncateText } from "helpers/string.helper"; +import { useIssueDetail, useMember } from "hooks/store"; +import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal"; // types import { TAttachmentOperations } from "./root"; diff --git a/web/components/issues/attachment/attachment-upload.tsx b/web/components/issues/attachment/attachment-upload.tsx index bf197980a..27dc572a9 100644 --- a/web/components/issues/attachment/attachment-upload.tsx +++ b/web/components/issues/attachment/attachment-upload.tsx @@ -2,11 +2,11 @@ import { useCallback, useState } from "react"; import { observer } from "mobx-react-lite"; import { useDropzone } from "react-dropzone"; // hooks -import { useApplication } from "hooks/store"; // constants import { MAX_FILE_SIZE } from "constants/common"; // helpers import { generateFileName } from "helpers/attachment.helper"; +import { useApplication } from "hooks/store"; // types import { TAttachmentOperations } from "./root"; diff --git a/web/components/issues/attachment/attachments-list.tsx b/web/components/issues/attachment/attachments-list.tsx index 2129a4f61..0f834c1a4 100644 --- a/web/components/issues/attachment/attachments-list.tsx +++ b/web/components/issues/attachment/attachments-list.tsx @@ -32,6 +32,7 @@ export const IssueAttachmentsList: FC = observer((props) issueAttachments.length > 0 && issueAttachments.map((attachmentId) => ( = (props) => { // hooks const { createAttachment, removeAttachment } = useIssueDetail(); const { captureIssueEvent } = useEventTracker(); - const { setToastAlert } = useToast(); const handleAttachmentOperations: TAttachmentOperations = useMemo( () => ({ create: async (data: FormData) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); - const res = await createAttachment(workspaceSlug, projectId, issueId, data); - setToastAlert({ - message: "The attachment has been successfully uploaded", - type: "success", - title: "Attachment uploaded", + + const attachmentUploadPromise = createAttachment(workspaceSlug, projectId, issueId, data); + setPromiseToast(attachmentUploadPromise, { + loading: "Uploading attachment...", + success: { + title: "Attachment uploaded", + message: () => "The attachment has been successfully uploaded", + }, + error: { + title: "Attachment not uploaded", + message: () => "The attachment could not be uploaded", + }, }); + + const res = await attachmentUploadPromise; captureIssueEvent({ eventName: "Issue attachment added", payload: { id: issueId, state: "SUCCESS", element: "Issue detail page" }, @@ -50,20 +59,15 @@ export const IssueAttachmentRoot: FC = (props) => { eventName: "Issue attachment added", payload: { id: issueId, state: "FAILED", element: "Issue detail page" }, }); - setToastAlert({ - message: "The attachment could not be uploaded", - type: "error", - title: "Attachment not uploaded", - }); } }, remove: async (attachmentId: string) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); await removeAttachment(workspaceSlug, projectId, issueId, attachmentId); - setToastAlert({ + setToast({ message: "The attachment has been successfully removed", - type: "success", + type: TOAST_TYPE.SUCCESS, title: "Attachment removed", }); captureIssueEvent({ @@ -83,15 +87,15 @@ export const IssueAttachmentRoot: FC = (props) => { change_details: "", }, }); - setToastAlert({ + setToast({ message: "The Attachment could not be removed", - type: "error", + type: TOAST_TYPE.ERROR, title: "Attachment not removed", }); } }, }), - [workspaceSlug, projectId, issueId, createAttachment, removeAttachment, setToastAlert] + [workspaceSlug, projectId, issueId, createAttachment, removeAttachment] ); return ( diff --git a/web/components/issues/delete-archived-issue-modal.tsx b/web/components/issues/delete-archived-issue-modal.tsx deleted file mode 100644 index 49d9e19dd..000000000 --- a/web/components/issues/delete-archived-issue-modal.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useEffect, useState, Fragment } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { Dialog, Transition } from "@headlessui/react"; -import { AlertTriangle } from "lucide-react"; -// hooks -import useToast from "hooks/use-toast"; -import { useIssues, useProject } from "hooks/store"; -// ui -import { Button } from "@plane/ui"; -// types -import type { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data: TIssue; - onSubmit?: () => Promise; -}; - -export const DeleteArchivedIssueModal: React.FC = observer((props) => { - const { data, isOpen, handleClose, onSubmit } = props; - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { setToastAlert } = useToast(); - const { getProjectById } = useProject(); - - const { - issues: { removeIssue }, - } = useIssues(EIssuesStoreType.ARCHIVED); - - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - - useEffect(() => { - setIsDeleteLoading(false); - }, [isOpen]); - - const onClose = () => { - setIsDeleteLoading(false); - handleClose(); - }; - - const handleIssueDelete = async () => { - if (!workspaceSlug) return; - - setIsDeleteLoading(true); - - await removeIssue(workspaceSlug.toString(), data.project_id, data.id) - .then(() => { - if (onSubmit) onSubmit(); - }) - .catch((err) => { - const error = err?.detail; - const errorString = Array.isArray(error) ? error[0] : error; - - setToastAlert({ - title: "Error", - type: "error", - message: errorString || "Something went wrong.", - }); - }) - .finally(() => { - setIsDeleteLoading(false); - onClose(); - }); - }; - - return ( - - - -
- - -
-
- - -
-
- - - -

Delete Archived Issue

-
-
- -

- Are you sure you want to delete issue{" "} - - {getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} - - {""}? All of the data related to the archived issue will be permanently removed. This action - cannot be undone. -

-
-
- - -
-
-
-
-
-
-
-
- ); -}); diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx deleted file mode 100644 index 6a2caba18..000000000 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { Dialog, Transition } from "@headlessui/react"; -// services -import { IssueDraftService } from "services/issue"; -// hooks -import useToast from "hooks/use-toast"; -// icons -import { AlertTriangle } from "lucide-react"; -// ui -import { Button } from "@plane/ui"; -// types -import type { TIssue } from "@plane/types"; -import { useProject } from "hooks/store"; - -type Props = { - isOpen: boolean; - handleClose: () => void; - data: TIssue | null; - onSubmit?: () => Promise | void; -}; - -const issueDraftService = new IssueDraftService(); - -export const DeleteDraftIssueModal: React.FC = (props) => { - const { isOpen, handleClose, data, onSubmit } = props; - // states - const [isDeleteLoading, setIsDeleteLoading] = useState(false); - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - // toast alert - const { setToastAlert } = useToast(); - // hooks - const { getProjectById } = useProject(); - - useEffect(() => { - setIsDeleteLoading(false); - }, [isOpen]); - - const onClose = () => { - setIsDeleteLoading(false); - handleClose(); - }; - - const handleDeletion = async () => { - if (!workspaceSlug || !data) return; - - setIsDeleteLoading(true); - - await issueDraftService - .deleteDraftIssue(workspaceSlug.toString(), data.project_id, data.id) - .then(() => { - setIsDeleteLoading(false); - handleClose(); - - setToastAlert({ - title: "Success", - message: "Draft Issue deleted successfully", - type: "success", - }); - }) - .catch((error) => { - console.error(error); - handleClose(); - setToastAlert({ - title: "Error", - message: "Something went wrong", - type: "error", - }); - setIsDeleteLoading(false); - }); - if (onSubmit) await onSubmit(); - }; - - return ( - - - -
- - -
-
- - -
-
- - - -

Delete Draft Issue

-
-
- -

- Are you sure you want to delete issue{" "} - - {data && getProjectById(data?.project_id)?.identifier}-{data?.sequence_id} - - {""}? All of the data related to the draft issue will be permanently removed. This action cannot - be undone. -

-
-
- - -
-
-
-
-
-
-
-
- ); -}; diff --git a/web/components/issues/delete-issue-modal.tsx b/web/components/issues/delete-issue-modal.tsx index a063980c0..ada126ccb 100644 --- a/web/components/issues/delete-issue-modal.tsx +++ b/web/components/issues/delete-issue-modal.tsx @@ -2,13 +2,9 @@ import { useEffect, useState, Fragment } from "react"; import { Dialog, Transition } from "@headlessui/react"; import { AlertTriangle } from "lucide-react"; // ui -import { Button } from "@plane/ui"; -// hooks -import useToast from "hooks/use-toast"; +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; // types -import { useIssues } from "hooks/store/use-issues"; import { TIssue } from "@plane/types"; -import { useProject } from "hooks/store"; type Props = { isOpen: boolean; @@ -23,14 +19,13 @@ export const DeleteIssueModal: React.FC = (props) => { const { issueMap } = useIssues(); - const [isDeleteLoading, setIsDeleteLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); - const { setToastAlert } = useToast(); // hooks const { getProjectById } = useProject(); useEffect(() => { - setIsDeleteLoading(false); + setIsDeleting(false); }, [isOpen]); if (!dataId && !data) return null; @@ -38,25 +33,25 @@ export const DeleteIssueModal: React.FC = (props) => { const issue = data ? data : issueMap[dataId!]; const onClose = () => { - setIsDeleteLoading(false); + setIsDeleting(false); handleClose(); }; const handleIssueDelete = async () => { - setIsDeleteLoading(true); + setIsDeleting(true); if (onSubmit) await onSubmit() .then(() => { onClose(); }) .catch(() => { - setToastAlert({ + setToast({ title: "Error", - type: "error", + type: TOAST_TYPE.ERROR, message: "Failed to delete issue", }); }) - .finally(() => setIsDeleteLoading(false)); + .finally(() => setIsDeleting(false)); }; return ( @@ -109,14 +104,8 @@ export const DeleteIssueModal: React.FC = (props) => { -
diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index c64c147ea..b13e124f3 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -1,18 +1,18 @@ import { ChangeEvent, FC, useCallback, useEffect, useState } from "react"; +import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; +import debounce from "lodash/debounce"; +import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; // hooks -import useReloadConfirmations from "hooks/use-reload-confirmation"; -import debounce from "lodash/debounce"; -// components import { Loader, TextArea } from "@plane/ui"; -import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; +import { useMention, useWorkspace } from "hooks/store"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; +// components // types +import { FileService } from "services/file.service"; import { TIssue } from "@plane/types"; import { TIssueOperations } from "./issue-detail"; // services -import { FileService } from "services/file.service"; -import { useMention, useWorkspace } from "hooks/store"; -import { observer } from "mobx-react"; export interface IssueDescriptionFormValues { name: string; @@ -71,16 +71,10 @@ export const IssueDescriptionForm: FC = observer((props) => { async (formData: Partial) => { if (!formData?.name || formData?.name.length === 0 || formData?.name.length > 255) return; - await issueOperations.update( - workspaceSlug, - projectId, - issueId, - { - name: formData.name ?? "", - description_html: formData.description_html ?? "

", - }, - false - ); + await issueOperations.update(workspaceSlug, projectId, issueId, { + name: formData.name ?? "", + description_html: formData.description_html ?? "

", + }); }, [workspaceSlug, projectId, issueId, issueOperations] ); @@ -142,7 +136,7 @@ export const IssueDescriptionForm: FC = observer((props) => { debouncedFormSave(); }} required - className="min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary" + className="block min-h-min w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary" hasError={Boolean(errors?.name)} role="textbox" /> @@ -179,7 +173,7 @@ export const IssueDescriptionForm: FC = observer((props) => { setIsSubmitting={setIsSubmitting} dragDropEnabled customClassName="min-h-[150px] shadow-sm" - onChange={(description: Object, description_html: string) => { + onChange={(description: any, description_html: string) => { setShowAlert(true); setIsSubmitting("submitting"); onChange(description_html); diff --git a/web/components/issues/description-input.tsx b/web/components/issues/description-input.tsx index 8f3dc8644..4f1f5c056 100644 --- a/web/components/issues/description-input.tsx +++ b/web/components/issues/description-input.tsx @@ -1,59 +1,54 @@ import { FC, useState, useEffect } from "react"; -import { observer } from "mobx-react"; // components -import { Loader } from "@plane/ui"; import { RichReadOnlyEditor, RichTextEditor } from "@plane/rich-text-editor"; -// store hooks +import { Loader } from "@plane/ui"; +// hooks import { useMention, useWorkspace } from "hooks/store"; +import useDebounce from "hooks/use-debounce"; // services import { FileService } from "services/file.service"; const fileService = new FileService(); // types import { TIssueOperations } from "./issue-detail"; -// hooks -import useDebounce from "hooks/use-debounce"; -import useReloadConfirmations from "hooks/use-reload-confirmation"; export type IssueDescriptionInputProps = { - disabled?: boolean; - value: string | undefined | null; workspaceSlug: string; - setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; - issueOperations: TIssueOperations; projectId: string; issueId: string; + value: string | undefined; + initialValue: string | undefined; + disabled?: boolean; + issueOperations: TIssueOperations; + setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; }; -export const IssueDescriptionInput: FC = observer((props) => { - const { disabled, value, workspaceSlug, setIsSubmitting, issueId, issueOperations, projectId } = props; +export const IssueDescriptionInput: FC = (props) => { + const { workspaceSlug, projectId, issueId, value, initialValue, disabled, issueOperations, setIsSubmitting } = props; // states const [descriptionHTML, setDescriptionHTML] = useState(value); // store hooks const { mentionHighlights, mentionSuggestions } = useMention(); - const workspaceStore = useWorkspace(); + const { getWorkspaceBySlug } = useWorkspace(); // hooks - const { setShowAlert } = useReloadConfirmations(); const debouncedValue = useDebounce(descriptionHTML, 1500); // computed values - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug)?.id as string; + const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id as string; useEffect(() => { setDescriptionHTML(value); }, [value]); useEffect(() => { - if (debouncedValue || debouncedValue === "") { - issueOperations - .update(workspaceSlug, projectId, issueId, { description_html: debouncedValue }, false) - .finally(() => { - setIsSubmitting("saved"); - }); + if (debouncedValue && debouncedValue !== value) { + issueOperations.update(workspaceSlug, projectId, issueId, { description_html: debouncedValue }).finally(() => { + setIsSubmitting("submitted"); + }); } // DO NOT Add more dependencies here. It will cause multiple requests to be sent. // eslint-disable-next-line react-hooks/exhaustive-deps }, [debouncedValue]); - if (!descriptionHTML && descriptionHTML !== "") { + if (!descriptionHTML) { return ( @@ -79,17 +74,15 @@ export const IssueDescriptionInput: FC = observer((p deleteFile={fileService.getDeleteImageFunction(workspaceId)} restoreFile={fileService.getRestoreImageFunction(workspaceId)} value={descriptionHTML} - setShouldShowAlert={setShowAlert} - setIsSubmitting={setIsSubmitting} + initialValue={initialValue} dragDropEnabled customClassName="min-h-[150px] shadow-sm" - onChange={(description: Object, description_html: string) => { - setShowAlert(true); + onChange={(description: any, description_html: string) => { setIsSubmitting("submitting"); - setDescriptionHTML(description_html); + setDescriptionHTML(description_html === "" ? "

" : description_html); }} mentionSuggestions={mentionSuggestions} mentionHighlights={mentionHighlights} /> ); -}); +}; diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx deleted file mode 100644 index cfd6370fa..000000000 --- a/web/components/issues/draft-issue-form.tsx +++ /dev/null @@ -1,668 +0,0 @@ -import React, { FC, useState, useEffect, useRef } from "react"; -import { useRouter } from "next/router"; -import { Controller, useForm } from "react-hook-form"; -import { observer } from "mobx-react-lite"; -import { Sparkle, X } from "lucide-react"; -// hooks -import { useApplication, useEstimate, useMention, useProject, useWorkspace } from "hooks/store"; -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; -// services -import { AIService } from "services/ai.service"; -import { FileService } from "services/file.service"; -// components -import { GptAssistantPopover } from "components/core"; -import { ParentIssuesListModal } from "components/issues"; -import { IssueLabelSelect } from "components/issues/select"; -import { CreateStateModal } from "components/states"; -import { CreateLabelModal } from "components/labels"; -import { RichTextEditorWithRef } from "@plane/rich-text-editor"; -import { - CycleDropdown, - DateDropdown, - EstimateDropdown, - ModuleDropdown, - PriorityDropdown, - ProjectDropdown, - ProjectMemberDropdown, - StateDropdown, -} from "components/dropdowns"; -// ui -import { Button, CustomMenu, Input, ToggleSwitch } from "@plane/ui"; -// helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -// types -import type { IUser, TIssue, ISearchIssueResponse } from "@plane/types"; - -const aiService = new AIService(); -const fileService = new FileService(); - -const defaultValues: Partial = { - project_id: "", - name: "", - description_html: "

", - estimate_point: null, - state_id: "", - parent_id: null, - priority: "none", - assignee_ids: [], - label_ids: [], - start_date: undefined, - target_date: undefined, -}; - -interface IssueFormProps { - handleFormSubmit: ( - formData: Partial, - action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" - ) => Promise; - data?: Partial | null; - isOpen: boolean; - prePopulatedData?: Partial | null; - projectId: string; - setActiveProject: React.Dispatch>; - createMore: boolean; - setCreateMore: React.Dispatch>; - handleClose: () => void; - handleDiscard: () => void; - status: boolean; - user: IUser | undefined; - fieldsToShow: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - )[]; -} - -export const DraftIssueForm: FC = observer((props) => { - const { - handleFormSubmit, - data, - isOpen, - prePopulatedData, - projectId, - setActiveProject, - createMore, - setCreateMore, - status, - fieldsToShow, - handleDiscard, - } = props; - // states - const [stateModal, setStateModal] = useState(false); - const [labelModal, setLabelModal] = useState(false); - const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false); - const [selectedParentIssue, setSelectedParentIssue] = useState(null); - const [gptAssistantModal, setGptAssistantModal] = useState(false); - const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); - // store hooks - const { areEstimatesEnabledForProject } = useEstimate(); - const { mentionHighlights, mentionSuggestions } = useMention(); - // hooks - const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); - const { setToastAlert } = useToast(); - // refs - const editorRef = useRef(null); - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - const workspaceStore = useWorkspace(); - const workspaceId = workspaceStore.getWorkspaceBySlug(workspaceSlug as string)?.id as string; - - // store - const { - config: { envConfig }, - } = useApplication(); - const { getProjectById } = useProject(); - // form info - const { - formState: { errors, isSubmitting }, - handleSubmit, - reset, - watch, - control, - getValues, - setValue, - setFocus, - } = useForm({ - defaultValues: prePopulatedData ?? defaultValues, - reValidateMode: "onChange", - }); - - const issueName = watch("name"); - - const payload: Partial = { - name: watch("name"), - description_html: watch("description_html"), - state_id: watch("state_id"), - priority: watch("priority"), - assignee_ids: watch("assignee_ids"), - label_ids: watch("label_ids"), - start_date: watch("start_date"), - target_date: watch("target_date"), - project_id: watch("project_id"), - parent_id: watch("parent_id"), - cycle_id: watch("cycle_id"), - module_ids: watch("module_ids"), - }; - - useEffect(() => { - if (!isOpen || data) return; - - setLocalStorageValue( - JSON.stringify({ - ...payload, - }) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(payload), isOpen, data]); - - // const onClose = () => { - // handleClose(); - // }; - - // const onClose = () => { - // handleClose(); - // }; - - const handleCreateUpdateIssue = async ( - formData: Partial, - action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" - ) => { - await handleFormSubmit( - { - ...(data ?? {}), - ...formData, - // is_draft: action === "createDraft" || action === "updateDraft", - }, - action - ); - // TODO: check_with_backend - - setGptAssistantModal(false); - - reset({ - ...defaultValues, - project_id: projectId, - description_html: "

", - }); - editorRef?.current?.clearEditor(); - }; - - const handleAiAssistance = async (response: string) => { - if (!workspaceSlug || !projectId) return; - - // setValue("description", {}); - setValue("description_html", `${watch("description_html")}

${response}

`); - editorRef.current?.setEditorValue(`${watch("description_html")}`); - }; - - const handleAutoGenerateDescription = async () => { - if (!workspaceSlug || !projectId) return; - - setIAmFeelingLucky(true); - - aiService - .createGptTask(workspaceSlug as string, projectId as string, { - prompt: issueName, - task: "Generate a proper description for this issue.", - }) - .then((res) => { - if (res.response === "") - setToastAlert({ - type: "error", - title: "Error!", - message: - "Issue title isn't informative enough to generate the description. Please try with a different title.", - }); - else handleAiAssistance(res.response_html); - }) - .catch((err) => { - const error = err?.data?.error; - - if (err.status === 429) - setToastAlert({ - type: "error", - title: "Error!", - message: error || "You have reached the maximum number of requests of 50 requests per month per user.", - }); - else - setToastAlert({ - type: "error", - title: "Error!", - message: error || "Some error occurred. Please try again.", - }); - }) - .finally(() => setIAmFeelingLucky(false)); - }; - - useEffect(() => { - setFocus("name"); - }, [setFocus]); - - // update projectId in form when projectId changes - useEffect(() => { - reset({ - ...getValues(), - project_id: projectId, - }); - }, [getValues, projectId, reset]); - - const startDate = watch("start_date"); - const targetDate = watch("target_date"); - - const minDate = startDate ? new Date(startDate) : null; - minDate?.setDate(minDate.getDate()); - - const maxDate = targetDate ? new Date(targetDate) : null; - maxDate?.setDate(maxDate.getDate()); - - const projectDetails = getProjectById(projectId); - - return ( - <> - {projectId && ( - <> - setStateModal(false)} projectId={projectId} /> - setLabelModal(false)} - projectId={projectId} - onSuccess={(response) => setValue("label_ids", [...watch("label_ids"), response.id])} - /> - - )} - - handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft") - )} - > -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("project")) && ( - ( -
- { - onChange(val); - setActiveProject(val); - }} - buttonVariant="border-with-text" - /> -
- )} - /> - )} -

- {status ? "Update" : "Create"} issue -

-
- {watch("parent_id") && - (fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && - selectedParentIssue && ( -
-
- - - {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - { - setValue("parent_id", null); - setSelectedParentIssue(null); - }} - /> -
-
- )} -
-
- {(fieldsToShow.includes("all") || fieldsToShow.includes("name")) && ( -
- ( - - )} - /> -
- )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("description")) && ( -
-
- {issueName && issueName !== "" && ( - - )} - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - button={ - - } - className=" !min-w-[38rem]" - placement="top-end" - /> - )} -
- ( - { - onChange(description_html); - }} - mentionHighlights={mentionHighlights} - mentionSuggestions={mentionSuggestions} - /> - )} - /> -
- )} -
- {(fieldsToShow.includes("all") || fieldsToShow.includes("state")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("assignee")) && ( - ( -
- 0 ? "transparent-without-text" : "border-with-text"} - buttonClassName={value?.length > 0 ? "hover:bg-transparent px-0" : ""} - placeholder="Assignees" - multiple - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("label")) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Start date" - maxDate={maxDate ?? undefined} - /> -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("dueDate")) && ( - ( -
- onChange(date ? renderFormattedPayloadDate(date) : null)} - buttonVariant="border-with-text" - placeholder="Due date" - minDate={minDate ?? undefined} - /> -
- )} - /> - )} - {projectDetails?.cycle_view && ( - ( -
- onChange(cycleId)} - value={value} - buttonVariant="border-with-text" - /> -
- )} - /> - )} - - {projectDetails?.module_view && workspaceSlug && ( - ( -
- -
- )} - /> - )} - - {(fieldsToShow.includes("all") || fieldsToShow.includes("estimate")) && - areEstimatesEnabledForProject(projectId) && ( - ( -
- -
- )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - ( - setParentIssueListModalOpen(false)} - onChange={(issue) => { - onChange(issue.id); - setSelectedParentIssue(issue); - }} - projectId={projectId} - /> - )} - /> - )} - {(fieldsToShow.includes("all") || fieldsToShow.includes("parent")) && ( - - {watch("parent_id") ? ( - <> - setParentIssueListModalOpen(true)}> - Change parent issue - - setValue("parent_id", null)}> - Remove parent issue - - - ) : ( - setParentIssueListModalOpen(true)}> - Select Parent Issue - - )} - - )} -
-
-
-
-
-
setCreateMore((prevData) => !prevData)} - > - Create more - {}} size="md" /> -
-
- - - -
-
- - - ); -}); diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx deleted file mode 100644 index 0324c1b03..000000000 --- a/web/components/issues/draft-issue-modal.tsx +++ /dev/null @@ -1,349 +0,0 @@ -import React, { useEffect, useState } from "react"; -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; -import { mutate } from "swr"; -import { Dialog, Transition } from "@headlessui/react"; -// services -import { IssueService } from "services/issue"; -import { ModuleService } from "services/module.service"; -// hooks -import useToast from "hooks/use-toast"; -import useLocalStorage from "hooks/use-local-storage"; -import { useIssues, useProject, useUser } from "hooks/store"; -// components -import { DraftIssueForm } from "components/issues"; -// types -import type { TIssue } from "@plane/types"; -import { EIssuesStoreType } from "constants/issue"; -// fetch-keys -import { PROJECT_ISSUES_DETAILS, USER_ISSUE, SUB_ISSUES } from "constants/fetch-keys"; - -interface IssuesModalProps { - data?: TIssue | null; - handleClose: () => void; - isOpen: boolean; - isUpdatingSingleIssue?: boolean; - prePopulateData?: Partial; - fieldsToShow?: ( - | "project" - | "name" - | "description" - | "state" - | "priority" - | "assignee" - | "label" - | "startDate" - | "dueDate" - | "estimate" - | "parent" - | "all" - )[]; - onSubmit?: (data: Partial) => Promise | void; -} - -// services -const issueService = new IssueService(); -const moduleService = new ModuleService(); - -export const CreateUpdateDraftIssueModal: React.FC = observer((props) => { - const { - data, - handleClose, - isOpen, - isUpdatingSingleIssue = false, - prePopulateData: prePopulateDataProps, - fieldsToShow = ["all"], - onSubmit, - } = props; - - // states - const [createMore, setCreateMore] = useState(false); - const [activeProject, setActiveProject] = useState(null); - const [prePopulateData, setPreloadedData] = useState | undefined>(undefined); - // router - const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; - // store - const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); - const { currentUser } = useUser(); - const { workspaceProjectIds: workspaceProjects } = useProject(); - // derived values - const projects = workspaceProjects; - - const { clearValue: clearDraftIssueLocalStorage } = useLocalStorage("draftedIssue", {}); - - const { setToastAlert } = useToast(); - - const onClose = () => { - handleClose(); - setActiveProject(null); - }; - - const onDiscard = () => { - clearDraftIssueLocalStorage(); - onClose(); - }; - - useEffect(() => { - setPreloadedData(prePopulateDataProps ?? {}); - - if (cycleId && !prePopulateDataProps?.cycle_id) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - cycle: cycleId.toString(), - })); - } - if (moduleId && !prePopulateDataProps?.module_ids) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - module: moduleId.toString(), - })); - } - if ( - (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignee_ids - ) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], - })); - } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); - - useEffect(() => { - setPreloadedData(prePopulateDataProps ?? {}); - - if (cycleId && !prePopulateDataProps?.cycle_id) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - cycle: cycleId.toString(), - })); - } - if (moduleId && !prePopulateDataProps?.module_ids) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - module: moduleId.toString(), - })); - } - if ( - (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && - !prePopulateDataProps?.assignee_ids - ) { - setPreloadedData((prevData) => ({ - ...(prevData ?? {}), - ...prePopulateDataProps, - assignees: prePopulateDataProps?.assignee_ids ?? [currentUser?.id ?? ""], - })); - } - }, [prePopulateDataProps, cycleId, moduleId, router.asPath, currentUser?.id]); - - useEffect(() => { - // if modal is closed, reset active project to null - // and return to avoid activeProject being set to some other project - if (!isOpen) { - setActiveProject(null); - return; - } - - // if data is present, set active project to the project of the - // issue. This has more priority than the project in the url. - if (data && data.project_id) return setActiveProject(data.project_id); - - if (prePopulateData && prePopulateData.project_id && !activeProject) - return setActiveProject(prePopulateData.project_id); - - if (prePopulateData && prePopulateData.project_id && !activeProject) - return setActiveProject(prePopulateData.project_id); - - // if data is not present, set active project to the project - // in the url. This has the least priority. - if (projects && projects.length > 0 && !activeProject) - setActiveProject(projects?.find((id) => id === projectId) ?? projects?.[0] ?? null); - }, [activeProject, data, projectId, projects, isOpen, prePopulateData]); - - const createDraftIssue = async (payload: Partial) => { - if (!workspaceSlug || !activeProject || !currentUser) return; - - await draftIssues - .createIssue(workspaceSlug as string, activeProject ?? "", payload) - .then(async () => { - await draftIssues.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation"); - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - - if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) - mutate(USER_ISSUE(workspaceSlug.toString())); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be created. Please try again.", - }); - }); - - if (!createMore) onClose(); - }; - - const updateDraftIssue = async (payload: Partial) => { - await draftIssues - .updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload) - .then((res) => { - if (isUpdatingSingleIssue) { - mutate(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false); - } else { - if (payload.parent_id) mutate(SUB_ISSUES(payload.parent_id.toString())); - } - - // if (!payload.is_draft) { // TODO: check_with_backend - // if (payload.cycle_id && payload.cycle_id !== "") addIssueToCycle(res.id, payload.cycle_id); - // if (payload.module_id && payload.module_id !== "") addIssueToModule(res.id, payload.module_id); - // } - - if (!createMore) onClose(); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue updated successfully.", - }); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be updated. Please try again.", - }); - }); - }; - - const addIssueToCycle = async (issueId: string, cycleId: string) => { - if (!workspaceSlug || !activeProject) return; - - await issueService.addIssueToCycle(workspaceSlug as string, activeProject ?? "", cycleId, { - issues: [issueId], - }); - }; - - const addIssueToModule = async (issueId: string, moduleIds: string[]) => { - if (!workspaceSlug || !activeProject) return; - - await moduleService.addModulesToIssue(workspaceSlug as string, activeProject ?? "", issueId as string, { - modules: moduleIds, - }); - }; - - const createIssue = async (payload: Partial) => { - if (!workspaceSlug || !activeProject) return; - - await issueService - .createIssue(workspaceSlug.toString(), activeProject, payload) - .then(async (res) => { - if (payload.cycle_id && payload.cycle_id !== "") await addIssueToCycle(res.id, payload.cycle_id); - if (payload.module_ids && payload.module_ids.length > 0) await addIssueToModule(res.id, payload.module_ids); - - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - - if (!createMore) onClose(); - - if (payload.assignee_ids?.some((assignee) => assignee === currentUser?.id)) - mutate(USER_ISSUE(workspaceSlug as string)); - - if (payload.parent_id && payload.parent_id !== "") mutate(SUB_ISSUES(payload.parent_id)); - }) - .catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "Issue could not be created. Please try again.", - }); - }); - }; - - const handleFormSubmit = async ( - formData: Partial, - action: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" = "createDraft" - ) => { - if (!workspaceSlug || !activeProject) return; - - const payload: Partial = { - ...formData, - // description: formData.description ?? "", - description_html: formData.description_html ?? "

", - }; - - if (action === "createDraft") await createDraftIssue(payload); - else if (action === "updateDraft" || action === "convertToNewIssue") await updateDraftIssue(payload); - else if (action === "createNewIssue") await createIssue(payload); - - clearDraftIssueLocalStorage(); - - if (onSubmit) await onSubmit(payload); - }; - - if (!projects || projects.length === 0) return null; - - return ( - <> - - - -
- - -
-
- - - - - -
-
-
-
- - ); -}); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 3904049e9..d001a29c2 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -14,10 +14,5 @@ export * from "./issue-detail"; export * from "./peek-overview"; -// draft issue -export * from "./draft-issue-form"; -export * from "./draft-issue-modal"; -export * from "./delete-draft-issue-modal"; - // archived issue -export * from "./delete-archived-issue-modal"; +export * from "./archive-issue-modal"; diff --git a/web/components/issues/issue-detail/cycle-select.tsx b/web/components/issues/issue-detail/cycle-select.tsx index fb8449d6f..8744857c1 100644 --- a/web/components/issues/issue-detail/cycle-select.tsx +++ b/web/components/issues/issue-detail/cycle-select.tsx @@ -1,13 +1,12 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail } from "hooks/store"; // components import { CycleDropdown } from "components/dropdowns"; // ui -import { Spinner } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; +import { useIssueDetail } from "hooks/store"; // types import type { TIssueOperations } from "./root"; @@ -41,22 +40,21 @@ export const IssueCycleSelect: React.FC = observer((props) => }; return ( -
+
- {isUpdating && }
); }); diff --git a/web/components/issues/issue-detail/inbox/index.ts b/web/components/issues/issue-detail/inbox/index.ts index 97c28cc7c..0c4adc7d0 100644 --- a/web/components/issues/issue-detail/inbox/index.ts +++ b/web/components/issues/issue-detail/inbox/index.ts @@ -1,3 +1,3 @@ -export * from "./root" -export * from "./main-content" -export * from "./sidebar" \ No newline at end of file +export * from "./root"; +export * from "./main-content"; +export * from "./sidebar"; diff --git a/web/components/issues/issue-detail/inbox/main-content.tsx b/web/components/issues/issue-detail/inbox/main-content.tsx index d25fe9260..f2aa78ad9 100644 --- a/web/components/issues/issue-detail/inbox/main-content.tsx +++ b/web/components/issues/issue-detail/inbox/main-content.tsx @@ -1,16 +1,17 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail, useProjectState, useUser } from "hooks/store"; -// components -import { IssueUpdateStatus, TIssueOperations } from "components/issues"; -import { IssueTitleInput } from "../../title-input"; -import { IssueDescriptionInput } from "../../description-input"; -import { IssueReaction } from "../reactions"; -import { IssueActivity } from "../issue-activity"; -import { InboxIssueStatus } from "../../../inbox/inbox-issue-status"; -// ui import { StateGroupIcon } from "@plane/ui"; +import { IssueUpdateStatus, TIssueOperations } from "components/issues"; +import { useIssueDetail, useProjectState, useUser } from "hooks/store"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; +// components +import { InboxIssueStatus } from "../../../inbox/inbox-issue-status"; +import { IssueDescriptionInput } from "../../description-input"; +import { IssueTitleInput } from "../../title-input"; +import { IssueActivity } from "../issue-activity"; +import { IssueReaction } from "../reactions"; +// ui type Props = { workspaceSlug: string; @@ -31,12 +32,31 @@ export const InboxIssueMainContent: React.FC = observer((props) => { const { issue: { getIssueById }, } = useIssueDetail(); + const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); - const issue = getIssueById(issueId); + useEffect(() => { + if (isSubmitting === "submitted") { + setShowAlert(false); + setTimeout(async () => { + setIsSubmitting("saved"); + }, 3000); + } else if (isSubmitting === "submitting") { + setShowAlert(true); + } + }, [isSubmitting, setShowAlert, setIsSubmitting]); + + const issue = issueId ? getIssueById(issueId) : undefined; if (!issue) return <>; const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + const issueDescription = + issue.description_html !== undefined || issue.description_html !== null + ? issue.description_html != "" + ? issue.description_html + : "

" + : undefined; + return ( <>
@@ -45,7 +65,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { projectId={projectId} inboxId={inboxId} issueId={issueId} - showDescription={true} + showDescription />
@@ -63,6 +83,7 @@ export const InboxIssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} + isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} issueOperations={issueOperations} disabled={!is_editable} @@ -73,10 +94,11 @@ export const InboxIssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} - setIsSubmitting={(value) => setIsSubmitting(value)} - issueOperations={issueOperations} + value={issueDescription} + initialValue={issueDescription} disabled={!is_editable} - value={issue.description_html} + issueOperations={issueOperations} + setIsSubmitting={(value) => setIsSubmitting(value)} /> {currentUser && ( diff --git a/web/components/issues/issue-detail/inbox/root.tsx b/web/components/issues/issue-detail/inbox/root.tsx index 3f0f1f128..144198085 100644 --- a/web/components/issues/issue-detail/inbox/root.tsx +++ b/web/components/issues/issue-detail/inbox/root.tsx @@ -2,16 +2,16 @@ import { FC, useMemo } from "react"; import { useRouter } from "next/router"; import useSWR from "swr"; // components -import { InboxIssueMainContent } from "./main-content"; -import { InboxIssueDetailsSidebar } from "./sidebar"; -// hooks +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { EUserProjectRoles } from "constants/project"; import { useEventTracker, useInboxIssues, useIssueDetail, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; +// ui // types import { TIssue } from "@plane/types"; import { TIssueOperations } from "../root"; +import { InboxIssueMainContent } from "./main-content"; +import { InboxIssueDetailsSidebar } from "./sidebar"; // constants -import { EUserProjectRoles } from "constants/project"; export type TInboxIssueDetailRoot = { workspaceSlug: string; @@ -30,9 +30,10 @@ export const InboxIssueDetailRoot: FC = (props) => { } = useInboxIssues(); const { issue: { getIssueById }, + fetchActivities, + fetchComments, } = useIssueDetail(); const { captureIssueEvent } = useEventTracker(); - const { setToastAlert } = useToast(); const { membership: { currentProjectRole }, } = useUser(); @@ -46,25 +47,12 @@ export const InboxIssueDetailRoot: FC = (props) => { console.error("Error fetching the parent issue"); } }, - update: async ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - showToast: boolean = true - ) => { + update: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - const response = await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); - if (showToast) { - setToastAlert({ - title: "Issue updated successfully", - type: "success", - message: "Issue updated successfully", - }); - } + await updateInboxIssue(workspaceSlug, projectId, inboxId, issueId, data); captureIssueEvent({ eventName: "Inbox issue updated", - payload: { ...response, state: "SUCCESS", element: "Inbox" }, + payload: { ...data, state: "SUCCESS", element: "Inbox" }, updates: { changed_property: Object.keys(data).join(","), change_details: Object.values(data).join(","), @@ -72,9 +60,9 @@ export const InboxIssueDetailRoot: FC = (props) => { path: router.asPath, }); } catch (error) { - setToastAlert({ + setToast({ title: "Issue update failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Issue update failed", }); captureIssueEvent({ @@ -91,9 +79,9 @@ export const InboxIssueDetailRoot: FC = (props) => { remove: async (workspaceSlug: string, projectId: string, issueId: string) => { try { await removeInboxIssue(workspaceSlug, projectId, inboxId, issueId); - setToastAlert({ + setToast({ title: "Issue deleted successfully", - type: "success", + type: TOAST_TYPE.SUCCESS, message: "Issue deleted successfully", }); captureIssueEvent({ @@ -107,15 +95,15 @@ export const InboxIssueDetailRoot: FC = (props) => { payload: { id: issueId, state: "FAILED", element: "Inbox" }, path: router.asPath, }); - setToastAlert({ + setToast({ title: "Issue delete failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Issue delete failed", }); } }, }), - [inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue, setToastAlert] + [inboxId, fetchInboxIssueById, updateInboxIssue, removeInboxIssue] ); useSWR( @@ -125,6 +113,8 @@ export const InboxIssueDetailRoot: FC = (props) => { async () => { if (workspaceSlug && projectId && inboxId && issueId) { await issueOperations.fetch(workspaceSlug, projectId, issueId); + await fetchActivities(workspaceSlug, projectId, issueId); + await fetchComments(workspaceSlug, projectId, issueId); } } ); @@ -138,7 +128,7 @@ export const InboxIssueDetailRoot: FC = (props) => { if (!issue) return <>; return (
-
+
= observer((props) => { Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} @@ -154,6 +154,10 @@ export const InboxIssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} issueId={issueId} disabled={!is_editable} + isInboxIssue + onLabelUpdate={(val: string[]) => + issueOperations.update(workspaceSlug, projectId, issueId, { label_ids: val }) + } />
diff --git a/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx b/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx index 575e8d841..af3266067 100644 --- a/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity-comment-root.tsx @@ -31,6 +31,7 @@ export const IssueActivityCommentRoot: FC = observer( {activityComments.map((activityComment, index) => activityComment.activity_type === "COMMENT" ? ( = observer((p const activity = getActivityById(activityId); if (!activity) return <>; + return ( ); }); diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx index 449297cbe..ce5aa6589 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/assignee.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks +import { UserGroupIcon } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; // icons -import { UserGroupIcon } from "@plane/ui"; type TIssueAssigneeActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; @@ -27,7 +27,6 @@ export const IssueAssigneeActivity: FC = observer((props > <> {activity.old_value === "" ? `added a new assignee ` : `removed the assignee `} - = observer((props > {activity.new_value && activity.new_value !== "" ? activity.new_value : activity.old_value} - {showIssue && (activity.old_value === "" ? ` to ` : ` from `)} {showIssue && }. diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx index 8336e516f..ec3c777fc 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/cycle.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks +import { ContrastIcon } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent } from "./"; // icons -import { ContrastIcon } from "@plane/ui"; type TIssueCycleActivity = { activityId: string; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx index e45387535..0eeb7ecac 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/default.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks +import { LayersIcon } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent } from "./"; // icons -import { LayersIcon } from "@plane/ui"; type TIssueDefaultActivity = { activityId: string; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx index e01b94e1b..a8c309bd5 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/estimate.tsx @@ -33,13 +33,11 @@ export const IssueEstimateActivity: FC = observer((props {activity.new_value ? `set the estimate point to ` : `removed the estimate point `} {activity.new_value && ( <> - {areEstimatesEnabledForCurrentProject ? estimateValue : `${currentPoint} ${currentPoint > 1 ? "points" : "point"}`} - )} {showIssue && (activity.new_value ? ` to ` : ` from `)} diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx index c7b75340b..0097b65b6 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/helpers/activity-block.tsx @@ -1,23 +1,24 @@ import { FC, ReactNode } from "react"; import { Network } from "lucide-react"; // hooks +import { Tooltip } from "@plane/ui"; +import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; import { useIssueDetail } from "hooks/store"; // ui -import { Tooltip } from "@plane/ui"; // components import { IssueUser } from "../"; // helpers -import { renderFormattedTime, renderFormattedDate, calculateTimeAgo } from "helpers/date-time.helper"; type TIssueActivityBlockComponent = { icon?: ReactNode; activityId: string; ends: "top" | "bottom" | undefined; children: ReactNode; + customUserName?: string; }; export const IssueActivityBlockComponent: FC = (props) => { - const { icon, activityId, ends, children } = props; + const { icon, activityId, ends, children, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -32,12 +33,12 @@ export const IssueActivityBlockComponent: FC = (pr ends === "top" ? `pb-2` : ends === "bottom" ? `pt-2` : `py-2` }`} > -
+
{icon ? icon : }
- + {children} = (props) => { - const { activityId } = props; + const { activityId, customUserName } = props; // hooks const { activity: { getActivityById }, @@ -18,12 +18,19 @@ export const IssueUser: FC = (props) => { const activity = getActivityById(activityId); if (!activity) return <>; + return ( - - {activity.actor_detail?.display_name} - + <> + {customUserName ? ( + {customUserName} + ) : ( + + {activity.actor_detail?.display_name} + + )} + ); }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx index c8089d233..0108c56b3 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/module.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks +import { DiceIcon } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent } from "./"; // icons -import { DiceIcon } from "@plane/ui"; type TIssueModuleActivity = { activityId: string; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx index e68a7c373..5ef67cf52 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/relation.tsx @@ -1,13 +1,13 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks +import { issueRelationObject } from "components/issues/issue-detail/relation-select"; import { useIssueDetail } from "hooks/store"; // components +import { TIssueRelationTypes } from "@plane/types"; import { IssueActivityBlockComponent } from "./"; // component helpers -import { issueRelationObject } from "components/issues/issue-detail/relation-select"; // types -import { TIssueRelationTypes } from "@plane/types"; type TIssueRelationActivity = { activityId: string; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx index 95b3cda80..0e3a80b34 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/start_date.tsx @@ -2,11 +2,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { CalendarDays } from "lucide-react"; // hooks +import { renderFormattedDate } from "helpers/date-time.helper"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; // helpers -import { renderFormattedDate } from "helpers/date-time.helper"; type TIssueStartDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx index 7cc47c2c8..757519388 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/state.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks +import { DoubleCircleIcon } from "@plane/ui"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; // icons -import { DoubleCircleIcon } from "@plane/ui"; type TIssueStateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx b/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx index a4b40ec31..947b2e6e6 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/actions/target_date.tsx @@ -2,11 +2,11 @@ import { FC } from "react"; import { observer } from "mobx-react"; import { CalendarDays } from "lucide-react"; // hooks +import { renderFormattedDate } from "helpers/date-time.helper"; import { useIssueDetail } from "hooks/store"; // components import { IssueActivityBlockComponent, IssueLink } from "./"; // helpers -import { renderFormattedDate } from "helpers/date-time.helper"; type TIssueTargetDateActivity = { activityId: string; showIssue?: boolean; ends: "top" | "bottom" | undefined }; diff --git a/web/components/issues/issue-detail/issue-activity/activity/root.tsx b/web/components/issues/issue-detail/issue-activity/activity/root.tsx index af44463d5..092633b06 100644 --- a/web/components/issues/issue-detail/issue-activity/activity/root.tsx +++ b/web/components/issues/issue-detail/issue-activity/activity/root.tsx @@ -23,6 +23,7 @@ export const IssueActivityRoot: FC = observer((props) => {
{activityIds.map((activityId, index) => ( diff --git a/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx b/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx index 4dbc36f6b..b00dd2a13 100644 --- a/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx +++ b/web/components/issues/issue-detail/issue-activity/comments/comment-block.tsx @@ -1,9 +1,9 @@ import { FC, ReactNode } from "react"; import { MessageCircle } from "lucide-react"; // hooks +import { calculateTimeAgo } from "helpers/date-time.helper"; import { useIssueDetail } from "hooks/store"; // helpers -import { calculateTimeAgo } from "helpers/date-time.helper"; type TIssueCommentBlock = { commentId: string; @@ -24,7 +24,7 @@ export const IssueCommentBlock: FC = (props) => { if (!comment) return <>; return (
-
+
{comment.actor_detail.avatar && comment.actor_detail.avatar !== "" ? ( = (props) => { isEditing && setFocus("comment_html"); }, [isEditing, setFocus]); + const isEmpty = + watch("comment_html") === "" || + watch("comment_html")?.trim() === "" || + watch("comment_html") === "

" || + isEmptyHtmlString(watch("comment_html") ?? ""); + if (!comment || !currentUser) return <>; return ( = (props) => { > <>
-
+
{ + if (e.key === "Enter" && !e.shiftKey && !isEmpty) { + handleSubmit(onEnter)(e); + } + }} + > = (props) => { value={watch("comment_html") ?? ""} debouncedUpdatesEnabled={false} customClassName="min-h-[50px] p-3 shadow-sm" - onChange={(comment_json: Object, comment_html: string) => setValue("comment_html", comment_html)} + onChange={(comment_json: any, comment_html: string) => setValue("comment_html", comment_html)} mentionSuggestions={mentionSuggestions} mentionHighlights={mentionHighlights} /> @@ -135,10 +148,14 @@ export const IssueCommentCard: FC = (props) => { )} diff --git a/web/components/issues/issue-detail/label/label-list-item.tsx b/web/components/issues/issue-detail/label/label-list-item.tsx index 3c3164c5a..69c0e08e9 100644 --- a/web/components/issues/issue-detail/label/label-list-item.tsx +++ b/web/components/issues/issue-detail/label/label-list-item.tsx @@ -1,8 +1,8 @@ import { FC } from "react"; import { X } from "lucide-react"; // types -import { TLabelOperations } from "./root"; import { useIssueDetail, useLabel } from "hooks/store"; +import { TLabelOperations } from "./root"; type TLabelListItem = { workspaceSlug: string; @@ -35,7 +35,7 @@ export const LabelListItem: FC = (props) => { return (
= (props) => { backgroundColor: label.color ?? "#000000", }} /> -
{label.name}
+
{label.name}
{!disabled && (
diff --git a/web/components/issues/issue-detail/label/label-list.tsx b/web/components/issues/issue-detail/label/label-list.tsx index fd714e002..fdf94be28 100644 --- a/web/components/issues/issue-detail/label/label-list.tsx +++ b/web/components/issues/issue-detail/label/label-list.tsx @@ -1,8 +1,8 @@ import { FC } from "react"; // components +import { useIssueDetail } from "hooks/store"; import { LabelListItem } from "./label-list-item"; // hooks -import { useIssueDetail } from "hooks/store"; // types import { TLabelOperations } from "./root"; @@ -29,6 +29,7 @@ export const LabelList: FC = (props) => { <> {issueLabels.map((labelId) => ( void; }; export type TLabelOperations = { @@ -21,26 +24,21 @@ export type TLabelOperations = { }; export const IssueLabel: FC = observer((props) => { - const { workspaceSlug, projectId, issueId, disabled = false } = props; + const { workspaceSlug, projectId, issueId, disabled = false, isInboxIssue = false, onLabelUpdate } = props; // hooks const { updateIssue } = useIssueDetail(); const { createLabel } = useLabel(); - const { setToastAlert } = useToast(); const labelOperations: TLabelOperations = useMemo( () => ({ updateIssue: async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - await updateIssue(workspaceSlug, projectId, issueId, data); - setToastAlert({ - title: "Issue updated successfully", - type: "success", - message: "Issue updated successfully", - }); + if (onLabelUpdate) onLabelUpdate(data.label_ids || []); + else await updateIssue(workspaceSlug, projectId, issueId, data); } catch (error) { - setToastAlert({ + setToast({ title: "Issue update failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Issue update failed", }); } @@ -48,23 +46,24 @@ export const IssueLabel: FC = observer((props) => { createLabel: async (workspaceSlug: string, projectId: string, data: Partial) => { try { const labelResponse = await createLabel(workspaceSlug, projectId, data); - setToastAlert({ - title: "Label created successfully", - type: "success", - message: "Label created successfully", - }); + if (!isInboxIssue) + setToast({ + title: "Label created successfully", + type: TOAST_TYPE.SUCCESS, + message: "Label created successfully", + }); return labelResponse; } catch (error) { - setToastAlert({ + setToast({ title: "Label creation failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Label creation failed", }); return error; } }, }), - [updateIssue, createLabel, setToastAlert] + [updateIssue, createLabel, onLabelUpdate] ); return ( diff --git a/web/components/issues/issue-detail/label/select/label-select.tsx b/web/components/issues/issue-detail/label/select/label-select.tsx index 4af089d5e..844a17b79 100644 --- a/web/components/issues/issue-detail/label/select/label-select.tsx +++ b/web/components/issues/issue-detail/label/select/label-select.tsx @@ -1,11 +1,11 @@ import { Fragment, useState } from "react"; import { observer } from "mobx-react-lite"; import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; import { Check, Search, Tag } from "lucide-react"; // hooks import { useIssueDetail, useLabel } from "hooks/store"; // components -import { Combobox } from "@headlessui/react"; export interface IIssueLabelSelect { workspaceSlug: string; @@ -24,7 +24,7 @@ export const IssueLabelSelect: React.FC = observer((props) => // states const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [query, setQuery] = useState(""); const issue = getIssueById(issueId); @@ -71,7 +71,7 @@ export const IssueLabelSelect: React.FC = observer((props) => const label = (
@@ -80,6 +80,13 @@ export const IssueLabelSelect: React.FC = observer((props) =>
); + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + if (!issue) return <>; return ( @@ -95,7 +102,7 @@ export const IssueLabelSelect: React.FC = observer((props) =>
diff --git a/web/components/issues/issue-detail/links/link-detail.tsx b/web/components/issues/issue-detail/links/link-detail.tsx index c92c13977..4504329f0 100644 --- a/web/components/issues/issue-detail/links/link-detail.tsx +++ b/web/components/issues/issue-detail/links/link-detail.tsx @@ -1,16 +1,15 @@ import { FC, useState } from "react"; // hooks -import useToast from "hooks/use-toast"; -import { useIssueDetail } from "hooks/store"; // ui -import { ExternalLinkIcon, Tooltip } from "@plane/ui"; -// icons import { Pencil, Trash2, LinkIcon } from "lucide-react"; +import { ExternalLinkIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; +// icons // types -import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal"; // helpers import { calculateTimeAgo } from "helpers/date-time.helper"; import { copyTextToClipboard } from "helpers/string.helper"; +import { useIssueDetail, useMember } from "hooks/store"; +import { IssueLinkCreateUpdateModal, TLinkOperationsModal } from "./create-update-link-modal"; export type TIssueLinkDetail = { linkId: string; @@ -26,7 +25,7 @@ export const IssueLinkDetail: FC = (props) => { toggleIssueLinkModal: toggleIssueLinkModalStore, link: { getLinkById }, } = useIssueDetail(); - const { setToastAlert } = useToast(); + const { getUserDetails } = useMember(); // state const [isIssueLinkModalOpen, setIsIssueLinkModalOpen] = useState(false); @@ -38,6 +37,8 @@ export const IssueLinkDetail: FC = (props) => { const linkDetail = getLinkById(linkId); if (!linkDetail) return <>; + const createdByDetails = getUserDetails(linkDetail.created_by_id); + return (
= (props) => {
{ copyTextToClipboard(linkDetail.url); - setToastAlert({ - type: "success", + setToast({ + type: TOAST_TYPE.SUCCESS, title: "Link copied!", message: "Link copied to clipboard", }); @@ -110,10 +111,11 @@ export const IssueLinkDetail: FC = (props) => {

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

diff --git a/web/components/issues/issue-detail/links/links.tsx b/web/components/issues/issue-detail/links/links.tsx index 368bddb91..1120c3a5c 100644 --- a/web/components/issues/issue-detail/links/links.tsx +++ b/web/components/issues/issue-detail/links/links.tsx @@ -1,9 +1,9 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; // computed +import { useIssueDetail, useUser } from "hooks/store"; import { IssueLinkDetail } from "./link-detail"; // hooks -import { useIssueDetail, useUser } from "hooks/store"; import { TLinkOperations } from "./root"; export type TLinkOperationsModal = Exclude; @@ -34,6 +34,7 @@ export const IssueLinkList: FC = observer((props) => { issueLinks.length > 0 && issueLinks.map((linkId) => ( ) => Promise; @@ -37,24 +38,22 @@ export const IssueLinkRoot: FC = (props) => { [toggleIssueLinkModalStore] ); - const { setToastAlert } = useToast(); - const handleLinkOperations: TLinkOperations = useMemo( () => ({ create: async (data: Partial) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); await createLink(workspaceSlug, projectId, issueId, data); - setToastAlert({ + setToast({ message: "The link has been successfully created", - type: "success", + type: TOAST_TYPE.SUCCESS, title: "Link created", }); toggleIssueLinkModal(false); } catch (error) { - setToastAlert({ + setToast({ message: "The link could not be created", - type: "error", + type: TOAST_TYPE.ERROR, title: "Link not created", }); } @@ -63,16 +62,16 @@ export const IssueLinkRoot: FC = (props) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); await updateLink(workspaceSlug, projectId, issueId, linkId, data); - setToastAlert({ + setToast({ message: "The link has been successfully updated", - type: "success", + type: TOAST_TYPE.SUCCESS, title: "Link updated", }); toggleIssueLinkModal(false); } catch (error) { - setToastAlert({ + setToast({ message: "The link could not be updated", - type: "error", + type: TOAST_TYPE.ERROR, title: "Link not updated", }); } @@ -81,22 +80,22 @@ export const IssueLinkRoot: FC = (props) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing required fields"); await removeLink(workspaceSlug, projectId, issueId, linkId); - setToastAlert({ + setToast({ message: "The link has been successfully removed", - type: "success", + type: TOAST_TYPE.SUCCESS, title: "Link removed", }); toggleIssueLinkModal(false); } catch (error) { - setToastAlert({ + setToast({ message: "The link could not be removed", - type: "error", + type: TOAST_TYPE.ERROR, title: "Link not removed", }); } }, }), - [workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, setToastAlert, toggleIssueLinkModal] + [workspaceSlug, projectId, issueId, createLink, updateLink, removeLink, toggleIssueLinkModal] ); return ( diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx index 14860a0cf..b65560953 100644 --- a/web/components/issues/issue-detail/main-content.tsx +++ b/web/components/issues/issue-detail/main-content.tsx @@ -1,17 +1,18 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail, useProjectState, useUser } from "hooks/store"; -// components +import { StateGroupIcon } from "@plane/ui"; import { IssueAttachmentRoot, IssueUpdateStatus } from "components/issues"; -import { IssueTitleInput } from "../title-input"; +import { useIssueDetail, useProjectState, useUser } from "hooks/store"; +import useReloadConfirmations from "hooks/use-reload-confirmation"; +// components import { IssueDescriptionInput } from "../description-input"; +import { SubIssuesRoot } from "../sub-issues"; +import { IssueTitleInput } from "../title-input"; +import { IssueActivity } from "./issue-activity"; import { IssueParentDetail } from "./parent"; import { IssueReaction } from "./reactions"; -import { SubIssuesRoot } from "../sub-issues"; -import { IssueActivity } from "./issue-activity"; // ui -import { StateGroupIcon } from "@plane/ui"; // types import { TIssueOperations } from "./root"; @@ -33,12 +34,31 @@ export const IssueMainContent: React.FC = observer((props) => { const { issue: { getIssueById }, } = useIssueDetail(); + const { setShowAlert } = useReloadConfirmations(isSubmitting === "submitting"); - const issue = getIssueById(issueId); + useEffect(() => { + if (isSubmitting === "submitted") { + setShowAlert(false); + setTimeout(async () => { + setIsSubmitting("saved"); + }, 2000); + } else if (isSubmitting === "submitting") { + setShowAlert(true); + } + }, [isSubmitting, setShowAlert, setIsSubmitting]); + + const issue = issueId ? getIssueById(issueId) : undefined; if (!issue) return <>; const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); + const issueDescription = + issue.description_html !== undefined || issue.description_html !== null + ? issue.description_html != "" + ? issue.description_html + : "

" + : undefined; + return ( <>
@@ -67,6 +87,7 @@ export const IssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} + isSubmitting={isSubmitting} setIsSubmitting={(value) => setIsSubmitting(value)} issueOperations={issueOperations} disabled={!is_editable} @@ -77,10 +98,11 @@ export const IssueMainContent: React.FC = observer((props) => { workspaceSlug={workspaceSlug} projectId={issue.project_id} issueId={issue.id} - setIsSubmitting={(value) => setIsSubmitting(value)} - issueOperations={issueOperations} + value={issueDescription} + initialValue={issueDescription} disabled={!is_editable} - value={issue.description_html} + issueOperations={issueOperations} + setIsSubmitting={(value) => setIsSubmitting(value)} /> {currentUser && ( diff --git a/web/components/issues/issue-detail/module-select.tsx b/web/components/issues/issue-detail/module-select.tsx index 1c4d80168..f157ede86 100644 --- a/web/components/issues/issue-detail/module-select.tsx +++ b/web/components/issues/issue-detail/module-select.tsx @@ -1,13 +1,13 @@ import React, { useState } from "react"; +import xor from "lodash/xor"; import { observer } from "mobx-react-lite"; // hooks -import { useIssueDetail } from "hooks/store"; // components import { ModuleDropdown } from "components/dropdowns"; // ui -import { Spinner } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; +import { useIssueDetail } from "hooks/store"; // types import type { TIssueOperations } from "./root"; @@ -36,29 +36,35 @@ export const IssueModuleSelect: React.FC = observer((props) if (!issue || !issue.module_ids) return; setIsUpdating(true); + const updatedModuleIds = xor(issue.module_ids, moduleIds); + const modulesToAdd: string[] = []; + const modulesToRemove: string[] = []; - if (moduleIds.length === 0) - await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, issue.module_ids); - else if (moduleIds.length > issue.module_ids.length) { - const newModuleIds = moduleIds.filter((m) => !issue.module_ids?.includes(m)); - await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, newModuleIds); - } else if (moduleIds.length < issue.module_ids.length) { - const removedModuleIds = issue.module_ids.filter((m) => !moduleIds.includes(m)); - await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, removedModuleIds); + for (const moduleId of updatedModuleIds) { + if (issue.module_ids.includes(moduleId)) { + modulesToRemove.push(moduleId); + } else { + modulesToAdd.push(moduleId); + } } + if (modulesToRemove.length > 0) + await issueOperations.removeModulesFromIssue?.(workspaceSlug, projectId, issueId, modulesToRemove); + + if (modulesToAdd.length > 0) + await issueOperations.addModulesToIssue?.(workspaceSlug, projectId, issueId, modulesToAdd); setIsUpdating(false); }; return ( -
+
= observer((props) showTooltip multiple /> - {isUpdating && }
); }); diff --git a/web/components/issues/issue-detail/parent-select.tsx b/web/components/issues/issue-detail/parent-select.tsx index 9a1aa48ad..0b6501027 100644 --- a/web/components/issues/issue-detail/parent-select.tsx +++ b/web/components/issues/issue-detail/parent-select.tsx @@ -1,15 +1,15 @@ import React from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { Pencil, X } from "lucide-react"; // hooks -import { useIssueDetail, useProject } from "hooks/store"; // components +import { Tooltip } from "@plane/ui"; import { ParentIssuesListModal } from "components/issues"; // ui -import { Tooltip } from "@plane/ui"; // helpers import { cn } from "helpers/common.helper"; +import { useIssueDetail, useProject } from "hooks/store"; // types import { TIssueOperations } from "./root"; diff --git a/web/components/issues/issue-detail/parent/root.tsx b/web/components/issues/issue-detail/parent/root.tsx index a1c755e62..64d0d1182 100644 --- a/web/components/issues/issue-detail/parent/root.tsx +++ b/web/components/issues/issue-detail/parent/root.tsx @@ -2,14 +2,14 @@ import { FC } from "react"; import Link from "next/link"; import { MinusCircle } from "lucide-react"; // component -import { IssueParentSiblings } from "./siblings"; // ui import { CustomMenu } from "@plane/ui"; // hooks import { useIssues, useProject, useProjectState } from "hooks/store"; // types -import { TIssueOperations } from "../root"; import { TIssue } from "@plane/types"; +import { TIssueOperations } from "../root"; +import { IssueParentSiblings } from "./siblings"; export type TIssueParentDetail = { workspaceSlug: string; diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx index 45eca81d4..b80a41327 100644 --- a/web/components/issues/issue-detail/parent/siblings.tsx +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -1,11 +1,11 @@ import { FC } from "react"; import useSWR from "swr"; // components -import { IssueParentSiblingItem } from "./sibling-item"; // hooks import { useIssueDetail } from "hooks/store"; // types import { TIssue } from "@plane/types"; +import { IssueParentSiblingItem } from "./sibling-item"; export type TIssueParentSiblings = { currentIssue: TIssue; @@ -39,7 +39,9 @@ export const IssueParentSiblings: FC = (props) => { Loading
) : subIssueIds && subIssueIds.length > 0 ? ( - subIssueIds.map((issueId) => currentIssue.id != issueId && ) + subIssueIds.map( + (issueId) => currentIssue.id != issueId && + ) ) : (
No sibling issues diff --git a/web/components/issues/issue-detail/reactions/issue-comment.tsx b/web/components/issues/issue-detail/reactions/issue-comment.tsx index 30a8621e4..97c63a017 100644 --- a/web/components/issues/issue-detail/reactions/issue-comment.tsx +++ b/web/components/issues/issue-detail/reactions/issue-comment.tsx @@ -1,13 +1,13 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; // components -import { ReactionSelector } from "./reaction-selector"; -// hooks +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { renderEmoji } from "helpers/emoji.helper"; import { useIssueDetail } from "hooks/store"; -import useToast from "hooks/use-toast"; +// ui // types import { IUser } from "@plane/types"; -import { renderEmoji } from "helpers/emoji.helper"; +import { ReactionSelector } from "./reaction-selector"; export type TIssueCommentReaction = { workspaceSlug: string; @@ -25,7 +25,6 @@ export const IssueCommentReaction: FC = observer((props) createCommentReaction, removeCommentReaction, } = useIssueDetail(); - const { setToastAlert } = useToast(); const reactionIds = getCommentReactionsByCommentId(commentId); const userReactions = commentReactionsByUser(commentId, currentUser.id).map((r) => r.reaction); @@ -36,15 +35,15 @@ export const IssueCommentReaction: FC = observer((props) try { if (!workspaceSlug || !projectId || !commentId) throw new Error("Missing fields"); await createCommentReaction(workspaceSlug, projectId, commentId, reaction); - setToastAlert({ + setToast({ title: "Reaction created successfully", - type: "success", + type: TOAST_TYPE.SUCCESS, message: "Reaction created successfully", }); } catch (error) { - setToastAlert({ + setToast({ title: "Reaction creation failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Reaction creation failed", }); } @@ -53,15 +52,15 @@ export const IssueCommentReaction: FC = observer((props) try { if (!workspaceSlug || !projectId || !commentId || !currentUser?.id) throw new Error("Missing fields"); removeCommentReaction(workspaceSlug, projectId, commentId, reaction, currentUser.id); - setToastAlert({ + setToast({ title: "Reaction removed successfully", - type: "success", + type: TOAST_TYPE.SUCCESS, message: "Reaction removed successfully", }); } catch (error) { - setToastAlert({ + setToast({ title: "Reaction remove failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Reaction remove failed", }); } @@ -71,16 +70,7 @@ export const IssueCommentReaction: FC = observer((props) else await issueCommentReactionOperations.create(reaction); }, }), - [ - workspaceSlug, - projectId, - commentId, - currentUser, - createCommentReaction, - removeCommentReaction, - setToastAlert, - userReactions, - ] + [workspaceSlug, projectId, commentId, currentUser, createCommentReaction, removeCommentReaction, userReactions] ); return ( diff --git a/web/components/issues/issue-detail/reactions/issue.tsx b/web/components/issues/issue-detail/reactions/issue.tsx index d6b33e36b..6f5610634 100644 --- a/web/components/issues/issue-detail/reactions/issue.tsx +++ b/web/components/issues/issue-detail/reactions/issue.tsx @@ -1,13 +1,13 @@ import { FC, useMemo } from "react"; import { observer } from "mobx-react-lite"; // components -import { ReactionSelector } from "./reaction-selector"; -// hooks +import { TOAST_TYPE, setToast } from "@plane/ui"; +import { renderEmoji } from "helpers/emoji.helper"; import { useIssueDetail } from "hooks/store"; -import useToast from "hooks/use-toast"; +// ui // types import { IUser } from "@plane/types"; -import { renderEmoji } from "helpers/emoji.helper"; +import { ReactionSelector } from "./reaction-selector"; export type TIssueReaction = { workspaceSlug: string; @@ -24,7 +24,6 @@ export const IssueReaction: FC = observer((props) => { createReaction, removeReaction, } = useIssueDetail(); - const { setToastAlert } = useToast(); const reactionIds = getReactionsByIssueId(issueId); const userReactions = reactionsByUser(issueId, currentUser.id).map((r) => r.reaction); @@ -35,15 +34,15 @@ export const IssueReaction: FC = observer((props) => { try { if (!workspaceSlug || !projectId || !issueId) throw new Error("Missing fields"); await createReaction(workspaceSlug, projectId, issueId, reaction); - setToastAlert({ + setToast({ title: "Reaction created successfully", - type: "success", + type: TOAST_TYPE.SUCCESS, message: "Reaction created successfully", }); } catch (error) { - setToastAlert({ + setToast({ title: "Reaction creation failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Reaction creation failed", }); } @@ -52,15 +51,15 @@ export const IssueReaction: FC = observer((props) => { try { if (!workspaceSlug || !projectId || !issueId || !currentUser?.id) throw new Error("Missing fields"); await removeReaction(workspaceSlug, projectId, issueId, reaction, currentUser.id); - setToastAlert({ + setToast({ title: "Reaction removed successfully", - type: "success", + type: TOAST_TYPE.SUCCESS, message: "Reaction removed successfully", }); } catch (error) { - setToastAlert({ + setToast({ title: "Reaction remove failed", - type: "error", + type: TOAST_TYPE.ERROR, message: "Reaction remove failed", }); } @@ -70,7 +69,7 @@ export const IssueReaction: FC = observer((props) => { else await issueReactionOperations.create(reaction); }, }), - [workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, setToastAlert, userReactions] + [workspaceSlug, projectId, issueId, currentUser, createReaction, removeReaction, userReactions] ); return ( diff --git a/web/components/issues/issue-detail/reactions/reaction-selector.tsx b/web/components/issues/issue-detail/reactions/reaction-selector.tsx index 0782e7e15..655fd9105 100644 --- a/web/components/issues/issue-detail/reactions/reaction-selector.tsx +++ b/web/components/issues/issue-detail/reactions/reaction-selector.tsx @@ -1,9 +1,9 @@ import { Fragment } from "react"; import { Popover, Transition } from "@headlessui/react"; // helper +import { SmilePlus } from "lucide-react"; import { renderEmoji } from "helpers/emoji.helper"; // icons -import { SmilePlus } from "lucide-react"; const reactionEmojis = ["128077", "128078", "128516", "128165", "128533", "129505", "9992", "128064"]; diff --git a/web/components/issues/issue-detail/relation-select.tsx b/web/components/issues/issue-detail/relation-select.tsx index 67bba8697..0fd0902c6 100644 --- a/web/components/issues/issue-detail/relation-select.tsx +++ b/web/components/issues/issue-detail/relation-select.tsx @@ -1,16 +1,15 @@ import React from "react"; -import Link from "next/link"; import { observer } from "mobx-react-lite"; +import Link from "next/link"; import { CircleDot, CopyPlus, Pencil, X, XCircle } from "lucide-react"; // hooks -import { useIssueDetail, useIssues, useProject } from "hooks/store"; -import useToast from "hooks/use-toast"; -// components +import { RelatedIcon, Tooltip, TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; -// ui -import { RelatedIcon, Tooltip } from "@plane/ui"; -// helpers import { cn } from "helpers/common.helper"; +import { useIssueDetail, useIssues, useProject } from "hooks/store"; +// components +// ui +// helpers // types import { TIssueRelationTypes, ISearchIssueResponse } from "@plane/types"; @@ -60,15 +59,13 @@ export const IssueRelationSelect: React.FC = observer((pro toggleRelationModal, } = useIssueDetail(); const { issueMap } = useIssues(); - // toast alert - const { setToastAlert } = useToast(); const relationIssueIds = getRelationByIssueIdRelationType(issueId, relationKey); const onSubmit = async (data: ISearchIssueResponse[]) => { if (data.length === 0) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Please select at least one issue.", }); @@ -102,7 +99,7 @@ export const IssueRelationSelect: React.FC = observer((pro - - {is_editable && ( - - )} +
+ + + + {isArchivingAllowed && ( + + + + )} + {is_editable && ( + + + + )} +
-
-
Properties
+
+
Properties
{/* TODO: render properties using a common component */} -
-
-
+
+
+
State
@@ -145,7 +199,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId?.toString() ?? ""} disabled={!is_editable} buttonVariant="transparent-with-text" - className="w-3/5 flex-grow group" + className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" buttonClassName="text-sm" dropdownArrow @@ -153,12 +207,12 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { />
-
-
+
+
Assignees
- issueOperations.update(workspaceSlug, projectId, issueId, { assignee_ids: val })} disabled={!is_editable} @@ -166,7 +220,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { placeholder="Add assignees" multiple buttonVariant={issue?.assignee_ids?.length > 1 ? "transparent-without-text" : "transparent-with-text"} - className="w-3/5 flex-grow group" + className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm justify-between ${ issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400" @@ -177,8 +231,8 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { />
-
-
+
+
Priority
@@ -193,8 +247,8 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { />
-
-
+
+
Start date
@@ -209,7 +263,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { maxDate={maxDate ?? undefined} disabled={!is_editable} buttonVariant="transparent-with-text" - className="w-3/5 flex-grow group" + className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm ${issue?.start_date ? "" : "text-custom-text-400"}`} hideIcon @@ -219,8 +273,8 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { />
-
-
+
+
Due date
@@ -235,19 +289,22 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { minDate={minDate ?? undefined} disabled={!is_editable} buttonVariant="transparent-with-text" - className="w-3/5 flex-grow group" + className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" - buttonClassName={`text-sm ${issue?.target_date ? "" : "text-custom-text-400"}`} + buttonClassName={cn("text-sm", { + "text-custom-text-400": !issue.target_date, + "text-red-500": shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group), + })} hideIcon - clearIconClassName="h-3 w-3 hidden group-hover:inline" + clearIconClassName="h-3 w-3 hidden group-hover:inline !text-custom-text-100" // TODO: add this logic // showPlaceholderIcon />
{areEstimatesEnabledForCurrentProject && ( -
-
+
+
Estimate
@@ -257,7 +314,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { projectId={projectId} disabled={!is_editable} buttonVariant="transparent-with-text" - className="w-3/5 flex-grow group" + className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm ${issue?.estimate_point !== null ? "" : "text-custom-text-400"}`} placeholder="None" @@ -269,8 +326,8 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )} {projectDetails?.module_view && ( -
-
+
+
Module
@@ -286,8 +343,8 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )} {projectDetails?.cycle_view && ( -
-
+
+
Cycle
@@ -302,13 +359,13 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
)} -
-
+
+
Parent
= observer((props) => { />
-
-
+
+
Relates to
= observer((props) => { />
-
-
+
+
Blocking
= observer((props) => { />
-
-
+
+
Blocked by
= observer((props) => { />
-
-
+
+
Duplicate of
= observer((props) => { disabled={!is_editable} />
-
-
-
- - Labels -
-
- +
+
+ + Labels +
+
+ +
diff --git a/web/components/issues/issue-detail/subscription.tsx b/web/components/issues/issue-detail/subscription.tsx index 8d05140b3..ee31de9db 100644 --- a/web/components/issues/issue-detail/subscription.tsx +++ b/web/components/issues/issue-detail/subscription.tsx @@ -1,11 +1,11 @@ import { FC, useState } from "react"; -import { Bell, BellOff } from "lucide-react"; +import isNil from "lodash/isNil"; import { observer } from "mobx-react-lite"; +import { Bell, BellOff } from "lucide-react"; // UI -import { Button } from "@plane/ui"; +import { Button, Loader, TOAST_TYPE, setToast } from "@plane/ui"; // hooks import { useIssueDetail } from "hooks/store"; -import useToast from "hooks/use-toast"; export type TIssueSubscription = { workspaceSlug: string; @@ -21,47 +21,53 @@ export const IssueSubscription: FC = observer((props) => { createSubscription, removeSubscription, } = useIssueDetail(); - const { setToastAlert } = useToast(); // state const [loading, setLoading] = useState(false); - const subscription = getSubscriptionByIssueId(issueId); + const isSubscribed = getSubscriptionByIssueId(issueId); const handleSubscription = async () => { setLoading(true); try { - if (subscription?.subscribed) await removeSubscription(workspaceSlug, projectId, issueId); + if (isSubscribed) await removeSubscription(workspaceSlug, projectId, issueId); else await createSubscription(workspaceSlug, projectId, issueId); - setToastAlert({ - type: "success", - title: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, - message: `Issue ${subscription?.subscribed ? `unsubscribed` : `subscribed`} successfully.!`, + setToast({ + type: TOAST_TYPE.SUCCESS, + title: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`, + message: `Issue ${isSubscribed ? `unsubscribed` : `subscribed`} successfully.!`, }); setLoading(false); } catch (error) { setLoading(false); - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error", message: "Something went wrong. Please try again later.", }); } }; + if (isNil(isSubscribed)) + return ( + + + + ); + return (
+ )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx index 891fd6ddd..fdaed4b9b 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/date.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/date.tsx @@ -2,10 +2,10 @@ import { observer } from "mobx-react-lite"; // icons import { X } from "lucide-react"; // helpers +import { DATE_FILTER_OPTIONS } from "constants/filters"; import { renderFormattedDate } from "helpers/date-time.helper"; import { capitalizeFirstLetter } from "helpers/string.helper"; // constants -import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { handleRemove: (val: string) => void; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 4ca2538e5..10ad265f3 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -1,23 +1,25 @@ import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; // hooks -import { useUser } from "hooks/store"; -// components import { + AppliedCycleFilters, AppliedDateFilters, AppliedLabelsFilters, AppliedMembersFilters, + AppliedModuleFilters, AppliedPriorityFilters, AppliedProjectFilters, AppliedStateFilters, AppliedStateGroupFilters, } from "components/issues"; -// helpers +import { EUserProjectRoles } from "constants/project"; import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +import { useApplication, useUser } from "hooks/store"; +// components +// helpers // types import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types"; // constants -import { EUserProjectRoles } from "constants/project"; type Props = { appliedFilters: IIssueFilterOptions; @@ -34,6 +36,9 @@ const dateFilters = ["start_date", "target_date"]; export const AppliedFiltersList: React.FC = observer((props) => { const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, states, alwaysAllowEditing } = props; // store hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); const { membership: { currentProjectRole }, } = useUser(); @@ -104,6 +109,20 @@ export const AppliedFiltersList: React.FC = observer((props) => { values={value} /> )} + {filterKey === "cycle" && !cycleId && ( + handleRemoveFilter("cycle", val)} + values={value} + /> + )} + {filterKey === "module" && !moduleId && ( + handleRemoveFilter("module", val)} + values={value} + /> + )} {isEditingAllowed && ( + )} +
+ ); + })} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx b/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx index be3240b55..aad394d8a 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx @@ -1,8 +1,8 @@ import { observer } from "mobx-react-lite"; // icons -import { PriorityIcon } from "@plane/ui"; import { X } from "lucide-react"; +import { PriorityIcon } from "@plane/ui"; // types import { TIssuePriorities } from "@plane/types"; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx index 4c5affe8d..84e81b6e8 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx @@ -2,8 +2,8 @@ import { observer } from "mobx-react-lite"; import { X } from "lucide-react"; // hooks import { useProject } from "hooks/store"; -// helpers -import { renderEmoji } from "helpers/emoji.helper"; +// components +import { ProjectLogo } from "components/project"; type Props = { handleRemove: (val: string) => void; @@ -25,15 +25,9 @@ export const AppliedProjectFilters: React.FC = observer((props) => { return (
- {projectDetails.emoji ? ( - {renderEmoji(projectDetails.emoji)} - ) : projectDetails.icon_prop ? ( -
{renderEmoji(projectDetails.icon_prop)}
- ) : ( - - {projectDetails?.name.charAt(0)} - - )} + + + {projectDetails.name} {editable && ( + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx index af8cfc84a..257aa1977 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/filters-selection.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; import { Search, X } from "lucide-react"; -// components +// hooks import { FilterAssignees, FilterMentions, @@ -13,11 +13,15 @@ import { FilterState, FilterStateGroup, FilterTargetDate, + FilterCycle, + FilterModule, } from "components/issues"; +import { ILayoutDisplayFiltersOptions } from "constants/issue"; +import { useApplication } from "hooks/store"; +// components // types import { IIssueFilterOptions, IIssueLabel, IState } from "@plane/types"; // constants -import { ILayoutDisplayFiltersOptions } from "constants/issue"; type Props = { filters: IIssueFilterOptions; @@ -30,6 +34,10 @@ type Props = { export const FilterSelection: React.FC = observer((props) => { const { filters, handleFiltersUpdate, layoutDisplayFiltersOptions, labels, memberIds, states } = props; + // hooks + const { + router: { moduleId, cycleId }, + } = useApplication(); // states const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); @@ -55,7 +63,7 @@ export const FilterSelection: React.FC = observer((props) => { )}
-
+
{/* priority */} {isFilterEnabled("priority") && (
@@ -102,6 +110,28 @@ export const FilterSelection: React.FC = observer((props) => {
)} + {/* cycle */} + {isFilterEnabled("cycle") && !cycleId && ( +
+ handleFiltersUpdate("cycle", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + + {/* module */} + {isFilterEnabled("module") && !moduleId && ( +
+ handleFiltersUpdate("module", val)} + searchQuery={filtersSearchQuery} + /> +
+ )} + {/* assignees */} {isFilterEnabled("mentions") && (
diff --git a/web/components/issues/issue-layouts/filters/header/filters/index.ts b/web/components/issues/issue-layouts/filters/header/filters/index.ts index 2d3a04d0f..ab5756bf4 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/index.ts +++ b/web/components/issues/issue-layouts/filters/header/filters/index.ts @@ -8,4 +8,6 @@ export * from "./project"; export * from "./start-date"; export * from "./state-group"; export * from "./state"; +export * from "./cycle"; +export * from "./module"; export * from "./target-date"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx index b226f42b3..42e955535 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx @@ -1,9 +1,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // components +import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; // ui -import { Loader } from "@plane/ui"; // types import { IIssueLabel } from "@plane/types"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx index a6af9833a..4d2839b2c 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx @@ -1,11 +1,11 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; // hooks +import { Loader, Avatar } from "@plane/ui"; +import { FilterHeader, FilterOption } from "components/issues"; import { useMember } from "hooks/store"; // components -import { FilterHeader, FilterOption } from "components/issues"; // ui -import { Loader, Avatar } from "@plane/ui"; type Props = { appliedFilters: string[] | null; @@ -24,8 +24,8 @@ export const FilterMentions: React.FC = observer((props: Props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = memberIds?.filter((memberId) => - getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + const filteredOptions = memberIds?.filter( + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) ); const handleViewToggle = () => { diff --git a/web/components/issues/issue-layouts/filters/header/filters/module.tsx b/web/components/issues/issue-layouts/filters/header/filters/module.tsx new file mode 100644 index 000000000..812cf939f --- /dev/null +++ b/web/components/issues/issue-layouts/filters/header/filters/module.tsx @@ -0,0 +1,89 @@ +import React, { useState } from "react"; +import sortBy from "lodash/sortBy"; +import { observer } from "mobx-react"; +// components +import { Loader, DiceIcon } from "@plane/ui"; +import { FilterHeader, FilterOption } from "components/issues"; +import { useApplication, useModule } from "hooks/store"; +// ui + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterModule: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + // hooks + const { + router: { projectId }, + } = useApplication(); + const { getModuleById, getProjectModuleIds } = useModule(); + + // states + const [itemsToRender, setItemsToRender] = useState(5); + const [previewEnabled, setPreviewEnabled] = useState(true); + + const moduleIds = projectId ? getProjectModuleIds(projectId) : undefined; + const modules = moduleIds?.map((projectId) => getModuleById(projectId)!) ?? null; + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = sortBy( + modules?.filter((module) => module.name.toLowerCase().includes(searchQuery.toLowerCase())), + (module) => module.name.toLowerCase() + ); + + const handleViewToggle = () => { + if (!filteredOptions) return; + + if (itemsToRender === filteredOptions.length) setItemsToRender(5); + else setItemsToRender(filteredOptions.length); + }; + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions ? ( + filteredOptions.length > 0 ? ( + <> + {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + handleUpdate(cycle.id)} + icon={} + title={cycle.name} + /> + ))} + {filteredOptions.length > 5 && ( + + )} + + ) : ( +

No matches found

+ ) + ) : ( + + + + + + )} +
+ )} + + ); +}); diff --git a/web/components/issues/issue-layouts/filters/header/filters/project.tsx b/web/components/issues/issue-layouts/filters/header/filters/project.tsx index 61b7d50c1..b9f864b4b 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/project.tsx @@ -1,13 +1,14 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // components +import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; // hooks import { useProject } from "hooks/store"; +// components +import { ProjectLogo } from "components/project"; // ui -import { Loader } from "@plane/ui"; // helpers -import { renderEmoji } from "helpers/emoji.helper"; type Props = { appliedFilters: string[] | null; @@ -52,19 +53,9 @@ export const FilterProjects: React.FC = observer((props) => { isChecked={appliedFilters?.includes(project.id) ? true : false} onClick={() => handleUpdate(project.id)} icon={ - project.emoji ? ( - - {renderEmoji(project.emoji)} - - ) : project.icon_prop ? ( -
- {renderEmoji(project.icon_prop)} -
- ) : ( - - {project?.name.charAt(0)} - - ) + + + } title={project.name} /> diff --git a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx index 2cb715158..87def7e29 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; // components -import { FilterHeader, FilterOption } from "components/issues"; import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; // constants import { DATE_FILTER_OPTIONS } from "constants/filters"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/state-group.tsx b/web/components/issues/issue-layouts/filters/header/filters/state-group.tsx index ea9097146..06c1aae9f 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/state-group.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/state-group.tsx @@ -2,9 +2,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; // components +import { StateGroupIcon } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; // icons -import { StateGroupIcon } from "@plane/ui"; import { STATE_GROUPS } from "constants/state"; // constants diff --git a/web/components/issues/issue-layouts/filters/header/filters/state.tsx b/web/components/issues/issue-layouts/filters/header/filters/state.tsx index c13a69b0a..5dde1d279 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/state.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/state.tsx @@ -1,9 +1,9 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; // components +import { Loader, StateGroupIcon } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; // ui -import { Loader, StateGroupIcon } from "@plane/ui"; // types import { IState } from "@plane/types"; diff --git a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx index b168af668..9e0ce18a7 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx @@ -2,8 +2,8 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; // components -import { FilterHeader, FilterOption } from "components/issues"; import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; // constants import { DATE_FILTER_OPTIONS } from "constants/filters"; diff --git a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx index 33b86ada1..0d00c3675 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -1,11 +1,11 @@ import React, { Fragment, useState } from "react"; +import { Placement } from "@popperjs/core"; import { usePopper } from "react-popper"; import { Popover, Transition } from "@headlessui/react"; -import { Placement } from "@popperjs/core"; // ui +import { ChevronUp } from "lucide-react"; import { Button } from "@plane/ui"; // icons -import { ChevronUp } from "lucide-react"; type Props = { children: React.ReactNode; @@ -34,22 +34,26 @@ export const FiltersDropdown: React.FC = (props) => { return ( <> - {menuButton ? : } + {menuButton ? ( + + ) : ( + + )} void; multiple?: boolean; + activePulse?: boolean; }; export const FilterOption: React.FC = (props) => { - const { icon, isChecked, multiple = true, onClick, title } = props; + const { icon, isChecked, multiple = true, onClick, title, activePulse = false } = props; return (
+ {activePulse && ( +
+ )} ); }; diff --git a/web/components/issues/issue-layouts/filters/header/layout-selection.tsx b/web/components/issues/issue-layouts/filters/header/layout-selection.tsx index ca7c914ef..4253638dd 100644 --- a/web/components/issues/issue-layouts/filters/header/layout-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/layout-selection.tsx @@ -2,9 +2,9 @@ import React from "react"; // ui import { IconTabs } from "@plane/ui"; // types +import { ISSUE_LAYOUTS } from "constants/issue"; import { TIssueLayouts } from "@plane/types"; // constants -import { ISSUE_LAYOUTS } from "constants/issue"; type Props = { layouts: TIssueLayouts[]; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index b5f092aba..1231a31c5 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -1,24 +1,21 @@ import React from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "components/gantt-chart"; +import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues"; +import { EUserProjectRoles } from "constants/project"; +import { renderIssueBlocksStructure } from "helpers/issue.helper"; import { useIssues, useUser } from "hooks/store"; // components -import { GanttQuickAddIssueForm, IssueGanttBlock } from "components/issues"; -import { - GanttChartRoot, - IBlockUpdateData, - renderIssueBlocksStructure, - IssueGanttSidebar, -} from "components/gantt-chart"; +// helpers // types -import { TIssue, TUnGroupedIssues } from "@plane/types"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; +import { TIssue, TUnGroupedIssues } from "@plane/types"; // constants -import { EUserProjectRoles } from "constants/project"; import { EIssueActions } from "../types"; interface IBaseGanttRoot { diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index 18a767455..98b05d0ca 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -1,10 +1,10 @@ import { observer } from "mobx-react"; // hooks -import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; // ui import { Tooltip, StateGroupIcon, ControlLink } from "@plane/ui"; // helpers import { renderFormattedDate } from "helpers/date-time.helper"; +import { useApplication, useIssueDetail, useProject, useProjectState } from "hooks/store"; type Props = { issueId: string; @@ -29,6 +29,7 @@ export const IssueGanttBlock: React.FC = observer((props) => { const handleIssuePeekOverview = () => workspaceSlug && issueDetails && + !issueDetails.tempId && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); return ( @@ -65,7 +66,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { const { issueId } = props; // store hooks const { getStateById } = useProjectState(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { router: { workspaceSlug }, } = useApplication(); @@ -75,7 +76,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { } = useIssueDetail(); // derived values const issueDetails = getIssueById(issueId); - const projectDetails = issueDetails && getProjectById(issueDetails?.project_id); + const projectIdentifier = issueDetails && getProjectIdentifierById(issueDetails?.project_id); const stateDetails = issueDetails && getStateById(issueDetails?.state_id); const handleIssuePeekOverview = () => @@ -89,13 +90,14 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { target="_blank" onClick={handleIssuePeekOverview} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issueDetails?.tempId} > -
+
{stateDetails && }
- {projectDetails?.identifier} {issueDetails?.sequence_id} + {projectIdentifier} {issueDetails?.sequence_id}
- + {issueDetails?.name}
diff --git a/web/components/issues/issue-layouts/gantt/cycle-root.tsx b/web/components/issues/issue-layouts/gantt/cycle-root.tsx index cf1f6121a..4d255b64f 100644 --- a/web/components/issues/issue-layouts/gantt/cycle-root.tsx +++ b/web/components/issues/issue-layouts/gantt/cycle-root.tsx @@ -1,12 +1,12 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks +import { EIssuesStoreType } from "constants/issue"; import { useCycle, useIssues } from "hooks/store"; // components -import { BaseGanttRoot } from "./base-gantt-root"; -import { EIssuesStoreType } from "constants/issue"; -import { EIssueActions } from "../types"; import { TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; +import { BaseGanttRoot } from "./base-gantt-root"; export const CycleGanttLayout: React.FC = observer(() => { // router diff --git a/web/components/issues/issue-layouts/gantt/module-root.tsx b/web/components/issues/issue-layouts/gantt/module-root.tsx index c7c8e8b03..3311b6c6a 100644 --- a/web/components/issues/issue-layouts/gantt/module-root.tsx +++ b/web/components/issues/issue-layouts/gantt/module-root.tsx @@ -1,12 +1,12 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks +import { EIssuesStoreType } from "constants/issue"; import { useIssues, useModule } from "hooks/store"; // components -import { BaseGanttRoot } from "./base-gantt-root"; -import { EIssuesStoreType } from "constants/issue"; -import { EIssueActions } from "../types"; import { TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; +import { BaseGanttRoot } from "./base-gantt-root"; export const ModuleGanttLayout: React.FC = observer(() => { // router diff --git a/web/components/issues/issue-layouts/gantt/project-root.tsx b/web/components/issues/issue-layouts/gantt/project-root.tsx index 18fd3ecef..1f9e560d3 100644 --- a/web/components/issues/issue-layouts/gantt/project-root.tsx +++ b/web/components/issues/issue-layouts/gantt/project-root.tsx @@ -1,13 +1,13 @@ import React from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { BaseGanttRoot } from "./base-gantt-root"; -import { EIssuesStoreType } from "constants/issue"; -import { EIssueActions } from "../types"; import { TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; +import { BaseGanttRoot } from "./base-gantt-root"; export const GanttLayout: React.FC = observer(() => { // router diff --git a/web/components/issues/issue-layouts/gantt/project-view-root.tsx b/web/components/issues/issue-layouts/gantt/project-view-root.tsx index 1ed02c2c9..cda2a1e53 100644 --- a/web/components/issues/issue-layouts/gantt/project-view-root.tsx +++ b/web/components/issues/issue-layouts/gantt/project-view-root.tsx @@ -1,14 +1,14 @@ -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components +import { TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; import { BaseGanttRoot } from "./base-gantt-root"; // constants -import { EIssuesStoreType } from "constants/issue"; // types -import { EIssueActions } from "../types"; -import { TIssue } from "@plane/types"; export interface IViewGanttLayout { issueActions: { diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index 7ed6a8730..94a6243e5 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -1,21 +1,21 @@ import { useEffect, useState, useRef, FC } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; -import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; // hooks +import { setPromiseToast } from "@plane/ui"; +import { cn } from "helpers/common.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { createIssuePayload } from "helpers/issue.helper"; import { useEventTracker, useProject } from "hooks/store"; -import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -import { createIssuePayload } from "helpers/issue.helper"; -import { cn } from "helpers/common.helper"; +// ui // types import { IProject, TIssue } from "@plane/types"; // constants -import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -70,7 +70,6 @@ export const GanttQuickAddIssueForm: React.FC = observe // hooks const { getProjectById } = useProject(); const { captureIssueEvent } = useEventTracker(); - const { setToastAlert } = useToast(); const projectDetail = (projectId && getProjectById(projectId.toString())) || undefined; @@ -110,31 +109,35 @@ export const GanttQuickAddIssueForm: React.FC = observe target_date: renderFormattedPayloadDate(targetDate), }); - try { - quickAddCallback && - (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { + if (quickAddCallback) { + const quickAddPromise = quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId); + setPromiseToast(quickAddPromise, { + loading: "Adding issue...", + success: { + title: "Success!", + message: () => "Issue created successfully.", + }, + error: { + title: "Error!", + message: (err) => err?.message || "Some error occurred. Please try again.", + }, + }); + + await quickAddPromise + .then((res) => { captureIssueEvent({ eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Gantt quick add" }, path: router.asPath, }); - })); - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - } catch (err: any) { - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { ...payload, state: "FAILED", element: "Gantt quick add" }, - path: router.asPath, - }); - setToastAlert({ - type: "error", - title: "Error!", - message: err?.message || "Some error occurred. Please try again.", - }); + }) + .catch(() => { + captureIssueEvent({ + eventName: ISSUE_CREATED, + payload: { ...payload, state: "FAILED", element: "Gantt quick add" }, + path: router.asPath, + }); + }); } }; return ( diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 83f72d8ea..775382f59 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -1,31 +1,29 @@ import { FC, useCallback, useRef, useState } from "react"; import { DragDropContext, DragStart, DraggableLocation, DropResult, Droppable } from "@hello-pangea/dnd"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { Spinner, TOAST_TYPE, setToast } from "@plane/ui"; +import { DeleteIssueModal } from "components/issues"; +import { ISSUE_DELETED } from "constants/event-tracker"; +import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; import { useEventTracker, useUser } from "hooks/store"; -import useToast from "hooks/use-toast"; // ui -import { Spinner } from "@plane/ui"; // types -import { TIssue } from "@plane/types"; -import { EIssueActions } from "../types"; -import { IQuickActionProps } from "../list/list-view-types"; +import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; +import { IDraftIssues, IDraftIssuesFilter } from "store/issue/draft"; +import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; +import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; +import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; +import { TIssue } from "@plane/types"; +import { IQuickActionProps } from "../list/list-view-types"; +import { EIssueActions } from "../types"; //components import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; -import { DeleteIssueModal } from "components/issues"; -import { EUserProjectRoles } from "constants/project"; -import { useIssues } from "hooks/store/use-issues"; import { handleDragDrop } from "./utils"; -import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; -import { IDraftIssues, IDraftIssuesFilter } from "store/issue/draft"; -import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; -import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; -import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; -import { EIssueFilterType, TCreateModalStoreTypes } from "constants/issue"; -import { ISSUE_DELETED } from "constants/event-tracker"; export interface IBaseKanBanLayout { issues: IProjectIssues | ICycleIssues | IDraftIssues | IModuleIssues | IProjectViewIssues | IProfileIssues; @@ -41,6 +39,8 @@ export interface IBaseKanBanLayout { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; showLoader?: boolean; viewId?: string; @@ -78,8 +78,6 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas } = useUser(); const { captureIssueEvent } = useEventTracker(); const { issueMap } = useIssues(); - // toast alert - const { setToastAlert } = useToast(); const issueIds = issues?.groupedIssueIds || []; @@ -157,9 +155,9 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas issueIds, viewId ).catch((err) => { - setToastAlert({ + setToast({ title: "Error", - type: "error", + type: TOAST_TYPE.ERROR, message: err.detail ?? "Failed to perform this action", }); }); @@ -188,6 +186,12 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + handleArchive={ + issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined + } + handleRestore={ + issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined + } readOnly={!isEditingAllowed || isCompletedCycle} /> ), @@ -222,12 +226,18 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const handleKanbanFilters = (toggle: "group_by" | "sub_group_by", value: string) => { if (workspaceSlug && projectId) { - let _kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; - if (_kanbanFilters.includes(value)) _kanbanFilters = _kanbanFilters.filter((_value) => _value != value); - else _kanbanFilters.push(value); - issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.KANBAN_FILTERS, { - [toggle]: _kanbanFilters, - }); + let kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters?.[toggle] || []; + if (kanbanFilters.includes(value)) kanbanFilters = kanbanFilters.filter((_value) => _value != value); + else kanbanFilters.push(value); + issuesFilter.updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.KANBAN_FILTERS, + { + [toggle]: kanbanFilters, + }, + viewId + ); } }; @@ -249,7 +259,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas )}
@@ -277,27 +287,29 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas
- +
+ +
diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index 6102ce0dd..55675dd39 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -2,18 +2,18 @@ import { MutableRefObject, memo } from "react"; import { Draggable, DraggableProvided, DraggableStateSnapshot } from "@hello-pangea/dnd"; import { observer } from "mobx-react-lite"; // hooks +import { Tooltip, ControlLink } from "@plane/ui"; +import RenderIfVisible from "components/core/render-if-visible-HOC"; +import { cn } from "helpers/common.helper"; import { useApplication, useIssueDetail, useProject } from "hooks/store"; // components -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; -import { IssueProperties } from "../properties/all-properties"; -// ui -import { Tooltip, ControlLink } from "@plane/ui"; -// types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; +import { IssueProperties } from "../properties/all-properties"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +// ui +// types import { EIssueActions } from "../types"; // helper -import { cn } from "helpers/common.helper"; -import RenderIfVisible from "components/core/render-if-visible-HOC"; interface IssueBlockProps { peekIssueId?: string; @@ -42,9 +42,9 @@ interface IssueDetailsBlockProps { const KanbanIssueDetailsBlock: React.FC = observer((props: IssueDetailsBlockProps) => { const { issue, handleIssues, quickActions, isReadOnly, displayProperties } = props; // hooks - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { - router: { workspaceSlug, projectId }, + router: { workspaceSlug }, } = useApplication(); const { setPeekIssue } = useIssueDetail(); @@ -64,24 +64,27 @@ const KanbanIssueDetailsBlock: React.FC = observer((prop
- {getProjectById(issue.project_id)?.identifier}-{issue.sequence_id} + {getProjectIdentifierById(issue.project_id)}-{issue.sequence_id}
{quickActions(issue)}
{issue?.is_draft ? ( - + {issue.name} ) : ( handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > - + {issue.name} @@ -138,7 +141,7 @@ export const KanbanIssueBlock: React.FC = memo((props) => { >
= memo((props) => { classNames="space-y-2 px-3 py-2" root={scrollableContainerRef} defaultHeight="100px" - horizonatlOffset={50} + horizontalOffset={50} alwaysRender={snapshot.isDragging} pauseHeightUpdateWhileRendering={isDragStarted} changingReference={issueIds} diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index 3746111e5..ff1c92873 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,9 +1,9 @@ import { MutableRefObject, memo } from "react"; //types +import { KanbanIssueBlock } from "components/issues"; import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { EIssueActions } from "../types"; // components -import { KanbanIssueBlock } from "components/issues"; interface IssueBlocksListProps { sub_group_id: string; diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index f11321944..ece578058 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -1,9 +1,18 @@ +import { MutableRefObject } from "react"; import { observer } from "mobx-react-lite"; +// constants +import { TCreateModalStoreTypes } from "constants/issue"; // hooks -import { useIssueDetail, useKanbanView, useLabel, useMember, useProject, useProjectState } from "hooks/store"; -// components -import { HeaderGroupByCard } from "./headers/group-by-card"; -import { KanbanGroup } from "./kanban-group"; +import { + useCycle, + useIssueDetail, + useKanbanView, + useLabel, + useMember, + useModule, + useProject, + useProjectState, +} from "hooks/store"; // types import { GroupByColumnTypes, @@ -16,11 +25,12 @@ import { TUnGroupedIssues, TIssueKanbanFilters, } from "@plane/types"; -// constants +// parent components import { EIssueActions } from "../types"; import { getGroupByColumns } from "../utils"; -import { TCreateModalStoreTypes } from "constants/issue"; -import { MutableRefObject } from "react"; +// components +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { KanbanGroup } from "./kanban-group"; export interface IGroupByKanBan { issuesMap: IIssueMap; @@ -48,6 +58,7 @@ export interface IGroupByKanBan { canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; isDragStarted?: boolean; + showEmptyGroup?: boolean; } const GroupByKanBan: React.FC = observer((props) => { @@ -72,34 +83,59 @@ const GroupByKanBan: React.FC = observer((props) => { canEditProperties, scrollableContainerRef, isDragStarted, + showEmptyGroup = true, } = props; const member = useMember(); const project = useProject(); const label = useLabel(); + const cycle = useCycle(); + const moduleInfo = useModule(); const projectState = useProjectState(); const { peekIssue } = useIssueDetail(); - const list = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); + const list = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + moduleInfo, + label, + projectState, + member + ); if (!list) return null; - const visibilityGroupBy = (_list: IGroupByColumn) => - sub_group_by ? false : kanbanFilters?.group_by.includes(_list.id) ? true : false; + const groupWithIssues = list.filter((_list) => (issueIds as TGroupedIssues)?.[_list.id]?.length > 0); + + const groupList = showEmptyGroup ? list : groupWithIssues; + + const visibilityGroupBy = (_list: IGroupByColumn) => { + if (sub_group_by) { + if (kanbanFilters?.sub_group_by.includes(_list.id)) return true; + return false; + } else { + if (kanbanFilters?.group_by.includes(_list.id)) return true; + return false; + } + }; const isGroupByCreatedBy = group_by === "created_by"; return ( -
- {list && - list.length > 0 && - list.map((_list: IGroupByColumn) => { +
+ {groupList && + groupList.length > 0 && + groupList.map((_list: IGroupByColumn) => { const groupByVisibilityToggle = visibilityGroupBy(_list); return ( -
+
{sub_group_by === null && ( -
+
= observer((props) => { canEditProperties, scrollableContainerRef, isDragStarted, + showEmptyGroup, } = props; const issueKanBanView = useKanbanView(); @@ -222,6 +259,7 @@ export const KanBan: React.FC = observer((props) => { canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} isDragStarted={isDragStarted} + showEmptyGroup={showEmptyGroup} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index f88bb6b92..b3cc24f28 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -1,19 +1,19 @@ import React, { FC } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; -// components -import { CustomMenu } from "@plane/ui"; -import { ExistingIssuesListModal } from "components/core"; -import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; +// ui +import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { ExistingIssuesListModal } from "components/core"; +import { CreateUpdateIssueModal } from "components/issues"; +// constants +import { TCreateModalStoreTypes } from "constants/issue"; // hooks -import useToast from "hooks/use-toast"; import { useEventTracker } from "hooks/store"; -// mobx -import { observer } from "mobx-react-lite"; // types import { TIssue, ISearchIssueResponse, TIssueKanbanFilters } from "@plane/types"; -import { TCreateModalStoreTypes } from "constants/issue"; interface IHeaderGroupByCard { sub_group_by: string | null; @@ -56,8 +56,6 @@ export const HeaderGroupByCard: FC = observer((props) => { const isDraftIssue = router.pathname.includes("draft-issue"); - const { setToastAlert } = useToast(); - const renderExistingIssueModal = moduleId || cycleId; const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; @@ -67,10 +65,16 @@ export const HeaderGroupByCard: FC = observer((props) => { const issues = data.map((i) => i.id); try { - addIssuesToView && addIssuesToView(issues); + await addIssuesToView?.(issues); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues added to the cycle successfully.", + }); } catch (error) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Selected issues could not be added to the cycle. Please try again.", }); @@ -106,13 +110,21 @@ export const HeaderGroupByCard: FC = observer((props) => { {icon ? icon : }
-
+
{title}
-
+
{count || 0}
@@ -138,6 +150,7 @@ export const HeaderGroupByCard: FC = observer((props) => { } + placement="bottom-end" > { diff --git a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx index ea9464780..b0859a70d 100644 --- a/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/sub-group-by-card.tsx @@ -1,7 +1,7 @@ import React from "react"; +import { observer } from "mobx-react-lite"; import { Circle, ChevronDown, ChevronUp } from "lucide-react"; // mobx -import { observer } from "mobx-react-lite"; import { TIssueKanbanFilters } from "@plane/types"; interface IHeaderSubGroupByCard { diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 7cbda05e1..9d7053216 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -3,7 +3,6 @@ import { Droppable } from "@hello-pangea/dnd"; // hooks import { useProjectState } from "hooks/store"; //components -import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; //types import { TGroupedIssues, @@ -14,6 +13,7 @@ import { TUnGroupedIssues, } from "@plane/types"; import { EIssueActions } from "../types"; +import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; interface IKanbanGroup { groupId: string; @@ -80,6 +80,10 @@ export const KanbanGroup = (props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: groupValue }; } else if (groupByKey === "priority") { preloadedData = { ...preloadedData, priority: groupValue }; + } else if (groupByKey === "cycle") { + preloadedData = { ...preloadedData, cycle_id: groupValue }; + } else if (groupByKey === "module") { + preloadedData = { ...preloadedData, module_ids: [groupValue] }; } else if (groupByKey === "labels" && groupValue != "None") { preloadedData = { ...preloadedData, label_ids: [groupValue] }; } else if (groupByKey === "assignees" && groupValue != "None") { @@ -96,6 +100,10 @@ export const KanbanGroup = (props: IKanbanGroup) => { preloadedData = { ...preloadedData, state_id: subGroupValue }; } else if (subGroupByKey === "priority") { preloadedData = { ...preloadedData, priority: subGroupValue }; + } else if (groupByKey === "cycle") { + preloadedData = { ...preloadedData, cycle_id: subGroupValue }; + } else if (groupByKey === "module") { + preloadedData = { ...preloadedData, module_ids: [subGroupValue] }; } else if (subGroupByKey === "labels" && subGroupValue != "None") { preloadedData = { ...preloadedData, label_ids: [subGroupValue] }; } else if (subGroupByKey === "assignees" && subGroupValue != "None") { @@ -115,9 +123,7 @@ export const KanbanGroup = (props: IKanbanGroup) => { {(provided: any, snapshot: any) => (
diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 513163431..71a0e661c 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -1,19 +1,20 @@ import { useEffect, useState, useRef } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; -import { observer } from "mobx-react-lite"; import { PlusIcon } from "lucide-react"; // hooks +import { setPromiseToast } from "@plane/ui"; +import { ISSUE_CREATED } from "constants/event-tracker"; +import { createIssuePayload } from "helpers/issue.helper"; import { useEventTracker, useProject } from "hooks/store"; -import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // helpers -import { createIssuePayload } from "helpers/issue.helper"; +// ui // types import { TIssue } from "@plane/types"; // constants -import { ISSUE_CREATED } from "constants/event-tracker"; const Inputs = (props: any) => { const { register, setFocus, projectDetail } = props; @@ -73,7 +74,6 @@ export const KanBanQuickAddIssueForm: React.FC = obser useKeypress("Escape", handleClose); useOutsideClickDetector(ref, handleClose); - const { setToastAlert } = useToast(); const { reset, @@ -97,46 +97,49 @@ export const KanBanQuickAddIssueForm: React.FC = obser ...formData, }); - try { - quickAddCallback && - (await quickAddCallback( - workspaceSlug.toString(), - projectId.toString(), - { - ...payload, - }, - viewId - ).then((res) => { + if (quickAddCallback) { + const quickAddPromise = quickAddCallback( + workspaceSlug.toString(), + projectId.toString(), + { + ...payload, + }, + viewId + ); + setPromiseToast(quickAddPromise, { + loading: "Adding issue...", + success: { + title: "Success!", + message: () => "Issue created successfully.", + }, + error: { + title: "Error!", + message: (err) => err?.message || "Some error occurred. Please try again.", + }, + }); + + await quickAddPromise + .then((res) => { captureIssueEvent({ eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "Kanban quick add" }, path: router.asPath, }); - })); - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - } catch (err: any) { - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { ...payload, state: "FAILED", element: "Kanban quick add" }, - path: router.asPath, - }); - console.error(err); - setToastAlert({ - type: "error", - title: "Error!", - message: err?.message || "Some error occurred. Please try again.", - }); + }) + .catch(() => { + captureIssueEvent({ + eventName: ISSUE_CREATED, + payload: { ...payload, state: "FAILED", element: "Kanban quick add" }, + path: router.asPath, + }); + }); } }; return ( <> {isOpen ? ( -
+
{ await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, }), [issues, workspaceSlug, cycleId] ); @@ -46,21 +51,26 @@ export const CycleKanBanLayout: React.FC = observer(() => { const isCompletedCycle = cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; - const canEditIssueProperties = () => !isCompletedCycle; + const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]); + + const addIssuesToView = useCallback( + (issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }, + [issues?.addIssueToCycle, workspaceSlug, projectId, cycleId] + ); return ( { - if (!workspaceSlug || !projectId || !cycleId) throw new Error(); - return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); - }} + addIssuesToView={addIssuesToView} canEditPropertiesBasedOnProject={canEditIssueProperties} isCompletedCycle={isCompletedCycle} /> diff --git a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx index 9152dbfe5..501734134 100644 --- a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx @@ -1,16 +1,16 @@ -import { useRouter } from "next/router"; +import { useMemo } from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { ProjectIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { ProjectIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; import { BaseKanBanRoot } from "../base-kanban-root"; -import { EIssuesStoreType } from "constants/issue"; -import { useMemo } from "react"; export interface IKanBanLayout {} @@ -42,7 +42,7 @@ export const DraftKanBanLayout: React.FC = observer(() => { issueActions={issueActions} issuesFilter={issuesFilter} issues={issues} - showLoader={true} + showLoader QuickActions={ProjectIssueQuickActions} /> ); diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index c3af69e6e..96cfaceda 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -1,16 +1,16 @@ import React, { useMemo } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hook +import { ModuleIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { ModuleIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; import { BaseKanBanRoot } from "../base-kanban-root"; -import { EIssuesStoreType } from "constants/issue"; export interface IModuleKanBanLayout {} @@ -38,6 +38,11 @@ export const ModuleKanBanLayout: React.FC = observer(() => { await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, }), [issues, workspaceSlug, moduleId] ); @@ -47,7 +52,7 @@ export const ModuleKanBanLayout: React.FC = observer(() => { issueActions={issueActions} issues={issues} issuesFilter={issuesFilter} - showLoader={true} + showLoader QuickActions={ModuleIssueQuickActions} viewId={moduleId?.toString()} storeType={EIssuesStoreType.MODULE} diff --git a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx index 2e189c9f4..99d703a72 100644 --- a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx @@ -1,17 +1,17 @@ -import { useRouter } from "next/router"; +import { useMemo } from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { ProjectIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; import { useIssues, useUser } from "hooks/store"; // components -import { ProjectIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; import { BaseKanBanRoot } from "../base-kanban-root"; -import { EUserProjectRoles } from "constants/project"; -import { EIssuesStoreType } from "constants/issue"; -import { useMemo } from "react"; export const ProfileIssuesKanBanLayout: React.FC = observer(() => { const router = useRouter(); @@ -35,6 +35,11 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, }), [issues, workspaceSlug, userId] ); @@ -50,7 +55,7 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => { issueActions={issueActions} issuesFilter={issuesFilter} issues={issues} - showLoader={true} + showLoader QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.PROFILE} canEditPropertiesBasedOnProject={canEditPropertiesBasedOnProject} diff --git a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 89e2ee187..432663a02 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -1,16 +1,16 @@ -import { useRouter } from "next/router"; -import { observer } from "mobx-react-lite"; import { useMemo } from "react"; +import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // mobx store +import { ProjectIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store/use-issues"; // components -import { ProjectIssueQuickActions } from "components/issues"; -import { BaseKanBanRoot } from "../base-kanban-root"; // types import { TIssue } from "@plane/types"; // constants import { EIssueActions } from "../../types"; -import { EIssuesStoreType } from "constants/issue"; +import { BaseKanBanRoot } from "../base-kanban-root"; export interface IKanBanLayout {} @@ -32,6 +32,11 @@ export const KanBanLayout: React.FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id); + }, }), [issues, workspaceSlug] ); @@ -41,7 +46,7 @@ export const KanBanLayout: React.FC = observer(() => { issueActions={issueActions} issues={issues} issuesFilter={issuesFilter} - showLoader={true} + showLoader QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.PROJECT} /> diff --git a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 1cdf71d45..77689e563 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -2,21 +2,22 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // constant -import { EIssuesStoreType } from "constants/issue"; // types import { TIssue } from "@plane/types"; +import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; import { EIssueActions } from "../../types"; // components import { BaseKanBanRoot } from "../base-kanban-root"; -import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; export interface IViewKanBanLayout { issueActions: { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } @@ -33,7 +34,7 @@ export const ProjectViewKanBanLayout: React.FC = observer((pr issueActions={issueActions} issuesFilter={issuesFilter} issues={issues} - showLoader={true} + showLoader QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.PROJECT_VIEW} viewId={viewId?.toString()} diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 5fdb58ef0..75cb830c6 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -1,10 +1,8 @@ import { MutableRefObject } from "react"; import { observer } from "mobx-react-lite"; // components -import { KanBan } from "./default"; -import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; -import { HeaderGroupByCard } from "./headers/group-by-card"; -// types +import { TCreateModalStoreTypes } from "constants/issue"; +import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "hooks/store"; import { GroupByColumnTypes, IGroupByColumn, @@ -16,11 +14,13 @@ import { TUnGroupedIssues, TIssueKanbanFilters, } from "@plane/types"; -// constants import { EIssueActions } from "../types"; -import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; import { getGroupByColumns } from "../utils"; -import { TCreateModalStoreTypes } from "constants/issue"; +import { KanBan } from "./default"; +import { HeaderGroupByCard } from "./headers/group-by-card"; +import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; +// types +// constants interface ISubGroupSwimlaneHeader { issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; @@ -30,6 +30,15 @@ interface ISubGroupSwimlaneHeader { kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; } + +const getSubGroupHeaderIssuesCount = (issueIds: TSubGroupedIssues, groupById: string) => { + let headerCount = 0; + Object.keys(issueIds).map((groupState) => { + headerCount = headerCount + (issueIds?.[groupState]?.[groupById]?.length || 0); + }); + return headerCount; +}; + const SubGroupSwimlaneHeader: React.FC = ({ issueIds, sub_group_by, @@ -38,18 +47,18 @@ const SubGroupSwimlaneHeader: React.FC = ({ kanbanFilters, handleKanbanFilters, }) => ( -
+
{list && list.length > 0 && list.map((_list: IGroupByColumn) => ( -
+
= observer((props) => { {list && list.length > 0 && list.map((_list: any) => ( -
+
= observer((props) => { const member = useMember(); const project = useProject(); const label = useLabel(); + const cycle = useCycle(); + const projectModule = useModule(); const projectState = useProjectState(); - const groupByList = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member); - const subGroupByList = getGroupByColumns(sub_group_by as GroupByColumnTypes, project, label, projectState, member); + const groupByList = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + projectModule, + label, + projectState, + member + ); + const subGroupByList = getGroupByColumns( + sub_group_by as GroupByColumnTypes, + project, + cycle, + projectModule, + label, + projectState, + member + ); if (!groupByList || !subGroupByList) return null; diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index b1441cff7..8a3d87e40 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,28 +1,23 @@ -import { List } from "./default"; import { FC, useCallback } from "react"; import { observer } from "mobx-react-lite"; // types -import { TIssue } from "@plane/types"; -import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; +import { TCreateModalStoreTypes } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; +import { useIssues, useUser } from "hooks/store"; +import { IArchivedIssuesFilter, IArchivedIssues } from "store/issue/archived"; import { ICycleIssues, ICycleIssuesFilter } from "store/issue/cycle"; +import { IDraftIssuesFilter, IDraftIssues } from "store/issue/draft"; import { IModuleIssues, IModuleIssuesFilter } from "store/issue/module"; import { IProfileIssues, IProfileIssuesFilter } from "store/issue/profile"; +import { IProjectIssues, IProjectIssuesFilter } from "store/issue/project"; import { IProjectViewIssues, IProjectViewIssuesFilter } from "store/issue/project-views"; -import { IDraftIssuesFilter, IDraftIssues } from "store/issue/draft"; -import { IArchivedIssuesFilter, IArchivedIssues } from "store/issue/archived"; +import { TIssue } from "@plane/types"; +import { EIssueActions } from "../types"; // components +import { List } from "./default"; import { IQuickActionProps } from "./list-view-types"; // constants -import { EUserProjectRoles } from "constants/project"; -import { TCreateModalStoreTypes } from "constants/issue"; // hooks -import { useIssues, useUser } from "hooks/store"; - -enum EIssueActions { - UPDATE = "update", - DELETE = "delete", - REMOVE = "remove", -} interface IBaseListRoot { issuesFilter: @@ -46,6 +41,8 @@ interface IBaseListRoot { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; + [EIssueActions.RESTORE]?: (issue: TIssue) => Promise; }; viewId?: string; storeType: TCreateModalStoreTypes; @@ -114,6 +111,12 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { handleRemoveFromView={ issueActions[EIssueActions.REMOVE] ? async () => handleIssues(issue, EIssueActions.REMOVE) : undefined } + handleArchive={ + issueActions[EIssueActions.ARCHIVE] ? async () => handleIssues(issue, EIssueActions.ARCHIVE) : undefined + } + handleRestore={ + issueActions[EIssueActions.RESTORE] ? async () => handleIssues(issue, EIssueActions.RESTORE) : undefined + } readOnly={!isEditingAllowed || isCompletedCycle} /> ), diff --git a/web/components/issues/issue-layouts/list/block.tsx b/web/components/issues/issue-layouts/list/block.tsx index 2e48e1f1c..a2148634c 100644 --- a/web/components/issues/issue-layouts/list/block.tsx +++ b/web/components/issues/issue-layouts/list/block.tsx @@ -1,14 +1,14 @@ import { observer } from "mobx-react-lite"; // components -import { IssueProperties } from "../properties/all-properties"; // hooks -import { useApplication, useIssueDetail, useProject } from "hooks/store"; // ui import { Spinner, Tooltip, ControlLink } from "@plane/ui"; // helper import { cn } from "helpers/common.helper"; +import { useApplication, useIssueDetail, useProject } from "hooks/store"; // types import { TIssue, IIssueDisplayProperties, TIssueMap } from "@plane/types"; +import { IssueProperties } from "../properties/all-properties"; import { EIssueActions } from "../types"; interface IssueBlockProps { @@ -24,9 +24,9 @@ export const IssueBlock: React.FC = observer((props: IssueBlock const { issuesMap, issueId, handleIssues, quickActions, displayProperties, canEditProperties } = props; // hooks const { - router: { workspaceSlug, projectId }, + router: { workspaceSlug }, } = useApplication(); - const { getProjectById } = useProject(); + const { getProjectIdentifierById } = useProject(); const { peekIssue, setPeekIssue } = useIssueDetail(); const updateIssue = async (issueToUpdate: TIssue) => { @@ -45,7 +45,7 @@ export const IssueBlock: React.FC = observer((props: IssueBlock if (!issue) return null; const canEditIssueProperties = canEditProperties(issue.project_id); - const projectDetails = getProjectById(issue.project_id); + const projectIdentifier = getProjectIdentifierById(issue.project_id); return (
= observer((props: IssueBlock > {displayProperties && displayProperties?.key && (
- {projectDetails?.identifier}-{issue.sequence_id} + {projectIdentifier}-{issue.sequence_id}
)} @@ -65,17 +65,20 @@ export const IssueBlock: React.FC = observer((props: IssueBlock )} {issue?.is_draft ? ( - + {issue.name} ) : ( handleIssuePeekOverview(issue)} className="w-full line-clamp-1 cursor-pointer text-sm text-custom-text-100" + disabled={!!issue?.tempId} > - + {issue.name} diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index d3c8d1406..23c364b67 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -1,10 +1,10 @@ import { FC, MutableRefObject } from "react"; // components +import RenderIfVisible from "components/core/render-if-visible-HOC"; import { IssueBlock } from "components/issues"; // types import { TGroupedIssues, TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; import { EIssueActions } from "../types"; -import RenderIfVisible from "components/core/render-if-visible-HOC"; interface Props { issueIds: TGroupedIssues | TUnGroupedIssues | any; diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 373897fda..db1bcb06a 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -1,9 +1,10 @@ import { useRef } from "react"; // components import { IssueBlocksList, ListQuickAddIssueForm } from "components/issues"; -import { HeaderGroupByCard } from "./headers/group-by-card"; // hooks -import { useLabel, useMember, useProject, useProjectState } from "hooks/store"; +import { TCreateModalStoreTypes } from "constants/issue"; +import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "hooks/store"; +// constants // types import { GroupByColumnTypes, @@ -15,9 +16,8 @@ import { IGroupByColumn, } from "@plane/types"; import { EIssueActions } from "../types"; -// constants -import { TCreateModalStoreTypes } from "constants/issue"; import { getGroupByColumns } from "../utils"; +import { HeaderGroupByCard } from "./headers/group-by-card"; export interface IGroupByList { issueIds: TGroupedIssues | TUnGroupedIssues | any; @@ -65,10 +65,21 @@ const GroupByList: React.FC = (props) => { const project = useProject(); const label = useLabel(); const projectState = useProjectState(); + const cycle = useCycle(); + const projectModule = useModule(); const containerRef = useRef(null); - const groups = getGroupByColumns(group_by as GroupByColumnTypes, project, label, projectState, member, true); + const groups = getGroupByColumns( + group_by as GroupByColumnTypes, + project, + cycle, + projectModule, + label, + projectState, + member, + true + ); if (!groups) return null; @@ -108,7 +119,7 @@ const GroupByList: React.FC = (props) => { const isGroupByCreatedBy = group_by === "created_by"; return ( -
+
{groups && groups.length > 0 && groups.map( diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index 5a6b3c462..acf26adb5 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -1,19 +1,19 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // lucide icons import { CircleDashed, Plus } from "lucide-react"; // components -import { CreateUpdateIssueModal, CreateUpdateDraftIssueModal } from "components/issues"; +import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; -import { CustomMenu } from "@plane/ui"; +import { CreateUpdateIssueModal } from "components/issues"; +// ui // mobx -import { observer } from "mobx-react-lite"; // hooks +import { TCreateModalStoreTypes } from "constants/issue"; import { useEventTracker } from "hooks/store"; // types import { TIssue, ISearchIssueResponse } from "@plane/types"; -import useToast from "hooks/use-toast"; -import { useState } from "react"; -import { TCreateModalStoreTypes } from "constants/issue"; interface IHeaderGroupByCard { icon?: React.ReactNode; @@ -38,8 +38,6 @@ export const HeaderGroupByCard = observer( const isDraftIssue = router.pathname.includes("draft-issue"); - const { setToastAlert } = useToast(); - const renderExistingIssueModal = moduleId || cycleId; const ExistingIssuesListModalPayload = moduleId ? { module: moduleId.toString() } : { cycle: true }; @@ -49,10 +47,16 @@ export const HeaderGroupByCard = observer( const issues = data.map((i) => i.id); try { - addIssuesToView && addIssuesToView(issues); + await addIssuesToView?.(issues); + + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues added to the cycle successfully.", + }); } catch (error) { - setToastAlert({ - type: "error", + setToast({ + type: TOAST_TYPE.ERROR, title: "Error!", message: "Selected issues could not be added to the cycle. Please try again.", }); diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index e369410af..f435d0639 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -5,6 +5,8 @@ export interface IQuickActionProps { handleDelete: () => Promise; handleUpdate?: (data: TIssue) => Promise; handleRemoveFromView?: () => Promise; + handleArchive?: () => Promise; + handleRestore?: () => Promise; customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index 8d1ce6d9c..7bae7ecff 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -1,19 +1,20 @@ -import { FC, useEffect, useState, useRef, use } from "react"; +import { FC, useEffect, useState, useRef } from "react"; +import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { PlusIcon } from "lucide-react"; -import { observer } from "mobx-react-lite"; // hooks +import { setPromiseToast } from "@plane/ui"; +import { ISSUE_CREATED } from "constants/event-tracker"; +import { createIssuePayload } from "helpers/issue.helper"; import { useEventTracker, useProject } from "hooks/store"; -import useToast from "hooks/use-toast"; import useKeypress from "hooks/use-keypress"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; -// constants -import { TIssue, IProject } from "@plane/types"; +// ui // types -import { createIssuePayload } from "helpers/issue.helper"; +import { TIssue, IProject } from "@plane/types"; +// helper // constants -import { ISSUE_CREATED } from "constants/event-tracker"; interface IInputProps { formKey: string; @@ -77,7 +78,6 @@ export const ListQuickAddIssueForm: FC = observer((props useKeypress("Escape", handleClose); useOutsideClickDetector(ref, handleClose); - const { setToastAlert } = useToast(); const { reset, @@ -101,31 +101,35 @@ export const ListQuickAddIssueForm: FC = observer((props ...formData, }); - try { - quickAddCallback && - (await quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId).then((res) => { + if (quickAddCallback) { + const quickAddPromise = quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId); + setPromiseToast(quickAddPromise, { + loading: "Adding issue...", + success: { + title: "Success!", + message: () => "Issue created successfully.", + }, + error: { + title: "Error!", + message: (err) => err?.message || "Some error occurred. Please try again.", + }, + }); + + await quickAddPromise + .then((res) => { captureIssueEvent({ eventName: ISSUE_CREATED, payload: { ...res, state: "SUCCESS", element: "List quick add" }, path: router.asPath, }); - })); - setToastAlert({ - type: "success", - title: "Success!", - message: "Issue created successfully.", - }); - } catch (err: any) { - captureIssueEvent({ - eventName: ISSUE_CREATED, - payload: { ...payload, state: "FAILED", element: "List quick add" }, - path: router.asPath, - }); - setToastAlert({ - type: "error", - title: "Error!", - message: err?.message || "Some error occurred. Please try again.", - }); + }) + .catch(() => { + captureIssueEvent({ + eventName: ISSUE_CREATED, + payload: { ...payload, state: "FAILED", element: "List quick add" }, + path: router.asPath, + }); + }); } }; diff --git a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx index 2ba4ea7f5..2f3807beb 100644 --- a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx @@ -1,16 +1,16 @@ import { FC, useMemo } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { ArchivedIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { ArchivedIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; // constants -import { BaseListRoot } from "../base-list-root"; import { EIssueActions } from "../../types"; -import { EIssuesStoreType } from "constants/issue"; +import { BaseListRoot } from "../base-list-root"; export const ArchivedIssueListLayout: FC = observer(() => { const router = useRouter(); @@ -24,6 +24,11 @@ export const ArchivedIssueListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, projectId, issue.id); }, + [EIssueActions.RESTORE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.restoreIssue(workspaceSlug, projectId, issue.id); + }, }), [issues, workspaceSlug, projectId] ); diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index e30c207b6..46ee7f32e 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -1,16 +1,16 @@ -import React, { useMemo } from "react"; -import { useRouter } from "next/router"; +import React, { useCallback, useMemo } from "react"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { CycleIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useCycle, useIssues } from "hooks/store"; // components -import { CycleIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; // constants -import { BaseListRoot } from "../base-list-root"; import { EIssueActions } from "../../types"; -import { EIssuesStoreType } from "constants/issue"; +import { BaseListRoot } from "../base-list-root"; export interface ICycleListLayout {} @@ -38,13 +38,26 @@ export const CycleListLayout: React.FC = observer(() => { await issues.removeIssueFromCycle(workspaceSlug.toString(), issue.project_id, cycleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !cycleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, cycleId.toString()); + }, }), [issues, workspaceSlug, cycleId] ); const isCompletedCycle = cycleId && currentProjectCompletedCycleIds ? currentProjectCompletedCycleIds.includes(cycleId.toString()) : false; - const canEditIssueProperties = () => !isCompletedCycle; + const canEditIssueProperties = useCallback(() => !isCompletedCycle, [isCompletedCycle]); + + const addIssuesToView = useCallback( + (issueIds: string[]) => { + if (!workspaceSlug || !projectId || !cycleId) throw new Error(); + return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); + }, + [issues?.addIssueToCycle, workspaceSlug, projectId, cycleId] + ); return ( { issueActions={issueActions} viewId={cycleId?.toString()} storeType={EIssuesStoreType.CYCLE} - addIssuesToView={(issueIds: string[]) => { - if (!workspaceSlug || !projectId || !cycleId) throw new Error(); - return issues.addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds); - }} + addIssuesToView={addIssuesToView} canEditPropertiesBasedOnProject={canEditIssueProperties} isCompletedCycle={isCompletedCycle} /> diff --git a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx index e11971874..10b75b115 100644 --- a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx @@ -1,16 +1,16 @@ import { FC, useMemo } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { ProjectIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { ProjectIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { EIssuesStoreType } from "constants/issue"; export const DraftIssueListLayout: FC = observer(() => { const router = useRouter(); diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 520a2da32..aca528a6a 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -1,16 +1,16 @@ import React, { useMemo } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // mobx store +import { ModuleIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { ModuleIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { EIssuesStoreType } from "constants/issue"; export interface IModuleListLayout {} @@ -37,6 +37,11 @@ export const ModuleListLayout: React.FC = observer(() => { await issues.removeIssueFromModule(workspaceSlug.toString(), issue.project_id, moduleId.toString(), issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !moduleId) return; + + await issues.archiveIssue(workspaceSlug.toString(), issue.project_id, issue.id, moduleId.toString()); + }, }), [issues, workspaceSlug, moduleId] ); diff --git a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx index 91e80382a..dc0c68cd8 100644 --- a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx @@ -1,17 +1,17 @@ import { FC, useMemo } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { ProjectIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; +import { EUserProjectRoles } from "constants/project"; import { useIssues, useUser } from "hooks/store"; // components -import { ProjectIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { EUserProjectRoles } from "constants/project"; -import { EIssuesStoreType } from "constants/issue"; export const ProfileIssuesListLayout: FC = observer(() => { // router @@ -36,6 +36,11 @@ export const ProfileIssuesListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, issue.project_id, issue.id, userId); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !userId) return; + + await issues.archiveIssue(workspaceSlug, issue.project_id, issue.id, userId); + }, }), [issues, workspaceSlug, userId] ); diff --git a/web/components/issues/issue-layouts/list/roots/project-root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx index f0479b71f..8a0935979 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -1,16 +1,16 @@ import { FC, useMemo } from "react"; -import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; +import { useRouter } from "next/router"; // hooks +import { ProjectIssueQuickActions } from "components/issues"; +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // components -import { ProjectIssueQuickActions } from "components/issues"; // types import { TIssue } from "@plane/types"; import { EIssueActions } from "../../types"; // constants import { BaseListRoot } from "../base-list-root"; -import { EIssuesStoreType } from "constants/issue"; export const ListLayout: FC = observer(() => { const router = useRouter(); @@ -33,6 +33,11 @@ export const ListLayout: FC = observer(() => { await issues.removeIssue(workspaceSlug, projectId, issue.id); }, + [EIssueActions.ARCHIVE]: async (issue: TIssue) => { + if (!workspaceSlug || !projectId) return; + + await issues.archiveIssue(workspaceSlug, projectId, issue.id); + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [issues] diff --git a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx index dd384ba93..82ca03d42 100644 --- a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -2,21 +2,22 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // store +import { EIssuesStoreType } from "constants/issue"; import { useIssues } from "hooks/store"; // constants -import { EIssuesStoreType } from "constants/issue"; // types -import { EIssueActions } from "../../types"; import { TIssue } from "@plane/types"; +import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; +import { EIssueActions } from "../../types"; // components import { BaseListRoot } from "../base-list-root"; -import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; export interface IViewListLayout { issueActions: { [EIssueActions.DELETE]: (issue: TIssue) => Promise; [EIssueActions.UPDATE]?: (issue: TIssue) => Promise; [EIssueActions.REMOVE]?: (issue: TIssue) => Promise; + [EIssueActions.ARCHIVE]?: (issue: TIssue) => Promise; }; } diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 4d851545e..c3a6bc037 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -1,25 +1,32 @@ +import { useCallback, useMemo } from "react"; +import xor from "lodash/xor"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; // hooks -import { useEventTracker, useEstimate, useLabel } from "hooks/store"; -// components -import { IssuePropertyLabels } from "../properties/labels"; import { Tooltip } from "@plane/ui"; -import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; import { DateDropdown, EstimateDropdown, PriorityDropdown, - ProjectMemberDropdown, + MemberDropdown, + ModuleDropdown, + CycleDropdown, StateDropdown, } from "components/dropdowns"; -// helpers -import { renderFormattedPayloadDate } from "helpers/date-time.helper"; -// types -import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; -// constants import { ISSUE_UPDATED } from "constants/event-tracker"; +import { EIssuesStoreType } from "constants/issue"; +import { cn } from "helpers/common.helper"; +import { renderFormattedPayloadDate } from "helpers/date-time.helper"; +import { shouldHighlightIssueDueDate } from "helpers/issue.helper"; +import { useEventTracker, useEstimate, useLabel, useIssues, useProjectState } from "hooks/store"; +// components +import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types"; +import { IssuePropertyLabels } from "../properties/labels"; +import { WithDisplayPropertiesHOC } from "../properties/with-display-properties-HOC"; +// helpers +// types +// constants export interface IIssueProperties { issue: TIssue; @@ -35,10 +42,43 @@ export const IssueProperties: React.FC = observer((props) => { // store hooks const { labelMap } = useLabel(); const { captureIssueEvent } = useEventTracker(); + const { + issues: { addModulesToIssue, removeModulesFromIssue }, + } = useIssues(EIssuesStoreType.MODULE); + const { + issues: { addIssueToCycle, removeIssueFromCycle }, + } = useIssues(EIssuesStoreType.CYCLE); + const { areEstimatesEnabledForCurrentProject } = useEstimate(); + const { getStateById } = useProjectState(); // router const router = useRouter(); - const { areEstimatesEnabledForCurrentProject } = useEstimate(); + const { workspaceSlug } = router.query; const currentLayout = `${activeLayout} layout`; + // derived values + const stateDetails = getStateById(issue.state_id); + + const issueOperations = useMemo( + () => ({ + addModulesToIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await addModulesToIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + }, + removeModulesFromIssue: async (moduleIds: string[]) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await removeModulesFromIssue?.(workspaceSlug.toString(), issue.project_id, issue.id, moduleIds); + }, + addIssueToCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await addIssueToCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, [issue.id]); + }, + removeIssueFromCycle: async (cycleId: string) => { + if (!workspaceSlug || !issue.project_id || !issue.id) return; + await removeIssueFromCycle?.(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); + }, + }), + [workspaceSlug, issue, addModulesToIssue, removeModulesFromIssue, addIssueToCycle, removeIssueFromCycle] + ); + const handleState = (stateId: string) => { handleIssues({ ...issue, state_id: stateId }).then(() => { captureIssueEvent({ @@ -95,6 +135,45 @@ export const IssueProperties: React.FC = observer((props) => { }); }; + const handleModule = useCallback( + (moduleIds: string[] | null) => { + if (!issue || !issue.module_ids || !moduleIds) return; + + const updatedModuleIds = xor(issue.module_ids, moduleIds); + const modulesToAdd: string[] = []; + const modulesToRemove: string[] = []; + for (const moduleId of updatedModuleIds) + if (issue.module_ids.includes(moduleId)) modulesToRemove.push(moduleId); + else modulesToAdd.push(moduleId); + if (modulesToAdd.length > 0) issueOperations.addModulesToIssue(modulesToAdd); + if (modulesToRemove.length > 0) issueOperations.removeModulesFromIssue(modulesToRemove); + + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...issue, state: "SUCCESS", element: currentLayout }, + path: router.asPath, + updates: { changed_property: "module_ids", change_details: { module_ids: moduleIds } }, + }); + }, + [issueOperations, captureIssueEvent, currentLayout, router, issue] + ); + + const handleCycle = useCallback( + (cycleId: string | null) => { + if (!issue || issue.cycle_id === cycleId) return; + if (cycleId) issueOperations.addIssueToCycle?.(cycleId); + else issueOperations.removeIssueFromCycle?.(issue.cycle_id ?? ""); + + captureIssueEvent({ + eventName: ISSUE_UPDATED, + payload: { ...issue, state: "SUCCESS", element: currentLayout }, + path: router.asPath, + updates: { changed_property: "cycle", change_details: { cycle_id: cycleId } }, + }); + }, + [issue, issueOperations, captureIssueEvent, currentLayout, router.asPath] + ); + const handleStartDate = (date: Date | null) => { handleIssues({ ...issue, start_date: date ? renderFormattedPayloadDate(date) : null }).then(() => { captureIssueEvent({ @@ -137,6 +216,15 @@ export const IssueProperties: React.FC = observer((props) => { }); }; + const redirectToIssueDetail = () => { + router.push({ + pathname: `/${workspaceSlug}/projects/${issue.project_id}/${issue.archived_at ? "archived-issues" : "issues"}/${ + issue.id + }`, + hash: "sub-issues", + }); + }; + if (!displayProperties) return null; const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; @@ -152,7 +240,7 @@ export const IssueProperties: React.FC = observer((props) => { {/* basic properties */} {/* state */} -
+
= observer((props) => { } maxDate={maxDate ?? undefined} placeholder="Start date" + icon={} buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} disabled={isReadOnly} showTooltip @@ -212,10 +300,12 @@ export const IssueProperties: React.FC = observer((props) => { } minDate={minDate ?? undefined} placeholder="Due date" + icon={} buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""} + clearIconClassName="!text-custom-text-100" disabled={isReadOnly} showTooltip /> @@ -225,7 +315,7 @@ export const IssueProperties: React.FC = observer((props) => { {/* assignee */}
- = observer((props) => {
+ {/* modules */} + +
+ +
+
+ + {/* cycles */} + +
+ +
+
+ {/* estimates */} {areEstimatesEnabledForCurrentProject && ( @@ -258,10 +378,18 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.sub_issue_count && !!issue.sub_issues_count} > -
+
{}} + className={cn( + "flex h-5 flex-shrink-0 items-center justify-center gap-2 overflow-hidden rounded border-[0.5px] border-custom-border-300 px-2.5 py-1", + { + "hover:bg-custom-background-80 cursor-pointer": issue.sub_issues_count, + } + )} + >
{issue.sub_issues_count}
@@ -272,7 +400,7 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.attachment_count && !!issue.attachment_count} >
@@ -286,7 +414,7 @@ export const IssueProperties: React.FC = observer((props) => { !!properties.link && !!issue.link_count} >
diff --git a/web/components/issues/issue-layouts/properties/labels.tsx b/web/components/issues/issue-layouts/properties/labels.tsx index 7e14ad3da..a57c60d6f 100644 --- a/web/components/issues/issue-layouts/properties/labels.tsx +++ b/web/components/issues/issue-layouts/properties/labels.tsx @@ -1,15 +1,16 @@ -import { Fragment, useState } from "react"; +import { Fragment, useEffect, useRef, useState } from "react"; +import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { usePopper } from "react-popper"; +import { Combobox } from "@headlessui/react"; import { Check, ChevronDown, Search, Tags } from "lucide-react"; // hooks +import { Tooltip } from "@plane/ui"; import { useApplication, useLabel } from "hooks/store"; import { useDropdownKeyDown } from "hooks/use-dropdown-key-down"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // components -import { Combobox } from "@headlessui/react"; -import { Tooltip } from "@plane/ui"; // types -import { Placement } from "@popperjs/core"; import { IIssueLabel } from "@plane/types"; export interface IIssuePropertyLabels { @@ -48,10 +49,14 @@ export const IssuePropertyLabels: React.FC = observer((pro } = props; // states const [query, setQuery] = useState(""); + const [isOpen, setIsOpen] = useState(false); + // refs + const dropdownRef = useRef(null); + const inputRef = useRef(null); // popper-js refs const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(false); // store hooks const { router: { workspaceSlug }, @@ -60,18 +65,45 @@ export const IssuePropertyLabels: React.FC = observer((pro const storeLabels = getProjectLabels(projectId); - const openDropDown = () => { - if (!storeLabels && workspaceSlug && projectId) { - setIsLoading(true); + const onOpen = () => { + if (!storeLabels && workspaceSlug && projectId) fetchProjectLabels(workspaceSlug, projectId).then(() => setIsLoading(false)); - } }; const handleClose = () => { + if (!isOpen) return; + setIsOpen(false); onClose && onClose(); }; - const handleKeyDown = useDropdownKeyDown(openDropDown, handleClose, false); + const toggleDropdown = () => { + if (!isOpen) onOpen(); + setIsOpen((prevIsOpen) => !prevIsOpen); + if (isOpen) onClose && onClose(); + }; + + const handleKeyDown = useDropdownKeyDown(toggleDropdown, handleClose); + + const handleOnClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + toggleDropdown(); + }; + + useOutsideClickDetector(dropdownRef, handleClose); + + const searchInputKeyDown = (e: React.KeyboardEvent) => { + if (query !== "" && e.key === "Escape") { + e.stopPropagation(); + setQuery(""); + } + }; + + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement ?? "bottom-start", @@ -117,7 +149,7 @@ export const IssuePropertyLabels: React.FC = observer((pro {projectLabels ?.filter((l) => value.includes(l?.id)) .map((label) => ( - +
= observer((pro return ( = observer((pro ? "cursor-pointer" : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} - onClick={openDropDown} + onClick={handleOnClick} > {label} {!hideDropdownArrow && !disabled &&