diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 2b40af672..000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,23 +0,0 @@ -version = 1 - -exclude_patterns = [ - "bin/**", - "**/node_modules/", - "**/*.min.js" -] - -[[analyzers]] -name = "shell" - -[[analyzers]] -name = "javascript" - - [analyzers.meta] - plugins = ["react"] - environment = ["nodejs"] - -[[analyzers]] -name = "python" - - [analyzers.meta] - runtime_version = "3.x.x" \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 92b44af5b..d7b94d245 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,8 +1,9 @@ name: "CodeQL" on: + workflow_dispatch: push: - branches: ["master"] + branches: ["develop", "preview", "master"] pull_request: branches: ["develop", "preview", "master"] schedule: diff --git a/README.md b/README.md index 6834199ff..11db5ceba 100644 --- a/README.md +++ b/README.md @@ -17,10 +17,10 @@

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

@@ -40,15 +40,15 @@

-Meet [Plane](https://plane.so). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️ +Meet [Plane](https://dub.sh/plane-website-readme). An open-source software development tool to manage issues, sprints, and product roadmaps with peace of mind. 🧘‍♀️ -> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. +> Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve in our upcoming releases. ## ⚡ Installation The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users. -If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). +If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose). | Installation Methods | Documentation Link | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | @@ -59,9 +59,9 @@ If you want more control over your data prefer to self-host Plane, please refer ## 🚀 Features -- **Issues**: Quickly create issues and add details using a powerful, rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. +- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking. -- **Cycles** +- **Cycles**: Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features. - **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily. @@ -74,11 +74,11 @@ If you want more control over your data prefer to self-host Plane, please refer - **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution. -## 🛠️ Contributors Quick Start +## 🛠️ Quick start for contributors > Development system must have docker engine installed and running. -Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute +Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute - 1. Clone the code locally using: ``` diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index a0e45416a..328b9db2b 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -182,7 +182,7 @@ def update_label_color(): labels = Label.objects.filter(color="") updated_labels = [] for label in labels: - label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF) + label.color = f"#{random.randint(0, 0xFFFFFF+1):06X}" updated_labels.append(label) Label.objects.bulk_update(updated_labels, ["color"], batch_size=100) diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 0f5aab3e3..447a9c65e 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,31 +1,32 @@ -from lxml import html +from django.core.exceptions import ValidationError +from django.core.validators import URLValidator # Django imports from django.utils import timezone -from django.core.validators import URLValidator -from django.core.exceptions import ValidationError +from lxml import html # Third party imports from rest_framework import serializers # Module imports from plane.db.models import ( - User, Issue, - State, + IssueActivity, IssueAssignee, - Label, + IssueComment, IssueLabel, IssueLink, - IssueComment, - IssueActivity, + Label, ProjectMember, + State, + User, ) + from .base import BaseSerializer -from .cycle import CycleSerializer, CycleLiteSerializer -from .module import ModuleSerializer, ModuleLiteSerializer -from .user import UserLiteSerializer +from .cycle import CycleLiteSerializer, CycleSerializer +from .module import ModuleLiteSerializer, ModuleSerializer from .state import StateLiteSerializer +from .user import UserLiteSerializer class IssueSerializer(BaseSerializer): @@ -78,7 +79,7 @@ class IssueSerializer(BaseSerializer): data["description_html"] = parsed_str except Exception as e: - raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + raise serializers.ValidationError("Invalid HTML passed") # Validate assignees are from project if data.get("assignees", []): @@ -293,7 +294,7 @@ class IssueLinkSerializer(BaseSerializer): raise serializers.ValidationError("Invalid URL format.") # Check URL scheme - if not value.startswith(('http://', 'https://')): + if not value.startswith(("http://", "https://")): raise serializers.ValidationError("Invalid URL scheme.") return value @@ -349,7 +350,7 @@ class IssueCommentSerializer(BaseSerializer): data["comment_html"] = parsed_str except Exception as e: - raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + raise serializers.ValidationError("Invalid HTML passed") return data diff --git a/apiserver/plane/app/serializers/issue.py b/apiserver/plane/app/serializers/issue.py index ee9dc5df1..3aedbcad2 100644 --- a/apiserver/plane/app/serializers/issue.py +++ b/apiserver/plane/app/serializers/issue.py @@ -504,8 +504,8 @@ class IssueReactionLiteSerializer(DynamicBaseSerializer): model = IssueReaction fields = [ "id", - "actor_id", - "issue_id", + "actor", + "issue", "reaction", ] diff --git a/apiserver/plane/app/views/cycle/base.py b/apiserver/plane/app/views/cycle/base.py index 8d8b54929..d323a0f63 100644 --- a/apiserver/plane/app/views/cycle/base.py +++ b/apiserver/plane/app/views/cycle/base.py @@ -101,59 +101,69 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) ) .annotate(is_favorite=Exists(favorite_subquery)) + .annotate( + total_issues=Count( + "issue_cycle__issue__id", + distinct=True, + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) .annotate( completed_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="completed", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, ), - distinct=True, ) ) .annotate( cancelled_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="cancelled", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, ), - distinct=True, ) ) .annotate( started_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="started", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, ), - distinct=True, ) ) .annotate( unstarted_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="unstarted", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, ), - distinct=True, ) ) .annotate( backlog_issues=Count( - "issue_cycle__issue__state__group", + "issue_cycle__issue__id", + distinct=True, filter=Q( issue_cycle__issue__state__group="backlog", issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__is_draft=False, ), - distinct=True, ) ) .annotate( @@ -182,9 +192,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet): distinct=True, filter=~Q( issue_cycle__issue__assignees__id__isnull=True - ) - & Q( - issue_cycle__issue__assignees__member_project__is_active=True ), ), Value([], output_field=ArrayField(UUIDField())), @@ -195,19 +202,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): ) def list(self, request, slug, project_id): - queryset = ( - self.get_queryset() - .filter(archived_at__isnull=True) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) - ) + queryset = self.get_queryset().filter(archived_at__isnull=True) cycle_view = request.GET.get("cycle_view", "all") # Update the order by @@ -361,8 +356,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "external_id", "progress_snapshot", # meta fields - "total_issues", "is_favorite", + "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -409,6 +404,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): # meta fields "is_favorite", "cancelled_issues", + "total_issues", "completed_issues", "started_issues", "unstarted_issues", @@ -484,6 +480,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet): "progress_snapshot", # meta fields "is_favorite", + "total_issues", "cancelled_issues", "completed_issues", "started_issues", @@ -497,32 +494,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet): def retrieve(self, request, slug, project_id, pk): queryset = ( - self.get_queryset() - .filter(archived_at__isnull=True) - .filter(pk=pk) - .annotate( - total_issues=Count( - "issue_cycle", - filter=Q( - issue_cycle__issue__archived_at__isnull=True, - issue_cycle__issue__is_draft=False, - ), - ) - ) + self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk) ) 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"), @@ -880,7 +856,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView): ) cycle.archived_at = timezone.now() cycle.save() - return Response(status=status.HTTP_204_NO_CONTENT) + return Response( + {"archived_at": str(cycle.archived_at)}, + status=status.HTTP_200_OK, + ) def delete(self, request, slug, project_id, cycle_id): cycle = Cycle.objects.get( diff --git a/apiserver/plane/app/views/issue/label.py b/apiserver/plane/app/views/issue/label.py index 557c2018f..c5dc35809 100644 --- a/apiserver/plane/app/views/issue/label.py +++ b/apiserver/plane/app/views/issue/label.py @@ -87,7 +87,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView): Label( name=label.get("name", "Migrated"), description=label.get("description", "Migrated Issue"), - color="#" + "%06x" % random.randint(0, 0xFFFFFF), + color=f"#{random.randint(0, 0xFFFFFF+1):06X}", project_id=project_id, workspace_id=project.workspace_id, created_by=request.user, diff --git a/apiserver/plane/app/views/module/base.py b/apiserver/plane/app/views/module/base.py index 8d3e0235d..3fe3a078a 100644 --- a/apiserver/plane/app/views/module/base.py +++ b/apiserver/plane/app/views/module/base.py @@ -8,9 +8,11 @@ from django.db.models import ( Exists, F, Func, + IntegerField, OuterRef, Prefetch, Q, + Subquery, UUIDField, Value, ) @@ -72,6 +74,59 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): project_id=self.kwargs.get("project_id"), workspace__slug=self.kwargs.get("slug"), ) + cancelled_issues = ( + Issue.issue_objects.filter( + state__group="cancelled", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + completed_issues = ( + Issue.issue_objects.filter( + state__group="completed", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + started_issues = ( + Issue.issue_objects.filter( + state__group="started", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + unstarted_issues = ( + Issue.issue_objects.filter( + state__group="unstarted", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + backlog_issues = ( + Issue.issue_objects.filter( + state__group="backlog", + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) + total_issues = ( + Issue.issue_objects.filter( + issue_module__module_id=OuterRef("pk"), + ) + .values("issue_module__module_id") + .annotate(cnt=Count("pk")) + .values("cnt") + ) return ( super() .get_queryset() @@ -91,68 +146,39 @@ 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, - ), - distinct=True, - ), - ) - .annotate( - completed_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="completed", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - distinct=True, + completed_issues=Coalesce( + Subquery(completed_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - cancelled_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="cancelled", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - distinct=True, + cancelled_issues=Coalesce( + Subquery(cancelled_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - started_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="started", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - distinct=True, + started_issues=Coalesce( + Subquery(started_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - unstarted_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="unstarted", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - distinct=True, + unstarted_issues=Coalesce( + Subquery(unstarted_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( - backlog_issues=Count( - "issue_module__issue__state__group", - filter=Q( - issue_module__issue__state__group="backlog", - issue_module__issue__archived_at__isnull=True, - issue_module__issue__is_draft=False, - ), - distinct=True, + backlog_issues=Coalesce( + Subquery(backlog_issues[:1]), + Value(0, output_field=IntegerField()), + ) + ) + .annotate( + total_issues=Coalesce( + Subquery(total_issues[:1]), + Value(0, output_field=IntegerField()), ) ) .annotate( @@ -202,6 +228,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "is_favorite", "cancelled_issues", "completed_issues", + "total_issues", "started_issues", "unstarted_issues", "backlog_issues", @@ -257,16 +284,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): self.get_queryset() .filter(archived_at__isnull=True) .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"), @@ -378,9 +395,11 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "completion_chart": {}, } - if queryset.first().start_date and queryset.first().target_date: + # Fetch the modules + modules = queryset.first() + if modules and modules.start_date and modules.target_date: data["distribution"]["completion_chart"] = burndown_plot( - queryset=queryset.first(), + queryset=modules, slug=slug, project_id=project_id, module_id=pk, @@ -429,6 +448,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet): "cancelled_issues", "completed_issues", "started_issues", + "total_issues", "unstarted_issues", "backlog_issues", "created_at", @@ -642,7 +662,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView): ) module.archived_at = timezone.now() module.save() - return Response(status=status.HTTP_204_NO_CONTENT) + return Response( + {"archived_at": str(module.archived_at)}, + status=status.HTTP_200_OK, + ) def delete(self, request, slug, project_id, module_id): module = Module.objects.get( diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 1c9c78e77..0ae951770 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -441,7 +441,10 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView): project = Project.objects.get(pk=project_id, workspace__slug=slug) project.archived_at = timezone.now() project.save() - return Response(status=status.HTTP_204_NO_CONTENT) + return Response( + {"archived_at": str(project.archived_at)}, + status=status.HTTP_200_OK, + ) def delete(self, request, slug, project_id): project = Project.objects.get(pk=project_id, workspace__slug=slug) diff --git a/apiserver/plane/app/views/workspace/estimate.py b/apiserver/plane/app/views/workspace/estimate.py index 6b64d8c90..59a23d867 100644 --- a/apiserver/plane/app/views/workspace/estimate.py +++ b/apiserver/plane/app/views/workspace/estimate.py @@ -3,15 +3,10 @@ from rest_framework import status from rest_framework.response import Response # Module imports +from plane.app.permissions import WorkspaceEntityPermission from plane.app.serializers import WorkspaceEstimateSerializer from plane.app.views.base import BaseAPIView -from plane.db.models import Project, Estimate -from plane.app.permissions import WorkspaceEntityPermission - -# Django imports -from django.db.models import ( - Prefetch, -) +from plane.db.models import Estimate, Project from plane.utils.cache import cache_response @@ -25,15 +20,11 @@ class WorkspaceEstimatesEndpoint(BaseAPIView): estimate_ids = Project.objects.filter( workspace__slug=slug, estimate__isnull=False ).values_list("estimate_id", flat=True) - estimates = Estimate.objects.filter( - pk__in=estimate_ids - ).prefetch_related( - Prefetch( - "points", - queryset=Project.objects.select_related( - "estimate", "workspace", "project" - ), - ) + estimates = ( + Estimate.objects.filter(pk__in=estimate_ids, workspace__slug=slug) + .prefetch_related("points") + .select_related("workspace", "project") ) + serializer = WorkspaceEstimateSerializer(estimates, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 28b45adbe..2b7d383ba 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -1,6 +1,6 @@ # base requirements -Django==4.2.10 +Django==4.2.11 psycopg==3.1.12 djangorestframework==3.14.0 redis==4.6.0 diff --git a/apiserver/templates/emails/invitations/project_invitation.html b/apiserver/templates/emails/invitations/project_invitation.html index 630a5eab3..def576601 100644 --- a/apiserver/templates/emails/invitations/project_invitation.html +++ b/apiserver/templates/emails/invitations/project_invitation.html @@ -1,349 +1,1815 @@ - - - - - - - {{ first_name }} invited you to join {{ project_name }} on Plane - - - - - - - - + + + + - - - - - - + + + + + + diff --git a/deploy/1-click/README.md b/deploy/1-click/README.md deleted file mode 100644 index 9ed2323de..000000000 --- a/deploy/1-click/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# One-click deploy - -Deployment methods for Plane have improved significantly to make self-managing super-easy. One of those is a single-line-command installation of Plane. - -This short guide will guide you through the process, the background tasks that run with the command for the Community, One, and Enterprise editions, and the post-deployment configuration options available to you. - -### Requirements - -- Operating systems: Debian, Ubuntu, CentOS -- Supported CPU architectures: AMD64, ARM64, x86_64, AArch64 - -### Download the latest stable release - -Run ↓ on any CLI. - -``` -curl -fsSL https://raw.githubusercontent.com/makeplane/plane/master/deploy/1-click/install.sh | sh - -``` - -### Download the Preview release - -`Preview` builds do not support ARM64, AArch64 CPU architectures - -Run ↓ on any CLI. - -``` -export BRANCH=preview -curl -fsSL https://raw.githubusercontent.com/makeplane/plane/preview/deploy/1-click/install.sh | sh - -``` - ---- - -### Successful installation - -You should see ↓ if there are no hitches. That output will also list the IP address you can use to access your Plane instance. - -![Install Output](images/install.png) - ---- - -### Manage your Plane instance - -Use `plane-app` [OPERATOR] to manage your Plane instance easily. Get a list of all operators with `plane-app ---help`. - -![Plane Help](images/help.png) - -1. Basic operators - - 1. `plane-app start` starts the Plane server. - 2. `plane-app restart` restarts the Plane server. - 3. `plane-app stop` stops the Plane server. - -2. Advanced operators - - `plane-app --configure` will show advanced configurators. - - - Change your proxy or listening port -
Default: 80 - - Change your domain name -
Default: Deployed server's public IP address - - File upload size -
Default: 5MB - - Specify external database address when using an external database -
Default: `Empty` -
`Default folder: /opt/plane/data/postgres` - - Specify external Redis URL when using external Redis -
Default: `Empty` -
`Default folder: /opt/plane/data/redis` - - Configure AWS S3 bucket -
Use only when you or your users want to use S3 -
`Default folder: /opt/plane/data/minio` - -3. Version operators - - 1. `plane-app --upgrade` gets the latest stable version of `docker-compose.yaml`, `.env`, and Docker images - 2. `plane-app --update-installer` updates the installer and the `plane-app` utility. - 3. `plane-app --uninstall` uninstalls the Plane application and all Docker containers from the server but leaves the data stored in - Postgres, Redis, and Minio alone. - 4. `plane-app --install` installs the Plane app again. diff --git a/deploy/1-click/images/help.png b/deploy/1-click/images/help.png deleted file mode 100644 index c14603a4b..000000000 Binary files a/deploy/1-click/images/help.png and /dev/null differ diff --git a/deploy/1-click/images/install.png b/deploy/1-click/images/install.png deleted file mode 100644 index c8ba1e5f8..000000000 Binary files a/deploy/1-click/images/install.png and /dev/null differ diff --git a/deploy/1-click/install.sh b/deploy/1-click/install.sh deleted file mode 100644 index 9a0eac902..000000000 --- a/deploy/1-click/install.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/bash - -export GIT_REPO=makeplane/plane - -# Check if the user has sudo access -if command -v curl &> /dev/null; then - sudo curl -sSL \ - -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) -else - sudo wget -q \ - -O /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/$GIT_REPO/${BRANCH:-master}/deploy/1-click/plane-app?token=$(date +%s) -fi - -sudo chmod +x /usr/local/bin/plane-app -sudo sed -i 's@export DEPLOY_BRANCH=${BRANCH:-master}@export DEPLOY_BRANCH='${BRANCH:-master}'@' /usr/local/bin/plane-app -sudo sed -i 's@CODE_REPO=${GIT_REPO:-makeplane/plane}@CODE_REPO='$GIT_REPO'@' /usr/local/bin/plane-app - -plane-app -i #--help diff --git a/deploy/1-click/plane-app b/deploy/1-click/plane-app deleted file mode 100644 index ace0a0b79..000000000 --- a/deploy/1-click/plane-app +++ /dev/null @@ -1,791 +0,0 @@ -#!/bin/bash - -function print_header() { -clear - -cat <<"EOF" ---------------------------------------- - ____ _ -| _ \| | __ _ _ __ ___ -| |_) | |/ _` | '_ \ / _ \ -| __/| | (_| | | | | __/ -|_| |_|\__,_|_| |_|\___| - ---------------------------------------- -Project management tool from the future ---------------------------------------- - -EOF -} -function update_env_file() { - config_file=$1 - key=$2 - value=$3 - - # Check if the config file exists - if [ ! -f "$config_file" ]; then - echo "Config file not found. Creating a new one..." >&2 - sudo touch "$config_file" - fi - - # Check if the key already exists in the config file - if sudo grep "^$key=" "$config_file"; then - sudo awk -v key="$key" -v value="$value" -F '=' '{if ($1 == key) $2 = value} 1' OFS='=' "$config_file" | sudo tee "$config_file.tmp" > /dev/null - sudo mv "$config_file.tmp" "$config_file" &> /dev/null - else - # sudo echo "$key=$value" >> "$config_file" - echo -e "$key=$value" | sudo tee -a "$config_file" > /dev/null - fi -} -function read_env_file() { - config_file=$1 - key=$2 - - # Check if the config file exists - if [ ! -f "$config_file" ]; then - echo "Config file not found. Creating a new one..." >&2 - sudo touch "$config_file" - fi - - # Check if the key already exists in the config file - if sudo grep -q "^$key=" "$config_file"; then - value=$(sudo awk -v key="$key" -F '=' '{if ($1 == key) print $2}' "$config_file") - echo "$value" - else - echo "" - fi -} -function update_config() { - config_file="$PLANE_INSTALL_DIR/config.env" - update_env_file $config_file $1 $2 -} -function read_config() { - config_file="$PLANE_INSTALL_DIR/config.env" - read_env_file $config_file $1 -} -function update_env() { - config_file="$PLANE_INSTALL_DIR/.env" - update_env_file $config_file $1 $2 -} -function read_env() { - config_file="$PLANE_INSTALL_DIR/.env" - read_env_file $config_file $1 -} -function show_message() { - print_header - - if [ "$2" == "replace_last_line" ]; then - PROGRESS_MSG[-1]="$1" - else - PROGRESS_MSG+=("$1") - fi - - for statement in "${PROGRESS_MSG[@]}"; do - echo "$statement" - done - -} -function prepare_environment() { - show_message "Prepare Environment..." >&2 - - show_message "- Updating OS with required tools ✋" >&2 - sudo "$PACKAGE_MANAGER" update -y - # sudo "$PACKAGE_MANAGER" upgrade -y - - local required_tools=("curl" "awk" "wget" "nano" "dialog" "git" "uidmap" "jq") - - for tool in "${required_tools[@]}"; do - if ! command -v $tool &> /dev/null; then - sudo "$PACKAGE_MANAGER" install -y $tool - fi - done - - show_message "- OS Updated ✅" "replace_last_line" >&2 - - # Install Docker if not installed - if ! command -v docker &> /dev/null; then - show_message "- Installing Docker ✋" >&2 - # curl -o- https://get.docker.com | bash - - - if [ "$PACKAGE_MANAGER" == "yum" ]; then - sudo $PACKAGE_MANAGER install -y yum-utils - sudo yum-config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo &> /dev/null - elif [ "$PACKAGE_MANAGER" == "apt-get" ]; then - # Add Docker's official GPG key: - sudo $PACKAGE_MANAGER update - sudo $PACKAGE_MANAGER install ca-certificates curl &> /dev/null - sudo install -m 0755 -d /etc/apt/keyrings &> /dev/null - sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc &> /dev/null - sudo chmod a+r /etc/apt/keyrings/docker.asc &> /dev/null - - # Add the repository to Apt sources: - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \ - $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \ - sudo tee /etc/apt/sources.list.d/docker.list > /dev/null - - sudo $PACKAGE_MANAGER update - fi - - sudo $PACKAGE_MANAGER install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin -y - - show_message "- Docker Installed ✅" "replace_last_line" >&2 - else - show_message "- Docker is already installed ✅" >&2 - fi - - update_config "PLANE_ARCH" "$CPU_ARCH" - update_config "DOCKER_VERSION" "$(docker -v | awk '{print $3}' | sed 's/,//g')" - update_config "PLANE_DATA_DIR" "$DATA_DIR" - update_config "PLANE_LOG_DIR" "$LOG_DIR" - - # echo "TRUE" - echo "Environment prepared successfully ✅" - show_message "Environment prepared successfully ✅" >&2 - show_message "" >&2 - return 0 -} -function download_plane() { - # Download Docker Compose File from github url - show_message "Downloading Plane Setup Files ✋" >&2 - sudo curl -H 'Cache-Control: no-cache, no-store' \ - -s -o $PLANE_INSTALL_DIR/docker-compose.yaml \ - https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/docker-compose.yml?token=$(date +%s) - - sudo curl -H 'Cache-Control: no-cache, no-store' \ - -s -o $PLANE_INSTALL_DIR/variables-upgrade.env \ - https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/selfhost/variables.env?token=$(date +%s) - - # if .env does not exists rename variables-upgrade.env to .env - if [ ! -f "$PLANE_INSTALL_DIR/.env" ]; then - sudo mv $PLANE_INSTALL_DIR/variables-upgrade.env $PLANE_INSTALL_DIR/.env - fi - - show_message "Plane Setup Files Downloaded ✅" "replace_last_line" >&2 - show_message "" >&2 - - echo "PLANE_DOWNLOADED" - return 0 -} -function printUsageInstructions() { - show_message "" >&2 - show_message "----------------------------------" >&2 - show_message "Usage Instructions" >&2 - show_message "----------------------------------" >&2 - show_message "" >&2 - show_message "To use the Plane Setup utility, use below commands" >&2 - show_message "" >&2 - - show_message "Usage: plane-app [OPTION]" >&2 - show_message "" >&2 - show_message " start Start Server" >&2 - show_message " stop Stop Server" >&2 - show_message " restart Restart Server" >&2 - show_message "" >&2 - show_message "other options" >&2 - show_message " -i, --install Install Plane" >&2 - show_message " -c, --configure Configure Plane" >&2 - show_message " -up, --upgrade Upgrade Plane" >&2 - show_message " -un, --uninstall Uninstall Plane" >&2 - show_message " -ui, --update-installer Update Plane Installer" >&2 - show_message " -h, --help Show help" >&2 - show_message "" >&2 - show_message "" >&2 - show_message "Application Data is stored in mentioned folders" >&2 - show_message " - DB Data: $DATA_DIR/postgres" >&2 - show_message " - Redis Data: $DATA_DIR/redis" >&2 - show_message " - Minio Data: $DATA_DIR/minio" >&2 - show_message "" >&2 - show_message "" >&2 - show_message "----------------------------------" >&2 - show_message "" >&2 -} -function build_local_image() { - show_message "- Downloading Plane Source Code ✋" >&2 - REPO=https://github.com/$CODE_REPO.git - CURR_DIR=$PWD - PLANE_TEMP_CODE_DIR=$PLANE_INSTALL_DIR/temp - sudo rm -rf $PLANE_TEMP_CODE_DIR > /dev/null - - sudo git clone $REPO $PLANE_TEMP_CODE_DIR --branch $DEPLOY_BRANCH --single-branch -q > /dev/null - - sudo cp $PLANE_TEMP_CODE_DIR/deploy/selfhost/build.yml $PLANE_TEMP_CODE_DIR/build.yml - - show_message "- Plane Source Code Downloaded ✅" "replace_last_line" >&2 - - show_message "- Building Docker Images ✋" >&2 - sudo docker compose --env-file=$PLANE_INSTALL_DIR/.env -f $PLANE_TEMP_CODE_DIR/build.yml build --no-cache -} -function check_for_docker_images() { - show_message "" >&2 - # show_message "Building Plane Images" >&2 - - CURR_DIR=$(pwd) - - if [ "$DEPLOY_BRANCH" == "master" ]; then - update_env "APP_RELEASE" "latest" - export APP_RELEASE=latest - else - update_env "APP_RELEASE" "$DEPLOY_BRANCH" - export APP_RELEASE=$DEPLOY_BRANCH - fi - - if [ $USE_GLOBAL_IMAGES == 1 ]; then - # show_message "Building Plane Images for $CPU_ARCH is not required. Skipping... ✅" "replace_last_line" >&2 - export DOCKERHUB_USER=makeplane - update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" - update_env "PULL_POLICY" "always" - echo "Building Plane Images for $CPU_ARCH is not required. Skipping..." - else - export DOCKERHUB_USER=myplane - show_message "Building Plane Images for $CPU_ARCH " >&2 - update_env "DOCKERHUB_USER" "$DOCKERHUB_USER" - update_env "PULL_POLICY" "never" - - build_local_image - - sudo rm -rf $PLANE_INSTALL_DIR/temp > /dev/null - - show_message "- Docker Images Built ✅" "replace_last_line" >&2 - sudo cd $CURR_DIR - fi - - sudo sed -i "s|- pgdata:|- $DATA_DIR/postgres:|g" $PLANE_INSTALL_DIR/docker-compose.yaml - sudo sed -i "s|- redisdata:|- $DATA_DIR/redis:|g" $PLANE_INSTALL_DIR/docker-compose.yaml - sudo sed -i "s|- uploads:|- $DATA_DIR/minio:|g" $PLANE_INSTALL_DIR/docker-compose.yaml - - show_message "Downloading Plane Images for $CPU_ARCH ✋" >&2 - sudo docker compose -f $PLANE_INSTALL_DIR/docker-compose.yaml --env-file=$PLANE_INSTALL_DIR/.env pull - show_message "Plane Images Downloaded ✅" "replace_last_line" >&2 -} -function configure_plane() { - show_message "" >&2 - show_message "Configuring Plane" >&2 - show_message "" >&2 - - exec 3>&1 - - nginx_port=$(read_env "NGINX_PORT") - domain_name=$(read_env "DOMAIN_NAME") - upload_limit=$(read_env "FILE_SIZE_LIMIT") - - NGINX_SETTINGS=$(dialog \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --backtitle "Plane Configuration" \ - --title "Nginx Settings" \ - --form "" \ - 0 0 0 \ - "Port:" 1 1 "${nginx_port:-80}" 1 10 50 0 \ - "Domain:" 2 1 "${domain_name:-localhost}" 2 10 50 0 \ - "Upload Limit:" 3 1 "${upload_limit:-5242880}" 3 10 15 0 \ - 2>&1 1>&3) - - save_nginx_settings=0 - if [ $? -eq 0 ]; then - save_nginx_settings=1 - nginx_port=$(echo "$NGINX_SETTINGS" | sed -n 1p) - domain_name=$(echo "$NGINX_SETTINGS" | sed -n 2p) - upload_limit=$(echo "$NGINX_SETTINGS" | sed -n 3p) - fi - - - # smtp_host=$(read_env "EMAIL_HOST") - # smtp_user=$(read_env "EMAIL_HOST_USER") - # smtp_password=$(read_env "EMAIL_HOST_PASSWORD") - # smtp_port=$(read_env "EMAIL_PORT") - # smtp_from=$(read_env "EMAIL_FROM") - # smtp_tls=$(read_env "EMAIL_USE_TLS") - # smtp_ssl=$(read_env "EMAIL_USE_SSL") - - # SMTP_SETTINGS=$(dialog \ - # --ok-label "Next" \ - # --cancel-label "Skip" \ - # --backtitle "Plane Configuration" \ - # --title "SMTP Settings" \ - # --form "" \ - # 0 0 0 \ - # "Host:" 1 1 "$smtp_host" 1 10 80 0 \ - # "User:" 2 1 "$smtp_user" 2 10 80 0 \ - # "Password:" 3 1 "$smtp_password" 3 10 80 0 \ - # "Port:" 4 1 "${smtp_port:-587}" 4 10 5 0 \ - # "From:" 5 1 "${smtp_from:-Mailer }" 5 10 80 0 \ - # "TLS:" 6 1 "${smtp_tls:-1}" 6 10 1 1 \ - # "SSL:" 7 1 "${smtp_ssl:-0}" 7 10 1 1 \ - # 2>&1 1>&3) - - # save_smtp_settings=0 - # if [ $? -eq 0 ]; then - # save_smtp_settings=1 - # smtp_host=$(echo "$SMTP_SETTINGS" | sed -n 1p) - # smtp_user=$(echo "$SMTP_SETTINGS" | sed -n 2p) - # smtp_password=$(echo "$SMTP_SETTINGS" | sed -n 3p) - # smtp_port=$(echo "$SMTP_SETTINGS" | sed -n 4p) - # smtp_from=$(echo "$SMTP_SETTINGS" | sed -n 5p) - # smtp_tls=$(echo "$SMTP_SETTINGS" | sed -n 6p) - # fi - external_pgdb_url=$(dialog \ - --backtitle "Plane Configuration" \ - --title "Using External Postgres Database ?" \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --inputbox "Enter your external database url" \ - 8 60 3>&1 1>&2 2>&3) - - - external_redis_url=$(dialog \ - --backtitle "Plane Configuration" \ - --title "Using External Redis Database ?" \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --inputbox "Enter your external redis url" \ - 8 60 3>&1 1>&2 2>&3) - - - aws_region=$(read_env "AWS_REGION") - aws_access_key=$(read_env "AWS_ACCESS_KEY_ID") - aws_secret_key=$(read_env "AWS_SECRET_ACCESS_KEY") - aws_bucket=$(read_env "AWS_S3_BUCKET_NAME") - - - AWS_S3_SETTINGS=$(dialog \ - --ok-label "Next" \ - --cancel-label "Skip" \ - --backtitle "Plane Configuration" \ - --title "AWS S3 Bucket Configuration" \ - --form "" \ - 0 0 0 \ - "Region:" 1 1 "$aws_region" 1 10 50 0 \ - "Access Key:" 2 1 "$aws_access_key" 2 10 50 0 \ - "Secret Key:" 3 1 "$aws_secret_key" 3 10 50 0 \ - "Bucket:" 4 1 "$aws_bucket" 4 10 50 0 \ - 2>&1 1>&3) - - save_aws_settings=0 - if [ $? -eq 0 ]; then - save_aws_settings=1 - aws_region=$(echo "$AWS_S3_SETTINGS" | sed -n 1p) - aws_access_key=$(echo "$AWS_S3_SETTINGS" | sed -n 2p) - aws_secret_key=$(echo "$AWS_S3_SETTINGS" | sed -n 3p) - aws_bucket=$(echo "$AWS_S3_SETTINGS" | sed -n 4p) - fi - - # display dialogbox asking for confirmation to continue - CONFIRM_CONFIG=$(dialog \ - --title "Confirm Configuration" \ - --backtitle "Plane Configuration" \ - --yes-label "Confirm" \ - --no-label "Cancel" \ - --yesno \ - " - save_ngnix_settings: $save_nginx_settings - nginx_port: $nginx_port - domain_name: $domain_name - upload_limit: $upload_limit - - save_aws_settings: $save_aws_settings - aws_region: $aws_region - aws_access_key: $aws_access_key - aws_secret_key: $aws_secret_key - aws_bucket: $aws_bucket - - pdgb_url: $external_pgdb_url - redis_url: $external_redis_url - " \ - 0 0 3>&1 1>&2 2>&3) - - if [ $? -eq 0 ]; then - if [ $save_nginx_settings == 1 ]; then - update_env "NGINX_PORT" "$nginx_port" - update_env "DOMAIN_NAME" "$domain_name" - update_env "WEB_URL" "http://$domain_name" - update_env "CORS_ALLOWED_ORIGINS" "http://$domain_name" - update_env "FILE_SIZE_LIMIT" "$upload_limit" - fi - - # check enable smpt settings value - # if [ $save_smtp_settings == 1 ]; then - # update_env "EMAIL_HOST" "$smtp_host" - # update_env "EMAIL_HOST_USER" "$smtp_user" - # update_env "EMAIL_HOST_PASSWORD" "$smtp_password" - # update_env "EMAIL_PORT" "$smtp_port" - # update_env "EMAIL_FROM" "$smtp_from" - # update_env "EMAIL_USE_TLS" "$smtp_tls" - # update_env "EMAIL_USE_SSL" "$smtp_ssl" - # fi - - # check enable aws settings value - if [[ $save_aws_settings == 1 && $aws_access_key != "" && $aws_secret_key != "" ]] ; then - update_env "USE_MINIO" "0" - update_env "AWS_REGION" "$aws_region" - update_env "AWS_ACCESS_KEY_ID" "$aws_access_key" - update_env "AWS_SECRET_ACCESS_KEY" "$aws_secret_key" - update_env "AWS_S3_BUCKET_NAME" "$aws_bucket" - elif [[ -z $aws_access_key || -z $aws_secret_key ]] ; then - update_env "USE_MINIO" "1" - update_env "AWS_REGION" "" - update_env "AWS_ACCESS_KEY_ID" "" - update_env "AWS_SECRET_ACCESS_KEY" "" - update_env "AWS_S3_BUCKET_NAME" "uploads" - fi - - if [ "$external_pgdb_url" != "" ]; then - update_env "DATABASE_URL" "$external_pgdb_url" - fi - if [ "$external_redis_url" != "" ]; then - update_env "REDIS_URL" "$external_redis_url" - fi - fi - - exec 3>&- -} -function upgrade_configuration() { - upg_env_file="$PLANE_INSTALL_DIR/variables-upgrade.env" - # Check if the file exists - if [ -f "$upg_env_file" ]; then - # Read each line from the file - while IFS= read -r line; do - # Skip comments and empty lines - if [[ "$line" =~ ^\s*#.*$ ]] || [[ -z "$line" ]]; then - continue - fi - - # Split the line into key and value - key=$(echo "$line" | cut -d'=' -f1) - value=$(echo "$line" | cut -d'=' -f2-) - - current_value=$(read_env "$key") - - if [ -z "$current_value" ]; then - update_env "$key" "$value" - fi - done < "$upg_env_file" - fi -} -function install() { - show_message "" - if [ "$(uname)" == "Linux" ]; then - OS="linux" - OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) - OS_NAME=$(echo "$OS_NAME" | tr -d '"') - print_header - if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || - [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then - OS_SUPPORTED=true - show_message "******** Installing Plane ********" - show_message "" - - prepare_environment - - if [ $? -eq 0 ]; then - download_plane - if [ $? -eq 0 ]; then - # create_service - check_for_docker_images - - last_installed_on=$(read_config "INSTALLATION_DATE") - # if [ "$last_installed_on" == "" ]; then - # configure_plane - # fi - - update_env "NGINX_PORT" "80" - update_env "DOMAIN_NAME" "$MY_IP" - update_env "WEB_URL" "http://$MY_IP" - update_env "CORS_ALLOWED_ORIGINS" "http://$MY_IP" - - update_config "INSTALLATION_DATE" "$(date '+%Y-%m-%d')" - - show_message "Plane Installed Successfully ✅" - show_message "" - else - show_message "Download Failed ❌" - exit 1 - fi - else - show_message "Initialization Failed ❌" - exit 1 - fi - - else - OS_SUPPORTED=false - PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" - show_message "" - exit 1 - fi - else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" - show_message "" - exit 1 - fi -} -function upgrade() { - print_header - if [ "$(uname)" == "Linux" ]; then - OS="linux" - OS_NAME=$(sudo awk -F= '/^ID=/{print $2}' /etc/os-release) - OS_NAME=$(echo "$OS_NAME" | tr -d '"') - if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || - [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then - - OS_SUPPORTED=true - show_message "******** Upgrading Plane ********" - show_message "" - - prepare_environment - - if [ $? -eq 0 ]; then - stop_server - download_plane - if [ $? -eq 0 ]; then - check_for_docker_images - upgrade_configuration - update_config "UPGRADE_DATE" "$(date)" - - start_server - - show_message "" - show_message "Plane Upgraded Successfully ✅" - show_message "" - printUsageInstructions - else - show_message "Download Failed ❌" - exit 1 - fi - else - show_message "Initialization Failed ❌" - exit 1 - fi - else - PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" - show_message "" - exit 1 - fi - else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" - show_message "" - exit 1 - fi -} -function uninstall() { - print_header - if [ "$(uname)" == "Linux" ]; then - OS="linux" - OS_NAME=$(awk -F= '/^ID=/{print $2}' /etc/os-release) - OS_NAME=$(echo "$OS_NAME" | tr -d '"') - if [ "$OS_NAME" == "ubuntu" ] || [ "$OS_NAME" == "debian" ] || - [ "$OS_NAME" == "centos" ] || [ "$OS_NAME" == "amazon" ]; then - - OS_SUPPORTED=true - show_message "******** Uninstalling Plane ********" - show_message "" - - stop_server - - if ! [ -x "$(command -v docker)" ]; then - echo "DOCKER_NOT_INSTALLED" &> /dev/null - else - # Ask of user input to confirm uninstall docker ? - CONFIRM_DOCKER_PURGE=$(dialog --title "Uninstall Docker" --defaultno --yesno "Are you sure you want to uninstall docker ?" 8 60 3>&1 1>&2 2>&3) - if [ $? -eq 0 ]; then - show_message "- Uninstalling Docker ✋" - sudo docker images -q | xargs -r sudo docker rmi -f &> /dev/null - sudo "$PACKAGE_MANAGER" remove -y docker-engine docker docker.io docker-ce docker-ce-cli docker-compose-plugin &> /dev/null - sudo "$PACKAGE_MANAGER" autoremove -y docker-engine docker docker.io docker-ce docker-compose-plugin &> /dev/null - show_message "- Docker Uninstalled ✅" "replace_last_line" >&2 - fi - fi - - sudo rm $PLANE_INSTALL_DIR/.env &> /dev/null - 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 - - # rm -rf $PLANE_INSTALL_DIR &> /dev/null - show_message "- Configuration Cleaned ✅" - - show_message "" - show_message "******** Plane Uninstalled ********" - show_message "" - show_message "" - show_message "Plane Configuration Cleaned with some exceptions" - show_message "- DB Data: $DATA_DIR/postgres" - show_message "- Redis Data: $DATA_DIR/redis" - show_message "- Minio Data: $DATA_DIR/minio" - show_message "" - show_message "" - show_message "Thank you for using Plane. We hope to see you again soon." - show_message "" - show_message "" - else - PROGRESS_MSG="❌❌ Unsupported OS Varient Detected : $OS_NAME ❌❌" - show_message "" - exit 1 - fi - else - PROGRESS_MSG="❌❌❌ Unsupported OS Detected : $(uname) ❌❌❌" - show_message "" - exit 1 - fi -} -function start_server() { - docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml" - env_file="$PLANE_INSTALL_DIR/.env" - # check if both the files exits - if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Starting Plane Server ($APP_RELEASE) ✋" - sudo docker compose -f $docker_compose_file --env-file=$env_file up -d - - # Wait for containers to be running - echo "Waiting for containers to start..." - while ! sudo docker compose -f "$docker_compose_file" --env-file="$env_file" ps --services --filter "status=running" --quiet | grep -q "."; do - sleep 1 - done - # wait for migrator container to exit with status 0 before starting the application - migrator_container_id=$(sudo docker container ls -aq -f "name=plane-migrator") - - # if migrator container is running, wait for it to exit - if [ -n "$migrator_container_id" ]; then - while sudo docker inspect --format='{{.State.Status}}' $migrator_container_id | grep -q "running"; do - show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (Migrator in progress)" "replace_last_line" >&2 - sleep 1 - done - fi - - # if migrator exit status is not 0, show error message and exit - if [ -n "$migrator_container_id" ]; then - migrator_exit_code=$(sudo docker inspect --format='{{.State.ExitCode}}' $migrator_container_id) - if [ $migrator_exit_code -ne 0 ]; then - # show_message "Migrator failed with exit code $migrator_exit_code ❌" "replace_last_line" >&2 - show_message "Plane Server failed to start ❌" "replace_last_line" >&2 - stop_server - exit 1 - fi - fi - - api_container_id=$(sudo docker container ls -q -f "name=plane-api") - while ! sudo docker logs $api_container_id 2>&1 | grep -i "Application startup complete"; - do - show_message "Waiting for Plane Server ($APP_RELEASE) to start...✋ (API starting)" "replace_last_line" >&2 - sleep 1 - done - show_message "Plane Server Started ($APP_RELEASE) ✅" "replace_last_line" >&2 - show_message "---------------------------------------------------------------" >&2 - show_message "Access the Plane application at http://$MY_IP" >&2 - show_message "---------------------------------------------------------------" >&2 - - else - show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 - fi -} -function stop_server() { - docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml" - env_file="$PLANE_INSTALL_DIR/.env" - # check if both the files exits - if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Stopping Plane Server ($APP_RELEASE) ✋" - sudo docker compose -f $docker_compose_file --env-file=$env_file down - show_message "Plane Server Stopped ($APP_RELEASE) ✅" "replace_last_line" >&2 - else - show_message "Plane Server not installed [Skipping] ✅" "replace_last_line" >&2 - fi -} -function restart_server() { - docker_compose_file="$PLANE_INSTALL_DIR/docker-compose.yaml" - env_file="$PLANE_INSTALL_DIR/.env" - # check if both the files exits - if [ -f "$docker_compose_file" ] && [ -f "$env_file" ]; then - show_message "Restarting Plane Server ($APP_RELEASE) ✋" - sudo docker compose -f $docker_compose_file --env-file=$env_file restart - show_message "Plane Server Restarted ($APP_RELEASE) ✅" "replace_last_line" >&2 - else - show_message "Plane Server not installed. Please install Plane first ❌" "replace_last_line" >&2 - fi -} -function show_help() { - # print_header - show_message "Usage: plane-app [OPTION]" >&2 - show_message "" >&2 - show_message " start Start Server" >&2 - show_message " stop Stop Server" >&2 - show_message " restart Restart Server" >&2 - show_message "" >&2 - show_message "other options" >&2 - show_message " -i, --install Install Plane" >&2 - show_message " -c, --configure Configure Plane" >&2 - show_message " -up, --upgrade Upgrade Plane" >&2 - show_message " -un, --uninstall Uninstall Plane" >&2 - show_message " -ui, --update-installer Update Plane Installer" >&2 - show_message " -h, --help Show help" >&2 - show_message "" >&2 - exit 1 - -} -function update_installer() { - show_message "Updating Plane Installer ✋" >&2 - sudo curl -H 'Cache-Control: no-cache, no-store' \ - -s -o /usr/local/bin/plane-app \ - https://raw.githubusercontent.com/$CODE_REPO/$DEPLOY_BRANCH/deploy/1-click/plane-app?token=$(date +%s) - - sudo chmod +x /usr/local/bin/plane-app > /dev/null&> /dev/null - show_message "Plane Installer Updated ✅" "replace_last_line" >&2 -} - -export DEPLOY_BRANCH=${BRANCH:-master} -export APP_RELEASE=$DEPLOY_BRANCH -export DOCKERHUB_USER=makeplane -export PULL_POLICY=always - -if [ "$DEPLOY_BRANCH" == "master" ]; then - export APP_RELEASE=latest -fi - -PLANE_INSTALL_DIR=/opt/plane -DATA_DIR=$PLANE_INSTALL_DIR/data -LOG_DIR=$PLANE_INSTALL_DIR/logs -CODE_REPO=${GIT_REPO:-makeplane/plane} -OS_SUPPORTED=false -CPU_ARCH=$(uname -m) -PROGRESS_MSG="" -USE_GLOBAL_IMAGES=0 -PACKAGE_MANAGER="" -MY_IP=$(curl -s ifconfig.me) - -if [[ $CPU_ARCH == "amd64" || $CPU_ARCH == "x86_64" || ( $DEPLOY_BRANCH == "master" && ( $CPU_ARCH == "arm64" || $CPU_ARCH == "aarch64" ) ) ]]; then - USE_GLOBAL_IMAGES=1 -fi - -sudo mkdir -p $PLANE_INSTALL_DIR/{data,log} - -if command -v apt-get &> /dev/null; then - PACKAGE_MANAGER="apt-get" -elif command -v yum &> /dev/null; then - PACKAGE_MANAGER="yum" -elif command -v apk &> /dev/null; then - PACKAGE_MANAGER="apk" -fi - -if [ "$1" == "start" ]; then - start_server -elif [ "$1" == "stop" ]; then - stop_server -elif [ "$1" == "restart" ]; then - restart_server -elif [ "$1" == "--install" ] || [ "$1" == "-i" ]; then - install - start_server - show_message "" >&2 - show_message "To view help, use plane-app --help " >&2 -elif [ "$1" == "--configure" ] || [ "$1" == "-c" ]; then - configure_plane - printUsageInstructions -elif [ "$1" == "--upgrade" ] || [ "$1" == "-up" ]; then - upgrade -elif [ "$1" == "--uninstall" ] || [ "$1" == "-un" ]; then - uninstall -elif [ "$1" == "--update-installer" ] || [ "$1" == "-ui" ]; then - update_installer -elif [ "$1" == "--help" ] || [ "$1" == "-h" ]; then - show_help -else - show_help -fi diff --git a/packages/types/src/project/project_filters.d.ts b/packages/types/src/project/project_filters.d.ts index 02ad09ee1..77da7365f 100644 --- a/packages/types/src/project/project_filters.d.ts +++ b/packages/types/src/project/project_filters.d.ts @@ -9,9 +9,14 @@ export type TProjectOrderByOptions = export type TProjectDisplayFilters = { my_projects?: boolean; + archived_projects?: boolean; order_by?: TProjectOrderByOptions; }; +export type TProjectAppliedDisplayFilterKeys = + | "my_projects" + | "archived_projects"; + export type TProjectFilters = { access?: string[] | null; lead?: string[] | null; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index 86384b401..157ecb16e 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -23,6 +23,7 @@ export type TProjectLogoProps = { export interface IProject { archive_in: number; + archived_at: string | null; archived_issues: number; archived_sub_issues: number; close_in: number; diff --git a/packages/ui/src/dropdowns/custom-menu.tsx b/packages/ui/src/dropdowns/custom-menu.tsx index b94faf436..549c83fe7 100644 --- a/packages/ui/src/dropdowns/custom-menu.tsx +++ b/packages/ui/src/dropdowns/custom-menu.tsx @@ -131,6 +131,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { ref={setReferenceElement} type="button" onClick={(e) => { + e.preventDefault(); e.stopPropagation(); isOpen ? closeDropdown() : openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); @@ -157,6 +158,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => { : "cursor-pointer hover:bg-custom-background-80" } ${buttonClassName}`} onClick={(e) => { + e.preventDefault(); e.stopPropagation(); isOpen ? closeDropdown() : openDropdown(); if (menuButtonOnClick) menuButtonOnClick(); diff --git a/space/next.config.js b/space/next.config.js index 18b9275a1..b36368720 100644 --- a/space/next.config.js +++ b/space/next.config.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ /** @type {import('next').NextConfig} */ require("dotenv").config({ path: ".env" }); const { withSentryConfig } = require("@sentry/nextjs"); @@ -26,8 +27,11 @@ const nextConfig = { output: "standalone", }; -if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0")) { - module.exports = withSentryConfig(nextConfig, { silent: true }, { hideSourceMaps: true }); +if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0"), 10) { + module.exports = withSentryConfig(nextConfig, + { silent: true, authToken: process.env.SENTRY_AUTH_TOKEN }, + { hideSourceMaps: true } + ); } else { module.exports = nextConfig; } diff --git a/space/package.json b/space/package.json index 4951d5e30..3d6127edb 100644 --- a/space/package.json +++ b/space/package.json @@ -22,7 +22,7 @@ "@plane/rich-text-editor": "*", "@plane/types": "*", "@plane/ui": "*", - "@sentry/nextjs": "^7.85.0", + "@sentry/nextjs": "^7.108.0", "axios": "^1.3.4", "clsx": "^2.0.0", "dotenv": "^16.3.1", diff --git a/turbo.json b/turbo.json index 9302a7183..4e8c4ee81 100644 --- a/turbo.json +++ b/turbo.json @@ -17,37 +17,25 @@ "NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_DEBUG", - "JITSU_TRACKER_ACCESS_KEY", - "JITSU_TRACKER_HOST" + "SENTRY_AUTH_TOKEN" ], "pipeline": { "build": { - "dependsOn": [ - "^build" - ], - "outputs": [ - ".next/**", - "dist/**" - ] + "dependsOn": ["^build"], + "outputs": [".next/**", "dist/**"] }, "develop": { "cache": false, "persistent": true, - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, "dev": { "cache": false, "persistent": true, - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, "test": { - "dependsOn": [ - "^build" - ], + "dependsOn": ["^build"], "outputs": [] }, "lint": { diff --git a/web/components/automation/select-month-modal.tsx b/web/components/automation/select-month-modal.tsx index e0768b1df..7b068fa41 100644 --- a/web/components/automation/select-month-modal.tsx +++ b/web/components/automation/select-month-modal.tsx @@ -98,6 +98,8 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, hasError={Boolean(errors.close_in)} placeholder="Enter Months" className="w-full border-custom-border-200" + min={1} + max={12} /> Months @@ -130,6 +132,8 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, hasError={Boolean(errors.archive_in)} placeholder="Enter Months" className="w-full border-custom-border-200" + min={1} + max={12} /> Months diff --git a/web/components/command-palette/command-palette.tsx b/web/components/command-palette/command-palette.tsx index 8d162f6ab..490d392e2 100644 --- a/web/components/command-palette/command-palette.tsx +++ b/web/components/command-palette/command-palette.tsx @@ -113,8 +113,6 @@ export const CommandPalette: FC = observer(() => { const canPerformWorkspaceCreateActions = useCallback( (showToast: boolean = true) => { const isAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; - console.log("currentWorkspaceRole", currentWorkspaceRole); - console.log("isAllowed", isAllowed); if (!isAllowed && showToast) setToast({ type: TOAST_TYPE.ERROR, diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index f8c247ce7..1397039d1 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -40,13 +40,14 @@ type Props = { onChange: (data: string) => void; disabled?: boolean; tabIndex?: number; + isProfileCover?: boolean; }; // services const fileService = new FileService(); export const ImagePickerPopover: React.FC = observer((props) => { - const { label, value, control, onChange, disabled = false, tabIndex } = props; + const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false } = props; // states const [image, setImage] = useState(null); const [isImageUploading, setIsImageUploading] = useState(false); @@ -97,37 +98,53 @@ export const ImagePickerPopover: React.FC = observer((props) => { const handleSubmit = async () => { setIsImageUploading(true); - if (!image || !workspaceSlug) return; + if (!image) return; const formData = new FormData(); formData.append("asset", image); formData.append("attributes", JSON.stringify({})); - fileService - .uploadFile(workspaceSlug.toString(), formData) - .then((res) => { - const oldValue = value; - const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com"; + const oldValue = value; + const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com"; - const imageUrl = res.asset; - onChange(imageUrl); - setIsImageUploading(false); - setImage(null); - setIsOpen(false); + const uploadCallback = (res: any) => { + const imageUrl = res.asset; + onChange(imageUrl); + setIsImageUploading(false); + setImage(null); + setIsOpen(false); + }; - if (isUnsplashImage) return; - - if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue); - }) - .catch((err) => { - console.error(err); - }); + if (isProfileCover) { + fileService + .uploadUserFile(formData) + .then((res) => { + uploadCallback(res); + if (isUnsplashImage) return; + if (oldValue && currentWorkspace) fileService.deleteUserFile(oldValue); + }) + .catch((err) => { + console.error(err); + }); + } else { + if (!workspaceSlug) return; + fileService + .uploadFile(workspaceSlug.toString(), formData) + .then((res) => { + uploadCallback(res); + if (isUnsplashImage) return; + if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue); + }) + .catch((err) => { + console.error(err); + }); + } }; useEffect(() => { if (!unsplashImages || value !== null) return; - onChange(unsplashImages[0].urls.regular); + onChange(unsplashImages[0]?.urls.regular); }, [value, onChange, unsplashImages]); const handleClose = () => { @@ -149,7 +166,7 @@ export const ImagePickerPopover: React.FC = observer((props) => { useOutsideClickDetector(ref, handleClose); return ( - + = observer((props) => { {isOpen && (
= observer((props) => { )) ) : ( -
- There are no high priority issues present in this cycle. +
+
) ) : ( @@ -195,63 +201,75 @@ export const ActiveCycleStats: FC = observer((props) => { as="div" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" > - {cycle.distribution?.assignees?.map((assignee, index) => { - if (assignee.assignee_id) - return ( - - + {cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? ( + cycle.distribution?.assignees?.map((assignee, index) => { + if (assignee.assignee_id) + return ( + + - {assignee.display_name} -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - else - return ( - -
- User + {assignee.display_name}
- No assignee -
- } - completed={assignee.completed_issues} - total={assignee.total_issues} - /> - ); - })} + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + else + return ( + +
+ User +
+ No assignee + + } + completed={assignee.completed_issues} + total={assignee.total_issues} + /> + ); + }) + ) : ( +
+ +
+ )} - {cycle.distribution?.labels?.map((label, index) => ( - - - {label.label_name ?? "No labels"} - - } - completed={label.completed_issues} - total={label.total_issues} - /> - ))} + {cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? ( + cycle.distribution.labels?.map((label, index) => ( + + + {label.label_name ?? "No labels"} + + } + completed={label.completed_issues} + total={label.total_issues} + /> + )) + ) : ( +
+ +
+ )}
diff --git a/web/components/cycles/active-cycle/productivity.tsx b/web/components/cycles/active-cycle/productivity.tsx index 59c2ac3c9..a3366d934 100644 --- a/web/components/cycles/active-cycle/productivity.tsx +++ b/web/components/cycles/active-cycle/productivity.tsx @@ -3,6 +3,9 @@ import { FC } from "react"; import { ICycle } from "@plane/types"; // components import ProgressChart from "@/components/core/sidebar/progress-chart"; +import { EmptyState } from "@/components/empty-state"; +// constants +import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProductivityProps = { cycle: ICycle; @@ -16,31 +19,40 @@ export const ActiveCycleProductivity: FC = (props)

