diff --git a/.github/workflows/build-branch.yml b/.github/workflows/build-branch.yml index 44bae0efa..0d8d2af09 100644 --- a/.github/workflows/build-branch.yml +++ b/.github/workflows/build-branch.yml @@ -2,6 +2,27 @@ name: Branch Build on: workflow_dispatch: + inputs: + build-web: + required: false + description: "Build Web" + type: boolean + default: false + build-space: + required: false + description: "Build Space" + type: boolean + default: false + build-api: + required: false + description: "Build API" + type: boolean + default: false + build-proxy: + required: false + description: "Build Proxy" + type: boolean + default: false push: branches: - master @@ -18,7 +39,7 @@ jobs: name: Build-Push Web/Space/API/Proxy Docker Image runs-on: ubuntu-latest outputs: - gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} + gh_branch_name: ${{ steps.set_env_variables.outputs.TARGET_BRANCH }} gh_buildx_driver: ${{ steps.set_env_variables.outputs.BUILDX_DRIVER }} gh_buildx_version: ${{ steps.set_env_variables.outputs.BUILDX_VERSION }} gh_buildx_platforms: ${{ steps.set_env_variables.outputs.BUILDX_PLATFORMS }} @@ -74,7 +95,7 @@ jobs: - 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' }} + if: ${{ needs.branch_build_setup.outputs.build_frontend == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -126,7 +147,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' }} + if: ${{ needs.branch_build_setup.outputs.build_space == 'true' || github.event.inputs.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: @@ -178,7 +199,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' }} + if: ${{ needs.branch_build_setup.outputs.build_backend == 'true' || github.event.inputs.build-api=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -230,7 +251,7 @@ jobs: 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' }} + if: ${{ needs.branch_build_setup.outputs.build_proxy == 'true' || github.event.inputs.build-web=='true' || github.event_name == 'release' || needs.branch_build_setup.outputs.gh_branch_name == 'master' }} runs-on: ubuntu-20.04 needs: [branch_build_setup] env: @@ -280,4 +301,3 @@ jobs: DOCKER_BUILDKIT: 1 DOCKER_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKERHUB_TOKEN }} - diff --git a/.github/workflows/feature-deployment.yml b/.github/workflows/feature-deployment.yml index 7b9f5ffcc..c5eec3cd3 100644 --- a/.github/workflows/feature-deployment.yml +++ b/.github/workflows/feature-deployment.yml @@ -4,70 +4,196 @@ on: workflow_dispatch: inputs: web-build: - required: true + required: false + description: 'Build Web' type: boolean default: true space-build: - required: true + required: false + description: 'Build Space' type: boolean default: false +env: + BUILD_WEB: ${{ github.event.inputs.web-build }} + BUILD_SPACE: ${{ github.event.inputs.space-build }} + jobs: + setup-feature-build: + name: Feature Build Setup + runs-on: ubuntu-latest + steps: + - name: Checkout + run: | + echo "BUILD_WEB=$BUILD_WEB" + echo "BUILD_SPACE=$BUILD_SPACE" + outputs: + web-build: ${{ env.BUILD_WEB}} + space-build: ${{env.BUILD_SPACE}} + + feature-build-web: + if: ${{ needs.setup-feature-build.outputs.web-build == 'true' }} + needs: setup-feature-build + name: Feature Build Web + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Web + id: build-web + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=web + cd $GITHUB_WORKSPACE + + TAR_NAME="web.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + + feature-build-space: + if: ${{ needs.setup-feature-build.outputs.space-build == 'true' }} + needs: setup-feature-build + name: Feature Build Space + runs-on: ubuntu-latest + env: + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + NEXT_PUBLIC_DEPLOY_WITH_NGINX: 1 + NEXT_PUBLIC_API_BASE_URL: ${{ vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL }} + outputs: + do-build: ${{ needs.setup-feature-build.outputs.space-build }} + s3-url: ${{ steps.build-space.outputs.S3_PRESIGNED_URL }} + steps: + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli + - name: Checkout + uses: actions/checkout@v4 + with: + path: plane + - name: Install Dependencies + run: | + cd $GITHUB_WORKSPACE/plane + yarn install + - name: Build Space + id: build-space + run: | + cd $GITHUB_WORKSPACE/plane + yarn build --filter=space + cd $GITHUB_WORKSPACE + + TAR_NAME="space.tar.gz" + tar -czf $TAR_NAME ./plane + + FILE_EXPIRY=$(date -u -d "+2 days" +"%Y-%m-%dT%H:%M:%SZ") + aws s3 cp $TAR_NAME s3://${{ env.AWS_BUCKET }}/${{github.sha}}/$TAR_NAME --expires $FILE_EXPIRY + feature-deploy: + if: ${{ always() && (needs.setup-feature-build.outputs.web-build == 'true' || needs.setup-feature-build.outputs.space-build == 'true') }} + needs: [feature-build-web, feature-build-space] 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 }} - + AWS_ACCESS_KEY_ID: ${{ vars.FEATURE_PREVIEW_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.FEATURE_PREVIEW_AWS_SECRET_ACCESS_KEY }} + AWS_BUCKET: ${{ vars.FEATURE_PREVIEW_AWS_BUCKET }} + KUBE_CONFIG_FILE: ${{ secrets.FEATURE_PREVIEW_KUBE_CONFIG }} steps: + - name: Install AWS cli + run: | + sudo apt-get update + sudo apt-get install -y python3-pip + pip3 install awscli - 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" + curl -LO "https://dl.k8s.io/release/${{ vars.FEATURE_PREVIEW_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 }} + WEB_S3_URL="" + if [ ${{ env.BUILD_WEB }} == true ]; then + WEB_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/web.tar.gz --expires-in 3600) + fi - 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) + SPACE_S3_URL="" + if [ ${{ env.BUILD_SPACE }} == true ]; then + SPACE_S3_URL=$(aws s3 presign s3://${{ vars.FEATURE_PREVIEW_AWS_BUCKET }}/${{github.sha}}/space.tar.gz --expires-in 3600) + fi - APP_NAME=$(echo $METADATA | jq -r '.name') + if [ ${{ env.BUILD_WEB }} == true ] || [ ${{ env.BUILD_SPACE }} == true ]; then - 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') + helm --kube-insecure-skip-tls-verify repo add feature-preview ${{ vars.FEATURE_PREVIEW_HELM_CHART_URL }} - echo "****************************************" - echo "APP NAME ::: $APP_NAME" - echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" - echo "****************************************" + APP_NAMESPACE="${{ vars.FEATURE_PREVIEW_NAMESPACE }}" + DEPLOY_SCRIPT_URL="${{ vars.FEATURE_PREVIEW_DEPLOY_SCRIPT_URL }}" + + METADATA=$(helm --kube-insecure-skip-tls-verify install feature-preview/${{ vars.FEATURE_PREVIEW_HELM_CHART_NAME }} \ + --generate-name \ + --namespace $APP_NAMESPACE \ + --set ingress.primaryDomain=${{vars.FEATURE_PREVIEW_PRIMARY_DOMAIN || 'feature.plane.tools' }} \ + --set web.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set web.enabled=${{ env.BUILD_WEB || false }} \ + --set web.artifact_url=$WEB_S3_URL \ + --set space.image=${{vars.FEATURE_PREVIEW_DOCKER_BASE}} \ + --set space.enabled=${{ env.BUILD_SPACE || false }} \ + --set space.artifact_url=$SPACE_S3_URL \ + --set shared_config.deploy_script_url=$DEPLOY_SCRIPT_URL \ + --set shared_config.api_base_url=${{vars.FEATURE_PREVIEW_NEXT_PUBLIC_API_BASE_URL}} \ + --output json \ + --timeout 1000s) + + APP_NAME=$(echo $METADATA | jq -r '.name') + + INGRESS_HOSTNAME=$(kubectl get ingress -n feature-builds --insecure-skip-tls-verify \ + -o jsonpath='{.items[?(@.metadata.annotations.meta\.helm\.sh\/release-name=="'$APP_NAME'")]}' | \ + jq -r '.spec.rules[0].host') + + echo "****************************************" + echo "APP NAME ::: $APP_NAME" + echo "INGRESS HOSTNAME ::: $INGRESS_HOSTNAME" + echo "****************************************" + fi diff --git a/apiserver/.env.example b/apiserver/.env.example index 42b0e32e5..97dc4dda8 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -14,10 +14,6 @@ POSTGRES_HOST="plane-db" POSTGRES_DB="plane" DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}/${POSTGRES_DB} -# Oauth variables -GOOGLE_CLIENT_ID="" -GITHUB_CLIENT_ID="" -GITHUB_CLIENT_SECRET="" # Redis Settings REDIS_HOST="plane-redis" @@ -34,11 +30,6 @@ AWS_S3_BUCKET_NAME="uploads" # Maximum file upload limit FILE_SIZE_LIMIT=5242880 -# GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # deprecated -OPENAI_API_KEY="sk-" # deprecated -GPT_ENGINE="gpt-3.5-turbo" # deprecated - # Settings related to Docker DOCKERIZED=1 # deprecated @@ -48,16 +39,6 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 - -# SignUps -ENABLE_SIGNUP="1" - -# Enable Email/Password Signup -ENABLE_EMAIL_PASSWORD="1" - -# Enable Magic link Login -ENABLE_MAGIC_LINK_LOGIN="0" - # Email redirections and minio domain settings WEB_URL="http://localhost" diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index efea53f87..5a1da1570 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -21,11 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256 export MACHINE_SIGNATURE=$SIGNATURE # Register instance -python manage.py register_instance $MACHINE_SIGNATURE +python manage.py register_instance "$MACHINE_SIGNATURE" + # Load the configuration variable python manage.py configure_instance # Create the default bucket python manage.py create_bucket -exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile - +# Clear Cache before starting to remove stale values +python manage.py clear_cache + +exec gunicorn -w "$GUNICORN_WORKERS" -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:"${PORT:-8000}" --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/bin/takeoff.local b/apiserver/bin/takeoff.local index 8f62370ec..3194009b2 100755 --- a/apiserver/bin/takeoff.local +++ b/apiserver/bin/takeoff.local @@ -21,12 +21,15 @@ SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256 export MACHINE_SIGNATURE=$SIGNATURE # Register instance -python manage.py register_instance $MACHINE_SIGNATURE +python manage.py register_instance "$MACHINE_SIGNATURE" # Load the configuration variable python manage.py configure_instance # Create the default bucket python manage.py create_bucket +# Clear Cache before starting to remove stale values +python manage.py clear_cache + python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local diff --git a/apiserver/plane/app/serializers/module.py b/apiserver/plane/app/serializers/module.py index 3b2468aee..100b6314a 100644 --- a/apiserver/plane/app/serializers/module.py +++ b/apiserver/plane/app/serializers/module.py @@ -215,9 +215,10 @@ class ModuleSerializer(DynamicBaseSerializer): class ModuleDetailSerializer(ModuleSerializer): link_module = ModuleLinkSerializer(read_only=True, many=True) + sub_issues = serializers.IntegerField(read_only=True) class Meta(ModuleSerializer.Meta): - fields = ModuleSerializer.Meta.fields + ["link_module"] + fields = ModuleSerializer.Meta.fields + ["link_module", "sub_issues"] class ModuleFavoriteSerializer(BaseSerializer): diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index 6840fa8f7..a0c2318e3 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -102,6 +102,12 @@ class ProjectLiteSerializer(BaseSerializer): class ProjectListSerializer(DynamicBaseSerializer): + total_issues = serializers.IntegerField(read_only=True) + archived_issues = serializers.IntegerField(read_only=True) + archived_sub_issues = serializers.IntegerField(read_only=True) + draft_issues = serializers.IntegerField(read_only=True) + draft_sub_issues = serializers.IntegerField(read_only=True) + sub_issues = serializers.IntegerField(read_only=True) is_favorite = serializers.BooleanField(read_only=True) total_members = serializers.IntegerField(read_only=True) total_cycles = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 83cdcb4ef..36d949f7c 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -114,15 +114,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) ) .annotate(is_favorite=Exists(favorite_subquery)) - .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", @@ -209,7 +200,15 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def list(self, request, slug, project_id): - queryset = self.get_queryset() + queryset = self.get_queryset().annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) cycle_view = request.GET.get("cycle_view", "all") # Update the order by @@ -364,7 +363,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -410,7 +408,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -482,7 +479,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -495,10 +491,42 @@ class CycleViewSet(WebhookMixin, BaseViewSet): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().filter(pk=pk) + queryset = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) + ) data = ( self.get_queryset() .filter(pk=pk) + .annotate( + total_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=True, + issue_cycle__cycle_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=False, + issue_cycle__cycle_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) .values( # necessary fields "id", @@ -515,6 +543,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "external_source", "external_id", "progress_snapshot", + "sub_issues", # meta fields "is_favorite", "total_issues", diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 5569768c5..84d30640a 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -1,7 +1,6 @@ # Python imports import json -# Django Imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.db.models import ( @@ -16,6 +15,8 @@ from django.db.models import ( Value, ) from django.db.models.functions import Coalesce + +# Django Imports from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page @@ -102,15 +103,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): ), ) ) - .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", @@ -206,7 +198,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_id", # computed fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -248,7 +239,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_id", # computed fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -260,7 +250,30 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): return Response(modules, status=status.HTTP_200_OK) def retrieve(self, request, slug, project_id, pk): - queryset = self.get_queryset().filter(pk=pk) + queryset = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + total_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=True, + issue_module__module_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("project_id"), + parent__isnull=False, + issue_module__module_id=pk, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ) assignee_distribution = ( Issue.objects.filter( @@ -403,7 +416,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "external_id", # computed fields "is_favorite", - "total_issues", "cancelled_issues", "completed_issues", "started_issues", diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index b1d3d8e88..5dbc99a27 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -46,9 +46,11 @@ from plane.db.models import ( Inbox, ProjectDeployBoard, IssueProperty, + Issue, ) from plane.utils.cache import cache_response + class ProjectViewSet(WebhookMixin, BaseViewSet): serializer_class = ProjectListSerializer model = Project @@ -171,6 +173,73 @@ class ProjectViewSet(WebhookMixin, BaseViewSet): ).data return Response(projects, status=status.HTTP_200_OK) + def retrieve(self, request, slug, pk): + project = ( + self.get_queryset() + .filter(pk=pk) + .annotate( + total_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("pk"), + parent__isnull=True, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + sub_issues=Issue.issue_objects.filter( + project_id=self.kwargs.get("pk"), + parent__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + archived_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + archived_at__isnull=False, + parent__isnull=True, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + archived_sub_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + archived_at__isnull=False, + parent__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + draft_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + is_draft=True, + parent__isnull=True, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + .annotate( + draft_sub_issues=Issue.objects.filter( + project_id=self.kwargs.get("pk"), + is_draft=True, + parent__isnull=False, + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ).first() + + serializer = ProjectListSerializer(project) + return Response(serializer.data, status=status.HTTP_200_OK) + def create(self, request, slug): try: workspace = Workspace.objects.get(slug=slug) @@ -471,6 +540,7 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): permission_classes = [ AllowAny, ] + # Cache the below api for 24 hours @cache_response(60 * 60 * 24, user=False) def get(self, request): diff --git a/apiserver/plane/db/management/commands/clear_cache.py b/apiserver/plane/db/management/commands/clear_cache.py new file mode 100644 index 000000000..4dfbe6c10 --- /dev/null +++ b/apiserver/plane/db/management/commands/clear_cache.py @@ -0,0 +1,17 @@ +# Django imports +from django.core.cache import cache +from django.core.management import BaseCommand + + +class Command(BaseCommand): + help = "Clear Cache before starting the server to remove stale values" + + def handle(self, *args, **options): + try: + cache.clear() + self.stdout.write(self.style.SUCCESS("Cache Cleared")) + return + except Exception: + # Another ClientError occurred + self.stdout.write(self.style.ERROR("Failed to clear cache")) + return diff --git a/apiserver/plane/db/management/commands/test_email.py b/apiserver/plane/db/management/commands/test_email.py new file mode 100644 index 000000000..d36a784d0 --- /dev/null +++ b/apiserver/plane/db/management/commands/test_email.py @@ -0,0 +1,61 @@ +from django.core.mail import EmailMultiAlternatives, get_connection +from django.core.management import BaseCommand, CommandError + +from plane.license.utils.instance_value import get_email_configuration + + +class Command(BaseCommand): + """Django command to pause execution until db is available""" + + def add_arguments(self, parser): + # Positional argument + parser.add_argument("to_email", type=str, help="receiver's email") + + def handle(self, *args, **options): + receiver_email = options.get("to_email") + + if not receiver_email: + raise CommandError("Reciever email is required") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration() + + connection = get_connection( + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=EMAIL_USE_TLS == "1", + timeout=30, + ) + # Prepare email details + subject = "Email Notification from Plane" + message = ( + "This is a sample email notification sent from Plane application." + ) + + self.stdout.write(self.style.SUCCESS("Trying to send test email...")) + + # Send the email + try: + msg = EmailMultiAlternatives( + subject=subject, + body=message, + from_email=EMAIL_FROM, + to=[receiver_email], + connection=connection, + ) + msg.send() + self.stdout.write(self.style.SUCCESS("Email succesfully sent")) + except Exception as e: + self.stdout.write( + self.style.ERROR( + f"Error: Email could not be delivered due to {e}" + ) + ) diff --git a/apiserver/plane/utils/cache.py b/apiserver/plane/utils/cache.py index 6a89f66c8..aece1d644 100644 --- a/apiserver/plane/utils/cache.py +++ b/apiserver/plane/utils/cache.py @@ -1,8 +1,11 @@ -# from django.utils.encoding import force_bytes -# import hashlib +# Python imports from functools import wraps +# Django imports +from django.conf import settings from django.core.cache import cache + +# Third party imports from rest_framework.response import Response @@ -36,7 +39,7 @@ def cache_response(timeout=60 * 60, path=None, user=True): ) response = view_func(instance, request, *args, **kwargs) - if response.status_code == 200: + if response.status_code == 200 and not settings.DEBUG: cache.set( key, {"data": response.data, "status": response.status_code}, diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index fa503fe87..3eef1d720 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -145,7 +145,7 @@ class OffsetPaginator: results=results, next=next_cursor, prev=prev_cursor, - hits=None, + hits=count, max_hits=max_hits, ) @@ -197,9 +197,6 @@ class GroupedOffsetPaginator(OffsetPaginator): if offset < 0: raise BadPaginationError("Pagination offset cannot be negative") - # Get the queryset - queryset = self.queryset - # Compute the results results = {} queryset = queryset.annotate( @@ -211,7 +208,7 @@ class GroupedOffsetPaginator(OffsetPaginator): ) # Filter the results - results = queryset.filter(row_number__gte=offset, row_number__lt=stop) + results = queryset.filter(row_number__gt=offset, row_number__lt=stop) # Adjust cursors based on the grouped results for pagination next_cursor = Cursor( @@ -227,6 +224,8 @@ class GroupedOffsetPaginator(OffsetPaginator): page > 0, ) + count = queryset.count() + # Optionally, calculate the total count and max_hits if needed # This might require adjustments based on specific use cases max_hits = math.ceil( @@ -243,7 +242,7 @@ class GroupedOffsetPaginator(OffsetPaginator): results=results, next=next_cursor, prev=prev_cursor, - hits=None, + hits=count, max_hits=max_hits, ) @@ -417,6 +416,7 @@ class BasePaginator: response = Response( { "grouped_by": group_by_field_name, + "total_count": (cursor_result.hits), "next_cursor": str(cursor_result.next), "prev_cursor": str(cursor_result.prev), "next_page_results": cursor_result.next.has_results, diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app index e6bd24b9e..ace0a0b79 100644 --- a/deploy/1-click/plane-app +++ b/deploy/1-click/plane-app @@ -494,13 +494,6 @@ function install() { 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 "" @@ -606,11 +599,6 @@ 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 ✅" diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 07e5ea9f6..3af4b8449 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -1,18 +1,12 @@ version: "3.8" -x-app-env : &app-env +x-app-env: &app-env environment: - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated - - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated - SENTRY_DSN=${SENTRY_DSN:-""} - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""} - - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""} - - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - - DOCKERIZED=${DOCKERIZED:-1} # deprecated - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} @@ -28,20 +22,6 @@ x-app-env : &app-env - REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/} - # EMAIL SETTINGS - Deprecated can be configured through admin panel - - EMAIL_HOST=${EMAIL_HOST:-""} - - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} - - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} - - EMAIL_PORT=${EMAIL_PORT:-587} - - EMAIL_FROM=${EMAIL_FROM:-"Team Plane "} - - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} - - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel - - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} # Application secret - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS @@ -122,8 +102,8 @@ services: pull_policy: ${PULL_POLICY:-always} restart: no command: > - sh -c "python manage.py wait_for_db && - python manage.py migrate" + sh -c "python manage.py wait_for_db && + python manage.py migrate" depends_on: - plane-db - plane-redis @@ -159,7 +139,7 @@ services: image: ${DOCKERHUB_USER:-makeplane}/plane-proxy:${APP_RELEASE:-latest} pull_policy: ${PULL_POLICY:-always} ports: - - ${NGINX_PORT}:80 + - ${NGINX_PORT}:80 depends_on: - web - api diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 6d2cde0ff..9a755d012 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -7,13 +7,8 @@ API_REPLICAS=1 NGINX_PORT=80 WEB_URL=http://localhost DEBUG=0 -NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN= SENTRY_ENVIRONMENT=production -GOOGLE_CLIENT_ID= -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -DOCKERIZED=1 # deprecated CORS_ALLOWED_ORIGINS=http://localhost #DB SETTINGS @@ -30,19 +25,7 @@ REDIS_HOST=plane-redis REDIS_PORT=6379 REDIS_URL=redis://${REDIS_HOST}:6379/ -# EMAIL SETTINGS -EMAIL_HOST= -EMAIL_HOST_USER= -EMAIL_HOST_PASSWORD= -EMAIL_PORT=587 -EMAIL_FROM=Team Plane -EMAIL_USE_TLS=1 -EMAIL_USE_SSL=0 - -# LOGIN/SIGNUP SETTINGS -ENABLE_SIGNUP=1 -ENABLE_EMAIL_PASSWORD=1 -ENABLE_MAGIC_LINK_LOGIN=0 +# Secret Key SECRET_KEY=60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5 # DATA STORE SETTINGS diff --git a/packages/editor/core/src/lib/utils.ts b/packages/editor/core/src/lib/utils.ts index 5c7a8f08f..c943d4c60 100644 --- a/packages/editor/core/src/lib/utils.ts +++ b/packages/editor/core/src/lib/utils.ts @@ -1,3 +1,4 @@ +import { Selection } from "@tiptap/pm/state"; import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; interface EditorClassNames { @@ -18,6 +19,19 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +// Helper function to find the parent node of a specific type +export function findParentNodeOfType(selection: Selection, typeName: string) { + let depth = selection.$anchor.depth; + while (depth > 0) { + const node = selection.$anchor.node(depth); + if (node.type.name === typeName) { + return { node, pos: selection.$anchor.start(depth) - 1 }; + } + depth--; + } + return null; +} + export const findTableAncestor = (node: Node | null): HTMLTableElement | null => { while (node !== null && node.nodeName !== "TABLE") { node = node.parentNode; diff --git a/packages/editor/core/src/ui/components/editor-container.tsx b/packages/editor/core/src/ui/components/editor-container.tsx index 5480a51e9..1b2504b58 100644 --- a/packages/editor/core/src/ui/components/editor-container.tsx +++ b/packages/editor/core/src/ui/components/editor-container.tsx @@ -1,5 +1,5 @@ import { Editor } from "@tiptap/react"; -import { ReactNode } from "react"; +import { FC, ReactNode } from "react"; interface EditorContainerProps { editor: Editor | null; @@ -8,17 +8,54 @@ interface EditorContainerProps { hideDragHandle?: () => void; } -export const EditorContainer = ({ editor, editorClassNames, hideDragHandle, children }: EditorContainerProps) => ( -
{ - editor?.chain().focus(undefined, { scrollIntoView: false }).run(); - }} - onMouseLeave={() => { - hideDragHandle?.(); - }} - className={`cursor-text ${editorClassNames}`} - > - {children} -
-); +export const EditorContainer: FC = (props) => { + const { editor, editorClassNames, hideDragHandle, children } = props; + + const handleContainerClick = () => { + if (!editor) return; + if (!editor.isEditable) return; + if (editor.isFocused) return; // If editor is already focused, do nothing + + const { selection } = editor.state; + const currentNode = selection.$from.node(); + + editor?.chain().focus("end", { scrollIntoView: false }).run(); // Focus the editor at the end + + if ( + currentNode.content.size === 0 && // Check if the current node is empty + !( + editor.isActive("orderedList") || + editor.isActive("bulletList") || + editor.isActive("taskItem") || + editor.isActive("table") || + editor.isActive("blockquote") || + editor.isActive("codeBlock") + ) // Check if it's an empty node within an orderedList, bulletList, taskItem, table, quote or code block + ) { + return; + } + + // Insert a new paragraph at the end of the document + const endPosition = editor?.state.doc.content.size; + editor?.chain().insertContentAt(endPosition, { type: "paragraph" }).run(); + + // Focus the newly added paragraph for immediate editing + editor + .chain() + .setTextSelection(endPosition + 1) + .run(); + }; + + return ( +
{ + hideDragHandle?.(); + }} + className={`cursor-text ${editorClassNames}`} + > + {children} +
+ ); +}; diff --git a/packages/editor/core/src/ui/components/editor-content.tsx b/packages/editor/core/src/ui/components/editor-content.tsx index 9c0938788..7a6ce30f7 100644 --- a/packages/editor/core/src/ui/components/editor-content.tsx +++ b/packages/editor/core/src/ui/components/editor-content.tsx @@ -1,17 +1,28 @@ import { Editor, EditorContent } from "@tiptap/react"; -import { ReactNode } from "react"; +import { FC, ReactNode } from "react"; import { ImageResizer } from "src/ui/extensions/image/image-resize"; interface EditorContentProps { editor: Editor | null; editorContentCustomClassNames: string | undefined; children?: ReactNode; + tabIndex?: number; } -export const EditorContentWrapper = ({ editor, editorContentCustomClassNames = "", children }: EditorContentProps) => ( -
- - {editor?.isActive("image") && editor?.isEditable && } - {children} -
-); +export const EditorContentWrapper: FC = (props) => { + const { editor, editorContentCustomClassNames = "", tabIndex, children } = props; + + return ( +
{ + editor?.chain().focus(undefined, { scrollIntoView: false }).run(); + }} + > + + {editor?.isActive("image") && editor?.isEditable && } + {children} +
+ ); +}; diff --git a/packages/editor/core/src/ui/extensions/image/index.tsx b/packages/editor/core/src/ui/extensions/image/index.tsx index db8b1c97b..1431b7755 100644 --- a/packages/editor/core/src/ui/extensions/image/index.tsx +++ b/packages/editor/core/src/ui/extensions/image/index.tsx @@ -5,6 +5,8 @@ import ImageExt from "@tiptap/extension-image"; import { onNodeDeleted, onNodeRestored } from "src/ui/plugins/delete-image"; import { DeleteImage } from "src/types/delete-image"; import { RestoreImage } from "src/types/restore-image"; +import { insertLineBelowImageAction } from "./utilities/insert-line-below-image"; +import { insertLineAboveImageAction } from "./utilities/insert-line-above-image"; interface ImageNode extends ProseMirrorNode { attrs: { @@ -18,6 +20,12 @@ const IMAGE_NODE_TYPE = "image"; export const ImageExtension = (deleteImage: DeleteImage, restoreFile: RestoreImage, cancelUploadImage?: () => any) => ImageExt.extend({ + addKeyboardShortcuts() { + return { + ArrowDown: insertLineBelowImageAction, + ArrowUp: insertLineAboveImageAction, + }; + }, addProseMirrorPlugins() { return [ UploadImagesPlugin(cancelUploadImage), diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts new file mode 100644 index 000000000..a18576b46 --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-above-image.ts @@ -0,0 +1,45 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { KeyboardShortcutCommand } from "@tiptap/core"; + +export const insertLineAboveImageAction: KeyboardShortcutCommand = ({ editor }) => { + const { selection, doc } = editor.state; + const { $from, $to } = selection; + + let imageNode: ProseMirrorNode | null = null; + let imagePos: number | null = null; + + // Check if the selection itself is an image node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === "image") { + imageNode = node; + imagePos = pos; + return false; // Stop iterating once an image node is found + } + return true; + }); + + if (imageNode === null || imagePos === null) return false; + + // Since we want to insert above the image, we use the imagePos directly + const insertPos = imagePos; + + if (insertPos < 0) return false; + + // Check for an existing node immediately before the image + if (insertPos === 0) { + // If the previous node doesn't exist or isn't a paragraph, create and insert a new empty node there + editor.chain().insertContentAt(insertPos, { type: "paragraph" }).run(); + editor.chain().setTextSelection(insertPos).run(); + } else { + const prevNode = doc.nodeAt(insertPos); + + if (prevNode && prevNode.type.name === "paragraph") { + // If the previous node is a paragraph, move the cursor there + editor.chain().setTextSelection(insertPos).run(); + } else { + return false; + } + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts new file mode 100644 index 000000000..e998c728b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/image/utilities/insert-line-below-image.ts @@ -0,0 +1,46 @@ +import { Node as ProseMirrorNode } from "@tiptap/pm/model"; +import { KeyboardShortcutCommand } from "@tiptap/core"; + +export const insertLineBelowImageAction: KeyboardShortcutCommand = ({ editor }) => { + const { selection, doc } = editor.state; + const { $from, $to } = selection; + + let imageNode: ProseMirrorNode | null = null; + let imagePos: number | null = null; + + // Check if the selection itself is an image node + doc.nodesBetween($from.pos, $to.pos, (node, pos) => { + if (node.type.name === "image") { + imageNode = node; + imagePos = pos; + return false; // Stop iterating once an image node is found + } + return true; + }); + + if (imageNode === null || imagePos === null) return false; + + const guaranteedImageNode: ProseMirrorNode = imageNode; + const nextNodePos = imagePos + guaranteedImageNode.nodeSize; + + // Check for an existing node immediately after the image + const nextNode = doc.nodeAt(nextNodePos); + + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is a paragraph, move the cursor there + const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there + editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(nextNodePos + 1) + .run(); + } else { + // If the next node is not a paragraph, do not proceed + return false; + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/index.tsx b/packages/editor/core/src/ui/extensions/index.tsx index 7da381e98..1a932d6d5 100644 --- a/packages/editor/core/src/ui/extensions/index.tsx +++ b/packages/editor/core/src/ui/extensions/index.tsx @@ -27,7 +27,7 @@ import { RestoreImage } from "src/types/restore-image"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; import { CustomTypographyExtension } from "src/ui/extensions/typography"; -import { CustomHorizontalRule } from "./horizontal-rule/horizontal-rule"; +import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; export const CoreEditorExtensions = ( mentionConfig: { @@ -66,7 +66,6 @@ export const CoreEditorExtensions = ( CustomQuoteExtension.configure({ HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, }), - CustomHorizontalRule.configure({ HTMLAttributes: { class: "mt-4 mb-4" }, }), 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 ef595eee2..5fd06caf6 100644 --- a/packages/editor/core/src/ui/extensions/table/table/table.ts +++ b/packages/editor/core/src/ui/extensions/table/table/table.ts @@ -25,6 +25,8 @@ import { tableControls } from "src/ui/extensions/table/table/table-controls"; import { TableView } from "src/ui/extensions/table/table/table-view"; import { createTable } from "src/ui/extensions/table/table/utilities/create-table"; import { deleteTableWhenAllCellsSelected } from "src/ui/extensions/table/table/utilities/delete-table-when-all-cells-selected"; +import { insertLineBelowTableAction } from "./utilities/insert-line-below-table-action"; +import { insertLineAboveTableAction } from "./utilities/insert-line-above-table-action"; export interface TableOptions { HTMLAttributes: Record; @@ -231,6 +233,8 @@ export const Table = Node.create({ "Mod-Backspace": deleteTableWhenAllCellsSelected, Delete: deleteTableWhenAllCellsSelected, "Mod-Delete": deleteTableWhenAllCellsSelected, + ArrowDown: insertLineBelowTableAction, + ArrowUp: insertLineAboveTableAction, }; }, diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts new file mode 100644 index 000000000..d61d21c5b --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-above-table-action.ts @@ -0,0 +1,50 @@ +import { KeyboardShortcutCommand } from "@tiptap/core"; +import { findParentNodeOfType } from "src/lib/utils"; + +export const insertLineAboveTableAction: KeyboardShortcutCommand = ({ editor }) => { + // Check if the current selection or the closest node is a table + if (!editor.isActive("table")) return false; + + // Get the current selection + const { selection } = editor.state; + + // Find the table node and its position + const tableNode = findParentNodeOfType(selection, "table"); + if (!tableNode) return false; + + const tablePos = tableNode.pos; + + // Determine if the selection is in the first row of the table + const firstRow = tableNode.node.child(0); + const selectionPath = (selection.$anchor as any).path; + const selectionInFirstRow = selectionPath.includes(firstRow); + + if (!selectionInFirstRow) return false; + + // Check if the table is at the very start of the document or its parent node + if (tablePos === 0) { + // The table is at the start, so just insert a paragraph at the current position + editor.chain().insertContentAt(tablePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(tablePos + 1) + .run(); + } else { + // The table is not at the start, check for the node immediately before the table + const prevNodePos = tablePos - 1; + + if (prevNodePos <= 0) return false; + + const prevNode = editor.state.doc.nodeAt(prevNodePos - 1); + + if (prevNode && prevNode.type.name === "paragraph") { + // If there's a paragraph before the table, move the cursor to the end of that paragraph + const endOfParagraphPos = tablePos - prevNode.nodeSize; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else { + return false; + } + } + + return true; +}; diff --git a/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts new file mode 100644 index 000000000..28b46084a --- /dev/null +++ b/packages/editor/core/src/ui/extensions/table/table/utilities/insert-line-below-table-action.ts @@ -0,0 +1,48 @@ +import { KeyboardShortcutCommand } from "@tiptap/core"; +import { findParentNodeOfType } from "src/lib/utils"; + +export const insertLineBelowTableAction: KeyboardShortcutCommand = ({ editor }) => { + // Check if the current selection or the closest node is a table + if (!editor.isActive("table")) return false; + + // Get the current selection + const { selection } = editor.state; + + // Find the table node and its position + const tableNode = findParentNodeOfType(selection, "table"); + if (!tableNode) return false; + + const tablePos = tableNode.pos; + const table = tableNode.node; + + // Determine if the selection is in the last row of the table + const rowCount = table.childCount; + const lastRow = table.child(rowCount - 1); + const selectionPath = (selection.$anchor as any).path; + const selectionInLastRow = selectionPath.includes(lastRow); + + if (!selectionInLastRow) return false; + + // Calculate the position immediately after the table + const nextNodePos = tablePos + table.nodeSize; + + // Check for an existing node immediately after the table + const nextNode = editor.state.doc.nodeAt(nextNodePos); + + if (nextNode && nextNode.type.name === "paragraph") { + // If the next node is an paragraph, move the cursor there + const endOfParagraphPos = nextNodePos + nextNode.nodeSize - 1; + editor.chain().setTextSelection(endOfParagraphPos).run(); + } else if (!nextNode) { + // If the next node doesn't exist i.e. we're at the end of the document, create and insert a new empty node there + editor.chain().insertContentAt(nextNodePos, { type: "paragraph" }).run(); + editor + .chain() + .setTextSelection(nextNodePos + 1) + .run(); + } else { + return false; + } + + return true; +}; diff --git a/packages/editor/core/src/ui/read-only/extensions.tsx b/packages/editor/core/src/ui/read-only/extensions.tsx index cf7c4ee18..93e1b3887 100644 --- a/packages/editor/core/src/ui/read-only/extensions.tsx +++ b/packages/editor/core/src/ui/read-only/extensions.tsx @@ -5,7 +5,6 @@ import { Color } from "@tiptap/extension-color"; import TaskItem from "@tiptap/extension-task-item"; import TaskList from "@tiptap/extension-task-list"; import { Markdown } from "tiptap-markdown"; -import Gapcursor from "@tiptap/extension-gapcursor"; import { TableHeader } from "src/ui/extensions/table/table-header/table-header"; import { Table } from "src/ui/extensions/table/table"; @@ -17,6 +16,11 @@ import { isValidHttpUrl } from "src/lib/utils"; import { Mentions } from "src/ui/mentions"; import { IMentionSuggestion } from "src/types/mention-suggestion"; import { CustomLinkExtension } from "src/ui/extensions/custom-link"; +import { CustomHorizontalRule } from "src/ui/extensions/horizontal-rule/horizontal-rule"; +import { CustomQuoteExtension } from "src/ui/extensions/quote"; +import { CustomTypographyExtension } from "src/ui/extensions/typography"; +import { CustomCodeBlockExtension } from "src/ui/extensions/code"; +import { CustomCodeInlineExtension } from "src/ui/extensions/code-inline"; export const CoreReadOnlyEditorExtensions = (mentionConfig: { mentionSuggestions: IMentionSuggestion[]; @@ -38,36 +42,31 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { class: "leading-normal -mb-2", }, }, - blockquote: { - HTMLAttributes: { - class: "border-l-4 border-custom-border-300", - }, - }, - code: { - HTMLAttributes: { - class: "rounded-md bg-custom-primary-30 mx-1 px-1 py-1 font-mono font-medium text-custom-text-1000", - spellcheck: "false", - }, - }, + code: false, codeBlock: false, - horizontalRule: { - HTMLAttributes: { class: "mt-4 mb-4" }, - }, - dropcursor: { - color: "rgba(var(--color-text-100))", - width: 2, - }, + horizontalRule: false, + blockquote: false, + dropcursor: false, gapcursor: false, }), - Gapcursor, + CustomQuoteExtension.configure({ + HTMLAttributes: { className: "border-l-4 border-custom-border-300" }, + }), + CustomHorizontalRule.configure({ + HTMLAttributes: { class: "mt-4 mb-4" }, + }), CustomLinkExtension.configure({ + openOnClick: true, + autolink: true, + linkOnPaste: true, protocols: ["http", "https"], - validate: (url) => isValidHttpUrl(url), + validate: (url: string) => isValidHttpUrl(url), HTMLAttributes: { class: "text-custom-primary-300 underline underline-offset-[3px] hover:text-custom-primary-500 transition-colors cursor-pointer", }, }), + CustomTypographyExtension, ReadOnlyImageExtension.configure({ HTMLAttributes: { class: "rounded-lg border border-custom-border-300", @@ -87,6 +86,8 @@ export const CoreReadOnlyEditorExtensions = (mentionConfig: { }, nested: true, }), + CustomCodeBlockExtension, + CustomCodeInlineExtension, Markdown.configure({ html: true, transformCopiedText: true, 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 be70067a2..926d9a53d 100644 --- a/packages/editor/document-editor/src/ui/components/content-browser.tsx +++ b/packages/editor/document-editor/src/ui/components/content-browser.tsx @@ -19,7 +19,7 @@ export const ContentBrowser = (props: ContentBrowserProps) => { return (
-

Table of Contents

+

Outline

{markings.length !== 0 ? ( markings.map((marking) => diff --git a/packages/editor/document-editor/src/ui/components/page-renderer.tsx b/packages/editor/document-editor/src/ui/components/page-renderer.tsx index 06b9e70ff..7c2717e80 100644 --- a/packages/editor/document-editor/src/ui/components/page-renderer.tsx +++ b/packages/editor/document-editor/src/ui/components/page-renderer.tsx @@ -29,11 +29,13 @@ type IPageRenderer = { editorContentCustomClassNames?: string; hideDragHandle?: () => void; readonly: boolean; + tabIndex?: number; }; export const PageRenderer = (props: IPageRenderer) => { const { documentDetails, + tabIndex, editor, editorClassNames, editorContentCustomClassNames, @@ -152,7 +154,7 @@ export const PageRenderer = (props: IPageRenderer) => { ); return ( -
+
{!readonly ? ( handlePageTitleChange(e.target.value)} @@ -169,7 +171,11 @@ export const PageRenderer = (props: IPageRenderer) => { )}
- +
{isOpen && linkViewProps && coordinates && ( diff --git a/packages/editor/document-editor/src/ui/index.tsx b/packages/editor/document-editor/src/ui/index.tsx index e9f6d884b..eb54a204b 100644 --- a/packages/editor/document-editor/src/ui/index.tsx +++ b/packages/editor/document-editor/src/ui/index.tsx @@ -47,6 +47,8 @@ interface IDocumentEditor { duplicationConfig?: IDuplicationConfig; pageLockConfig?: IPageLockConfig; pageArchiveConfig?: IPageArchiveConfig; + + tabIndex?: number; } interface DocumentEditorProps extends IDocumentEditor { forwardedRef?: React.Ref; @@ -79,6 +81,7 @@ const DocumentEditor = ({ cancelUploadImage, onActionCompleteHandler, rerenderOnPropsChange, + tabIndex, }: IDocumentEditor) => { const { markings, updateMarkings } = useEditorMarkings(); const [sidePeekVisible, setSidePeekVisible] = useState(true); @@ -160,6 +163,7 @@ const DocumentEditor = ({
void; + tabIndex?: number; } interface DocumentReadOnlyEditorProps extends IDocumentReadOnlyEditor { @@ -51,6 +52,7 @@ const DocumentReadOnlyEditor = ({ pageArchiveConfig, rerenderOnPropsChange, onActionCompleteHandler, + tabIndex, }: DocumentReadOnlyEditorProps) => { const router = useRouter(); const [sidePeekVisible, setSidePeekVisible] = useState(true); @@ -108,9 +110,10 @@ const DocumentReadOnlyEditor = ({
Promise.resolve()} - readonly={true} + readonly editor={editor} editorClassNames={editorClassNames} documentDetails={documentDetails} diff --git a/packages/editor/lite-text-editor/src/ui/index.tsx b/packages/editor/lite-text-editor/src/ui/index.tsx index 57774ab5d..7986e0c6b 100644 --- a/packages/editor/lite-text-editor/src/ui/index.tsx +++ b/packages/editor/lite-text-editor/src/ui/index.tsx @@ -42,6 +42,7 @@ interface ILiteTextEditor { mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; submitButton?: React.ReactNode; + tabIndex?: number; } interface LiteTextEditorProps extends ILiteTextEditor { @@ -74,6 +75,7 @@ const LiteTextEditor = (props: LiteTextEditorProps) => { mentionHighlights, mentionSuggestions, submitButton, + tabIndex, } = props; const editor = useEditor({ @@ -103,7 +105,11 @@ const LiteTextEditor = (props: LiteTextEditorProps) => { return (
- +
{ const editor = useReadOnlyEditor({ value, @@ -45,7 +47,11 @@ const LiteReadOnlyEditor = ({ return (
- +
); diff --git a/packages/editor/rich-text-editor/src/ui/index.tsx b/packages/editor/rich-text-editor/src/ui/index.tsx index 2aff5d265..366fa471f 100644 --- a/packages/editor/rich-text-editor/src/ui/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/index.tsx @@ -36,6 +36,7 @@ export type IRichTextEditor = { debouncedUpdatesEnabled?: boolean; mentionHighlights?: string[]; mentionSuggestions?: IMentionSuggestion[]; + tabIndex?: number; }; export interface RichTextEditorProps extends IRichTextEditor { @@ -68,6 +69,7 @@ const RichTextEditor = ({ mentionHighlights, rerenderOnPropsChange, mentionSuggestions, + tabIndex, }: RichTextEditorProps) => { const [hideDragHandleOnMouseLeave, setHideDragHandleOnMouseLeave] = React.useState<() => void>(() => {}); @@ -100,17 +102,21 @@ const RichTextEditor = ({ customClassName, }); - React.useEffect(() => { - if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); - }, [editor, initialValue]); - + // React.useEffect(() => { + // if (editor && initialValue && editor.getHTML() != initialValue) editor.commands.setContent(initialValue); + // }, [editor, initialValue]); + // if (!editor) return null; return ( {editor && }
- +
); diff --git a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx index 2e7dd25b8..f96e7293e 100644 --- a/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx +++ b/packages/editor/rich-text-editor/src/ui/menus/bubble-menu/index.tsx @@ -121,7 +121,10 @@ export const EditorBubbleMenu: FC = (props: any) => { +

+
+
+ + + ); + } const endDate = new Date(activeCycle.end_date ?? ""); const startDate = new Date(activeCycle.start_date ?? ""); + const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0; + const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; const groupedIssues: any = { backlog: activeCycle.backlog_issues, @@ -94,8 +134,6 @@ export const ActiveCycleDetails: React.FC = observer((props cancelled: activeCycle.cancelled_issues, }; - const cycleStatus = activeCycle.status.toLowerCase() as TCycleGroups; - const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -148,8 +186,6 @@ export const ActiveCycleDetails: React.FC = observer((props color: group.color, })); - const daysLeft = findHowManyDaysLeft(activeCycle.end_date) ?? 0; - return (
@@ -203,27 +239,15 @@ export const ActiveCycleDetails: React.FC = observer((props
- {cycleOwnerDetails?.avatar && cycleOwnerDetails?.avatar !== "" ? ( - {cycleOwnerDetails?.display_name} - ) : ( - - {cycleOwnerDetails?.display_name.charAt(0)} - - )} + {cycleOwnerDetails?.display_name}
{activeCycle.assignee_ids.length > 0 && (
- {activeCycle.assignee_ids.map((assigne_id) => { - const member = getUserDetails(assigne_id); + {activeCycle.assignee_ids.map((assignee_id) => { + const member = getUserDetails(assignee_id); return ; })} @@ -233,7 +257,7 @@ export const ActiveCycleDetails: React.FC = observer((props
- + {activeCycle.total_issues} issues
@@ -244,9 +268,9 @@ export const ActiveCycleDetails: React.FC = observer((props - View Cycle + View cycle
@@ -287,11 +311,11 @@ export const ActiveCycleDetails: React.FC = observer((props
-
High Priority Issues
+
High priority issues
{activeCycleIssues ? ( activeCycleIssues.length > 0 ? ( - activeCycleIssues.map((issue: any) => ( + activeCycleIssues.map((issue) => ( = observer((props
{}} - projectId={projectId?.toString() ?? ""} + projectId={projectId} disabled buttonVariant="background-with-text" /> @@ -359,10 +383,10 @@ export const ActiveCycleDetails: React.FC = observer((props
- + - Pending Issues -{" "} + Pending issues-{" "} {activeCycle.total_issues - (activeCycle.completed_issues + activeCycle.cancelled_issues)}
diff --git a/web/components/cycles/active-cycle-stats.tsx b/web/components/cycles/active-cycle/stats.tsx similarity index 98% rename from web/components/cycles/active-cycle-stats.tsx rename to web/components/cycles/active-cycle/stats.tsx index 0cf7449ae..9ccd11077 100644 --- a/web/components/cycles/active-cycle-stats.tsx +++ b/web/components/cycles/active-cycle/stats.tsx @@ -134,7 +134,7 @@ export const ActiveCycleProgressStats: React.FC = ({ cycle }) => { ) : (
- There are no high priority issues present in this cycle. + There are no issues present in this cycle.
)} diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx new file mode 100644 index 000000000..af2b02726 --- /dev/null +++ b/web/components/cycles/active-cycle/upcoming-cycles-list-item.tsx @@ -0,0 +1,135 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react"; +import { Star, User2 } from "lucide-react"; +// hooks +import { useCycle, useEventTracker, useMember } from "hooks/store"; +// components +import { CycleQuickActions } from "components/cycles"; +// ui +import { Avatar, AvatarGroup, setPromiseToast } from "@plane/ui"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +// constants +import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "constants/event-tracker"; + +type Props = { + cycleId: string; +}; + +export const UpcomingCycleListItem: React.FC = observer((props) => { + const { cycleId } = props; + // router + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + // store hooks + const { captureEvent } = useEventTracker(); + const { addCycleToFavorites, getCycleById, removeCycleFromFavorites } = useCycle(); + const { getUserDetails } = useMember(); + // derived values + const cycle = getCycleById(cycleId); + + const handleAddToFavorites = (e: React.MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then( + () => { + captureEvent(CYCLE_FAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + } + ); + + setPromiseToast(addToFavoritePromise, { + loading: "Adding cycle to favorites...", + success: { + title: "Success!", + message: () => "Cycle added to favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't add the cycle to favorites. Please try again.", + }, + }); + }; + + const handleRemoveFromFavorites = (e: React.MouseEvent) => { + e.preventDefault(); + if (!workspaceSlug || !projectId) return; + + const removeFromFavoritePromise = removeCycleFromFavorites( + workspaceSlug?.toString(), + projectId.toString(), + cycleId + ).then(() => { + captureEvent(CYCLE_UNFAVORITED, { + cycle_id: cycleId, + element: "List layout", + state: "SUCCESS", + }); + }); + + setPromiseToast(removeFromFavoritePromise, { + loading: "Removing cycle from favorites...", + success: { + title: "Success!", + message: () => "Cycle removed from favorites.", + }, + error: { + title: "Error!", + message: () => "Couldn't remove the cycle from favorites. Please try again.", + }, + }); + }; + + if (!cycle) return null; + + return ( + +
{cycle.name}
+
+ {cycle.start_date && cycle.end_date && ( +
+ {renderFormattedDate(cycle.start_date)} - {renderFormattedDate(cycle.end_date)} +
+ )} + {cycle.assignee_ids?.length > 0 ? ( + + {cycle.assignee_ids?.map((assigneeId) => { + const member = getUserDetails(assigneeId); + return ; + })} + + ) : ( + + + + )} + + {cycle.is_favorite ? ( + + ) : ( + + )} + + {workspaceSlug && projectId && ( + + )} +
+ + ); +}); diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx new file mode 100644 index 000000000..60fa9bb30 --- /dev/null +++ b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx @@ -0,0 +1,25 @@ +import { observer } from "mobx-react"; +// hooks +import { useCycle } from "hooks/store"; +// components +import { UpcomingCycleListItem } from "components/cycles"; + +export const UpcomingCyclesList = observer(() => { + // store hooks + const { currentProjectUpcomingCycleIds } = useCycle(); + + if (!currentProjectUpcomingCycleIds) return null; + + return ( +
+
+ Upcoming cycles +
+
+ {currentProjectUpcomingCycleIds.map((cycleId) => ( + + ))} +
+
+ ); +}); diff --git a/web/components/cycles/applied-filters/date.tsx b/web/components/cycles/applied-filters/date.tsx new file mode 100644 index 000000000..0298f12d2 --- /dev/null +++ b/web/components/cycles/applied-filters/date.tsx @@ -0,0 +1,55 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// helpers +import { renderFormattedDate } from "helpers/date-time.helper"; +import { capitalizeFirstLetter } from "helpers/string.helper"; +// constants +import { DATE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + editable: boolean | undefined; + handleRemove: (val: string) => void; + values: string[]; +}; + +export const AppliedDateFilters: React.FC = observer((props) => { + const { editable, handleRemove, values } = props; + + const getDateLabel = (value: string): string => { + let dateLabel = ""; + + const dateDetails = DATE_FILTER_OPTIONS.find((d) => d.value === value); + + if (dateDetails) dateLabel = dateDetails.name; + else { + const dateParts = value.split(";"); + + if (dateParts.length === 2) { + const [date, time] = dateParts; + + dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`; + } + } + + return dateLabel; + }; + + return ( + <> + {values.map((date) => ( +
+ {getDateLabel(date)} + {editable && ( + + )} +
+ ))} + + ); +}); diff --git a/web/components/cycles/applied-filters/index.ts b/web/components/cycles/applied-filters/index.ts new file mode 100644 index 000000000..cee9ae349 --- /dev/null +++ b/web/components/cycles/applied-filters/index.ts @@ -0,0 +1,3 @@ +export * from "./date"; +export * from "./root"; +export * from "./status"; diff --git a/web/components/cycles/applied-filters/root.tsx b/web/components/cycles/applied-filters/root.tsx new file mode 100644 index 000000000..39d2ae827 --- /dev/null +++ b/web/components/cycles/applied-filters/root.tsx @@ -0,0 +1,90 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +// hooks +import { useUser } from "hooks/store"; +// components +import { AppliedDateFilters, AppliedStatusFilters } from "components/cycles"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +// types +import { TCycleFilters } from "@plane/types"; +// constants +import { EUserProjectRoles } from "constants/project"; + +type Props = { + appliedFilters: TCycleFilters; + handleClearAllFilters: () => void; + handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void; + alwaysAllowEditing?: boolean; +}; + +const DATE_FILTERS = ["start_date", "end_date"]; + +export const CycleAppliedFiltersList: React.FC = observer((props) => { + const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props; + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + + if (!appliedFilters) return null; + + if (Object.keys(appliedFilters).length === 0) return null; + + const isEditingAllowed = alwaysAllowEditing || (currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER); + + return ( +
+ {Object.entries(appliedFilters).map(([key, value]) => { + const filterKey = key as keyof TCycleFilters; + + if (!value) return; + if (Array.isArray(value) && value.length === 0) return; + + return ( +
+ {replaceUnderscoreIfSnakeCase(filterKey)} +
+ {filterKey === "status" && ( + handleRemoveFilter("status", val)} + values={value} + /> + )} + {DATE_FILTERS.includes(filterKey) && ( + handleRemoveFilter(filterKey, val)} + values={value} + /> + )} + {isEditingAllowed && ( + + )} +
+
+ ); + })} + {isEditingAllowed && ( + + )} +
+ ); +}); diff --git a/web/components/cycles/applied-filters/status.tsx b/web/components/cycles/applied-filters/status.tsx new file mode 100644 index 000000000..1eb28db74 --- /dev/null +++ b/web/components/cycles/applied-filters/status.tsx @@ -0,0 +1,43 @@ +import { observer } from "mobx-react-lite"; +import { X } from "lucide-react"; +import { CYCLE_STATUS } from "constants/cycle"; +import { cn } from "helpers/common.helper"; + +type Props = { + handleRemove: (val: string) => void; + values: string[]; + editable: boolean | undefined; +}; + +export const AppliedStatusFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((status) => { + const statusDetails = CYCLE_STATUS.find((s) => s.value === status); + return ( +
+ {statusDetails?.title} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/cycles/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx similarity index 71% rename from web/components/cycles/cycles-board-card.tsx rename to web/components/cycles/board/cycles-board-card.tsx index da97f2d9d..ac95f790d 100644 --- a/web/components/cycles/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -1,22 +1,12 @@ -import { FC, MouseEvent, useState } from "react"; +import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks // 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"; +import { Info, Star } from "lucide-react"; +import { Avatar, AvatarGroup, Tooltip, LayersIcon, CycleGroupIcon, setPromiseToast } from "@plane/ui"; +import { CycleQuickActions } from "components/cycles"; // ui // icons // helpers @@ -24,7 +14,6 @@ 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 { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; //.types @@ -38,13 +27,10 @@ export interface ICyclesBoardCard { export const CyclesBoardCard: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; - // states - const [updateModal, setUpdateModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); // store - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); @@ -56,7 +42,6 @@ export const CyclesBoardCard: FC = observer((props) => { if (!cycleDetails) return null; const cycleStatus = cycleDetails.status.toLocaleLowerCase(); - const isCompleted = cycleStatus === "completed"; const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); const isDateValid = cycleDetails.start_date || cycleDetails.end_date; @@ -78,24 +63,10 @@ export const CyclesBoardCard: FC = observer((props) => { ? cycleTotalIssues === 0 ? "0 Issue" : cycleTotalIssues === cycleDetails.completed_issues - ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` - : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` + ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` + : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : "0 Issue"; - const handleCopyText = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "Cycle link copied to clipboard.", - }); - }); - }; - const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -152,20 +123,6 @@ export const CyclesBoardCard: FC = observer((props) => { }); }; - const handleEditCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page grid layout"); - setUpdateModal(true); - }; - - const handleDeleteCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page grid layout"); - setDeleteModal(true); - }; - const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -181,22 +138,6 @@ export const CyclesBoardCard: FC = observer((props) => { return (
- setUpdateModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> - - setDeleteModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> -
@@ -288,30 +229,8 @@ export const CyclesBoardCard: FC = observer((props) => { ))} - - {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - - - - - Delete cycle - - - - )} - - - - Copy cycle link - - - + +
diff --git a/web/components/cycles/board/cycles-board-map.tsx b/web/components/cycles/board/cycles-board-map.tsx new file mode 100644 index 000000000..4218c0d1c --- /dev/null +++ b/web/components/cycles/board/cycles-board-map.tsx @@ -0,0 +1,25 @@ +// components +import { CyclesBoardCard } from "components/cycles"; + +type Props = { + cycleIds: string[]; + peekCycle: string | undefined; + projectId: string; + workspaceSlug: string; +}; + +export const CyclesBoardMap: React.FC = (props) => { + const { cycleIds, peekCycle, projectId, workspaceSlug } = props; + + return ( +
+ {cycleIds.map((cycleId) => ( + + ))} +
+ ); +}; diff --git a/web/components/cycles/board/index.ts b/web/components/cycles/board/index.ts new file mode 100644 index 000000000..2e6933d99 --- /dev/null +++ b/web/components/cycles/board/index.ts @@ -0,0 +1,3 @@ +export * from "./cycles-board-card"; +export * from "./cycles-board-map"; +export * from "./root"; diff --git a/web/components/cycles/board/root.tsx b/web/components/cycles/board/root.tsx new file mode 100644 index 000000000..26154becf --- /dev/null +++ b/web/components/cycles/board/root.tsx @@ -0,0 +1,60 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import { Disclosure } from "@headlessui/react"; +import { ChevronRight } from "lucide-react"; +// components +import { CyclePeekOverview, CyclesBoardMap } from "components/cycles"; +// helpers +import { cn } from "helpers/common.helper"; + +export interface ICyclesBoard { + completedCycleIds: string[]; + cycleIds: string[]; + workspaceSlug: string; + projectId: string; + peekCycle: string | undefined; +} + +export const CyclesBoard: FC = observer((props) => { + const { completedCycleIds, cycleIds, workspaceSlug, projectId, peekCycle } = props; + + return ( +
+
+
+ + {completedCycleIds.length !== 0 && ( + + + {({ open }) => ( + <> + Completed cycles ({completedCycleIds.length}) + + + )} + + + + + + )} +
+ +
+
+ ); +}); diff --git a/web/components/cycles/cycles-board.tsx b/web/components/cycles/cycles-board.tsx deleted file mode 100644 index 278d55071..000000000 --- a/web/components/cycles/cycles-board.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { CyclePeekOverview, CyclesBoardCard } from "components/cycles"; -import { EmptyState } from "components/empty-state"; -// constants -import { EMPTY_STATE_DETAILS } from "constants/empty-state"; - -export interface ICyclesBoard { - cycleIds: string[]; - filter: string; - workspaceSlug: string; - projectId: string; - peekCycle: string | undefined; -} - -export const CyclesBoard: FC = observer((props) => { - const { cycleIds, filter, workspaceSlug, projectId, peekCycle } = props; - - return ( - <> - {cycleIds?.length > 0 ? ( -
-
-
- {cycleIds.map((cycleId) => ( - - ))} -
- -
-
- ) : ( - - )} - - ); -}); diff --git a/web/components/cycles/cycles-list.tsx b/web/components/cycles/cycles-list.tsx deleted file mode 100644 index f6ad64f99..000000000 --- a/web/components/cycles/cycles-list.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { FC } from "react"; -import { observer } from "mobx-react-lite"; -// components -import { CyclePeekOverview, CyclesListItem } from "components/cycles"; -import { EmptyState } from "components/empty-state"; -// ui -import { Loader } from "@plane/ui"; -// constants -import { EMPTY_STATE_DETAILS } from "constants/empty-state"; - -export interface ICyclesList { - cycleIds: string[]; - filter: string; - workspaceSlug: string; - projectId: string; -} - -export const CyclesList: FC = observer((props) => { - const { cycleIds, filter, workspaceSlug, projectId } = props; - - return ( - <> - {cycleIds ? ( - <> - {cycleIds.length > 0 ? ( -
-
-
- {cycleIds.map((cycleId) => ( - - ))} -
- -
-
- ) : ( - - )} - - ) : ( - - - - - - )} - - ); -}); diff --git a/web/components/cycles/cycles-view-header.tsx b/web/components/cycles/cycles-view-header.tsx new file mode 100644 index 000000000..b0feede0e --- /dev/null +++ b/web/components/cycles/cycles-view-header.tsx @@ -0,0 +1,164 @@ +import { useCallback, useRef, useState } from "react"; +import { observer } from "mobx-react"; +import { Tab } from "@headlessui/react"; +import { ListFilter, Search, X } from "lucide-react"; +// hooks +import { useCycleFilter } from "hooks/store"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +// components +import { CycleFiltersSelection } from "components/cycles"; +import { FiltersDropdown } from "components/issues"; +// ui +import { Tooltip } from "@plane/ui"; +// helpers +import { cn } from "helpers/common.helper"; +// types +import { TCycleFilters } from "@plane/types"; +// constants +import { CYCLE_TABS_LIST, CYCLE_VIEW_LAYOUTS } from "constants/cycle"; + +type Props = { + projectId: string; +}; + +export const CyclesViewHeader: React.FC = observer((props) => { + const { projectId } = props; + // states + const [isSearchOpen, setIsSearchOpen] = useState(false); + // refs + const inputRef = useRef(null); + // hooks + const { + currentProjectDisplayFilters, + currentProjectFilters, + searchQuery, + updateDisplayFilters, + updateFilters, + updateSearchQuery, + } = useCycleFilter(); + // outside click detector hook + useOutsideClickDetector(inputRef, () => { + if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false); + }); + + const handleFilters = useCallback( + (key: keyof TCycleFilters, value: string | string[]) => { + const newValues = currentProjectFilters?.[key] ?? []; + + if (Array.isArray(value)) + value.forEach((val) => { + if (!newValues.includes(val)) newValues.push(val); + }); + else { + if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); + else newValues.push(value); + } + + updateFilters(projectId, { [key]: newValues }); + }, + [currentProjectFilters, projectId, updateFilters] + ); + + const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (searchQuery && searchQuery.trim() !== "") updateSearchQuery(""); + else setIsSearchOpen(false); + } + }; + + return ( +
+ + {CYCLE_TABS_LIST.map((tab) => ( + + `border-b-2 p-4 text-sm font-medium outline-none ${ + selected ? "border-custom-primary-100 text-custom-primary-100" : "border-transparent" + }` + } + > + {tab.name} + + ))} + + {currentProjectDisplayFilters?.active_tab !== "active" && ( +
+ {!isSearchOpen && ( + + )} +
+ + updateSearchQuery(e.target.value)} + onKeyDown={handleInputKeyDown} + /> + {isSearchOpen && ( + + )} +
+ } title="Filters" placement="bottom-end"> + + +
+ {CYCLE_VIEW_LAYOUTS.map((layout) => ( + + + + ))} +
+
+ )} +
+ ); +}); diff --git a/web/components/cycles/cycles-view.tsx b/web/components/cycles/cycles-view.tsx index 745ca1bd3..447bd048c 100644 --- a/web/components/cycles/cycles-view.tsx +++ b/web/components/cycles/cycles-view.tsx @@ -1,43 +1,35 @@ import { FC } from "react"; +import Image from "next/image"; import { observer } from "mobx-react-lite"; // hooks +import { useCycle, useCycleFilter } from "hooks/store"; // components import { CyclesBoard, CyclesList, CyclesListGanttChartView } from "components/cycles"; -// ui components +// ui import { CycleModuleBoardLayout, CycleModuleListLayout, GanttLayoutLoader } from "components/ui"; -import { useCycle } from "hooks/store"; +// assets +import NameFilterImage from "public/empty-state/cycle/name-filter.svg"; +import AllFiltersImage from "public/empty-state/cycle/all-filters.svg"; // types -import { TCycleLayout, TCycleView } from "@plane/types"; +import { TCycleLayoutOptions } from "@plane/types"; export interface ICyclesView { - filter: TCycleView; - layout: TCycleLayout; + layout: TCycleLayoutOptions; workspaceSlug: string; projectId: string; peekCycle: string | undefined; } export const CyclesView: FC = observer((props) => { - const { filter, layout, workspaceSlug, projectId, peekCycle } = props; + const { layout, workspaceSlug, projectId, peekCycle } = props; // store hooks - const { - currentProjectCompletedCycleIds, - currentProjectDraftCycleIds, - currentProjectUpcomingCycleIds, - currentProjectCycleIds, - loader, - } = useCycle(); + const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader } = useCycle(); + const { searchQuery } = useCycleFilter(); + // derived values + const filteredCycleIds = getFilteredCycleIds(projectId); + const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId); - const cyclesList = - filter === "completed" - ? currentProjectCompletedCycleIds - : filter === "draft" - ? currentProjectDraftCycleIds - : filter === "upcoming" - ? currentProjectUpcomingCycleIds - : currentProjectCycleIds; - - if (loader || !cyclesList) + if (loader || !filteredCycleIds) return ( <> {layout === "list" && } @@ -46,23 +38,45 @@ export const CyclesView: FC = observer((props) => { ); + if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0) + return ( +
+
+ No matching cycles +
No matching cycles
+

+ {searchQuery.trim() === "" + ? "Remove the filters to see all cycles" + : "Remove the search criteria to see all cycles"} +

+
+
+ ); + return ( <> {layout === "list" && ( - + )} - {layout === "board" && ( )} - - {layout === "gantt" && } + {layout === "gantt" && } ); }); diff --git a/web/components/cycles/delete-modal.tsx b/web/components/cycles/delete-modal.tsx index fd7b1f356..0d1cc5921 100644 --- a/web/components/cycles/delete-modal.tsx +++ b/web/components/cycles/delete-modal.tsx @@ -103,7 +103,7 @@ export const CycleDeleteModal: React.FC = observer((props) => {
-
Delete Cycle
+
Delete cycle

@@ -118,8 +118,8 @@ export const CycleDeleteModal: React.FC = observer((props) => { Cancel -

diff --git a/web/components/cycles/dropdowns/filters/end-date.tsx b/web/components/cycles/dropdowns/filters/end-date.tsx new file mode 100644 index 000000000..10a401500 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/end-date.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterEndDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Due date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/filters/index.ts b/web/components/cycles/dropdowns/filters/index.ts new file mode 100644 index 000000000..3d097b6f0 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/index.ts @@ -0,0 +1,4 @@ +export * from "./end-date"; +export * from "./root"; +export * from "./start-date"; +export * from "./status"; diff --git a/web/components/cycles/dropdowns/filters/root.tsx b/web/components/cycles/dropdowns/filters/root.tsx new file mode 100644 index 000000000..d97fcad03 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/root.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { Search, X } from "lucide-react"; +// components +import { FilterEndDate, FilterStartDate, FilterStatus } from "components/cycles"; +// types +import { TCycleFilters, TCycleGroups } from "@plane/types"; + +type Props = { + filters: TCycleFilters; + handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void; +}; + +export const CycleFiltersSelection: React.FC = observer((props) => { + const { filters, handleFiltersUpdate } = props; + // states + const [filtersSearchQuery, setFiltersSearchQuery] = useState(""); + + return ( +
+
+
+ + setFiltersSearchQuery(e.target.value)} + autoFocus + /> + {filtersSearchQuery !== "" && ( + + )} +
+
+
+ {/* cycle status */} +
+ handleFiltersUpdate("status", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* start date */} +
+ handleFiltersUpdate("start_date", val)} + searchQuery={filtersSearchQuery} + /> +
+ + {/* end date */} +
+ handleFiltersUpdate("end_date", val)} + searchQuery={filtersSearchQuery} + /> +
+
+
+ ); +}); diff --git a/web/components/cycles/dropdowns/filters/start-date.tsx b/web/components/cycles/dropdowns/filters/start-date.tsx new file mode 100644 index 000000000..87def7e29 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/start-date.tsx @@ -0,0 +1,63 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; + +// components +import { DateFilterModal } from "components/core"; +import { FilterHeader, FilterOption } from "components/issues"; +// constants +import { DATE_FILTER_OPTIONS } from "constants/filters"; + +type Props = { + appliedFilters: string[] | null; + handleUpdate: (val: string | string[]) => void; + searchQuery: string; +}; + +export const FilterStartDate: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + + const [previewEnabled, setPreviewEnabled] = useState(true); + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + + const filteredOptions = DATE_FILTER_OPTIONS.filter((d) => d.name.toLowerCase().includes(searchQuery.toLowerCase())); + + return ( + <> + {isDateFilterModalOpen && ( + setIsDateFilterModalOpen(false)} + isOpen={isDateFilterModalOpen} + onSelect={(val) => handleUpdate(val)} + title="Start date" + /> + )} + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + <> + {filteredOptions.map((option) => ( + handleUpdate(option.value)} + title={option.name} + multiple + /> + ))} + setIsDateFilterModalOpen(true)} title="Custom" multiple /> + + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/filters/status.tsx b/web/components/cycles/dropdowns/filters/status.tsx new file mode 100644 index 000000000..79e53a5c8 --- /dev/null +++ b/web/components/cycles/dropdowns/filters/status.tsx @@ -0,0 +1,49 @@ +import React, { useState } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { FilterHeader, FilterOption } from "components/issues"; +// types +import { TCycleGroups } from "@plane/types"; +// constants +import { CYCLE_STATUS } from "constants/cycle"; + +type Props = { + appliedFilters: TCycleGroups[] | null; + handleUpdate: (val: string) => void; + searchQuery: string; +}; + +export const FilterStatus: React.FC = observer((props) => { + const { appliedFilters, handleUpdate, searchQuery } = props; + // states + const [previewEnabled, setPreviewEnabled] = useState(true); + + const appliedFiltersCount = appliedFilters?.length ?? 0; + const filteredOptions = CYCLE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase())); + + return ( + <> + 0 ? ` (${appliedFiltersCount})` : ""}`} + isPreviewEnabled={previewEnabled} + handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)} + /> + {previewEnabled && ( +
+ {filteredOptions.length > 0 ? ( + filteredOptions.map((status) => ( + handleUpdate(status.value)} + title={status.title} + /> + )) + ) : ( +

No matches found

+ )} +
+ )} + + ); +}); diff --git a/web/components/cycles/dropdowns/index.ts b/web/components/cycles/dropdowns/index.ts new file mode 100644 index 000000000..302e3a1a6 --- /dev/null +++ b/web/components/cycles/dropdowns/index.ts @@ -0,0 +1 @@ +export * from "./filters"; diff --git a/web/components/cycles/gantt-chart/cycles-list-layout.tsx b/web/components/cycles/gantt-chart/cycles-list-layout.tsx index 521273c51..094fbea7b 100644 --- a/web/components/cycles/gantt-chart/cycles-list-layout.tsx +++ b/web/components/cycles/gantt-chart/cycles-list-layout.tsx @@ -4,8 +4,7 @@ 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"; +import { useCycle } from "hooks/store"; // components // types import { ICycle } from "@plane/types"; @@ -22,9 +21,6 @@ export const CyclesListGanttChartView: FC = observer((props) => { const router = useRouter(); const { workspaceSlug } = router.query; // store hooks - const { - membership: { currentProjectRole }, - } = useUser(); const { getCycleById, updateCycleDetails } = useCycle(); const handleCycleUpdate = async (cycle: ICycle, data: IBlockUpdateData) => { @@ -52,9 +48,6 @@ export const CyclesListGanttChartView: FC = observer((props) => { return structuredBlocks; }; - const isAllowed = - currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - return (
= observer((props) => { enableBlockLeftResize={false} enableBlockRightResize={false} enableBlockMove={false} - enableReorder={isAllowed} + enableReorder={false} />
); diff --git a/web/components/cycles/index.ts b/web/components/cycles/index.ts index db5e9de9e..e37d266b7 100644 --- a/web/components/cycles/index.ts +++ b/web/components/cycles/index.ts @@ -1,17 +1,16 @@ -export * from "./cycles-view"; -export * from "./active-cycle-details"; -export * from "./active-cycle-stats"; +export * from "./active-cycle"; +export * from "./applied-filters"; +export * from "./board/"; +export * from "./dropdowns"; export * from "./gantt-chart"; +export * from "./list"; +export * from "./cycle-peek-overview"; +export * from "./cycles-view-header"; export * from "./cycles-view"; +export * from "./delete-modal"; export * from "./form"; export * from "./modal"; +export * from "./quick-actions"; export * from "./sidebar"; export * from "./transfer-issues-modal"; export * from "./transfer-issues"; -export * from "./cycles-list"; -export * from "./cycles-list-item"; -export * from "./cycles-board"; -export * from "./cycles-board-card"; -export * from "./delete-modal"; -export * from "./cycle-peek-overview"; -export * from "./cycles-list-item"; diff --git a/web/components/cycles/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx similarity index 71% rename from web/components/cycles/cycles-list-item.tsx rename to web/components/cycles/list/cycles-list-item.tsx index 9bf1866ff..90c6d5d02 100644 --- a/web/components/cycles/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -1,26 +1,14 @@ -import { FC, MouseEvent, useState } from "react"; +import { FC, MouseEvent } from "react"; import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks -import { Check, Info, LinkIcon, Pencil, Star, Trash2, User2 } from "lucide-react"; -import { - CustomMenu, - Tooltip, - CircularProgressIndicator, - CycleGroupIcon, - AvatarGroup, - Avatar, - TOAST_TYPE, - setToast, - setPromiseToast, -} from "@plane/ui"; -import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +import { Check, Info, Star, User2 } from "lucide-react"; +import { Tooltip, CircularProgressIndicator, CycleGroupIcon, AvatarGroup, Avatar, setPromiseToast } from "@plane/ui"; +import { CycleQuickActions } 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 @@ -29,6 +17,7 @@ import { useEventTracker, useCycle, useUser, useMember } from "hooks/store"; // constants // types import { TCycleGroups } from "@plane/types"; +import { EUserProjectRoles } from "constants/project"; type TCyclesListItem = { cycleId: string; @@ -42,33 +31,16 @@ type TCyclesListItem = { export const CyclesListItem: FC = observer((props) => { const { cycleId, workspaceSlug, projectId } = props; - // states - const [updateModal, setUpdateModal] = useState(false); - const [deleteModal, setDeleteModal] = useState(false); // router const router = useRouter(); // store hooks - const { setTrackElement, captureEvent } = useEventTracker(); + const { captureEvent } = useEventTracker(); const { membership: { currentProjectRole }, } = useUser(); const { getCycleById, addCycleToFavorites, removeCycleFromFavorites } = useCycle(); const { getUserDetails } = useMember(); - const handleCopyText = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; - - copyTextToClipboard(`${originURL}/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { - setToast({ - type: TOAST_TYPE.SUCCESS, - title: "Link Copied!", - message: "Cycle link copied to clipboard.", - }); - }); - }; - const handleAddToFavorites = (e: MouseEvent) => { e.preventDefault(); if (!workspaceSlug || !projectId) return; @@ -125,20 +97,6 @@ export const CyclesListItem: FC = observer((props) => { }); }; - const handleEditCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page list layout"); - setUpdateModal(true); - }; - - const handleDeleteCycle = (e: MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setTrackElement("Cycles page list layout"); - setDeleteModal(true); - }; - const openCycleOverview = (e: MouseEvent) => { const { query } = router; e.preventDefault(); @@ -161,7 +119,7 @@ export const CyclesListItem: FC = observer((props) => { const endDate = new Date(cycleDetails.end_date ?? ""); const startDate = new Date(cycleDetails.start_date ?? ""); - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const cycleTotalIssues = cycleDetails.backlog_issues + @@ -184,20 +142,6 @@ export const CyclesListItem: FC = observer((props) => { return ( <> - setUpdateModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - /> - setDeleteModal(false)} - workspaceSlug={workspaceSlug} - projectId={projectId} - />
@@ -246,7 +190,7 @@ export const CyclesListItem: FC = observer((props) => {
)}
-
+
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
@@ -256,8 +200,8 @@ export const CyclesListItem: FC = observer((props) => {
{cycleDetails.assignee_ids?.length > 0 ? ( - {cycleDetails.assignee_ids?.map((assigne_id) => { - const member = getUserDetails(assigne_id); + {cycleDetails.assignee_ids?.map((assignee_id) => { + const member = getUserDetails(assignee_id); return ; })} @@ -281,30 +225,7 @@ export const CyclesListItem: FC = observer((props) => { )} - - {!isCompleted && isEditingAllowed && ( - <> - - - - Edit cycle - - - - - - Delete cycle - - - - )} - - - - Copy cycle link - - - + )}
diff --git a/web/components/cycles/list/cycles-list-map.tsx b/web/components/cycles/list/cycles-list-map.tsx new file mode 100644 index 000000000..c07b204b1 --- /dev/null +++ b/web/components/cycles/list/cycles-list-map.tsx @@ -0,0 +1,20 @@ +// components +import { CyclesListItem } from "components/cycles"; + +type Props = { + cycleIds: string[]; + projectId: string; + workspaceSlug: string; +}; + +export const CyclesListMap: React.FC = (props) => { + const { cycleIds, projectId, workspaceSlug } = props; + + return ( + <> + {cycleIds.map((cycleId) => ( + + ))} + + ); +}; diff --git a/web/components/cycles/list/index.ts b/web/components/cycles/list/index.ts new file mode 100644 index 000000000..46a3557d7 --- /dev/null +++ b/web/components/cycles/list/index.ts @@ -0,0 +1,3 @@ +export * from "./cycles-list-item"; +export * from "./cycles-list-map"; +export * from "./root"; diff --git a/web/components/cycles/list/root.tsx b/web/components/cycles/list/root.tsx new file mode 100644 index 000000000..27488d238 --- /dev/null +++ b/web/components/cycles/list/root.tsx @@ -0,0 +1,49 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +import { Disclosure } from "@headlessui/react"; +import { ChevronRight } from "lucide-react"; +// components +import { CyclePeekOverview, CyclesListMap } from "components/cycles"; +// helpers +import { cn } from "helpers/common.helper"; + +export interface ICyclesList { + completedCycleIds: string[]; + cycleIds: string[]; + workspaceSlug: string; + projectId: string; +} + +export const CyclesList: FC = observer((props) => { + const { completedCycleIds, cycleIds, workspaceSlug, projectId } = props; + + return ( +
+
+
+ + {completedCycleIds.length !== 0 && ( + + + {({ open }) => ( + <> + Completed cycles ({completedCycleIds.length}) + + + )} + + + + + + )} +
+ +
+
+ ); +}); diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 2d1640ec9..3f57fc204 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -11,7 +11,7 @@ import { CycleService } from "services/cycle.service"; // components // ui // types -import type { CycleDateCheckData, ICycle, TCycleView } from "@plane/types"; +import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types"; // constants type CycleModalProps = { @@ -34,7 +34,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { const { workspaceProjectIds } = useProject(); const { createCycle, updateCycleDetails } = useCycle(); - const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); + const { setValue: setCycleTab } = useLocalStorage("cycle_tab", "active"); const handleCreateCycle = async (payload: Partial) => { if (!workspaceSlug || !projectId) return; diff --git a/web/components/cycles/quick-actions.tsx b/web/components/cycles/quick-actions.tsx new file mode 100644 index 000000000..f1c930ccb --- /dev/null +++ b/web/components/cycles/quick-actions.tsx @@ -0,0 +1,112 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { LinkIcon, Pencil, Trash2 } from "lucide-react"; +// hooks +import { useCycle, useEventTracker, useUser } from "hooks/store"; +// components +import { CycleCreateUpdateModal, CycleDeleteModal } from "components/cycles"; +// ui +import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; +// helpers +import { copyUrlToClipboard } from "helpers/string.helper"; +// constants +import { EUserProjectRoles } from "constants/project"; + +type Props = { + cycleId: string; + projectId: string; + workspaceSlug: string; +}; + +export const CycleQuickActions: React.FC = observer((props) => { + const { cycleId, projectId, workspaceSlug } = props; + // states + const [updateModal, setUpdateModal] = useState(false); + const [deleteModal, setDeleteModal] = useState(false); + // store hooks + const { setTrackElement } = useEventTracker(); + const { + membership: { currentWorkspaceAllProjectsRole }, + } = useUser(); + const { getCycleById } = useCycle(); + // derived values + const cycleDetails = getCycleById(cycleId); + const isCompleted = cycleDetails?.status.toLowerCase() === "completed"; + // auth + const isEditingAllowed = + !!currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId] >= EUserProjectRoles.MEMBER; + + const handleCopyText = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`).then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Link Copied!", + message: "Cycle link copied to clipboard.", + }); + }); + }; + + const handleEditCycle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Cycles page list layout"); + setUpdateModal(true); + }; + + const handleDeleteCycle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setTrackElement("Cycles page list layout"); + setDeleteModal(true); + }; + + return ( + <> + {cycleDetails && ( +
+ setUpdateModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + /> + setDeleteModal(false)} + workspaceSlug={workspaceSlug} + projectId={projectId} + /> +
+ )} + + {!isCompleted && isEditingAllowed && ( + <> + + + + Edit cycle + + + + + + Delete cycle + + + + )} + + + + Copy cycle link + + + + + ); +}); diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index adf986123..bdbe206fc 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -216,15 +216,15 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { ? "0 Issue" : `${cycleDetails.progress_snapshot.completed_issues}/${cycleDetails.progress_snapshot.total_issues}` : cycleDetails.total_issues === 0 - ? "0 Issue" - : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; + ? "0 Issue" + : `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; return ( - <> +
{cycleDetails && workspaceSlug && projectId && ( = observer((props) => { )} <> -
+
- +
); }); diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index 1e031cacd..cd4115ac7 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -7,6 +7,7 @@ import { useDashboard } from "hooks/store"; // components import { DurationFilterDropdown, + IssuesErrorState, TabsList, WidgetIssuesList, WidgetLoader, @@ -26,10 +27,12 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { // states const [fetching, setFetching] = useState(false); // store hooks - const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } = + useDashboard(); // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY); const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; @@ -73,74 +76,91 @@ export const AssignedIssuesWidget: React.FC = observer((props) => { const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); - if (!widgetDetails || !widgetStats) return ; + if ((!widgetDetails || !widgetStats) && !widgetStatsError) return ; return (
-
- - Assigned to you - - { - if (val === "custom" && customDates) { - handleUpdateFilters({ - duration: val, - custom_dates: customDates, - }); - return; - } - - 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"; - + {widgetStatsError ? ( + handleUpdateFilters({ - duration: val, - tab: newTab, - }); - }} + duration: EDurationFilters.NONE, + tab: "pending", + }) + } /> -
- { - const newSelectedTab = tabsList[i]; - handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); - }} - className="h-full flex flex-col" - > -
- -
- - {tabsList.map((tab) => { - if (tab.key !== selectedTab) return null; + ) : ( + widgetStats && ( + <> +
+ + Assigned to you + + { + if (val === "custom" && customDates) { + handleUpdateFilters({ + duration: val, + custom_dates: customDates, + }); + return; + } - return ( - - - - ); - })} - - + 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, + }); + }} + /> +
+ { + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab?.key ?? "completed" }); + }} + className="h-full flex flex-col" + > +
+ +
+ + {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 d36260f21..2b2954018 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -7,6 +7,7 @@ import { useDashboard } from "hooks/store"; // components import { DurationFilterDropdown, + IssuesErrorState, TabsList, WidgetIssuesList, WidgetLoader, @@ -26,10 +27,12 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { // states const [fetching, setFetching] = useState(false); // store hooks - const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard(); + const { fetchWidgetStats, getWidgetDetails, getWidgetStats, getWidgetStatsError, updateDashboardWidgetFilters } = + useDashboard(); // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const widgetStatsError = getWidgetStatsError(workspaceSlug, dashboardId, WIDGET_KEY); const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE; const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab); const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? []; @@ -70,74 +73,91 @@ export const CreatedIssuesWidget: React.FC = observer((props) => { const tabsList = selectedDurationFilter === "none" ? UNFILTERED_ISSUES_TABS_LIST : FILTERED_ISSUES_TABS_LIST; const selectedTabIndex = tabsList.findIndex((tab) => tab.key === selectedTab); - if (!widgetDetails || !widgetStats) return ; + if ((!widgetDetails || !widgetStats) && !widgetStatsError) return ; return (
-
- - Created by you - - { - if (val === "custom" && customDates) { - handleUpdateFilters({ - duration: val, - custom_dates: customDates, - }); - return; - } - - 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"; - + {widgetStatsError ? ( + handleUpdateFilters({ - duration: val, - tab: newTab, - }); - }} + duration: EDurationFilters.NONE, + tab: "pending", + }) + } /> -
- { - const newSelectedTab = tabsList[i]; - handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); - }} - className="h-full flex flex-col" - > -
- -
- - {tabsList.map((tab) => { - if (tab.key !== selectedTab) return null; + ) : ( + widgetStats && ( + <> +
+ + Created by you + + { + if (val === "custom" && customDates) { + handleUpdateFilters({ + duration: val, + custom_dates: customDates, + }); + return; + } - return ( - - - - ); - })} - - + 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, + }); + }} + /> +
+ { + const newSelectedTab = tabsList[i]; + handleUpdateFilters({ tab: newSelectedTab.key ?? "completed" }); + }} + className="h-full flex flex-col" + > +
+ +
+ + {tabsList.map((tab) => { + if (tab.key !== selectedTab) return null; + + return ( + + + + ); + })} + +
+ + ) + )}
); }); diff --git a/web/components/dashboard/widgets/error-states/index.ts b/web/components/dashboard/widgets/error-states/index.ts new file mode 100644 index 000000000..bd8854f37 --- /dev/null +++ b/web/components/dashboard/widgets/error-states/index.ts @@ -0,0 +1 @@ +export * from "./issues"; diff --git a/web/components/dashboard/widgets/error-states/issues.tsx b/web/components/dashboard/widgets/error-states/issues.tsx new file mode 100644 index 000000000..6cfce13b4 --- /dev/null +++ b/web/components/dashboard/widgets/error-states/issues.tsx @@ -0,0 +1,32 @@ +import { AlertTriangle, RefreshCcw } from "lucide-react"; +// ui +import { Button } from "@plane/ui"; + +type Props = { + isRefreshing: boolean; + onClick: () => void; +}; + +export const IssuesErrorState: React.FC = (props) => { + const { isRefreshing, onClick } = props; + + return ( +
+
+
+ +
+

There was an error in fetching widget details

+ +
+
+ ); +}; diff --git a/web/components/dashboard/widgets/index.ts b/web/components/dashboard/widgets/index.ts index a481a8881..31fc645d4 100644 --- a/web/components/dashboard/widgets/index.ts +++ b/web/components/dashboard/widgets/index.ts @@ -1,5 +1,6 @@ export * from "./dropdowns"; export * from "./empty-states"; +export * from "./error-states"; export * from "./issue-panels"; export * from "./loaders"; export * from "./assigned-issues"; diff --git a/web/components/dashboard/widgets/recent-collaborators/default-list.tsx b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx index a27534bbf..8fb884c9b 100644 --- a/web/components/dashboard/widgets/recent-collaborators/default-list.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx @@ -37,21 +37,36 @@ export const DefaultCollaboratorsList: React.FC = (props) => { /> ); + const showViewMoreButton = pageCount < totalPages && resultsCount !== 0; + const showViewLessButton = pageCount > 1; + return ( <>
{collaboratorsPages}
- {pageCount < totalPages && resultsCount !== 0 && ( + {(showViewLessButton || showViewMoreButton) && (
- + {showViewLessButton && ( + + )} + {showViewMoreButton && ( + + )}
)} diff --git a/web/components/dashboard/widgets/recent-collaborators/root.tsx b/web/components/dashboard/widgets/recent-collaborators/root.tsx index d65b15db7..f544e0da6 100644 --- a/web/components/dashboard/widgets/recent-collaborators/root.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/root.tsx @@ -17,9 +17,9 @@ export const RecentCollaboratorsWidget: React.FC = (props) => {
-

Most active members

+

Collaborators

- Top eight active members in your project by last activity + View and find all members you collaborate with across projects

diff --git a/web/components/dashboard/widgets/recent-collaborators/search-list.tsx b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx index 32baa72ad..7323ad944 100644 --- a/web/components/dashboard/widgets/recent-collaborators/search-list.tsx +++ b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx @@ -48,6 +48,9 @@ export const SearchedCollaboratorsList: React.FC = (props) => { /> ); + const showViewMoreButton = pageCount < totalPages && resultsCount !== 0; + const showViewLessButton = pageCount > 1; + const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage; return ( @@ -63,16 +66,28 @@ export const SearchedCollaboratorsList: React.FC = (props) => {

No matching member

)} - {pageCount < totalPages && resultsCount !== 0 && ( + {(showViewLessButton || showViewMoreButton) && (
- + {showViewLessButton && ( + + )} + {showViewMoreButton && ( + + )}
)} diff --git a/web/components/empty-state/empty-state.tsx b/web/components/empty-state/empty-state.tsx index 9ef216068..e718c065a 100644 --- a/web/components/empty-state/empty-state.tsx +++ b/web/components/empty-state/empty-state.tsx @@ -9,12 +9,12 @@ import { useUser } from "hooks/store"; import { Button, TButtonVariant } from "@plane/ui"; import { ComicBoxButton } from "./comic-box-button"; // constant -import { EMPTY_STATE_DETAILS, EmptyStateKeys } from "constants/empty-state"; +import { EMPTY_STATE_DETAILS, EmptyStateType } from "constants/empty-state"; // helpers import { cn } from "helpers/common.helper"; export type EmptyStateProps = { - type: EmptyStateKeys; + type: EmptyStateType; size?: "sm" | "md" | "lg"; layout?: "widget-simple" | "screen-detailed" | "screen-simple"; additionalPath?: string; @@ -39,6 +39,10 @@ export const EmptyState: React.FC = (props) => { } = useUser(); // theme const { resolvedTheme } = useTheme(); + + // if empty state type is not found + if (!EMPTY_STATE_DETAILS[type]) return null; + // current empty state details const { key, title, description, path, primaryButton, secondaryButton, accessType, access } = EMPTY_STATE_DETAILS[type]; diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index fe35c9e52..b4dcd6a62 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -25,7 +25,7 @@ export const GanttChartHeader: React.FC = observer((props) => { const { currentView } = useGanttChart(); return ( -
+
{title}
{blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}
diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index 5ef1ebf2c..6f9c61545 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -4,7 +4,7 @@ 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 { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -142,6 +142,12 @@ export const CycleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const issueCount = cycleDetails + ? issueFilters?.displayFilters?.sub_issue + ? cycleDetails.total_issues + cycleDetails?.sub_issues + : cycleDetails.total_issues + : undefined; + return ( <> { label={ <> -
- {cycleDetails?.name && cycleDetails.name} +
+

{cycleDetails?.name && cycleDetails.name}

+ {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue" + } in this cycle`} + position="bottom" + > + + {issueCount} + + + ) : null}
} - className="ml-1.5 flex-shrink-0" + className="ml-1.5 flex-shrink-0 truncate" placement="bottom-start" > - {currentProjectCycleIds?.map((cycleId) => )} + {currentProjectCycleIds?.map((cycleId) => ( + + ))} } /> diff --git a/web/components/headers/cycles.tsx b/web/components/headers/cycles.tsx index 22637147f..6f019f3bd 100644 --- a/web/components/headers/cycles.tsx +++ b/web/components/headers/cycles.tsx @@ -13,7 +13,7 @@ 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 { TCycleLayoutOptions } from "@plane/types"; import { ProjectLogo } from "components/project"; export const CyclesHeader: FC = observer(() => { @@ -33,10 +33,10 @@ export const CyclesHeader: FC = observer(() => { const canUserCreateCycle = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); - const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); + const { setValue: setCycleLayout } = useLocalStorage("cycle_layout", "list"); const handleCurrentLayout = useCallback( - (_layout: TCycleLayout) => { + (_layout: TCycleLayoutOptions) => { setCycleLayout(_layout); }, [setCycleLayout] @@ -109,7 +109,7 @@ export const CyclesHeader: FC = observer(() => { key={layout.key} onClick={() => { // handleLayoutChange(ISSUE_LAYOUTS[index].key); - handleCurrentLayout(layout.key as TCycleLayout); + handleCurrentLayout(layout.key as TCycleLayoutOptions); }} className="flex items-center gap-2" > diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index 10717ecc3..9505d7145 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -4,7 +4,7 @@ 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 { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -143,6 +143,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const issueCount = moduleDetails + ? issueFilters?.displayFilters?.sub_issue + ? moduleDetails.total_issues + moduleDetails.sub_issues + : moduleDetails.total_issues + : undefined; + return ( <> { label={ <> -
- {moduleDetails?.name && moduleDetails.name} +
+

{moduleDetails?.name && moduleDetails.name}

+ {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue" + } in this module`} + position="bottom" + > + + {issueCount} + + + ) : null}
} className="ml-1.5 flex-shrink-0" placement="bottom-start" > - {projectModuleIds?.map((moduleId) => )} + {projectModuleIds?.map((moduleId) => ( + + ))} } /> diff --git a/web/components/headers/project-archived-issues.tsx b/web/components/headers/project-archived-issues.tsx index db208aa21..ce226b58e 100644 --- a/web/components/headers/project-archived-issues.tsx +++ b/web/components/headers/project-archived-issues.tsx @@ -5,7 +5,7 @@ import { ArrowLeft } from "lucide-react"; // hooks // constants // ui -import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -69,6 +69,12 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => { updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); }; + const issueCount = currentProjectDetails + ? issueFilters?.displayFilters?.sub_issue + ? currentProjectDetails.archived_issues + currentProjectDetails.archived_sub_issues + : currentProjectDetails.archived_issues + : undefined; + return (
@@ -82,7 +88,7 @@ export const ProjectArchivedIssuesHeader: FC = observer(() => {
-
+
{ } /> + {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue"} in project's archived`} + position="bottom" + > + + {issueCount} + + + ) : null}
diff --git a/web/components/headers/project-draft-issues.tsx b/web/components/headers/project-draft-issues.tsx index 4f2929621..789c3f60f 100644 --- a/web/components/headers/project-draft-issues.tsx +++ b/web/components/headers/project-draft-issues.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks // components -import { Breadcrumbs, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, LayersIcon, Tooltip } 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"; @@ -73,11 +73,18 @@ export const ProjectDraftIssueHeader: FC = observer(() => { }, [workspaceSlug, projectId, updateFilters] ); + + const issueCount = currentProjectDetails + ? issueFilters?.displayFilters?.sub_issue + ? currentProjectDetails.draft_issues + currentProjectDetails.draft_sub_issues + : currentProjectDetails.draft_issues + : undefined; + return (
-
+
{ } /> + {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue"} in project's draft`} + position="bottom" + > + + {issueCount} + + + ) : null}
diff --git a/web/components/headers/project-issues.tsx b/web/components/headers/project-issues.tsx index 19eaf4f4f..9739e7832 100644 --- a/web/components/headers/project-issues.tsx +++ b/web/components/headers/project-issues.tsx @@ -3,7 +3,7 @@ import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import { Briefcase, Circle, ExternalLink, Plus } from "lucide-react"; // hooks -import { Breadcrumbs, Button, LayersIcon } from "@plane/ui"; +import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; import { ProjectAnalyticsModal } from "components/analytics"; import { BreadcrumbLink } from "components/common"; import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle"; @@ -102,6 +102,12 @@ export const ProjectIssuesHeader: React.FC = observer(() => { const canUserCreateIssue = currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); + const issueCount = currentProjectDetails + ? issueFilters?.displayFilters?.sub_issue + ? currentProjectDetails?.total_issues + currentProjectDetails?.sub_issues + : currentProjectDetails?.total_issues + : undefined; + return ( <> {
-
+
router.back()}> { } /> + {issueCount && issueCount > 0 ? ( + 1 ? "issues" : "issue"} in this project`} + position="bottom" + > + + {issueCount} + + + ) : null}
{currentProjectDetails?.is_deployed && deployUrl && ( = observer((props) => { reset, watch, getValues, - setValue, } = useForm({ defaultValues }); const handleClose = () => { diff --git a/web/components/issues/issue-layouts/empty-states/archived-issues.tsx b/web/components/issues/issue-layouts/empty-states/archived-issues.tsx index c9de2279c..36c895de3 100644 --- a/web/components/issues/issue-layouts/empty-states/archived-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/archived-issues.tsx @@ -32,7 +32,7 @@ export const ProjectArchivedEmptyState: React.FC = observer(() => { if (!workspaceSlug || !projectId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters, diff --git a/web/components/issues/issue-layouts/empty-states/cycle.tsx b/web/components/issues/issue-layouts/empty-states/cycle.tsx index 350e4dbb4..1a49794c6 100644 --- a/web/components/issues/issue-layouts/empty-states/cycle.tsx +++ b/web/components/issues/issue-layouts/empty-states/cycle.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; import { observer } from "mobx-react-lite"; +import isEmpty from "lodash/isEmpty"; // hooks -import { useApplication, useEventTracker, useIssues } from "hooks/store"; +import { useApplication, useCycle, useEventTracker, useIssues } from "hooks/store"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal } from "components/core"; @@ -27,12 +28,15 @@ export const CycleEmptyState: React.FC = observer((props) => { // states const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); // store hooks + const { getCycleById } = useCycle(); const { issues } = useIssues(EIssuesStoreType.CYCLE); const { commandPalette: { toggleCreateIssueModal }, } = useApplication(); const { setTrackElement } = useEventTracker(); + const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId || !cycleId) return; @@ -56,8 +60,14 @@ export const CycleEmptyState: React.FC = observer((props) => { ); }; - const emptyStateType = isEmptyFilters ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_CYCLE_NO_ISSUES; - const additionalPath = activeLayout ?? "list"; + const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {}); + + const emptyStateType = isCompletedCycleSnapshotAvailable + ? EmptyStateType.PROJECT_CYCLE_COMPLETED_NO_ISSUES + : isEmptyFilters + ? EmptyStateType.PROJECT_EMPTY_FILTER + : EmptyStateType.PROJECT_CYCLE_NO_ISSUES; + const additionalPath = isCompletedCycleSnapshotAvailable ? undefined : activeLayout ?? "list"; const emptyStateSize = isEmptyFilters ? "lg" : "sm"; return ( @@ -76,14 +86,18 @@ export const CycleEmptyState: React.FC = observer((props) => { additionalPath={additionalPath} size={emptyStateSize} primaryButtonOnClick={ - isEmptyFilters - ? undefined - : () => { + !isCompletedCycleSnapshotAvailable && !isEmptyFilters + ? () => { setTrackElement("Cycle issue empty state"); toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); } + : undefined + } + secondaryButtonOnClick={ + !isCompletedCycleSnapshotAvailable && isEmptyFilters + ? handleClearAllFilters + : () => setCycleIssuesListModal(true) } - secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setCycleIssuesListModal(true)} />
diff --git a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx index 0968ed07a..c23fea100 100644 --- a/web/components/issues/issue-layouts/empty-states/draft-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/draft-issues.tsx @@ -31,7 +31,7 @@ export const ProjectDraftEmptyState: React.FC = observer(() => { if (!workspaceSlug || !projectId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters, diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index 6c0cd0cd6..d8e3516cd 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -77,8 +77,8 @@ export const ModuleEmptyState: React.FC = observer((props) => { isEmptyFilters ? undefined : () => { - setTrackElement("Cycle issue empty state"); - toggleCreateIssueModal(true, EIssuesStoreType.CYCLE); + setTrackElement("Module issue empty state"); + toggleCreateIssueModal(true, EIssuesStoreType.MODULE); } } secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setModuleIssuesListModal(true)} diff --git a/web/components/issues/issue-layouts/empty-states/project-issues.tsx b/web/components/issues/issue-layouts/empty-states/project-issues.tsx index 12642d364..58929e48d 100644 --- a/web/components/issues/issue-layouts/empty-states/project-issues.tsx +++ b/web/components/issues/issue-layouts/empty-states/project-issues.tsx @@ -34,7 +34,7 @@ export const ProjectEmptyState: React.FC = observer(() => { if (!workspaceSlug || !projectId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); issuesFilter.updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters, 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 10ad265f3..94f2b30d1 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 @@ -55,14 +55,15 @@ export const AppliedFiltersList: React.FC = observer((props) => { const filterKey = key as keyof IIssueFilterOptions; if (!value) return; + if (Array.isArray(value) && value.length === 0) return; return (
- {replaceUnderscoreIfSnakeCase(filterKey)} -
+
+ {replaceUnderscoreIfSnakeCase(filterKey)} {membersFilters.includes(filterKey) && ( { const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { @@ -68,7 +68,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => { if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (!workspaceSlug || !projectId || !cycleId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters( workspaceSlug.toString(), @@ -77,7 +77,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => { if (Object.keys(appliedFilters).length === 0 || !workspaceSlug || !projectId) return null; return ( -
+
{ const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters }); @@ -63,7 +63,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => { if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (!workspaceSlug || !globalViewId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters( workspaceSlug.toString(), diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx index b49ddf4d6..7fd7b624f 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/module-root.tsx @@ -61,7 +61,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId || !moduleId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters( workspaceSlug.toString(), @@ -76,7 +76,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId || Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (!workspaceSlug || !userId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters(workspaceSlug.toString(), undefined, EIssueFilterType.FILTERS, { ...newFilters }, userId.toString()); }; diff --git a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx index c0b67043a..96b79a596 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/roots/project-root.tsx @@ -59,7 +59,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters(workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { ...newFilters }); }; @@ -68,7 +68,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => { if (Object.keys(appliedFilters).length === 0) return null; return ( -
+
{ if (!workspaceSlug || !projectId || !viewId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); updateFilters( workspaceSlug.toString(), diff --git a/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx b/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx index b26b688af..c51fcf7ab 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/assignee.tsx @@ -1,11 +1,12 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; // hooks -import { Avatar, Loader } from "@plane/ui"; -import { FilterHeader, FilterOption } from "components/issues"; import { useMember } from "hooks/store"; // components +import { FilterHeader, FilterOption } from "components/issues"; // ui +import { Avatar, Loader } from "@plane/ui"; type Props = { appliedFilters: string[] | null; @@ -24,15 +25,23 @@ export const FilterAssignees: React.FC = observer((props: Props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = memberIds?.filter( - (memberId) => getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -44,10 +53,10 @@ export const FilterAssignees: React.FC = observer((props: Props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((memberId) => { + {sortedOptions.slice(0, itemsToRender).map((memberId) => { const member = getUserDetails(memberId); if (!member) return null; @@ -61,13 +70,13 @@ export const FilterAssignees: React.FC = observer((props: Props) => { /> ); })} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} diff --git a/web/components/issues/issue-layouts/filters/header/filters/created-by.tsx b/web/components/issues/issue-layouts/filters/header/filters/created-by.tsx index 45e3309a9..765955bf9 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/created-by.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/created-by.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; // hooks import { Avatar, Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; @@ -22,16 +23,25 @@ export const FilterCreatedBy: React.FC = observer((props: Props) => { // store hooks const { getUserDetails } = useMember(); - const filteredOptions = memberIds?.filter( - (memberId) => getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); + const appliedFiltersCount = appliedFilters?.length ?? 0; const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -43,10 +53,10 @@ export const FilterCreatedBy: React.FC = observer((props: Props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((memberId) => { + {sortedOptions.slice(0, itemsToRender).map((memberId) => { const member = getUserDetails(memberId); if (!member) return null; @@ -60,13 +70,13 @@ export const FilterCreatedBy: React.FC = observer((props: Props) => { /> ); })} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} diff --git a/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx b/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx index 396addde6..b3a65a399 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/cycle.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; // components @@ -31,16 +31,24 @@ export const FilterCycle: React.FC = observer((props) => { const cycleIds = projectId ? getProjectCycleIds(projectId) : undefined; const cycles = cycleIds?.map((projectId) => getCycleById(projectId)!) ?? null; const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = sortBy( - cycles?.filter((cycle) => cycle.name.toLowerCase().includes(searchQuery.toLowerCase())), - (cycle) => cycle.name.toLowerCase() - ); + + const sortedOptions = useMemo(() => { + const filteredOptions = (cycles || []).filter((cycle) => + cycle.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (cycle) => !appliedFilters?.includes(cycle.id), + (cycle) => cycle.name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; const cycleStatus = (status: TCycleGroups) => (status ? status.toLocaleLowerCase() : "draft") as TCycleGroups; @@ -54,10 +62,10 @@ export const FilterCycle: React.FC = observer((props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + {sortedOptions.slice(0, itemsToRender).map((cycle) => ( = observer((props) => { activePulse={cycleStatus(cycle?.status) === "current" ? true : false} /> ))} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} 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 42e955535..7097b1337 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/labels.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/labels.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; // components import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; @@ -26,13 +27,23 @@ export const FilterLabels: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = labels?.filter((label) => label.name.toLowerCase().includes(searchQuery.toLowerCase())); + const sortedOptions = useMemo(() => { + const filteredOptions = (labels || []).filter((label) => + label.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (label) => !(appliedFilters ?? []).includes(label.id), + (label) => label.name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -44,10 +55,10 @@ export const FilterLabels: React.FC = observer((props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((label) => ( + {sortedOptions.slice(0, itemsToRender).map((label) => ( = observer((props) => { title={label.name} /> ))} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} 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 4d2839b2c..80c16478a 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/mentions.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; +import sortBy from "lodash/sortBy"; // hooks import { Loader, Avatar } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; @@ -24,15 +25,23 @@ 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 sortedOptions = useMemo(() => { + const filteredOptions = (memberIds || []).filter((memberId) => + getUserDetails(memberId)?.display_name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (memberId) => !(appliedFilters ?? []).includes(memberId), + (memberId) => getUserDetails(memberId)?.display_name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -44,10 +53,10 @@ export const FilterMentions: React.FC = observer((props: Props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((memberId) => { + {sortedOptions.slice(0, itemsToRender).map((memberId) => { const member = getUserDetails(memberId); if (!member) return null; @@ -61,13 +70,13 @@ export const FilterMentions: React.FC = observer((props: Props) => { /> ); })} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} diff --git a/web/components/issues/issue-layouts/filters/header/filters/module.tsx b/web/components/issues/issue-layouts/filters/header/filters/module.tsx index 812cf939f..6b6cd2b4d 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/module.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/module.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import sortBy from "lodash/sortBy"; import { observer } from "mobx-react"; // components @@ -29,16 +29,24 @@ export const FilterModule: React.FC = observer((props) => { 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 sortedOptions = useMemo(() => { + const filteredOptions = (modules || []).filter((module) => + module.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return sortBy(filteredOptions, [ + (module) => !appliedFilters?.includes(module.id), + (module) => module.name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -50,10 +58,10 @@ export const FilterModule: React.FC = observer((props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((cycle) => ( + {sortedOptions.slice(0, itemsToRender).map((cycle) => ( = observer((props) => { title={cycle.name} /> ))} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} 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 b9f864b4b..b97001b00 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/project.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; // components import { Loader } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; @@ -26,13 +27,23 @@ export const FilterProjects: React.FC = observer((props) => { // derived values const projects = workspaceProjectIds?.map((projectId) => getProjectById(projectId)!) ?? null; const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = projects?.filter((project) => project.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const sortedOptions = useMemo(() => { + const filteredOptions = (projects || []).filter((project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + return sortBy(filteredOptions, [ + (project) => !(appliedFilters ?? []).includes(project.id), + (project) => project.name.toLowerCase(), + ]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -44,10 +55,10 @@ export const FilterProjects: React.FC = observer((props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((project) => ( + {sortedOptions.slice(0, itemsToRender).map((project) => ( = observer((props) => { title={project.name} /> ))} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} 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 06c1aae9f..ca7fe270d 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 @@ -1,6 +1,5 @@ import React, { useState } from "react"; import { observer } from "mobx-react-lite"; - // components import { StateGroupIcon } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; 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 5dde1d279..2c2cca53b 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/state.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/state.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { observer } from "mobx-react"; +import sortBy from "lodash/sortBy"; // components import { Loader, StateGroupIcon } from "@plane/ui"; import { FilterHeader, FilterOption } from "components/issues"; @@ -22,13 +23,18 @@ export const FilterState: React.FC = observer((props) => { const appliedFiltersCount = appliedFilters?.length ?? 0; - const filteredOptions = states?.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + const sortedOptions = useMemo(() => { + const filteredOptions = (states ?? []).filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase())); + + return sortBy(filteredOptions, [(s) => !(appliedFilters ?? []).includes(s.id), (s) => s.name.toLowerCase()]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery]); const handleViewToggle = () => { - if (!filteredOptions) return; + if (!sortedOptions) return; - if (itemsToRender === filteredOptions.length) setItemsToRender(5); - else setItemsToRender(filteredOptions.length); + if (itemsToRender === sortedOptions.length) setItemsToRender(5); + else setItemsToRender(sortedOptions.length); }; return ( @@ -40,10 +46,10 @@ export const FilterState: React.FC = observer((props) => { /> {previewEnabled && (
- {filteredOptions ? ( - filteredOptions.length > 0 ? ( + {sortedOptions ? ( + sortedOptions.length > 0 ? ( <> - {filteredOptions.slice(0, itemsToRender).map((state) => ( + {sortedOptions.slice(0, itemsToRender).map((state) => ( = observer((props) => { title={state.name} /> ))} - {filteredOptions.length > 5 && ( + {sortedOptions.length > 5 && ( )} 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 0d00c3675..4ee92cc23 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -9,6 +9,7 @@ import { Button } from "@plane/ui"; type Props = { children: React.ReactNode; + icon?: React.ReactNode; title?: string; placement?: Placement; disabled?: boolean; @@ -17,7 +18,7 @@ type Props = { }; export const FiltersDropdown: React.FC = (props) => { - const { children, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props; + const { children, icon, title = "Dropdown", placement, disabled = false, tabIndex, menuButton } = props; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -44,6 +45,7 @@ export const FiltersDropdown: React.FC = (props) => { ref={setReferenceElement} variant="neutral-primary" size="sm" + prependIcon={icon} appendIcon={ } @@ -64,9 +66,9 @@ export const FiltersDropdown: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0" leaveTo="opacity-0 translate-y-1" > - +
= observer((props: IssueBlock return (
- {displayProperties && displayProperties?.key && ( -
- {projectIdentifier}-{issue.sequence_id} +
+
+ {displayProperties && displayProperties?.key && ( +
+ {projectIdentifier}-{issue.sequence_id} +
+ )} + + {issue?.tempId !== undefined && ( +
+ )} + + {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} + + + )}
- )} - - {issue?.tempId !== undefined && ( -
- )} - - {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} - - - )} - -
+ {!issue?.tempId && ( +
{quickActions(issue)}
+ )} +
+
{!issue?.tempId ? ( <> - {quickActions(issue)} +
{quickActions(issue)}
) : (
diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 12179ee97..4009df6d2 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -123,7 +123,10 @@ 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/quick-action-dropdowns/all-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx index f6c63191f..e1ff30f9b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/all-issue.tsx @@ -99,6 +99,7 @@ export const AllIssueQuickActions: React.FC = observer((props placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx index dae88a387..9cf394a1b 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/archived-issue.tsx @@ -60,6 +60,7 @@ export const ArchivedIssueQuickActions: React.FC = (props) => placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx index fe713ed23..38b38926f 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/cycle-issue.tsx @@ -110,6 +110,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx index f24f6869e..00d69cba3 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/module-issue.tsx @@ -109,6 +109,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx index 24a2433d5..a198b6104 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/project-issue.tsx @@ -110,6 +110,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p placement="bottom-start" customButton={customActionButton} portalElement={portalElement} + maxHeight="lg" closeOnSelect ellipsis > diff --git a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx index 5f308fbd1..ce0a9943e 100644 --- a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -68,7 +68,7 @@ export const CycleLayoutRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId || !cycleId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); issuesFilter.updateFilters( workspaceSlug.toString(), diff --git a/web/components/issues/issue-layouts/roots/module-layout-root.tsx b/web/components/issues/issue-layouts/roots/module-layout-root.tsx index 0c6ba3b66..268a2c60c 100644 --- a/web/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -59,7 +59,7 @@ export const ModuleLayoutRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId || !moduleId) return; const newFilters: IIssueFilterOptions = {}; Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = null; + newFilters[key as keyof IIssueFilterOptions] = []; }); issuesFilter.updateFilters( workspaceSlug.toString(), diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index bb333c898..9413431c7 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -467,7 +467,7 @@ export const IssueFormRoot: FC = observer((props) => { }} mentionHighlights={mentionHighlights} mentionSuggestions={mentionSuggestions} - // tabIndex={2} + tabIndex={getTabIndex("description_html")} /> )} /> @@ -703,6 +703,7 @@ export const IssueFormRoot: FC = observer((props) => { setSelectedParentIssue(issue); }} projectId={projectId ?? undefined} + issueId={data?.id} /> )} /> diff --git a/web/components/modules/sidebar.tsx b/web/components/modules/sidebar.tsx index c9f28cf98..55be14a60 100644 --- a/web/components/modules/sidebar.tsx +++ b/web/components/modules/sidebar.tsx @@ -242,7 +242,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( - <> +
{ @@ -257,7 +257,7 @@ export const ModuleDetailsSidebar: React.FC = observer((props) => { setModuleDeleteModal(false)} data={moduleDetails} /> <> -
+
- +
); }); diff --git a/web/components/project/create-project-form.tsx b/web/components/project/create-project-form.tsx new file mode 100644 index 000000000..509cf310c --- /dev/null +++ b/web/components/project/create-project-form.tsx @@ -0,0 +1,392 @@ +import { useState, FC, ChangeEvent } from "react"; +import { observer } from "mobx-react-lite"; +import { useForm, Controller } from "react-hook-form"; +import { Info, X } from "lucide-react"; +// ui +import { + Button, + CustomEmojiIconPicker, + CustomSelect, + EmojiIconPickerTypes, + Input, + setToast, + TextArea, + TOAST_TYPE, + Tooltip, +} from "@plane/ui"; +// components +import { ImagePickerPopover } from "components/core"; +import { MemberDropdown } from "components/dropdowns"; +import { ProjectLogo } from "./project-logo"; +// constants +import { PROJECT_CREATED } from "constants/event-tracker"; +import { NETWORK_CHOICES, PROJECT_UNSPLASH_COVERS } from "constants/project"; +// helpers +import { convertHexEmojiToDecimal, getRandomEmoji } from "helpers/emoji.helper"; +import { cn } from "helpers/common.helper"; +import { projectIdentifierSanitizer } from "helpers/project.helper"; +// hooks +import { useEventTracker, useProject } from "hooks/store"; +// types +import { IProject } from "@plane/types"; + +type Props = { + setToFavorite?: boolean; + workspaceSlug: string; + onClose: () => void; + handleNextStep: (projectId: string) => void; +}; + +const defaultValues: Partial = { + cover_image: PROJECT_UNSPLASH_COVERS[Math.floor(Math.random() * PROJECT_UNSPLASH_COVERS.length)], + description: "", + logo_props: { + in_use: "emoji", + emoji: { + value: getRandomEmoji(), + }, + }, + identifier: "", + name: "", + network: 2, + project_lead: null, +}; + +export const CreateProjectForm: FC = observer((props) => { + const { setToFavorite, workspaceSlug, onClose, handleNextStep } = props; + // store + const { captureProjectEvent } = useEventTracker(); + const { addProjectToFavorites, createProject } = useProject(); + // states + const [isChangeInIdentifierRequired, setIsChangeInIdentifierRequired] = useState(true); + // form info + const { + formState: { errors, isSubmitting }, + handleSubmit, + reset, + control, + watch, + setValue, + } = useForm({ + defaultValues, + reValidateMode: "onChange", + }); + + const handleAddToFavorites = (projectId: string) => { + if (!workspaceSlug) return; + + addProjectToFavorites(workspaceSlug.toString(), projectId).catch(() => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Couldn't remove the project from favorites. Please try again.", + }); + }); + }; + + const onSubmit = async (formData: Partial) => { + // Upper case identifier + formData.identifier = formData.identifier?.toUpperCase(); + + return createProject(workspaceSlug.toString(), formData) + .then((res) => { + const newPayload = { + ...res, + state: "SUCCESS", + }; + captureProjectEvent({ + eventName: PROJECT_CREATED, + payload: newPayload, + }); + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Project created successfully.", + }); + if (setToFavorite) { + handleAddToFavorites(res.id); + } + handleNextStep(res.id); + }) + .catch((err) => { + Object.keys(err.data).map((key) => { + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: err.data[key], + }); + captureProjectEvent({ + eventName: PROJECT_CREATED, + payload: { + ...formData, + state: "FAILED", + }, + }); + }); + }); + }; + + const handleNameChange = (onChange: any) => (e: ChangeEvent) => { + if (!isChangeInIdentifierRequired) { + onChange(e); + return; + } + if (e.target.value === "") setValue("identifier", ""); + else setValue("identifier", projectIdentifierSanitizer(e.target.value).substring(0, 5)); + onChange(e); + }; + + const handleIdentifierChange = (onChange: any) => (e: ChangeEvent) => { + const { value } = e.target; + const alphanumericValue = projectIdentifierSanitizer(value); + setIsChangeInIdentifierRequired(false); + onChange(alphanumericValue); + }; + + const handleClose = () => { + onClose(); + setIsChangeInIdentifierRequired(true); + setTimeout(() => { + reset(); + }, 300); + }; + + return ( + <> +
+ {watch("cover_image") && ( + Cover image + )} + +
+ +
+
+ ( + + )} + /> +
+
+ ( + + + + } + onChange={(val: any) => { + let logoValue = {}; + + if (val.type === "emoji") + logoValue = { + value: convertHexEmojiToDecimal(val.value.unified), + url: val.value.imageUrl, + }; + else if (val.type === "icon") logoValue = val.value; + + onChange({ + in_use: val.type, + [val.type]: logoValue, + }); + }} + defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined} + defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON} + /> + )} + /> +
+
+
+
+
+
+ ( + + )} + /> + + <>{errors?.name?.message} + +
+
+ + /^[ÇŞĞIİÖÜA-Z0-9]+$/.test(value.toUpperCase()) || + "Only Alphanumeric & Non-latin characters are allowed.", + minLength: { + value: 1, + message: "Project ID must at least be of 1 character", + }, + maxLength: { + value: 5, + message: "Project ID must at most be of 5 characters", + }, + }} + render={({ field: { value, onChange } }) => ( + + )} + /> + + + + {errors?.identifier?.message} +
+
+ ( +