Issue burndown

- -
-
-
-
- - Ideal + {cycle.total_issues > 0 ? ( + <> +
+
+
+
+ + Ideal +
+
+ + Current +
+
+ {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}
-
- - Current +
+
- {`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`} -
-
- -
-
+ + ) : ( + <> +
+ +
+ + )}
); }; diff --git a/web/components/cycles/active-cycle/progress.tsx b/web/components/cycles/active-cycle/progress.tsx index dea3b496a..752f72bcc 100644 --- a/web/components/cycles/active-cycle/progress.tsx +++ b/web/components/cycles/active-cycle/progress.tsx @@ -3,8 +3,11 @@ import { FC } from "react"; import { ICycle } from "@plane/types"; // ui import { LinearProgressIndicator } from "@plane/ui"; +// components +import { EmptyState } from "@/components/empty-state"; // constants import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; +import { EmptyStateType } from "@/constants/empty-state"; export type ActiveCycleProgressProps = { cycle: ICycle; @@ -32,48 +35,56 @@ export const ActiveCycleProgress: FC = (props) => {

Progress

- - {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ - cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" - } closed`} - + {cycle.total_issues > 0 && ( + + {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ + cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" + } closed`} + + )}
- + {cycle.total_issues > 0 && }
-
- {Object.keys(groupedIssues).map((group, index) => ( - <> - {groupedIssues[group] > 0 && ( -
-
-
- - {group} + {cycle.total_issues > 0 ? ( +
+ {Object.keys(groupedIssues).map((group, index) => ( + <> + {groupedIssues[group] > 0 && ( +
+
+
+ + {group} +
+ {`${groupedIssues[group]} ${ + groupedIssues[group] > 1 ? "Issues" : "Issue" + }`}
- {`${groupedIssues[group]} ${ - groupedIssues[group] > 1 ? "Issues" : "Issue" - }`}
-
- )} - - ))} - {cycle.cancelled_issues > 0 && ( - - - {`${cycle.cancelled_issues} cancelled ${ - cycle.cancelled_issues > 1 ? "issues are" : "issue is" - } excluded from this report.`}{" "} + )} + + ))} + {cycle.cancelled_issues > 0 && ( + + + {`${cycle.cancelled_issues} cancelled ${ + cycle.cancelled_issues > 1 ? "issues are" : "issue is" + } excluded from this report.`}{" "} + - - )} -
+ )} +
+ ) : ( +
+ +
+ )}
); }; diff --git a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx index f4156f341..221ffab0b 100644 --- a/web/components/cycles/active-cycle/upcoming-cycles-list.tsx +++ b/web/components/cycles/active-cycle/upcoming-cycles-list.tsx @@ -1,5 +1,7 @@ import { FC } from "react"; import { observer } from "mobx-react"; +import Image from "next/image"; +import { useTheme } from "next-themes"; // components import { UpcomingCycleListItem } from "@/components/cycles"; // hooks @@ -14,6 +16,11 @@ export const UpcomingCyclesList: FC = observer((props) => { // store hooks const { currentProjectUpcomingCycleIds } = useCycle(); + // theme + const { resolvedTheme } = useTheme(); + + const resolvedEmptyStatePath = `/empty-state/active-cycle/cycle-${resolvedTheme === "light" ? "light" : "dark"}.webp`; + if (!currentProjectUpcomingCycleIds) return null; return ( @@ -28,8 +35,18 @@ export const UpcomingCyclesList: FC = observer((props) => { ))}
) : ( -
-
+
+
+
+ button image +
No upcoming cycles

Create new cycles to find them here or check diff --git a/web/components/cycles/archived-cycles/modal.tsx b/web/components/cycles/archived-cycles/modal.tsx index a9b421351..6e0ddef35 100644 --- a/web/components/cycles/archived-cycles/modal.tsx +++ b/web/components/cycles/archived-cycles/modal.tsx @@ -31,7 +31,7 @@ export const ArchiveCycleModal: React.FC = (props) => { handleClose(); }; - const handleArchiveIssue = async () => { + const handleArchiveCycle = async () => { setIsArchiving(true); await archiveCycle(workspaceSlug, projectId, cycleId) .then(() => { @@ -89,7 +89,7 @@ export const ArchiveCycleModal: React.FC = (props) => { -

diff --git a/web/components/cycles/board/cycles-board-card.tsx b/web/components/cycles/board/cycles-board-card.tsx index 8426fe313..34d395db4 100644 --- a/web/components/cycles/board/cycles-board-card.tsx +++ b/web/components/cycles/board/cycles-board-card.tsx @@ -69,8 +69,8 @@ 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 handleAddToFavorites = (e: MouseEvent) => { @@ -134,10 +134,18 @@ export const CyclesBoardCard: FC = observer((props) => { e.preventDefault(); e.stopPropagation(); - router.push({ - pathname: router.pathname, - query: { ...query, peekCycle: cycleId }, - }); + if (query.peekCycle) { + delete query.peekCycle; + router.push({ + pathname: router.pathname, + query: { ...query }, + }); + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekCycle: cycleId }, + }); + } }; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; diff --git a/web/components/cycles/list/cycles-list-item.tsx b/web/components/cycles/list/cycles-list-item.tsx index a418f9b04..7ee797bb2 100644 --- a/web/components/cycles/list/cycles-list-item.tsx +++ b/web/components/cycles/list/cycles-list-item.tsx @@ -106,10 +106,18 @@ export const CyclesListItem: FC = observer((props) => { e.preventDefault(); e.stopPropagation(); - router.push({ - pathname: router.pathname, - query: { ...query, peekCycle: cycleId }, - }); + if (query.peekCycle) { + delete query.peekCycle; + router.push({ + pathname: router.pathname, + query: { ...query }, + }); + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekCycle: cycleId }, + }); + } }; const cycleDetails = getCycleById(cycleId); @@ -190,7 +198,7 @@ export const CyclesListItem: FC = observer((props) => { {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
-
+
{currentCycle && (
= observer((props) => { isArchived={isArchived} /> {completedCycleIds.length !== 0 && ( - + {({ open }) => ( <> diff --git a/web/components/cycles/modal.tsx b/web/components/cycles/modal.tsx index 008cdcbde..6ed53eb32 100644 --- a/web/components/cycles/modal.tsx +++ b/web/components/cycles/modal.tsx @@ -56,7 +56,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: err.detail ?? "Error in creating cycle. Please try again.", + message: err?.detail ?? "Error in creating cycle. Please try again.", }); captureCycleEvent({ eventName: CYCLE_CREATED, @@ -90,7 +90,7 @@ export const CycleCreateUpdateModal: React.FC = (props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: err.detail ?? "Error in updating cycle. Please try again.", + message: err?.detail ?? "Error in updating cycle. Please try again.", }); }); }; diff --git a/web/components/dropdowns/cycle/index.tsx b/web/components/dropdowns/cycle/index.tsx index d59cb8b83..ce721e0a1 100644 --- a/web/components/dropdowns/cycle/index.tsx +++ b/web/components/dropdowns/cycle/index.tsx @@ -41,7 +41,7 @@ export const CycleDropdown: React.FC = observer((props) => { hideIcon = false, onChange, onClose, - placeholder = "Cycle", + placeholder = "", placement, projectId, showTooltip = false, @@ -132,7 +132,7 @@ export const CycleDropdown: React.FC = observer((props) => { variant={buttonVariant} > {!hideIcon && } - {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( + {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (!!selectedName || !!placeholder) && ( {selectedName ?? placeholder} )} {dropdownArrow && ( diff --git a/web/components/dropdowns/member/index.tsx b/web/components/dropdowns/member/index.tsx index edc2b604e..5691fd920 100644 --- a/web/components/dropdowns/member/index.tsx +++ b/web/components/dropdowns/member/index.tsx @@ -37,6 +37,7 @@ export const MemberDropdown: React.FC = observer((props) => { onChange, onClose, placeholder = "Members", + tooltipContent, placement, projectId, showTooltip = false, @@ -123,7 +124,7 @@ export const MemberDropdown: React.FC = observer((props) => { className={buttonClassName} isActive={isOpen} tooltipHeading={placeholder} - tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`} + tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`} showTooltip={showTooltip} variant={buttonVariant} > diff --git a/web/components/dropdowns/member/types.d.ts b/web/components/dropdowns/member/types.d.ts index 21e6a534e..bfae9c65e 100644 --- a/web/components/dropdowns/member/types.d.ts +++ b/web/components/dropdowns/member/types.d.ts @@ -5,6 +5,7 @@ export type MemberDropdownProps = TDropdownProps & { dropdownArrow?: boolean; dropdownArrowClassName?: string; placeholder?: string; + tooltipContent?: string; onClose?: () => void; } & ( | { diff --git a/web/components/dropdowns/module/index.tsx b/web/components/dropdowns/module/index.tsx index 89e6a8217..1d843db8a 100644 --- a/web/components/dropdowns/module/index.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -46,7 +46,7 @@ type ButtonContentProps = { hideIcon: boolean; hideText: boolean; onChange: (moduleIds: string[]) => void; - placeholder: string; + placeholder?: string; showCount: boolean; showTooltip?: boolean; value: string | string[] | null; @@ -73,15 +73,17 @@ const ButtonContent: React.FC = (props) => { return ( <> {showCount ? ( -
+
{!hideIcon && } -
- {value.length > 0 - ? value.length === 1 - ? `${getModuleById(value[0])?.name || "module"}` - : `${value.length} Module${value.length === 1 ? "" : "s"}` - : placeholder} -
+ {(value.length > 0 || !!placeholder) && ( +
+ {value.length > 0 + ? value.length === 1 + ? `${getModuleById(value[0])?.name || "module"}` + : `${value.length} Module${value.length === 1 ? "" : "s"}` + : placeholder} +
+ )}
) : value.length > 0 ? (
@@ -158,7 +160,7 @@ export const ModuleDropdown: React.FC = observer((props) => { multiple, onChange, onClose, - placeholder = "Module", + placeholder = "", placement, projectId, showCount = false, diff --git a/web/components/dropdowns/state.tsx b/web/components/dropdowns/state.tsx index 7ba69d1ce..7775f3946 100644 --- a/web/components/dropdowns/state.tsx +++ b/web/components/dropdowns/state.tsx @@ -4,7 +4,7 @@ import { usePopper } from "react-popper"; import { Check, ChevronDown, Search } from "lucide-react"; import { Combobox } from "@headlessui/react"; // hooks -import { StateGroupIcon } from "@plane/ui"; +import { Spinner, StateGroupIcon } from "@plane/ui"; import { cn } from "@/helpers/common.helper"; import { useApplication, useProjectState } from "@/hooks/store"; import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; @@ -50,6 +50,7 @@ export const StateDropdown: React.FC = observer((props) => { // states const [query, setQuery] = useState(""); const [isOpen, setIsOpen] = useState(false); + const [stateLoader, setStateLoader] = useState(false); // refs const dropdownRef = useRef(null); const inputRef = useRef(null); @@ -74,6 +75,8 @@ export const StateDropdown: React.FC = observer((props) => { } = useApplication(); const { fetchProjectStates, getProjectStates, getStateById } = useProjectState(); const statesList = getProjectStates(projectId); + const defaultStateList = statesList?.find((state) => state.default); + const stateValue = value ? value : defaultStateList?.id; const options = statesList?.map((state) => ({ value: state.id, @@ -89,11 +92,19 @@ export const StateDropdown: React.FC = observer((props) => { const filteredOptions = query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); - const selectedState = getStateById(value); + const selectedState = stateValue ? getStateById(stateValue) : undefined; - const onOpen = () => { - if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId); + const onOpen = async () => { + if (!statesList && workspaceSlug) { + setStateLoader(true); + await fetchProjectStates(workspaceSlug, projectId); + setStateLoader(false); + } }; + useEffect(() => { + if (projectId) onOpen(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectId]); const handleClose = () => { if (!isOpen) return; @@ -141,7 +152,7 @@ export const StateDropdown: React.FC = observer((props) => { ref={dropdownRef} tabIndex={tabIndex} className={cn("h-full", className)} - value={value} + value={stateValue} onChange={dropdownOnChange} disabled={disabled} onKeyDown={handleKeyDown} @@ -178,18 +189,27 @@ export const StateDropdown: React.FC = observer((props) => { showTooltip={showTooltip} variant={buttonVariant} > - {!hideIcon && ( - - )} - {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( - {selectedState?.name ?? "State"} - )} - {dropdownArrow && ( -
diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index 711639470..79cc84ecb 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Draggable } from "@hello-pangea/dnd"; +import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TIssue, TIssueMap } from "@plane/types"; // components @@ -12,7 +13,7 @@ type Props = { date: Date; issues: TIssueMap | undefined; issueIdList: string[] | null; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; isDragDisabled?: boolean; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 4bac9ff4f..7e17496d4 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -230,7 +230,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { {!isOpen && (
diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 765680827..429c237e2 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -1,3 +1,4 @@ +import { Placement } from "@popperjs/core"; import { observer } from "mobx-react-lite"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; // components @@ -16,7 +17,7 @@ type Props = { issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; - quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; + quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; quickAddCallback?: ( diff --git a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx index 2b55ada35..b1c68aaed 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/start-date.tsx @@ -25,6 +25,17 @@ export const FilterStartDate: React.FC = observer((props) => { d.name.toLowerCase().includes(searchQuery.toLowerCase()) ); + const isCustomDateSelected = () => { + const isCustomFateApplied = appliedFilters?.filter((f) => f.includes("-")) || []; + return isCustomFateApplied.length > 0 ? true : false; + }; + const handleCustomDate = () => { + if (isCustomDateSelected()) { + const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || []; + handleUpdate(updateAppliedFilters); + } else setIsDateFilterModalOpen(true); + }; + return ( <> {isDateFilterModalOpen && ( @@ -53,7 +64,7 @@ export const FilterStartDate: React.FC = observer((props) => { multiple /> ))} - setIsDateFilterModalOpen(true)} title="Custom" multiple /> + ) : (

No matches found

diff --git a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx index e46e52a41..f696e66cf 100644 --- a/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx +++ b/web/components/issues/issue-layouts/filters/header/filters/target-date.tsx @@ -25,6 +25,17 @@ export const FilterTargetDate: React.FC = observer((props) => { d.name.toLowerCase().includes(searchQuery.toLowerCase()) ); + const isCustomDateSelected = () => { + const isCustomFateApplied = appliedFilters?.filter((f) => f.includes("-")) || []; + return isCustomFateApplied.length > 0 ? true : false; + }; + const handleCustomDate = () => { + if (isCustomDateSelected()) { + const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || []; + handleUpdate(updateAppliedFilters); + } else setIsDateFilterModalOpen(true); + }; + return ( <> {isDateFilterModalOpen && ( @@ -53,7 +64,7 @@ export const FilterTargetDate: React.FC = observer((props) => { multiple /> ))} - setIsDateFilterModalOpen(true)} title="Custom" multiple /> + ) : (

No matches found

diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index 21e47c1cd..410430853 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -143,7 +143,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas setToast({ title: "Error", type: TOAST_TYPE.ERROR, - message: err.detail ?? "Failed to perform this action", + message: err?.detail ?? "Failed to perform this action", }); }); } diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 63925c21c..ee1ceef53 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -25,7 +25,7 @@ import { } from "@/hooks/store"; // types // parent components -import { getGroupByColumns } from "../utils"; +import { getGroupByColumns, isWorkspaceLevel } from "../utils"; // components import { KanbanStoreType } from "./base-kanban-root"; import { HeaderGroupByCard } from "./headers/group-by-card"; @@ -102,7 +102,9 @@ const GroupByKanBan: React.FC = observer((props) => { moduleInfo, label, projectState, - member + member, + true, + isWorkspaceLevel(storeType) ); if (!list) return null; diff --git a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx index 6ff958a72..4bd840b5e 100644 --- a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx @@ -1,11 +1,11 @@ import { observer } from "mobx-react-lite"; // components -import { ProjectIssueQuickActions } from "@/components/issues"; +import { DraftIssueQuickActions } from "@/components/issues"; import { EIssuesStoreType } from "@/constants/issue"; import { BaseKanBanRoot } from "../base-kanban-root"; export interface IKanBanLayout {} export const DraftKanBanLayout: React.FC = observer(() => ( - + )); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index e83103e89..197d8d293 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -13,7 +13,7 @@ import { } from "@plane/types"; // components import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; -import { getGroupByColumns } from "../utils"; +import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { KanbanStoreType } from "./base-kanban-root"; import { KanBan } from "./default"; import { HeaderGroupByCard } from "./headers/group-by-card"; @@ -291,7 +291,9 @@ export const KanBanSwimLanes: React.FC = observer((props) => { projectModule, label, projectState, - member + member, + true, + isWorkspaceLevel(storeType) ); const subGroupByList = getGroupByColumns( sub_group_by as GroupByColumnTypes, @@ -300,7 +302,9 @@ export const KanBanSwimLanes: React.FC = observer((props) => { projectModule, label, projectState, - member + member, + true, + isWorkspaceLevel(storeType) ); if (!groupByList || !subGroupByList) return null; diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 41cc755a0..2fcadaa13 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -13,9 +13,8 @@ import { IssueBlocksList, ListQuickAddIssueForm } from "@/components/issues"; // hooks import { EIssuesStoreType } from "@/constants/issue"; import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; -// constants -// types -import { getGroupByColumns } from "../utils"; +// utils +import { getGroupByColumns, isWorkspaceLevel } from "../utils"; import { HeaderGroupByCard } from "./headers/group-by-card"; export interface IGroupByList { @@ -78,7 +77,7 @@ const GroupByList: React.FC = (props) => { projectState, member, true, - true + isWorkspaceLevel(storeType) ); if (!groups) return null; diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index c53719f7f..3d089e42e 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -70,8 +70,8 @@ export const HeaderGroupByCard = observer( {icon ? icon : }
-
-
{title}
+
+
{title}
{count || 0}
diff --git a/web/components/issues/issue-layouts/list/list-view-types.d.ts b/web/components/issues/issue-layouts/list/list-view-types.d.ts index f435d0639..f38f52b9c 100644 --- a/web/components/issues/issue-layouts/list/list-view-types.d.ts +++ b/web/components/issues/issue-layouts/list/list-view-types.d.ts @@ -1,3 +1,4 @@ +import { Placement } from "@popperjs/core"; import { TIssue } from "@plane/types"; export interface IQuickActionProps { @@ -10,4 +11,5 @@ export interface IQuickActionProps { customActionButton?: React.ReactElement; portalElement?: HTMLDivElement | null; readOnly?: boolean; + placements?: Placement; } diff --git a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx index 4f303ffd4..9911b8ed4 100644 --- a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; // hooks -import { ProjectIssueQuickActions } from "@/components/issues"; +import { DraftIssueQuickActions } from "@/components/issues"; import { EIssuesStoreType } from "@/constants/issue"; // components // types @@ -15,5 +15,5 @@ export const DraftIssueListLayout: FC = observer(() => { if (!workspaceSlug || !projectId) return null; - return ; + return ; }); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index 190d4a3fc..72614079a 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -257,8 +257,9 @@ export const IssueProperties: React.FC = observer((props) => { {/* basic properties */} {/* state */} -
+
= observer((props) => { multiple buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"} buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} + showTooltip={issue?.assignee_ids?.length === 0} + placeholder="Assignees" + tooltipContent="" />
@@ -348,6 +352,7 @@ export const IssueProperties: React.FC = observer((props) => {
= observer((props) => { {/* cycles */} -
+
= observer((pro customActionButton, portalElement, readOnly = false, + placements = "bottom-start", } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -107,7 +108,7 @@ export const CycleIssueQuickActions: React.FC = observer((pro /> = observer((props) => { + const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props; + // states + const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState(undefined); + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + // store hooks + const { + membership: { currentProjectRole }, + } = useUser(); + const { setTrackElement } = useEventTracker(); + const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT); + // derived values + const activeLayout = `${issuesFilter.issueFilters?.displayFilters?.layout} layout`; + // auth + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER && !readOnly; + const isDeletingAllowed = isEditingAllowed; + + const duplicateIssuePayload = omit( + { + ...issue, + name: `${issue.name} (copy)`, + is_draft: true, + }, + ["id"] + ); + + return ( + <> + setDeleteIssueModal(false)} + onSubmit={handleDelete} + /> + + { + setCreateUpdateIssueModal(false); + setIssueToEdit(undefined); + }} + data={issueToEdit ?? duplicateIssuePayload} + onSubmit={async (data) => { + if (issueToEdit && handleUpdate) await handleUpdate(data); + }} + storeType={EIssuesStoreType.PROJECT} + isDraft + /> + + + {isEditingAllowed && ( + { + setTrackElement(activeLayout); + setIssueToEdit(issue); + setCreateUpdateIssueModal(true); + }} + > +
+ + Edit +
+
+ )} + {isEditingAllowed && ( + { + setTrackElement(activeLayout); + setCreateUpdateIssueModal(true); + }} + > +
+ + Make a copy +
+
+ )} + {isDeletingAllowed && ( + { + setTrackElement(activeLayout); + setDeleteIssueModal(true); + }} + > +
+ + Delete +
+
+ )} +
+ + ); +}); diff --git a/web/components/issues/issue-layouts/quick-action-dropdowns/index.ts b/web/components/issues/issue-layouts/quick-action-dropdowns/index.ts index e38440017..212a43f91 100644 --- a/web/components/issues/issue-layouts/quick-action-dropdowns/index.ts +++ b/web/components/issues/issue-layouts/quick-action-dropdowns/index.ts @@ -2,4 +2,5 @@ export * from "./cycle-issue"; export * from "./module-issue"; export * from "./project-issue"; export * from "./archived-issue"; +export * from "./draft-issue"; export * from "./all-issue"; 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 a8d920a74..1f155d066 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 @@ -30,6 +30,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr customActionButton, portalElement, readOnly = false, + placements = "bottom-start", } = props; // states const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); @@ -106,7 +107,7 @@ export const ModuleIssueQuickActions: React.FC = observer((pr /> = observer((p customActionButton, portalElement, readOnly = false, + placements = "bottom-start", } = props; // router const router = useRouter(); @@ -107,7 +108,7 @@ export const ProjectIssueQuickActions: React.FC = observer((p /> + [EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false; + export const getGroupByColumns = ( groupBy: GroupByColumnTypes | null, project: IProjectStore, diff --git a/web/components/issues/issue-modal/draft-issue-layout.tsx b/web/components/issues/issue-modal/draft-issue-layout.tsx index 55f8d711f..5ec7e3a99 100644 --- a/web/components/issues/issue-modal/draft-issue-layout.tsx +++ b/web/components/issues/issue-modal/draft-issue-layout.tsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import type { TIssue } from "@plane/types"; @@ -6,12 +7,10 @@ import type { TIssue } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; import { ConfirmIssueDiscard } from "@/components/issues"; import { IssueFormRoot } from "@/components/issues/issue-modal/form"; +import { isEmptyHtmlString } from "@/helpers/string.helper"; import { useEventTracker } from "@/hooks/store"; // services import { IssueDraftService } from "@/services/issue"; -// ui -// components -// types export interface DraftIssueProps { changesMade: Partial | null; @@ -50,8 +49,34 @@ export const DraftIssueLayout: React.FC = observer((props) => { const { captureIssueEvent } = useEventTracker(); const handleClose = () => { - if (changesMade) setIssueDiscardModal(true); - else onClose(false); + if (data?.id) { + onClose(false); + setIssueDiscardModal(false); + } else { + if (changesMade) { + Object.entries(changesMade).forEach(([key, value]) => { + const issueKey = key as keyof TIssue; + if (value === null || value === undefined || value === "") delete changesMade[issueKey]; + if (typeof value === "object" && isEmpty(value)) delete changesMade[issueKey]; + if (Array.isArray(value) && value.length === 0) delete changesMade[issueKey]; + if (issueKey === "project_id") delete changesMade.project_id; + if (issueKey === "priority" && value && value === "none") delete changesMade.priority; + if ( + issueKey === "description_html" && + changesMade.description_html && + isEmptyHtmlString(changesMade.description_html) + ) + delete changesMade.description_html; + }); + if (isEmpty(changesMade)) { + onClose(false); + setIssueDiscardModal(false); + } else setIssueDiscardModal(true); + } else { + onClose(false); + setIssueDiscardModal(false); + } + } }; const handleCreateDraftIssue = async () => { @@ -59,7 +84,7 @@ export const DraftIssueLayout: React.FC = observer((props) => { const payload = { ...changesMade, - name: changesMade.name?.trim() === "" ? "Untitled" : changesMade.name?.trim(), + name: changesMade?.name && changesMade?.name?.trim() === "" ? changesMade.name?.trim() : "Untitled", }; await issueDraftService diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index b6c773335..6c9872369 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -178,6 +178,10 @@ export const IssueFormRoot: FC = observer((props) => { id: data.id, description_html: formData.description_html ?? "

", }; + + // this condition helps to move the issues from draft to project issues + if (formData.hasOwnProperty("is_draft")) submitData.is_draft = formData.is_draft; + await onSubmit(submitData, is_draft_issue); setGptAssistantModal(false); @@ -597,6 +601,7 @@ export const IssueFormRoot: FC = observer((props) => { onChange(cycleId); handleFormChange(); }} + placeholder="Cycle" value={value} buttonVariant="border-with-text" tabIndex={getTabIndex("cycle_id")} @@ -618,6 +623,7 @@ export const IssueFormRoot: FC = observer((props) => { onChange(moduleIds); handleFormChange(); }} + placeholder="Modules" buttonVariant="border-with-text" tabIndex={getTabIndex("module_ids")} multiple @@ -716,19 +722,24 @@ export const IssueFormRoot: FC = observer((props) => {
-
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); - }} - tabIndex={getTabIndex("create_more")} - > -
- {}} size="sm" /> -
- Create more +
+ {!data?.id && ( +
onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} + onKeyDown={(e) => { + if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); + }} + tabIndex={getTabIndex("create_more")} + > +
+ {}} size="sm" /> +
+ Create more +
+ )}
+
); -}; +}); diff --git a/web/components/issues/issues-mobile-header.tsx b/web/components/issues/issues-mobile-header.tsx index 0293380ce..4bc90b686 100644 --- a/web/components/issues/issues-mobile-header.tsx +++ b/web/components/issues/issues-mobile-header.tsx @@ -52,8 +52,10 @@ export const IssuesMobileHeader = observer(() => { const newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); }); } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); diff --git a/web/components/issues/peek-overview/header.tsx b/web/components/issues/peek-overview/header.tsx index ba1b0f5b6..70c57c3f3 100644 --- a/web/components/issues/peek-overview/header.tsx +++ b/web/components/issues/peek-overview/header.tsx @@ -53,7 +53,7 @@ export type PeekOverviewHeaderProps = { issueId: string; isArchived: boolean; disabled: boolean; - toggleDeleteIssueModal: (value: boolean) => void; + toggleDeleteIssueModal: (issueId: string | null) => void; toggleArchiveIssueModal: (value: boolean) => void; handleRestoreIssue: () => void; isSubmitting: "submitting" | "submitted" | "saved"; @@ -188,7 +188,7 @@ export const IssuePeekOverviewHeader: FC = observer((pr )} {!disabled && ( - diff --git a/web/components/issues/peek-overview/view.tsx b/web/components/issues/peek-overview/view.tsx index 493b8bd51..35925d6cd 100644 --- a/web/components/issues/peek-overview/view.tsx +++ b/web/components/issues/peek-overview/view.tsx @@ -91,23 +91,14 @@ export const IssueView: FC = observer((props) => { /> )} - {issue && !is_archived && ( + {issue && isDeleteIssueModalOpen === issue.id && ( { - toggleDeleteIssueModal(false); + toggleDeleteIssueModal(null); }} data={issue} - onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)} - /> - )} - - {issue && is_archived && ( - toggleDeleteIssueModal(false)} - onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)} + onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId).then(() => removeRoutePeekId())} /> )} diff --git a/web/components/issues/sub-issues/issue-list-item.tsx b/web/components/issues/sub-issues/issue-list-item.tsx index 825f19796..27d6ca209 100644 --- a/web/components/issues/sub-issues/issue-list-item.tsx +++ b/web/components/issues/sub-issues/issue-list-item.tsx @@ -158,7 +158,7 @@ export const IssueListItem: React.FC = observer((props) => { { handleIssueCrudState("delete", parentIssueId, issue); - toggleDeleteIssueModal(true); + toggleDeleteIssueModal(issue.id); }} >
diff --git a/web/components/issues/sub-issues/root.tsx b/web/components/issues/sub-issues/root.tsx index b9c40f6e2..25cbc2b17 100644 --- a/web/components/issues/sub-issues/root.tsx +++ b/web/components/issues/sub-issues/root.tsx @@ -523,7 +523,7 @@ export const SubIssuesRoot: FC = observer((props) => { isOpen={issueCrudState?.delete?.toggle} handleClose={() => { handleIssueCrudState("delete", null, null); - toggleDeleteIssueModal(false); + toggleDeleteIssueModal(null); }} data={issueCrudState?.delete?.issue as TIssue} onSubmit={async () => diff --git a/web/components/modules/archived-modules/modal.tsx b/web/components/modules/archived-modules/modal.tsx index f34aff260..f922e0ba9 100644 --- a/web/components/modules/archived-modules/modal.tsx +++ b/web/components/modules/archived-modules/modal.tsx @@ -31,7 +31,7 @@ export const ArchiveModuleModal: React.FC = (props) => { handleClose(); }; - const handleArchiveIssue = async () => { + const handleArchiveModule = async () => { setIsArchiving(true); await archiveModule(workspaceSlug, projectId, moduleId) .then(() => { @@ -89,7 +89,7 @@ export const ArchiveModuleModal: React.FC = (props) => { -
diff --git a/web/components/modules/modal.tsx b/web/components/modules/modal.tsx index 7242073b1..89ae7f321 100644 --- a/web/components/modules/modal.tsx +++ b/web/components/modules/modal.tsx @@ -68,7 +68,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: err.detail ?? "Module could not be created. Please try again.", + message: err?.detail ?? "Module could not be created. Please try again.", }); captureModuleEvent({ eventName: MODULE_CREATED, @@ -99,7 +99,7 @@ export const CreateUpdateModuleModal: React.FC = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: err.detail ?? "Module could not be updated. Please try again.", + message: err?.detail ?? "Module could not be updated. Please try again.", }); captureModuleEvent({ eventName: MODULE_UPDATED, diff --git a/web/components/modules/module-card-item.tsx b/web/components/modules/module-card-item.tsx index ff228c82e..5f040fdd6 100644 --- a/web/components/modules/module-card-item.tsx +++ b/web/components/modules/module-card-item.tsx @@ -101,10 +101,18 @@ export const ModuleCardItem: React.FC = observer((props) => { e.preventDefault(); const { query } = router; - router.push({ - pathname: router.pathname, - query: { ...query, peekModule: moduleId }, - }); + if (query.peekModule) { + delete query.peekModule; + router.push({ + pathname: router.pathname, + query: { ...query }, + }); + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekModule: moduleId }, + }); + } }; if (!moduleDetails) return null; diff --git a/web/components/modules/module-list-item.tsx b/web/components/modules/module-list-item.tsx index 3fd630f29..0f8903430 100644 --- a/web/components/modules/module-list-item.tsx +++ b/web/components/modules/module-list-item.tsx @@ -102,10 +102,18 @@ export const ModuleListItem: React.FC = observer((props) => { e.preventDefault(); const { query } = router; - router.push({ - pathname: router.pathname, - query: { ...query, peekModule: moduleId }, - }); + if (query.peekModule) { + delete query.peekModule; + router.push({ + pathname: router.pathname, + query: { ...query }, + }); + } else { + router.push({ + pathname: router.pathname, + query: { ...query, peekModule: moduleId }, + }); + } }; if (!moduleDetails) return null; @@ -177,7 +185,7 @@ export const ModuleListItem: React.FC = observer((props) => {
-
+
{renderDate && ( diff --git a/web/components/modules/module-mobile-header.tsx b/web/components/modules/module-mobile-header.tsx index 8e632dac8..6419b13b0 100644 --- a/web/components/modules/module-mobile-header.tsx +++ b/web/components/modules/module-mobile-header.tsx @@ -54,8 +54,10 @@ export const ModuleMobileHeader = observer(() => { const newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); }); } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); diff --git a/web/components/modules/modules-list-view.tsx b/web/components/modules/modules-list-view.tsx index ce8ce4e65..b0ff67609 100644 --- a/web/components/modules/modules-list-view.tsx +++ b/web/components/modules/modules-list-view.tsx @@ -38,7 +38,7 @@ export const ModulesListView: React.FC = observer(() => { ); - if (totalFilters > 0 || searchQuery.trim() !== "") + if (totalFilters > 0 && filteredModuleIds.length === 0) return (
diff --git a/web/components/profile/profile-issues-filter.tsx b/web/components/profile/profile-issues-filter.tsx index fe2c004ba..1a7d8cfb0 100644 --- a/web/components/profile/profile-issues-filter.tsx +++ b/web/components/profile/profile-issues-filter.tsx @@ -44,8 +44,10 @@ export const ProfileIssuesFilter = observer(() => { const newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { + // this validation is majorly for the filter start_date, target_date custom value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); + else newValues.splice(newValues.indexOf(val), 1); }); } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); diff --git a/web/components/project/applied-filters/index.ts b/web/components/project/applied-filters/index.ts index 818aa6134..85bcda446 100644 --- a/web/components/project/applied-filters/index.ts +++ b/web/components/project/applied-filters/index.ts @@ -1,4 +1,5 @@ export * from "./access"; export * from "./date"; export * from "./members"; +export * from "./project-display-filters"; export * from "./root"; diff --git a/web/components/project/applied-filters/project-display-filters.tsx b/web/components/project/applied-filters/project-display-filters.tsx new file mode 100644 index 000000000..0c8af7097 --- /dev/null +++ b/web/components/project/applied-filters/project-display-filters.tsx @@ -0,0 +1,39 @@ +import { observer } from "mobx-react-lite"; +// icons +import { X } from "lucide-react"; +// types +import { TProjectAppliedDisplayFilterKeys } from "@plane/types"; +// constants +import { PROJECT_DISPLAY_FILTER_OPTIONS } from "@/constants/project"; + +type Props = { + handleRemove: (key: TProjectAppliedDisplayFilterKeys) => void; + values: TProjectAppliedDisplayFilterKeys[]; + editable: boolean | undefined; +}; + +export const AppliedProjectDisplayFilters: React.FC = observer((props) => { + const { handleRemove, values, editable } = props; + + return ( + <> + {values.map((key) => { + const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.label; + return ( +
+ {filterLabel} + {editable && ( + + )} +
+ ); + })} + + ); +}); diff --git a/web/components/project/applied-filters/root.tsx b/web/components/project/applied-filters/root.tsx index 66a2513c4..7c4381989 100644 --- a/web/components/project/applied-filters/root.tsx +++ b/web/components/project/applied-filters/root.tsx @@ -1,17 +1,24 @@ import { X } from "lucide-react"; -import { TProjectFilters } from "@plane/types"; -// components -import { Tooltip } from "@plane/ui"; -import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "@/components/project"; +// types +import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types"; // ui +import { Tooltip } from "@plane/ui"; +// components +import { + AppliedAccessFilters, + AppliedDateFilters, + AppliedMembersFilters, + AppliedProjectDisplayFilters, +} from "@/components/project"; // helpers import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper"; -// types type Props = { appliedFilters: TProjectFilters; + appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[]; handleClearAllFilters: () => void; handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; + handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void; alwaysAllowEditing?: boolean; filteredProjects: number; totalProjects: number; @@ -23,21 +30,24 @@ const DATE_FILTERS = ["created_at"]; export const ProjectAppliedFiltersList: React.FC = (props) => { const { appliedFilters, + appliedDisplayFilters, handleClearAllFilters, handleRemoveFilter, + handleRemoveDisplayFilter, alwaysAllowEditing, filteredProjects, totalProjects, } = props; - if (!appliedFilters) return null; - if (Object.keys(appliedFilters).length === 0) return null; + if (!appliedFilters && !appliedDisplayFilters) return null; + if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null; const isEditingAllowed = alwaysAllowEditing; return (
+ {/* Applied filters */} {Object.entries(appliedFilters).map(([key, value]) => { const filterKey = key as keyof TProjectFilters; @@ -85,6 +95,22 @@ export const ProjectAppliedFiltersList: React.FC = (props) => {
); })} + {/* Applied display filters */} + {appliedDisplayFilters.length > 0 && ( +
+
+ Projects + handleRemoveDisplayFilter(key)} + /> +
+
+ )} {isEditingAllowed && (
-
- - -
+ {!isArchived && ( +
+ + +
+ )}
-
+

{project.description && project.description.trim() !== "" ? project.description : `Created on ${renderFormattedDate(project.created_at)}`}

- 0 ? `${project.members.length} Members` : "No Member" - } - position="top" - > - {projectMembersIds && projectMembersIds.length > 0 ? ( -
- - {projectMembersIds.map((memberId) => { - const member = project.members?.find((m) => m.member_id === memberId); - - if (!member) return null; - - return ; - })} - +
+ 0 ? `${project.members.length} Members` : "No Member" + } + position="top" + > + {projectMembersIds && projectMembersIds.length > 0 ? ( +
+ + {projectMembersIds.map((memberId) => { + const member = project.members?.find((m) => m.member_id === memberId); + if (!member) return null; + return ( + + ); + })} + +
+ ) : ( + No Member Yet + )} +
+ {isArchived &&
Archived
} +
+ {isArchived ? ( + isOwner && ( +
+
{ + e.preventDefault(); + e.stopPropagation(); + setRestoreProject(true); + }} + > +
+ + Restore +
+
+
{ + e.preventDefault(); + e.stopPropagation(); + setDeleteProjectModal(true); + }} + > + +
- ) : ( - No Member Yet - )} - - {project.is_member && - (isOwner || isMember ? ( - { - e.stopPropagation(); - }} - href={`/${workspaceSlug}/projects/${project.id}/settings`} - > - - - ) : ( - - - Joined - - ))} - {!project.is_member && ( -
- -
+ ) + ) : ( + <> + {project.is_member && + (isOwner || isMember ? ( + { + e.stopPropagation(); + }} + href={`/${workspaceSlug}/projects/${project.id}/settings`} + > + + + ) : ( + + + Joined + + ))} + {!project.is_member && ( +
+ +
+ )} + )}
diff --git a/web/components/project/create-project-form.tsx b/web/components/project/create-project-form.tsx index a9d76210d..14d723e6d 100644 --- a/web/components/project/create-project-form.tsx +++ b/web/components/project/create-project-form.tsx @@ -209,8 +209,10 @@ export const CreateProjectForm: FC = observer((props) => { [val.type]: logoValue, }); }} - defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined} - defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON} + defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined} + defaultOpen={ + value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON + } /> )} /> diff --git a/web/components/project/dropdowns/filters/root.tsx b/web/components/project/dropdowns/filters/root.tsx index a7e50cec1..12008eecd 100644 --- a/web/components/project/dropdowns/filters/root.tsx +++ b/web/components/project/dropdowns/filters/root.tsx @@ -51,6 +51,15 @@ export const ProjectFiltersSelection: React.FC = observer((props) => { } title="My projects" /> + + handleDisplayFiltersUpdate({ + archived_projects: !displayFilters.archived_projects, + }) + } + title="Archived" + />
{/* access */} diff --git a/web/components/project/dropdowns/order-by.tsx b/web/components/project/dropdowns/order-by.tsx index 1d262f8ac..048513d08 100644 --- a/web/components/project/dropdowns/order-by.tsx +++ b/web/components/project/dropdowns/order-by.tsx @@ -40,7 +40,8 @@ export const ProjectOrderByDropdown: React.FC = (props) => { key={option.key} className="flex items-center justify-between gap-2" onClick={() => { - if (isDescending) onChange(`-${option.key}` as TProjectOrderByOptions); + if (isDescending) + onChange(option.key == "sort_order" ? option.key : (`-${option.key}` as TProjectOrderByOptions)); else onChange(option.key); }} > diff --git a/web/components/project/form.tsx b/web/components/project/form.tsx index aaaaf03b3..3e770a618 100644 --- a/web/components/project/form.tsx +++ b/web/components/project/form.tsx @@ -166,8 +166,10 @@ export const ProjectDetailsForm: FC = (props) => { [val.type]: logoValue, }); }} - defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined} - defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON} + defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined} + defaultOpen={ + value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON + } disabled={!isAdmin} /> )} diff --git a/web/components/project/project-logo.tsx b/web/components/project/project-logo.tsx index c06dc3061..fc90fdba3 100644 --- a/web/components/project/project-logo.tsx +++ b/web/components/project/project-logo.tsx @@ -11,7 +11,7 @@ type Props = { export const ProjectLogo: React.FC = (props) => { const { className, logo } = props; - if (logo && logo.in_use === "icon" && logo.icon) + if (logo?.in_use === "icon" && logo?.icon) return ( = (props) => { ); - if (logo && logo.in_use === "emoji" && logo.emoji) + if (logo?.in_use === "emoji" && logo?.emoji) return ( {logo.emoji.value?.split("-").map((emoji) => String.fromCodePoint(parseInt(emoji, 10)))} ); - return ; + return <>; }; diff --git a/web/components/project/settings/archive-project/archive-restore-modal.tsx b/web/components/project/settings/archive-project/archive-restore-modal.tsx new file mode 100644 index 000000000..88361895c --- /dev/null +++ b/web/components/project/settings/archive-project/archive-restore-modal.tsx @@ -0,0 +1,135 @@ +import { useState, Fragment } from "react"; +import { useRouter } from "next/router"; +import { Dialog, Transition } from "@headlessui/react"; +// ui +import { Button, TOAST_TYPE, setToast } from "@plane/ui"; +// hooks +import { useProject } from "@/hooks/store"; + +type Props = { + workspaceSlug: string; + projectId: string; + isOpen: boolean; + onClose: () => void; + archive: boolean; +}; + +export const ArchiveRestoreProjectModal: React.FC = (props) => { + const { workspaceSlug, projectId, isOpen, onClose, archive } = props; + // router + const router = useRouter(); + // states + const [isLoading, setIsLoading] = useState(false); + // store hooks + const { getProjectById, archiveProject, restoreProject } = useProject(); + + const projectDetails = getProjectById(projectId); + if (!projectDetails) return null; + + const handleClose = () => { + setIsLoading(false); + onClose(); + }; + + const handleArchiveProject = async () => { + setIsLoading(true); + await archiveProject(workspaceSlug, projectId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Archive success", + message: `${projectDetails.name} has been archived successfully`, + }); + onClose(); + router.push(`/${workspaceSlug}/projects/`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Project could not be archived. Please try again.", + }) + ) + .finally(() => setIsLoading(false)); + }; + + const handleRestoreProject = async () => { + setIsLoading(true); + await restoreProject(workspaceSlug, projectId) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Restore success", + message: `You can find ${projectDetails.name} in your projects.`, + }); + onClose(); + router.push(`/${workspaceSlug}/projects/`); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Project could not be restored. Please try again.", + }) + ) + .finally(() => setIsLoading(false)); + }; + + return ( + + + +
+ + +
+
+ + +
+

+ {archive ? "Archive" : "Restore"} {projectDetails.name} +

+

+ {archive + ? "This project and its issues, cycles, modules, and pages will be archived. Its issues won’t appear in search. Only project admins can restore the project." + : "Restoring a project will activate it and make it visible to all members of the project. Are you sure you want to continue?"} +

+
+ + +
+
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/project/settings/archive-project/index.tsx b/web/components/project/settings/archive-project/index.tsx new file mode 100644 index 000000000..23da8dcb2 --- /dev/null +++ b/web/components/project/settings/archive-project/index.tsx @@ -0,0 +1,2 @@ +export * from "./selection"; +export * from "./archive-restore-modal"; diff --git a/web/components/project/settings/archive-project/selection.tsx b/web/components/project/settings/archive-project/selection.tsx new file mode 100644 index 000000000..14fb43053 --- /dev/null +++ b/web/components/project/settings/archive-project/selection.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { ChevronRight, ChevronUp } from "lucide-react"; +import { Disclosure, Transition } from "@headlessui/react"; +// types +import { IProject } from "@plane/types"; +// ui +import { Button, Loader } from "@plane/ui"; + +export interface IArchiveProject { + projectDetails: IProject; + handleArchive: () => void; +} + +export const ArchiveProjectSelection: React.FC = (props) => { + const { projectDetails, handleArchive } = props; + + return ( + + {({ open }) => ( +
+ + Archive project + {open ? : } + + + +
+ + Archiving a project will unlist your project from your side navigation although you will still be able + to access it from your projects page. You can restore the project or delete it whenever you want. + +
+ {projectDetails ? ( +
+ +
+ ) : ( + + + + )} +
+
+
+
+
+ )} +
+ ); +}; diff --git a/web/components/project/settings/delete-project-section.tsx b/web/components/project/settings/delete-project-section.tsx index 991b29207..690f67432 100644 --- a/web/components/project/settings/delete-project-section.tsx +++ b/web/components/project/settings/delete-project-section.tsx @@ -1,12 +1,10 @@ import React from "react"; - -// ui -import { ChevronDown, ChevronUp } from "lucide-react"; +import { ChevronRight, ChevronUp } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; -import { IProject } from "@plane/types"; -import { Button, Loader } from "@plane/ui"; -// icons // types +import { IProject } from "@plane/types"; +// ui +import { Button, Loader } from "@plane/ui"; export interface IDeleteProjectSection { projectDetails: IProject; @@ -17,12 +15,12 @@ export const DeleteProjectSection: React.FC = (props) => const { projectDetails, handleDelete } = props; return ( - + {({ open }) => (
- + Delete Project - {open ? : } + {open ? : } = (props) => leaveTo="transform opacity-0" > -
+
The danger zone of the project delete page is a critical area that requires careful consideration and attention. When deleting a project, all of the data and resources within that project will be diff --git a/web/components/project/settings/index.ts b/web/components/project/settings/index.ts index 0bf79ec17..0f8e9aa6d 100644 --- a/web/components/project/settings/index.ts +++ b/web/components/project/settings/index.ts @@ -1,2 +1,3 @@ export * from "./delete-project-section"; export * from "./features-list"; +export * from "./archive-project"; diff --git a/web/components/views/modal.tsx b/web/components/views/modal.tsx index c7b9c9a31..6e30a76ec 100644 --- a/web/components/views/modal.tsx +++ b/web/components/views/modal.tsx @@ -54,7 +54,7 @@ export const CreateUpdateProjectViewModal: FC = observer((props) => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", - message: err.detail ?? "Something went wrong. Please try again.", + message: err?.detail ?? "Something went wrong. Please try again.", }) ); }; diff --git a/web/components/workspace/views/header.tsx b/web/components/workspace/views/header.tsx index fd9bc42f0..35c01481b 100644 --- a/web/components/workspace/views/header.tsx +++ b/web/components/workspace/views/header.tsx @@ -96,7 +96,9 @@ export const GlobalViewsHeader: React.FC = observer(() => { ))} - {currentWorkspaceViews?.map((viewId) => )} + {currentWorkspaceViews?.map((viewId) => ( + + ))}
{isAuthorizedUser && ( diff --git a/web/constants/empty-state.ts b/web/constants/empty-state.ts index 8be4a52e2..363147775 100644 --- a/web/constants/empty-state.ts +++ b/web/constants/empty-state.ts @@ -84,6 +84,12 @@ export enum EmptyStateType { NOTIFICATION_ARCHIVED_EMPTY_STATE = "notification-archived-empty-state", NOTIFICATION_SNOOZED_EMPTY_STATE = "notification-snoozed-empty-state", NOTIFICATION_UNREAD_EMPTY_STATE = "notification-unread-empty-state", + + ACTIVE_CYCLE_PROGRESS_EMPTY_STATE = "active-cycle-progress-empty-state", + ACTIVE_CYCLE_CHART_EMPTY_STATE = "active-cycle-chart-empty-state", + ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE = "active-cycle-priority-issue-empty-state", + ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE = "active-cycle-assignee-empty-state", + ACTIVE_CYCLE_LABEL_EMPTY_STATE = "active-cycle-label-empty-state", } const emptyStateDetails = { @@ -125,9 +131,9 @@ const emptyStateDetails = { }, [EmptyStateType.WORKSPACE_PROJECTS]: { key: EmptyStateType.WORKSPACE_PROJECTS, - title: "Start a Project", + title: "No active projects", description: - "Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal.", + "Think of each project as the parent for goal-oriented work. Projects are where Jobs, Cycles, and Modules live and, along with your colleagues, help you achieve that goal. Create a new project or filter for archived projects.", path: "/empty-state/onboarding/projects", primaryButton: { text: "Start your first project", @@ -584,6 +590,31 @@ const emptyStateDetails = { description: "Any notification you archive will be \n available here to help you focus", path: "/empty-state/search/archive", }, + [EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE]: { + key: EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE, + title: "Add issues to the cycle to view it's \n progress", + path: "/empty-state/active-cycle/progress", + }, + [EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE]: { + key: EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE, + title: "Add issues to the cycle to view the \n burndown chart.", + path: "/empty-state/active-cycle/chart", + }, + [EmptyStateType.ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE]: { + key: EmptyStateType.ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE, + title: "Observe high priority issues tackled in \n the cycle at a glance.", + path: "/empty-state/active-cycle/priority", + }, + [EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE]: { + key: EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE, + title: "Add assignees to issues to see a \n breakdown of work by assignees.", + path: "/empty-state/active-cycle/assignee", + }, + [EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE]: { + key: EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE, + title: "Add labels to issues to see the \n breakdown of work by labels.", + path: "/empty-state/active-cycle/label", + }, } as const; export const EMPTY_STATE_DETAILS: Record = emptyStateDetails; diff --git a/web/constants/project.ts b/web/constants/project.ts index 6393a6a6c..1715c0e82 100644 --- a/web/constants/project.ts +++ b/web/constants/project.ts @@ -1,9 +1,9 @@ // icons import { Globe2, Lock, LucideIcon } from "lucide-react"; +import { TProjectAppliedDisplayFilterKeys, TProjectOrderByOptions } from "@plane/types"; import { SettingIcon } from "@/components/icons"; // types import { Props } from "@/components/icons/types"; -import { TProjectOrderByOptions } from "@plane/types"; export enum EUserProjectRoles { GUEST = 5, @@ -115,14 +115,6 @@ export const PROJECT_SETTINGS_LINKS: { highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/labels`, Icon: SettingIcon, }, - { - key: "integrations", - label: "Integrations", - href: `/settings/integrations`, - access: EUserProjectRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`, - Icon: SettingIcon, - }, { key: "estimates", label: "Estimates", @@ -162,3 +154,17 @@ export const PROJECT_ORDER_BY_OPTIONS: { label: "Number of members", }, ]; + +export const PROJECT_DISPLAY_FILTER_OPTIONS: { + key: TProjectAppliedDisplayFilterKeys; + label: string; +}[] = [ + { + key: "my_projects", + label: "My projects", + }, + { + key: "archived_projects", + label: "Archived", + }, +]; diff --git a/web/constants/workspace.ts b/web/constants/workspace.ts index 18cfac3a8..cbb8d2324 100644 --- a/web/constants/workspace.ts +++ b/web/constants/workspace.ts @@ -149,22 +149,6 @@ export const WORKSPACE_SETTINGS_LINKS: { highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/billing`, Icon: SettingIcon, }, - { - key: "integrations", - label: "Integrations", - href: `/settings/integrations`, - access: EUserWorkspaceRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/integrations`, - Icon: SettingIcon, - }, - { - key: "import", - label: "Imports", - href: `/settings/imports`, - access: EUserWorkspaceRoles.ADMIN, - highlight: (pathname: string, baseUrl: string) => pathname === `${baseUrl}/settings/imports`, - Icon: SettingIcon, - }, { key: "export", label: "Exports", diff --git a/web/helpers/module.helper.ts b/web/helpers/module.helper.ts index 1a01915f2..f5cbfe08a 100644 --- a/web/helpers/module.helper.ts +++ b/web/helpers/module.helper.ts @@ -22,7 +22,7 @@ export const orderModules = (modules: IModule[], orderByKey: TModuleOrderByOptio (m) => { let progress = (m.completed_issues + m.cancelled_issues) / m.total_issues; if (isNaN(progress)) progress = 0; - return orderByKey === "progress" ? progress : !progress; + return orderByKey === "progress" ? progress : -progress; }, "name", ]); diff --git a/web/helpers/project.helper.ts b/web/helpers/project.helper.ts index b4c461bb4..f479eb25c 100644 --- a/web/helpers/project.helper.ts +++ b/web/helpers/project.helper.ts @@ -1,11 +1,11 @@ import sortBy from "lodash/sortBy"; -// helpers -import { satisfiesDateFilter } from "@/helpers/filter.helper"; -import { getDate } from "@/helpers/date-time.helper"; // types import { IProject, TProjectDisplayFilters, TProjectFilters, TProjectOrderByOptions } from "@plane/types"; // constants import { EUserProjectRoles } from "@/constants/project"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +import { satisfiesDateFilter } from "@/helpers/filter.helper"; /** * Updates the sort order of the project. @@ -93,6 +93,8 @@ export const shouldFilterProject = ( } }); if (displayFilters.my_projects && !project.is_member) fallsInFilters = false; + if (displayFilters.archived_projects && !project.archived_at) fallsInFilters = false; + if (project.archived_at) fallsInFilters = displayFilters.archived_projects ? fallsInFilters : false; return fallsInFilters; }; diff --git a/web/helpers/string.helper.ts b/web/helpers/string.helper.ts index ac0256731..03233a918 100644 --- a/web/helpers/string.helper.ts +++ b/web/helpers/string.helper.ts @@ -34,7 +34,7 @@ export const createSimilarString = (str: string) => { }; const fallbackCopyTextToClipboard = (text: string) => { - var textArea = document.createElement("textarea"); + const textArea = document.createElement("textarea"); textArea.value = text; // Avoid scrolling to bottom @@ -49,7 +49,7 @@ const fallbackCopyTextToClipboard = (text: string) => { try { // FIXME: Even though we are using this as a fallback, execCommand is deprecated 👎. We should find a better way to do this. // https://developer.mozilla.org/en-US/docs/Web/API/Document/execCommand - var successful = document.execCommand("copy"); + document.execCommand("copy"); } catch (err) {} document.body.removeChild(textArea); @@ -117,9 +117,9 @@ export const getFirstCharacters = (str: string) => { * console.log(text); // Some text */ -export const stripHTML = (html: string) => { - const strippedText = html.replace(/]*>[\s\S]*?<\/script>/gi, ""); // Remove script tags - return strippedText.replace(/<[^>]*>/g, ""); // Remove all other HTML tags +export const sanitizeHTML = (htmlString: string) => { + const sanitizedText = DOMPurify.sanitize(htmlString, { ALLOWED_TAGS: [] }); // sanitize the string to remove all HTML tags + return sanitizedText.trim(); // trim the string to remove leading and trailing whitespaces }; /** @@ -130,7 +130,7 @@ export const stripHTML = (html: string) => { * console.log(text); // Some text */ -export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(stripHTML(html), length); +export const stripAndTruncateHTML = (html: string, length: number = 55) => truncateText(sanitizeHTML(html), length); /** * @description: This function return number count in string if number is more than 100 then it will return 99+ @@ -172,10 +172,10 @@ export const getFetchKeysForIssueMutation = (options: { const ganttFetchKey = cycleId ? { ganttFetchKey: CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), ganttParams) } : moduleId - ? { ganttFetchKey: MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), ganttParams) } - : viewId - ? { ganttFetchKey: VIEW_ISSUES(viewId.toString(), viewGanttParams) } - : { ganttFetchKey: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", ganttParams) }; + ? { ganttFetchKey: MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), ganttParams) } + : viewId + ? { ganttFetchKey: VIEW_ISSUES(viewId.toString(), viewGanttParams) } + : { ganttFetchKey: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId?.toString() ?? "", ganttParams) }; return { ...ganttFetchKey, diff --git a/web/layouts/settings-layout/workspace/sidebar.tsx b/web/layouts/settings-layout/workspace/sidebar.tsx index d6cb77cf9..f49eb84d9 100644 --- a/web/layouts/settings-layout/workspace/sidebar.tsx +++ b/web/layouts/settings-layout/workspace/sidebar.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { observer } from "mobx-react"; import Link from "next/link"; import { useRouter } from "next/router"; // hooks @@ -6,7 +7,7 @@ import { EUserWorkspaceRoles, WORKSPACE_SETTINGS_LINKS } from "@/constants/works import { useUser } from "@/hooks/store"; // constants -export const WorkspaceSettingsSidebar = () => { +export const WorkspaceSettingsSidebar = observer(() => { // router const router = useRouter(); const { workspaceSlug } = router.query; @@ -44,4 +45,4 @@ export const WorkspaceSettingsSidebar = () => {
); -}; +}); diff --git a/web/next.config.js b/web/next.config.js index e018ea317..a7c658b59 100644 --- a/web/next.config.js +++ b/web/next.config.js @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ require("dotenv").config({ path: ".env" }); const { withSentryConfig } = require("@sentry/nextjs"); @@ -24,8 +25,11 @@ const nextConfig = { output: "standalone", }; -if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0")) { - module.exports = withSentryConfig(nextConfig, { silent: true }, { hideSourceMaps: true }); +if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0"), 10) { + module.exports = withSentryConfig(nextConfig, + { silent: true, authToken: process.env.SENTRY_AUTH_TOKEN }, + { hideSourceMaps: true } + ); } else { module.exports = nextConfig; } diff --git a/web/package.json b/web/package.json index 99e351191..3ec941b80 100644 --- a/web/package.json +++ b/web/package.json @@ -28,12 +28,12 @@ "@plane/types": "*", "@plane/ui": "*", "@popperjs/core": "^2.11.8", - "@sentry/nextjs": "^7.85.0", + "@sentry/nextjs": "^7.108.0", "axios": "^1.1.3", "clsx": "^2.0.0", "cmdk": "^0.2.0", "date-fns": "^2.30.0", - "dompurify": "^3.0.9", + "dompurify": "^3.0.11", "dotenv": "^16.0.3", "js-cookie": "^3.0.1", "lodash": "^4.17.21", diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx index f099a97f7..946d9b9cb 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/index.tsx @@ -2,26 +2,29 @@ import { useState, ReactElement } from "react"; import { observer } from "mobx-react-lite"; import { useRouter } from "next/router"; import useSWR from "swr"; -// hooks +// components import { PageHead } from "@/components/core"; import { ProjectSettingHeader } from "@/components/headers"; import { + ArchiveRestoreProjectModal, + ArchiveProjectSelection, DeleteProjectModal, DeleteProjectSection, ProjectDetailsForm, ProjectDetailsFormLoader, } from "@/components/project"; +// hooks import { useProject } from "@/hooks/store"; // layouts import { AppLayout } from "@/layouts/app-layout"; import { ProjectSettingLayout } from "@/layouts/settings-layout"; -// components // types import { NextPageWithLayout } from "@/lib/types"; const GeneralSettingsPage: NextPageWithLayout = observer(() => { // states const [selectProject, setSelectedProject] = useState(null); + const [archiveProject, setArchiveProject] = useState(false); // router const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -42,12 +45,21 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => { return ( <> - {currentProjectDetails && ( - setSelectedProject(null)} - /> + {currentProjectDetails && workspaceSlug && projectId && ( + <> + setArchiveProject(false)} + archive + /> + setSelectedProject(null)} + /> + )}
@@ -63,10 +75,16 @@ const GeneralSettingsPage: NextPageWithLayout = observer(() => { )} {isAdmin && ( - setSelectedProject(currentProjectDetails.id ?? null)} - /> + <> + setArchiveProject(true)} + /> + setSelectedProject(currentProjectDetails.id ?? null)} + /> + )}
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx deleted file mode 100644 index a4d8c4dd7..000000000 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/integrations.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { ReactElement } from "react"; -import { observer } from "mobx-react"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -import { IProject } from "@plane/types"; -// hooks -import { PageHead } from "@/components/core"; -import { EmptyState } from "@/components/empty-state"; -import { ProjectSettingHeader } from "@/components/headers"; -import { IntegrationCard } from "@/components/project"; -import { IntegrationsSettingsLoader } from "@/components/ui"; -// layouts -import { EmptyStateType } from "@/constants/empty-state"; -import { PROJECT_DETAILS, WORKSPACE_INTEGRATIONS } from "@/constants/fetch-keys"; -import { AppLayout } from "@/layouts/app-layout"; -import { ProjectSettingLayout } from "@/layouts/settings-layout"; -// services -import { NextPageWithLayout } from "@/lib/types"; -import { IntegrationService } from "@/services/integrations"; -import { ProjectService } from "@/services/project"; -// components -// ui -// types -// fetch-keys -// constants - -// services -const integrationService = new IntegrationService(); -const projectService = new ProjectService(); - -const ProjectIntegrationsPage: NextPageWithLayout = observer(() => { - const router = useRouter(); - const { workspaceSlug, projectId } = router.query; - // fetch project details - const { data: projectDetails } = useSWR( - workspaceSlug && projectId ? PROJECT_DETAILS(projectId as string) : null, - workspaceSlug && projectId ? () => projectService.getProject(workspaceSlug as string, projectId as string) : null - ); - // fetch Integrations list - const { data: workspaceIntegrations } = useSWR( - workspaceSlug ? WORKSPACE_INTEGRATIONS(workspaceSlug as string) : null, - () => (workspaceSlug ? integrationService.getWorkspaceIntegrationsList(workspaceSlug as string) : null) - ); - // derived values - const isAdmin = projectDetails?.member_role === 20; - const pageTitle = projectDetails?.name ? `${projectDetails?.name} - Integrations` : undefined; - - return ( - <> - -
-
-

Integrations

-
- {workspaceIntegrations ? ( - workspaceIntegrations.length > 0 ? ( -
- {workspaceIntegrations.map((integration) => ( - - ))} -
- ) : ( -
- -
- ) - ) : ( - - )} -
- - ); -}); - -ProjectIntegrationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ProjectIntegrationsPage; diff --git a/web/pages/[workspaceSlug]/projects/index.tsx b/web/pages/[workspaceSlug]/projects/index.tsx index 1e153b688..5db5daa34 100644 --- a/web/pages/[workspaceSlug]/projects/index.tsx +++ b/web/pages/[workspaceSlug]/projects/index.tsx @@ -1,6 +1,6 @@ import { ReactElement, useCallback } from "react"; import { observer } from "mobx-react"; -import { TProjectFilters } from "@plane/types"; +import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types"; // components import { PageHead } from "@/components/core"; import { ProjectsHeader } from "@/components/headers"; @@ -19,8 +19,15 @@ const ProjectsPage: NextPageWithLayout = observer(() => { router: { workspaceSlug }, } = useApplication(); const { currentWorkspace } = useWorkspace(); - const { workspaceProjectIds, filteredProjectIds } = useProject(); - const { currentWorkspaceFilters, clearAllFilters, updateFilters } = useProjectFilter(); + const { totalProjectIds, filteredProjectIds } = useProject(); + const { + currentWorkspaceFilters, + currentWorkspaceAppliedDisplayFilters, + clearAllFilters, + clearAllAppliedDisplayFilters, + updateFilters, + updateDisplayFilters, + } = useProjectFilter(); // derived values const pageTitle = currentWorkspace?.name ? `${currentWorkspace?.name} - Projects` : undefined; @@ -37,18 +44,35 @@ const ProjectsPage: NextPageWithLayout = observer(() => { [currentWorkspaceFilters, updateFilters, workspaceSlug] ); + const handleRemoveDisplayFilter = useCallback( + (key: TProjectAppliedDisplayFilterKeys) => { + if (!workspaceSlug) return; + updateDisplayFilters(workspaceSlug.toString(), { [key]: false }); + }, + [updateDisplayFilters, workspaceSlug] + ); + + const handleClearAllFilters = useCallback(() => { + if (!workspaceSlug) return; + clearAllFilters(workspaceSlug.toString()); + clearAllAppliedDisplayFilters(workspaceSlug.toString()); + }, [clearAllFilters, clearAllAppliedDisplayFilters, workspaceSlug]); + return ( <>
- {calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 && ( + {(calculateTotalFilters(currentWorkspaceFilters ?? {}) !== 0 || + currentWorkspaceAppliedDisplayFilters?.length !== 0) && (
clearAllFilters(`${workspaceSlug}`)} + appliedDisplayFilters={currentWorkspaceAppliedDisplayFilters ?? []} + handleClearAllFilters={handleClearAllFilters} handleRemoveFilter={handleRemoveFilter} + handleRemoveDisplayFilter={handleRemoveDisplayFilter} filteredProjects={filteredProjectIds?.length ?? 0} - totalProjects={workspaceProjectIds?.length ?? 0} + totalProjects={totalProjectIds?.length ?? 0} alwaysAllowEditing />
diff --git a/web/pages/[workspaceSlug]/settings/imports.tsx b/web/pages/[workspaceSlug]/settings/imports.tsx deleted file mode 100644 index caf958d38..000000000 --- a/web/pages/[workspaceSlug]/settings/imports.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { observer } from "mobx-react-lite"; -// hooks -import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; -import IntegrationGuide from "@/components/integration/guide"; -import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { useUser, useWorkspace } from "@/hooks/store"; -// layouts -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -// components -// types -import { NextPageWithLayout } from "@/lib/types"; -// constants - -const ImportsPage: NextPageWithLayout = observer(() => { - // store hooks - const { - membership: { currentWorkspaceRole }, - } = useUser(); - const { currentWorkspace } = useWorkspace(); - - // derived values - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Imports` : undefined; - - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); - - return ( - <> - -
-
-

Imports

-
- -
- - ); -}); - -ImportsPage.getLayout = function getLayout(page: React.ReactElement) { - return ( - }> - {page} - - ); -}; - -export default ImportsPage; diff --git a/web/pages/[workspaceSlug]/settings/integrations.tsx b/web/pages/[workspaceSlug]/settings/integrations.tsx deleted file mode 100644 index 7748f976d..000000000 --- a/web/pages/[workspaceSlug]/settings/integrations.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import { ReactElement } from "react"; -import { observer } from "mobx-react-lite"; -import { useRouter } from "next/router"; -import useSWR from "swr"; -// hooks -// services -// layouts -// components -import { PageHead } from "@/components/core"; -import { WorkspaceSettingHeader } from "@/components/headers"; -import { SingleIntegrationCard } from "@/components/integration"; -// ui -import { IntegrationAndImportExportBanner, IntegrationsSettingsLoader } from "@/components/ui"; -// types -// fetch-keys -import { APP_INTEGRATIONS } from "@/constants/fetch-keys"; -// constants -import { EUserWorkspaceRoles } from "@/constants/workspace"; -import { useUser, useWorkspace } from "@/hooks/store"; -import { AppLayout } from "@/layouts/app-layout"; -import { WorkspaceSettingLayout } from "@/layouts/settings-layout"; -import { NextPageWithLayout } from "@/lib/types"; -import { IntegrationService } from "@/services/integrations"; - -const integrationService = new IntegrationService(); - -const WorkspaceIntegrationsPage: NextPageWithLayout = observer(() => { - // router - const router = useRouter(); - const { workspaceSlug } = router.query; - // store hooks - const { - membership: { currentWorkspaceRole }, - } = useUser(); - const { currentWorkspace } = useWorkspace(); - - // derived values - const isAdmin = currentWorkspaceRole === EUserWorkspaceRoles.ADMIN; - const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - Integrations` : undefined; - - if (!isAdmin) - return ( - <> - -
-

You are not authorized to access this page.

-
- - ); - - const { data: appIntegrations } = useSWR(workspaceSlug && isAdmin ? APP_INTEGRATIONS : null, () => - workspaceSlug && isAdmin ? integrationService.getAppIntegrationsList() : null - ); - - return ( - <> - -
- -
- {appIntegrations ? ( - appIntegrations.map((integration) => ( - - )) - ) : ( - - )} -
-
- - ); -}); - -WorkspaceIntegrationsPage.getLayout = function getLayout(page: ReactElement) { - return ( - }> - {page} - - ); -}; - -export default WorkspaceIntegrationsPage; diff --git a/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx b/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx index 4fb459d3e..5979755a9 100644 --- a/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx +++ b/web/pages/[workspaceSlug]/workspace-views/[globalViewId].tsx @@ -29,8 +29,8 @@ const GlobalViewIssuesPage: NextPageWithLayout = observer(() => { currentWorkspace?.name && defaultView?.label ? `${currentWorkspace?.name} - ${defaultView?.label}` : currentWorkspace?.name && globalViewDetails?.name - ? `${currentWorkspace?.name} - ${globalViewDetails?.name}` - : undefined; + ? `${currentWorkspace?.name} - ${globalViewDetails?.name}` + : undefined; return ( <> diff --git a/web/pages/profile/activity.tsx b/web/pages/profile/activity.tsx index b3d88b1b3..f2a73c180 100644 --- a/web/pages/profile/activity.tsx +++ b/web/pages/profile/activity.tsx @@ -41,17 +41,19 @@ const ProfileActivityPage: NextPageWithLayout = observer(() => { /> ); + const isLoadMoreVisible = pageCount < totalPages && resultsCount !== 0; + return ( <> -
-
+
+
themeStore.toggleSidebar()} />

Activity

-
+
{activityPages} - {pageCount < totalPages && resultsCount !== 0 && ( + {isLoadMoreVisible && (