Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads

This commit is contained in:
pablohashescobar 2024-03-27 15:45:23 +05:30
commit 6cc29dca52
145 changed files with 3555 additions and 2282 deletions

View File

@ -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"

View File

@ -1,8 +1,9 @@
name: "CodeQL" name: "CodeQL"
on: on:
workflow_dispatch:
push: push:
branches: ["master"] branches: ["develop", "preview", "master"]
pull_request: pull_request:
branches: ["develop", "preview", "master"] branches: ["develop", "preview", "master"]
schedule: schedule:

View File

@ -17,10 +17,10 @@
</p> </p>
<p align="center"> <p align="center">
<a href="http://www.plane.so"><b>Website</b></a> <a href="https://dub.sh/plane-website-readme"><b>Website</b></a>
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> <a href="https://git.new/releases"><b>Releases</b></a>
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> <a href="https://dub.sh/planepowershq"><b>Twitter</b></a>
<a href="https://docs.plane.so/"><b>Documentation</b></a> <a href="https://dub.sh/planedocs"><b>Documentation</b></a>
</p> </p>
<p> <p>
@ -40,15 +40,15 @@
</a> </a>
</p> </p>
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 ## ⚡ 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. 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 | | Installation Methods | Documentation Link |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@ -59,9 +59,9 @@ If you want more control over your data prefer to self-host Plane, please refer
## 🚀 Features ## 🚀 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. 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. - **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. - **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. > 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: 1. Clone the code locally using:
``` ```

View File

@ -182,7 +182,7 @@ def update_label_color():
labels = Label.objects.filter(color="") labels = Label.objects.filter(color="")
updated_labels = [] updated_labels = []
for label in 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) updated_labels.append(label)
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100) Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)

View File

@ -1,31 +1,32 @@
from lxml import html from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
from django.core.validators import URLValidator from lxml import html
from django.core.exceptions import ValidationError
# Third party imports # Third party imports
from rest_framework import serializers from rest_framework import serializers
# Module imports # Module imports
from plane.db.models import ( from plane.db.models import (
User,
Issue, Issue,
State, IssueActivity,
IssueAssignee, IssueAssignee,
Label, IssueComment,
IssueLabel, IssueLabel,
IssueLink, IssueLink,
IssueComment, Label,
IssueActivity,
ProjectMember, ProjectMember,
State,
User,
) )
from .base import BaseSerializer from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer from .cycle import CycleLiteSerializer, CycleSerializer
from .module import ModuleSerializer, ModuleLiteSerializer from .module import ModuleLiteSerializer, ModuleSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer from .state import StateLiteSerializer
from .user import UserLiteSerializer
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
@ -78,7 +79,7 @@ class IssueSerializer(BaseSerializer):
data["description_html"] = parsed_str data["description_html"] = parsed_str
except Exception as e: except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}") raise serializers.ValidationError("Invalid HTML passed")
# Validate assignees are from project # Validate assignees are from project
if data.get("assignees", []): if data.get("assignees", []):
@ -293,7 +294,7 @@ class IssueLinkSerializer(BaseSerializer):
raise serializers.ValidationError("Invalid URL format.") raise serializers.ValidationError("Invalid URL format.")
# Check URL scheme # Check URL scheme
if not value.startswith(('http://', 'https://')): if not value.startswith(("http://", "https://")):
raise serializers.ValidationError("Invalid URL scheme.") raise serializers.ValidationError("Invalid URL scheme.")
return value return value
@ -349,7 +350,7 @@ class IssueCommentSerializer(BaseSerializer):
data["comment_html"] = parsed_str data["comment_html"] = parsed_str
except Exception as e: except Exception as e:
raise serializers.ValidationError(f"Invalid HTML: {str(e)}") raise serializers.ValidationError("Invalid HTML passed")
return data return data

View File

@ -504,8 +504,8 @@ class IssueReactionLiteSerializer(DynamicBaseSerializer):
model = IssueReaction model = IssueReaction
fields = [ fields = [
"id", "id",
"actor_id", "actor",
"issue_id", "issue",
"reaction", "reaction",
] ]

View File

@ -101,59 +101,69 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
) )
.annotate(is_favorite=Exists(favorite_subquery)) .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( .annotate(
completed_issues=Count( completed_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__id",
distinct=True,
filter=Q( filter=Q(
issue_cycle__issue__state__group="completed", issue_cycle__issue__state__group="completed",
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
), ),
distinct=True,
) )
) )
.annotate( .annotate(
cancelled_issues=Count( cancelled_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__id",
distinct=True,
filter=Q( filter=Q(
issue_cycle__issue__state__group="cancelled", issue_cycle__issue__state__group="cancelled",
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
), ),
distinct=True,
) )
) )
.annotate( .annotate(
started_issues=Count( started_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__id",
distinct=True,
filter=Q( filter=Q(
issue_cycle__issue__state__group="started", issue_cycle__issue__state__group="started",
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
), ),
distinct=True,
) )
) )
.annotate( .annotate(
unstarted_issues=Count( unstarted_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__id",
distinct=True,
filter=Q( filter=Q(
issue_cycle__issue__state__group="unstarted", issue_cycle__issue__state__group="unstarted",
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
), ),
distinct=True,
) )
) )
.annotate( .annotate(
backlog_issues=Count( backlog_issues=Count(
"issue_cycle__issue__state__group", "issue_cycle__issue__id",
distinct=True,
filter=Q( filter=Q(
issue_cycle__issue__state__group="backlog", issue_cycle__issue__state__group="backlog",
issue_cycle__issue__archived_at__isnull=True, issue_cycle__issue__archived_at__isnull=True,
issue_cycle__issue__is_draft=False, issue_cycle__issue__is_draft=False,
), ),
distinct=True,
) )
) )
.annotate( .annotate(
@ -182,9 +192,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
distinct=True, distinct=True,
filter=~Q( filter=~Q(
issue_cycle__issue__assignees__id__isnull=True issue_cycle__issue__assignees__id__isnull=True
)
& Q(
issue_cycle__issue__assignees__member_project__is_active=True
), ),
), ),
Value([], output_field=ArrayField(UUIDField())), Value([], output_field=ArrayField(UUIDField())),
@ -195,19 +202,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
) )
def list(self, request, slug, project_id): def list(self, request, slug, project_id):
queryset = ( queryset = self.get_queryset().filter(archived_at__isnull=True)
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,
),
)
)
)
cycle_view = request.GET.get("cycle_view", "all") cycle_view = request.GET.get("cycle_view", "all")
# Update the order by # Update the order by
@ -361,8 +356,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"external_id", "external_id",
"progress_snapshot", "progress_snapshot",
# meta fields # meta fields
"total_issues",
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -409,6 +404,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
# meta fields # meta fields
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
"total_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
"unstarted_issues", "unstarted_issues",
@ -484,6 +480,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
"progress_snapshot", "progress_snapshot",
# meta fields # meta fields
"is_favorite", "is_favorite",
"total_issues",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
@ -497,32 +494,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
def retrieve(self, request, slug, project_id, pk): def retrieve(self, request, slug, project_id, pk):
queryset = ( queryset = (
self.get_queryset() self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
.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,
),
)
)
) )
data = ( data = (
self.get_queryset() self.get_queryset()
.filter(pk=pk) .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( .annotate(
sub_issues=Issue.issue_objects.filter( sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
@ -880,7 +856,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
) )
cycle.archived_at = timezone.now() cycle.archived_at = timezone.now()
cycle.save() 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): def delete(self, request, slug, project_id, cycle_id):
cycle = Cycle.objects.get( cycle = Cycle.objects.get(

View File

@ -87,7 +87,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
Label( Label(
name=label.get("name", "Migrated"), name=label.get("name", "Migrated"),
description=label.get("description", "Migrated Issue"), description=label.get("description", "Migrated Issue"),
color="#" + "%06x" % random.randint(0, 0xFFFFFF), color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
project_id=project_id, project_id=project_id,
workspace_id=project.workspace_id, workspace_id=project.workspace_id,
created_by=request.user, created_by=request.user,

View File

@ -8,9 +8,11 @@ from django.db.models import (
Exists, Exists,
F, F,
Func, Func,
IntegerField,
OuterRef, OuterRef,
Prefetch, Prefetch,
Q, Q,
Subquery,
UUIDField, UUIDField,
Value, Value,
) )
@ -72,6 +74,59 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
workspace__slug=self.kwargs.get("slug"), 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 ( return (
super() super()
.get_queryset() .get_queryset()
@ -91,68 +146,39 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
) )
) )
.annotate( .annotate(
total_issues=Count( completed_issues=Coalesce(
"issue_module", Subquery(completed_issues[:1]),
filter=Q( Value(0, output_field=IntegerField()),
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,
) )
) )
.annotate( .annotate(
cancelled_issues=Count( cancelled_issues=Coalesce(
"issue_module__issue__state__group", Subquery(cancelled_issues[:1]),
filter=Q( Value(0, output_field=IntegerField()),
issue_module__issue__state__group="cancelled",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
) )
) )
.annotate( .annotate(
started_issues=Count( started_issues=Coalesce(
"issue_module__issue__state__group", Subquery(started_issues[:1]),
filter=Q( Value(0, output_field=IntegerField()),
issue_module__issue__state__group="started",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
) )
) )
.annotate( .annotate(
unstarted_issues=Count( unstarted_issues=Coalesce(
"issue_module__issue__state__group", Subquery(unstarted_issues[:1]),
filter=Q( Value(0, output_field=IntegerField()),
issue_module__issue__state__group="unstarted",
issue_module__issue__archived_at__isnull=True,
issue_module__issue__is_draft=False,
),
distinct=True,
) )
) )
.annotate( .annotate(
backlog_issues=Count( backlog_issues=Coalesce(
"issue_module__issue__state__group", Subquery(backlog_issues[:1]),
filter=Q( Value(0, output_field=IntegerField()),
issue_module__issue__state__group="backlog", )
issue_module__issue__archived_at__isnull=True, )
issue_module__issue__is_draft=False, .annotate(
), total_issues=Coalesce(
distinct=True, Subquery(total_issues[:1]),
Value(0, output_field=IntegerField()),
) )
) )
.annotate( .annotate(
@ -202,6 +228,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"is_favorite", "is_favorite",
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"total_issues",
"started_issues", "started_issues",
"unstarted_issues", "unstarted_issues",
"backlog_issues", "backlog_issues",
@ -257,16 +284,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
self.get_queryset() self.get_queryset()
.filter(archived_at__isnull=True) .filter(archived_at__isnull=True)
.filter(pk=pk) .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( .annotate(
sub_issues=Issue.issue_objects.filter( sub_issues=Issue.issue_objects.filter(
project_id=self.kwargs.get("project_id"), project_id=self.kwargs.get("project_id"),
@ -378,9 +395,11 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"completion_chart": {}, "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( data["distribution"]["completion_chart"] = burndown_plot(
queryset=queryset.first(), queryset=modules,
slug=slug, slug=slug,
project_id=project_id, project_id=project_id,
module_id=pk, module_id=pk,
@ -429,6 +448,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
"cancelled_issues", "cancelled_issues",
"completed_issues", "completed_issues",
"started_issues", "started_issues",
"total_issues",
"unstarted_issues", "unstarted_issues",
"backlog_issues", "backlog_issues",
"created_at", "created_at",
@ -642,7 +662,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
) )
module.archived_at = timezone.now() module.archived_at = timezone.now()
module.save() 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): def delete(self, request, slug, project_id, module_id):
module = Module.objects.get( module = Module.objects.get(

View File

@ -441,7 +441,10 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)
project.archived_at = timezone.now() project.archived_at = timezone.now()
project.save() 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): def delete(self, request, slug, project_id):
project = Project.objects.get(pk=project_id, workspace__slug=slug) project = Project.objects.get(pk=project_id, workspace__slug=slug)

View File

@ -3,15 +3,10 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import WorkspaceEntityPermission
from plane.app.serializers import WorkspaceEstimateSerializer from plane.app.serializers import WorkspaceEstimateSerializer
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import Project, Estimate from plane.db.models import Estimate, Project
from plane.app.permissions import WorkspaceEntityPermission
# Django imports
from django.db.models import (
Prefetch,
)
from plane.utils.cache import cache_response from plane.utils.cache import cache_response
@ -25,15 +20,11 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
estimate_ids = Project.objects.filter( estimate_ids = Project.objects.filter(
workspace__slug=slug, estimate__isnull=False workspace__slug=slug, estimate__isnull=False
).values_list("estimate_id", flat=True) ).values_list("estimate_id", flat=True)
estimates = Estimate.objects.filter( estimates = (
pk__in=estimate_ids Estimate.objects.filter(pk__in=estimate_ids, workspace__slug=slug)
).prefetch_related( .prefetch_related("points")
Prefetch( .select_related("workspace", "project")
"points",
queryset=Project.objects.select_related(
"estimate", "workspace", "project"
),
)
) )
serializer = WorkspaceEstimateSerializer(estimates, many=True) serializer = WorkspaceEstimateSerializer(estimates, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)

View File

@ -1,6 +1,6 @@
# base requirements # base requirements
Django==4.2.10 Django==4.2.11
psycopg==3.1.12 psycopg==3.1.12
djangorestframework==3.14.0 djangorestframework==3.14.0
redis==4.6.0 redis==4.6.0

File diff suppressed because it is too large Load Diff

View File

@ -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
<br>Default: 80
- Change your domain name
<br>Default: Deployed server's public IP address
- File upload size
<br>Default: 5MB
- Specify external database address when using an external database
<br>Default: `Empty`
<br>`Default folder: /opt/plane/data/postgres`
- Specify external Redis URL when using external Redis
<br>Default: `Empty`
<br>`Default folder: /opt/plane/data/redis`
- Configure AWS S3 bucket
<br>Use only when you or your users want to use S3
<br>`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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

View File

@ -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

View File

@ -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 <mailer@example.com>}" 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

View File

@ -9,9 +9,14 @@ export type TProjectOrderByOptions =
export type TProjectDisplayFilters = { export type TProjectDisplayFilters = {
my_projects?: boolean; my_projects?: boolean;
archived_projects?: boolean;
order_by?: TProjectOrderByOptions; order_by?: TProjectOrderByOptions;
}; };
export type TProjectAppliedDisplayFilterKeys =
| "my_projects"
| "archived_projects";
export type TProjectFilters = { export type TProjectFilters = {
access?: string[] | null; access?: string[] | null;
lead?: string[] | null; lead?: string[] | null;

View File

@ -23,6 +23,7 @@ export type TProjectLogoProps = {
export interface IProject { export interface IProject {
archive_in: number; archive_in: number;
archived_at: string | null;
archived_issues: number; archived_issues: number;
archived_sub_issues: number; archived_sub_issues: number;
close_in: number; close_in: number;

View File

@ -131,6 +131,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
ref={setReferenceElement} ref={setReferenceElement}
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
isOpen ? closeDropdown() : openDropdown(); isOpen ? closeDropdown() : openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();
@ -157,6 +158,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
: "cursor-pointer hover:bg-custom-background-80" : "cursor-pointer hover:bg-custom-background-80"
} ${buttonClassName}`} } ${buttonClassName}`}
onClick={(e) => { onClick={(e) => {
e.preventDefault();
e.stopPropagation(); e.stopPropagation();
isOpen ? closeDropdown() : openDropdown(); isOpen ? closeDropdown() : openDropdown();
if (menuButtonOnClick) menuButtonOnClick(); if (menuButtonOnClick) menuButtonOnClick();

View File

@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
require("dotenv").config({ path: ".env" }); require("dotenv").config({ path: ".env" });
const { withSentryConfig } = require("@sentry/nextjs"); const { withSentryConfig } = require("@sentry/nextjs");
@ -26,8 +27,11 @@ const nextConfig = {
output: "standalone", output: "standalone",
}; };
if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0")) { if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0"), 10) {
module.exports = withSentryConfig(nextConfig, { silent: true }, { hideSourceMaps: true }); module.exports = withSentryConfig(nextConfig,
{ silent: true, authToken: process.env.SENTRY_AUTH_TOKEN },
{ hideSourceMaps: true }
);
} else { } else {
module.exports = nextConfig; module.exports = nextConfig;
} }

View File

@ -22,7 +22,7 @@
"@plane/rich-text-editor": "*", "@plane/rich-text-editor": "*",
"@plane/types": "*", "@plane/types": "*",
"@plane/ui": "*", "@plane/ui": "*",
"@sentry/nextjs": "^7.85.0", "@sentry/nextjs": "^7.108.0",
"axios": "^1.3.4", "axios": "^1.3.4",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",

View File

@ -17,37 +17,25 @@
"NEXT_PUBLIC_POSTHOG_KEY", "NEXT_PUBLIC_POSTHOG_KEY",
"NEXT_PUBLIC_POSTHOG_HOST", "NEXT_PUBLIC_POSTHOG_HOST",
"NEXT_PUBLIC_POSTHOG_DEBUG", "NEXT_PUBLIC_POSTHOG_DEBUG",
"JITSU_TRACKER_ACCESS_KEY", "SENTRY_AUTH_TOKEN"
"JITSU_TRACKER_HOST"
], ],
"pipeline": { "pipeline": {
"build": { "build": {
"dependsOn": [ "dependsOn": ["^build"],
"^build" "outputs": [".next/**", "dist/**"]
],
"outputs": [
".next/**",
"dist/**"
]
}, },
"develop": { "develop": {
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"dependsOn": [ "dependsOn": ["^build"]
"^build"
]
}, },
"dev": { "dev": {
"cache": false, "cache": false,
"persistent": true, "persistent": true,
"dependsOn": [ "dependsOn": ["^build"]
"^build"
]
}, },
"test": { "test": {
"dependsOn": [ "dependsOn": ["^build"],
"^build"
],
"outputs": [] "outputs": []
}, },
"lint": { "lint": {

View File

@ -98,6 +98,8 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
hasError={Boolean(errors.close_in)} hasError={Boolean(errors.close_in)}
placeholder="Enter Months" placeholder="Enter Months"
className="w-full border-custom-border-200" className="w-full border-custom-border-200"
min={1}
max={12}
/> />
<span className="absolute right-8 top-2.5 text-sm text-custom-text-200">Months</span> <span className="absolute right-8 top-2.5 text-sm text-custom-text-200">Months</span>
</div> </div>
@ -130,6 +132,8 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
hasError={Boolean(errors.archive_in)} hasError={Boolean(errors.archive_in)}
placeholder="Enter Months" placeholder="Enter Months"
className="w-full border-custom-border-200" className="w-full border-custom-border-200"
min={1}
max={12}
/> />
<span className="absolute right-8 top-2.5 text-sm text-custom-text-200">Months</span> <span className="absolute right-8 top-2.5 text-sm text-custom-text-200">Months</span>
</div> </div>

View File

@ -113,8 +113,6 @@ export const CommandPalette: FC = observer(() => {
const canPerformWorkspaceCreateActions = useCallback( const canPerformWorkspaceCreateActions = useCallback(
(showToast: boolean = true) => { (showToast: boolean = true) => {
const isAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; const isAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
console.log("currentWorkspaceRole", currentWorkspaceRole);
console.log("isAllowed", isAllowed);
if (!isAllowed && showToast) if (!isAllowed && showToast)
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,

View File

@ -40,13 +40,14 @@ type Props = {
onChange: (data: string) => void; onChange: (data: string) => void;
disabled?: boolean; disabled?: boolean;
tabIndex?: number; tabIndex?: number;
isProfileCover?: boolean;
}; };
// services // services
const fileService = new FileService(); const fileService = new FileService();
export const ImagePickerPopover: React.FC<Props> = observer((props) => { export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const { label, value, control, onChange, disabled = false, tabIndex } = props; const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false } = props;
// states // states
const [image, setImage] = useState<File | null>(null); const [image, setImage] = useState<File | null>(null);
const [isImageUploading, setIsImageUploading] = useState(false); const [isImageUploading, setIsImageUploading] = useState(false);
@ -97,37 +98,53 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
const handleSubmit = async () => { const handleSubmit = async () => {
setIsImageUploading(true); setIsImageUploading(true);
if (!image || !workspaceSlug) return; if (!image) return;
const formData = new FormData(); const formData = new FormData();
formData.append("asset", image); formData.append("asset", image);
formData.append("attributes", JSON.stringify({})); formData.append("attributes", JSON.stringify({}));
fileService const oldValue = value;
.uploadFile(workspaceSlug.toString(), formData) const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
.then((res) => {
const oldValue = value;
const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
const imageUrl = res.asset; const uploadCallback = (res: any) => {
onChange(imageUrl); const imageUrl = res.asset;
setIsImageUploading(false); onChange(imageUrl);
setImage(null); setIsImageUploading(false);
setIsOpen(false); setImage(null);
setIsOpen(false);
};
if (isUnsplashImage) return; if (isProfileCover) {
fileService
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue); .uploadUserFile(formData)
}) .then((res) => {
.catch((err) => { uploadCallback(res);
console.error(err); 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(() => { useEffect(() => {
if (!unsplashImages || value !== null) return; if (!unsplashImages || value !== null) return;
onChange(unsplashImages[0].urls.regular); onChange(unsplashImages[0]?.urls.regular);
}, [value, onChange, unsplashImages]); }, [value, onChange, unsplashImages]);
const handleClose = () => { const handleClose = () => {
@ -149,7 +166,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
useOutsideClickDetector(ref, handleClose); useOutsideClickDetector(ref, handleClose);
return ( return (
<Popover className="relative z-[2]" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}> <Popover className="relative z-20" ref={ref} tabIndex={tabIndex} onKeyDown={handleKeyDown}>
<Popover.Button <Popover.Button
className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100" className="rounded border border-custom-border-300 bg-custom-background-100 px-2 py-1 text-xs text-custom-text-200 hover:text-custom-text-100"
onClick={handleOnClick} onClick={handleOnClick}
@ -160,7 +177,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
{isOpen && ( {isOpen && (
<Popover.Panel <Popover.Panel
className="absolute right-0 z-10 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm" className="absolute right-0 z-20 mt-2 rounded-md border border-custom-border-200 bg-custom-background-100 shadow-custom-shadow-sm"
static static
> >
<div <div

View File

@ -11,7 +11,9 @@ import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components // components
import { SingleProgressStats } from "@/components/core"; import { SingleProgressStats } from "@/components/core";
import { StateDropdown } from "@/components/dropdowns"; import { StateDropdown } from "@/components/dropdowns";
import { EmptyState } from "@/components/empty-state";
// constants // constants
import { EmptyStateType } from "@/constants/empty-state";
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys"; import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
// helper // helper
@ -177,8 +179,12 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
</Link> </Link>
)) ))
) : ( ) : (
<div className="flex items-center justify-center text-center h-full text-sm text-custom-text-200"> <div className="flex items-center justify-center h-full w-full">
<span>There are no high priority issues present in this cycle.</span> <EmptyState
type={EmptyStateType.ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE}
layout="screen-simple"
size="sm"
/>
</div> </div>
) )
) : ( ) : (
@ -195,63 +201,75 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
as="div" as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" 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) => { {cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
if (assignee.assignee_id) cycle.distribution?.assignees?.map((assignee, index) => {
return ( if (assignee.assignee_id)
<SingleProgressStats return (
key={assignee.assignee_id} <SingleProgressStats
title={ key={assignee.assignee_id}
<div className="flex items-center gap-2"> title={
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} /> <div className="flex items-center gap-2">
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
<span>{assignee.display_name}</span> <span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div> </div>
<span>No assignee</span> }
</div> completed={assignee.completed_issues}
} total={assignee.total_issues}
completed={assignee.completed_issues} />
total={assignee.total_issues} );
/> else
); return (
})} <SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-custom-border-200 bg-custom-background-80">
<img src="/user.png" height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>No assignee</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_ASSIGNEE_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Tab.Panel> </Tab.Panel>
<Tab.Panel <Tab.Panel
as="div" as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm" className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
> >
{cycle.distribution?.labels?.map((label, index) => ( {cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
<SingleProgressStats cycle.distribution.labels?.map((label, index) => (
key={label.label_id ?? `no-label-${index}`} <SingleProgressStats
title={ key={label.label_id ?? `no-label-${index}`}
<div className="flex items-center gap-2"> title={
<span <div className="flex items-center gap-2">
className="block h-3 w-3 rounded-full" <span
style={{ className="block h-3 w-3 rounded-full"
backgroundColor: label.color ?? "#000000", style={{
}} backgroundColor: label.color ?? "#000000",
/> }}
<span className="text-xs">{label.label_name ?? "No labels"}</span> />
</div> <span className="text-xs">{label.label_name ?? "No labels"}</span>
} </div>
completed={label.completed_issues} }
total={label.total_issues} completed={label.completed_issues}
/> total={label.total_issues}
))} />
))
) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_LABEL_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Tab.Panel> </Tab.Panel>
</Tab.Panels> </Tab.Panels>
</Tab.Group> </Tab.Group>

View File

@ -3,6 +3,9 @@ import { FC } from "react";
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// components // components
import ProgressChart from "@/components/core/sidebar/progress-chart"; import ProgressChart from "@/components/core/sidebar/progress-chart";
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
export type ActiveCycleProductivityProps = { export type ActiveCycleProductivityProps = {
cycle: ICycle; cycle: ICycle;
@ -16,31 +19,40 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props)
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3> <h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
</div> </div>
{cycle.total_issues > 0 ? (
<div className="h-full w-full px-2"> <>
<div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300"> <div className="h-full w-full px-2">
<div className="flex items-center gap-3 text-custom-text-300"> <div className="flex items-center justify-between gap-4 py-1 text-xs text-custom-text-300">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center gap-3 text-custom-text-300">
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" /> <div className="flex items-center justify-center gap-1">
<span>Ideal</span> <span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
<span>Ideal</span>
</div>
<div className="flex items-center justify-center gap-1">
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
<span>Current</span>
</div>
</div>
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
</div> </div>
<div className="flex items-center justify-center gap-1"> <div className="relative h-full">
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" /> <ProgressChart
<span>Current</span> className="h-full"
distribution={cycle.distribution?.completion_chart ?? {}}
startDate={cycle.start_date ?? ""}
endDate={cycle.end_date ?? ""}
totalIssues={cycle.total_issues}
/>
</div> </div>
</div> </div>
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span> </>
</div> ) : (
<div className="relative h-full"> <>
<ProgressChart <div className="flex items-center justify-center h-full w-full">
className="h-full" <EmptyState type={EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE} layout="screen-simple" size="sm" />
distribution={cycle.distribution?.completion_chart ?? {}} </div>
startDate={cycle.start_date ?? ""} </>
endDate={cycle.end_date ?? ""} )}
totalIssues={cycle.total_issues}
/>
</div>
</div>
</div> </div>
); );
}; };

View File

@ -3,8 +3,11 @@ import { FC } from "react";
import { ICycle } from "@plane/types"; import { ICycle } from "@plane/types";
// ui // ui
import { LinearProgressIndicator } from "@plane/ui"; import { LinearProgressIndicator } from "@plane/ui";
// components
import { EmptyState } from "@/components/empty-state";
// constants // constants
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle"; import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
import { EmptyStateType } from "@/constants/empty-state";
export type ActiveCycleProgressProps = { export type ActiveCycleProgressProps = {
cycle: ICycle; cycle: ICycle;
@ -32,48 +35,56 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">Progress</h3> <h3 className="text-base text-custom-text-300 font-semibold">Progress</h3>
<span className="flex gap-1 text-sm text-custom-text-400 font-medium whitespace-nowrap rounded-sm px-3 py-1 "> {cycle.total_issues > 0 && (
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${ <span className="flex gap-1 text-sm text-custom-text-400 font-medium whitespace-nowrap rounded-sm px-3 py-1 ">
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue" {`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
} closed`} cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
</span> } closed`}
</span>
)}
</div> </div>
<LinearProgressIndicator size="lg" data={progressIndicatorData} /> {cycle.total_issues > 0 && <LinearProgressIndicator size="lg" data={progressIndicatorData} />}
</div> </div>
<div className="flex flex-col gap-5"> {cycle.total_issues > 0 ? (
{Object.keys(groupedIssues).map((group, index) => ( <div className="flex flex-col gap-5">
<> {Object.keys(groupedIssues).map((group, index) => (
{groupedIssues[group] > 0 && ( <>
<div key={index}> {groupedIssues[group] > 0 && (
<div className="flex items-center justify-between gap-2 text-sm"> <div key={index}>
<div className="flex items-center gap-1.5"> <div className="flex items-center justify-between gap-2 text-sm">
<span <div className="flex items-center gap-1.5">
className="block h-3 w-3 rounded-full" <span
style={{ className="block h-3 w-3 rounded-full"
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color, style={{
}} backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
/> }}
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span> />
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
</div>
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Issues" : "Issue"
}`}</span>
</div> </div>
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Issues" : "Issue"
}`}</span>
</div> </div>
</div> )}
)} </>
</> ))}
))} {cycle.cancelled_issues > 0 && (
{cycle.cancelled_issues > 0 && ( <span className="flex items-center gap-2 text-sm text-custom-text-300">
<span className="flex items-center gap-2 text-sm text-custom-text-300"> <span>
<span> {`${cycle.cancelled_issues} cancelled ${
{`${cycle.cancelled_issues} cancelled ${ cycle.cancelled_issues > 1 ? "issues are" : "issue is"
cycle.cancelled_issues > 1 ? "issues are" : "issue is" } excluded from this report.`}{" "}
} excluded from this report.`}{" "} </span>
</span> </span>
</span> )}
)} </div>
</div> ) : (
<div className="flex items-center justify-center h-full w-full">
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</div> </div>
); );
}; };

View File

@ -1,5 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import Image from "next/image";
import { useTheme } from "next-themes";
// components // components
import { UpcomingCycleListItem } from "@/components/cycles"; import { UpcomingCycleListItem } from "@/components/cycles";
// hooks // hooks
@ -14,6 +16,11 @@ export const UpcomingCyclesList: FC<Props> = observer((props) => {
// store hooks // store hooks
const { currentProjectUpcomingCycleIds } = useCycle(); const { currentProjectUpcomingCycleIds } = useCycle();
// theme
const { resolvedTheme } = useTheme();
const resolvedEmptyStatePath = `/empty-state/active-cycle/cycle-${resolvedTheme === "light" ? "light" : "dark"}.webp`;
if (!currentProjectUpcomingCycleIds) return null; if (!currentProjectUpcomingCycleIds) return null;
return ( return (
@ -28,8 +35,18 @@ export const UpcomingCyclesList: FC<Props> = observer((props) => {
))} ))}
</div> </div>
) : ( ) : (
<div className="w-full grid place-items-center py-20"> <div className="flex items-center justify-center h-full w-full py-20">
<div className="text-center"> <div className="text-center flex flex-col gap-2.5 items-center">
<div className="h-24 w-24">
<Image
src={resolvedEmptyStatePath}
alt="button image"
width={78}
height={78}
layout="responsive"
lazyBoundary="100%"
/>
</div>
<h5 className="text-xl font-medium mb-1">No upcoming cycles</h5> <h5 className="text-xl font-medium mb-1">No upcoming cycles</h5>
<p className="text-custom-text-400 text-base"> <p className="text-custom-text-400 text-base">
Create new cycles to find them here or check Create new cycles to find them here or check

View File

@ -31,7 +31,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
handleClose(); handleClose();
}; };
const handleArchiveIssue = async () => { const handleArchiveCycle = async () => {
setIsArchiving(true); setIsArchiving(true);
await archiveCycle(workspaceSlug, projectId, cycleId) await archiveCycle(workspaceSlug, projectId, cycleId)
.then(() => { .then(() => {
@ -89,7 +89,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}> <Button size="sm" tabIndex={1} onClick={handleArchiveCycle} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"} {isArchiving ? "Archiving" : "Archive"}
</Button> </Button>
</div> </div>

View File

@ -69,8 +69,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
? cycleTotalIssues === 0 ? cycleTotalIssues === 0
? "0 Issue" ? "0 Issue"
: cycleTotalIssues === cycleDetails.completed_issues : cycleTotalIssues === cycleDetails.completed_issues
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}` ? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues` : `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
: "0 Issue"; : "0 Issue";
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => { const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
@ -134,10 +134,18 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
router.push({ if (query.peekCycle) {
pathname: router.pathname, delete query.peekCycle;
query: { ...query, peekCycle: cycleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycleId },
});
}
}; };
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0; const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;

View File

@ -106,10 +106,18 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
router.push({ if (query.peekCycle) {
pathname: router.pathname, delete query.peekCycle;
query: { ...query, peekCycle: cycleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekCycle: cycleId },
});
}
}; };
const cycleDetails = getCycleById(cycleId); const cycleDetails = getCycleById(cycleId);
@ -190,7 +198,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`} {renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
</div> </div>
</div> </div>
<div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 overflow-hidden md:w-auto md:flex-shrink-0 md:justify-end"> <div className="relative flex w-full flex-shrink-0 items-center justify-between gap-2.5 md:w-auto md:flex-shrink-0 md:justify-end">
{currentCycle && ( {currentCycle && (
<div <div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs" className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"

View File

@ -29,7 +29,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
isArchived={isArchived} isArchived={isArchived}
/> />
{completedCycleIds.length !== 0 && ( {completedCycleIds.length !== 0 && (
<Disclosure as="div" className="mt-4 space-y-4"> <Disclosure as="div" className="pt-8 pl-3 space-y-4">
<Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1"> <Disclosure.Button className="bg-custom-background-80 font-semibold text-sm py-1 px-2 rounded ml-5 flex items-center gap-1">
{({ open }) => ( {({ open }) => (
<> <>

View File

@ -56,7 +56,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",
message: err.detail ?? "Error in creating cycle. Please try again.", message: err?.detail ?? "Error in creating cycle. Please try again.",
}); });
captureCycleEvent({ captureCycleEvent({
eventName: CYCLE_CREATED, eventName: CYCLE_CREATED,
@ -90,7 +90,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Error!",
message: err.detail ?? "Error in updating cycle. Please try again.", message: err?.detail ?? "Error in updating cycle. Please try again.",
}); });
}); });
}; };

View File

@ -41,7 +41,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
hideIcon = false, hideIcon = false,
onChange, onChange,
onClose, onClose,
placeholder = "Cycle", placeholder = "",
placement, placement,
projectId, projectId,
showTooltip = false, showTooltip = false,
@ -132,7 +132,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
variant={buttonVariant} variant={buttonVariant}
> >
{!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />} {!hideIcon && <ContrastIcon className="h-3 w-3 flex-shrink-0" />}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (!!selectedName || !!placeholder) && (
<span className="flex-grow truncate max-w-40">{selectedName ?? placeholder}</span> <span className="flex-grow truncate max-w-40">{selectedName ?? placeholder}</span>
)} )}
{dropdownArrow && ( {dropdownArrow && (

View File

@ -37,6 +37,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
onChange, onChange,
onClose, onClose,
placeholder = "Members", placeholder = "Members",
tooltipContent,
placement, placement,
projectId, projectId,
showTooltip = false, showTooltip = false,
@ -123,7 +124,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
className={buttonClassName} className={buttonClassName}
isActive={isOpen} isActive={isOpen}
tooltipHeading={placeholder} tooltipHeading={placeholder}
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`} tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}
> >

View File

@ -5,6 +5,7 @@ export type MemberDropdownProps = TDropdownProps & {
dropdownArrow?: boolean; dropdownArrow?: boolean;
dropdownArrowClassName?: string; dropdownArrowClassName?: string;
placeholder?: string; placeholder?: string;
tooltipContent?: string;
onClose?: () => void; onClose?: () => void;
} & ( } & (
| { | {

View File

@ -46,7 +46,7 @@ type ButtonContentProps = {
hideIcon: boolean; hideIcon: boolean;
hideText: boolean; hideText: boolean;
onChange: (moduleIds: string[]) => void; onChange: (moduleIds: string[]) => void;
placeholder: string; placeholder?: string;
showCount: boolean; showCount: boolean;
showTooltip?: boolean; showTooltip?: boolean;
value: string | string[] | null; value: string | string[] | null;
@ -73,15 +73,17 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => {
return ( return (
<> <>
{showCount ? ( {showCount ? (
<div className="relative flex items-center gap-1"> <div className="relative flex items-center gap-1 max-w-full">
{!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />} {!hideIcon && <DiceIcon className="h-3 w-3 flex-shrink-0" />}
<div className="max-w-40 flex-grow truncate"> {(value.length > 0 || !!placeholder) && (
{value.length > 0 <div className="max-w-40 flex-grow truncate">
? value.length === 1 {value.length > 0
? `${getModuleById(value[0])?.name || "module"}` ? value.length === 1
: `${value.length} Module${value.length === 1 ? "" : "s"}` ? `${getModuleById(value[0])?.name || "module"}`
: placeholder} : `${value.length} Module${value.length === 1 ? "" : "s"}`
</div> : placeholder}
</div>
)}
</div> </div>
) : value.length > 0 ? ( ) : value.length > 0 ? (
<div className="flex max-w-full flex-grow flex-wrap items-center gap-2 truncate py-0.5"> <div className="flex max-w-full flex-grow flex-wrap items-center gap-2 truncate py-0.5">
@ -158,7 +160,7 @@ export const ModuleDropdown: React.FC<Props> = observer((props) => {
multiple, multiple,
onChange, onChange,
onClose, onClose,
placeholder = "Module", placeholder = "",
placement, placement,
projectId, projectId,
showCount = false, showCount = false,

View File

@ -4,7 +4,7 @@ import { usePopper } from "react-popper";
import { Check, ChevronDown, Search } from "lucide-react"; import { Check, ChevronDown, Search } from "lucide-react";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// hooks // hooks
import { StateGroupIcon } from "@plane/ui"; import { Spinner, StateGroupIcon } from "@plane/ui";
import { cn } from "@/helpers/common.helper"; import { cn } from "@/helpers/common.helper";
import { useApplication, useProjectState } from "@/hooks/store"; import { useApplication, useProjectState } from "@/hooks/store";
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down"; import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
@ -50,6 +50,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
// states // states
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [stateLoader, setStateLoader] = useState(false);
// refs // refs
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
@ -74,6 +75,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
} = useApplication(); } = useApplication();
const { fetchProjectStates, getProjectStates, getStateById } = useProjectState(); const { fetchProjectStates, getProjectStates, getStateById } = useProjectState();
const statesList = getProjectStates(projectId); const statesList = getProjectStates(projectId);
const defaultStateList = statesList?.find((state) => state.default);
const stateValue = value ? value : defaultStateList?.id;
const options = statesList?.map((state) => ({ const options = statesList?.map((state) => ({
value: state.id, value: state.id,
@ -89,11 +92,19 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
const filteredOptions = const filteredOptions =
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase())); query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
const selectedState = getStateById(value); const selectedState = stateValue ? getStateById(stateValue) : undefined;
const onOpen = () => { const onOpen = async () => {
if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId); 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 = () => { const handleClose = () => {
if (!isOpen) return; if (!isOpen) return;
@ -141,7 +152,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
ref={dropdownRef} ref={dropdownRef}
tabIndex={tabIndex} tabIndex={tabIndex}
className={cn("h-full", className)} className={cn("h-full", className)}
value={value} value={stateValue}
onChange={dropdownOnChange} onChange={dropdownOnChange}
disabled={disabled} disabled={disabled}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@ -178,18 +189,27 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
showTooltip={showTooltip} showTooltip={showTooltip}
variant={buttonVariant} variant={buttonVariant}
> >
{!hideIcon && ( {stateLoader ? (
<StateGroupIcon <Spinner className="w-3.5 h-3.5" />
stateGroup={selectedState?.group ?? "backlog"} ) : (
color={selectedState?.color} <>
className="h-3 w-3 flex-shrink-0" {!hideIcon && (
/> <StateGroupIcon
)} stateGroup={selectedState?.group ?? "backlog"}
{BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && ( color={selectedState?.color}
<span className="flex-grow truncate">{selectedState?.name ?? "State"}</span> className="h-3 w-3 flex-shrink-0"
)} />
{dropdownArrow && ( )}
<ChevronDown className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" /> {BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
<span className="flex-grow truncate">{selectedState?.name ?? "State"}</span>
)}
{dropdownArrow && (
<ChevronDown
className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)}
aria-hidden="true"
/>
)}
</>
)} )}
</DropdownButton> </DropdownButton>
</button> </button>

View File

@ -151,12 +151,12 @@ export const EmptyState: React.FC<EmptyStateProps> = (props) => {
)} )}
{layout === "screen-simple" && ( {layout === "screen-simple" && (
<div className="text-center flex flex-col gap-2.5 items-center"> <div className="text-center flex flex-col gap-2.5 items-center">
<div className="h-28 w-28"> <div className={`${size === "sm" ? "h-24 w-24" : "h-28 w-28"}`}>
<Image <Image
src={resolvedEmptyStatePath} src={resolvedEmptyStatePath}
alt={key || "button image"} alt={key || "button image"}
width={96} width={size === "sm" ? 78 : 96}
height={96} height={size === "sm" ? 78 : 96}
layout="responsive" layout="responsive"
lazyBoundary="100%" lazyBoundary="100%"
/> />

View File

@ -109,8 +109,10 @@ export const CycleIssuesHeader: React.FC = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
@ -224,7 +226,9 @@ export const CycleIssuesHeader: React.FC = observer(() => {
className="ml-1.5 flex-shrink-0 truncate" className="ml-1.5 flex-shrink-0 truncate"
placement="bottom-start" placement="bottom-start"
> >
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)} {currentProjectCycleIds?.map((cycleId) => (
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
))}
</CustomMenu> </CustomMenu>
} }
/> />

View File

@ -56,8 +56,10 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -110,8 +110,10 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
@ -225,7 +227,9 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
className="ml-1.5 flex-shrink-0" className="ml-1.5 flex-shrink-0"
placement="bottom-start" placement="bottom-start"
> >
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)} {projectModuleIds?.map((moduleId) => (
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
))}
</CustomMenu> </CustomMenu>
} }
/> />

View File

@ -38,8 +38,10 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -60,8 +60,10 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -74,8 +74,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -1,18 +1,19 @@
import { FC, useState } from "react"; import { FC } from "react";
import { observer } from "mobx-react";
import Link from "next/link"; import Link from "next/link";
import { AlertCircle, X } from "lucide-react"; import { AlertCircle, X } from "lucide-react";
// hooks
// ui
import { Tooltip } from "@plane/ui"; import { Tooltip } from "@plane/ui";
// components
// icons
import { getFileIcon } from "@/components/icons"; import { getFileIcon } from "@/components/icons";
// helper
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper"; import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
import { renderFormattedDate } from "@/helpers/date-time.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper";
import { truncateText } from "@/helpers/string.helper"; import { truncateText } from "@/helpers/string.helper";
import { useIssueDetail, useMember } from "@/hooks/store"; import { useIssueDetail, useMember } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// hooks
// ui
// components
// icons
// helper
import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal"; import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal";
// types // types
import { TAttachmentOperations } from "./root"; import { TAttachmentOperations } from "./root";
@ -25,16 +26,17 @@ type TIssueAttachmentsDetail = {
disabled?: boolean; disabled?: boolean;
}; };
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => { export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((props) => {
// props // props
const { attachmentId, handleAttachmentOperations, disabled } = props; const { attachmentId, handleAttachmentOperations, disabled } = props;
// store hooks // store hooks
const { getUserDetails } = useMember(); const { getUserDetails } = useMember();
const { const {
attachment: { getAttachmentById }, attachment: { getAttachmentById },
isDeleteAttachmentModalOpen,
toggleDeleteAttachmentModal,
} = useIssueDetail(); } = useIssueDetail();
// states // states
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const attachment = attachmentId && getAttachmentById(attachmentId); const attachment = attachmentId && getAttachmentById(attachmentId);
@ -42,8 +44,8 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => {
return ( return (
<> <>
<IssueAttachmentDeleteModal <IssueAttachmentDeleteModal
isOpen={attachmentDeleteModal} isOpen={isDeleteAttachmentModalOpen}
setIsOpen={setAttachmentDeleteModal} setIsOpen={() => toggleDeleteAttachmentModal(false)}
handleAttachmentOperations={handleAttachmentOperations} handleAttachmentOperations={handleAttachmentOperations}
data={attachment} data={attachment}
/> />
@ -81,15 +83,11 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => {
</Link> </Link>
{!disabled && ( {!disabled && (
<button <button onClick={() => toggleDeleteAttachmentModal(true)}>
onClick={() => {
setAttachmentDeleteModal(true);
}}
>
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" /> <X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
</button> </button>
)} )}
</div> </div>
</> </>
); );
}; });

View File

@ -1,14 +1,13 @@
import { FC, useState, Fragment, useEffect } from "react"; import { FC, useState, Fragment, useEffect } from "react";
import { TwitterPicker } from "react-color"; import { TwitterPicker } from "react-color";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { usePopper } from "react-popper";
import { Plus, X, Loader } from "lucide-react"; import { Plus, X, Loader } from "lucide-react";
import { Popover, Transition } from "@headlessui/react"; import { Popover } from "@headlessui/react";
import { IIssueLabel } from "@plane/types"; import { IIssueLabel } from "@plane/types";
// hooks // hooks
import { Input, TOAST_TYPE, setToast } from "@plane/ui"; import { Input, TOAST_TYPE, setToast } from "@plane/ui";
import { useIssueDetail } from "@/hooks/store"; import { useIssueDetail } from "@/hooks/store";
// helpers
import { cn } from "helpers/common.helper";
// ui // ui
// types // types
import { TLabelOperations } from "./root"; import { TLabelOperations } from "./root";
@ -31,11 +30,12 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
// hooks // hooks
const { const {
issue: { getIssueById }, issue: { getIssueById },
peekIssue,
} = useIssueDetail(); } = useIssueDetail();
// state // state
const [isCreateToggle, setIsCreateToggle] = useState(false); const [isCreateToggle, setIsCreateToggle] = useState(false);
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle); const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
// react hook form // react hook form
const { const {
handleSubmit, handleSubmit,
@ -47,6 +47,18 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
defaultValues, defaultValues,
}); });
const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: "bottom-start",
modifiers: [
{
name: "preventOverflow",
options: {
padding: 12,
},
},
],
});
useEffect(() => { useEffect(() => {
if (!isCreateToggle) return; if (!isCreateToggle) return;
@ -93,36 +105,28 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
render={({ field: { value, onChange } }) => ( render={({ field: { value, onChange } }) => (
<Popover> <Popover>
<> <>
<Popover.Button className="grid place-items-center outline-none"> <Popover.Button as={Fragment}>
{value && value?.trim() !== "" && ( <button type="button" ref={setReferenceElement} className="grid place-items-center outline-none">
<span {value && value?.trim() !== "" && (
className="h-6 w-6 rounded" <span
style={{ className="h-6 w-6 rounded"
backgroundColor: value ?? "black", style={{
}} backgroundColor: value ?? "black",
/> }}
)} />
)}
</button>
</Popover.Button> </Popover.Button>
<Popover.Panel className="fixed z-10">
<Transition <div
as={Fragment} className="p-2 max-w-xs sm:px-0"
enter="transition ease-out duration-200" ref={setPopperElement}
enterFrom="opacity-0 translate-y-1" style={styles.popper}
enterTo="opacity-100 translate-y-0" {...attributes.popper}
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel
className={cn("absolute z-10 mt-1.5 max-w-xs px-2 sm:px-0", !peekIssue ? "right-0" : "")}
> >
<TwitterPicker <TwitterPicker triangle={"hide"} color={value} onChange={(value) => onChange(value.hex)} />
triangle={!peekIssue ? "hide" : "top-left"} </div>
color={value} </Popover.Panel>
onChange={(value) => onChange(value.hex)}
/>
</Popover.Panel>
</Transition>
</> </>
</Popover> </Popover>
)} )}

View File

@ -73,7 +73,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
setToast({ setToast({
title: "Error", title: "Error",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
message: err.detail ?? "Failed to perform this action", message: err?.detail ?? "Failed to perform this action",
}); });
}); });
} }
@ -89,7 +89,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
groupedIssueIds={groupedIssueIds} groupedIssueIds={groupedIssueIds}
layout={displayFilters?.calendar?.layout} layout={displayFilters?.calendar?.layout}
showWeekends={displayFilters?.calendar?.show_weekends ?? false} showWeekends={displayFilters?.calendar?.show_weekends ?? false}
quickActions={(issue, customActionButton) => ( quickActions={(issue, customActionButton, placement) => (
<QuickActions <QuickActions
customActionButton={customActionButton} customActionButton={customActionButton}
issue={issue} issue={issue}
@ -101,6 +101,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)} handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)} handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
readOnly={!isEditingAllowed || isCompletedCycle} readOnly={!isEditingAllowed || isCompletedCycle}
placements={placement}
/> />
)} )}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}

View File

@ -1,4 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import type { import type {
IIssueDisplayFilterOptions, IIssueDisplayFilterOptions,
@ -37,7 +38,7 @@ type Props = {
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
layout: "month" | "week" | undefined; layout: "month" | "week" | undefined;
showWeekends: boolean; showWeekends: boolean;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
quickAddCallback?: ( quickAddCallback?: (
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View File

@ -1,4 +1,5 @@
import { Droppable } from "@hello-pangea/dnd"; import { Droppable } from "@hello-pangea/dnd";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
// components // components
@ -19,7 +20,7 @@ type Props = {
date: ICalendarDate; date: ICalendarDate;
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: ( quickAddCallback?: (

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { Placement } from "@popperjs/core";
// components // components
import { TIssue, TIssueMap } from "@plane/types"; import { TIssue, TIssueMap } from "@plane/types";
import { CalendarIssueBlock } from "@/components/issues"; import { CalendarIssueBlock } from "@/components/issues";
@ -7,7 +8,7 @@ import { CalendarIssueBlock } from "@/components/issues";
type Props = { type Props = {
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
issueId: string; issueId: string;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
isDragging?: boolean; isDragging?: boolean;
}; };

View File

@ -1,4 +1,5 @@
import { useState, useRef } from "react"; import { useState, useRef } from "react";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { MoreHorizontal } from "lucide-react"; import { MoreHorizontal } from "lucide-react";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
@ -14,7 +15,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
type Props = { type Props = {
issue: TIssue; issue: TIssue;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
isDragging?: boolean; isDragging?: boolean;
}; };
@ -56,6 +57,11 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
</div> </div>
); );
const isMenuActionRefAboveScreenBottom =
menuActionRef?.current && menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220;
const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";
return ( return (
<ControlLink <ControlLink
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`} href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
@ -104,7 +110,7 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
e.stopPropagation(); e.stopPropagation();
}} }}
> >
{quickActions(issue, customActionButton)} {quickActions(issue, customActionButton, placement)}
</div> </div>
</div> </div>
</> </>

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Draggable } from "@hello-pangea/dnd"; import { Draggable } from "@hello-pangea/dnd";
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TIssue, TIssueMap } from "@plane/types"; import { TIssue, TIssueMap } from "@plane/types";
// components // components
@ -12,7 +13,7 @@ type Props = {
date: Date; date: Date;
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
issueIdList: string[] | null; issueIdList: string[] | null;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
isDragDisabled?: boolean; isDragDisabled?: boolean;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;

View File

@ -230,7 +230,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
{!isOpen && ( {!isOpen && (
<div <div
className={cn("md:hidden rounded md:border-[0.5px] border-custom-border-200 md:group-hover:block", { className={cn("md:opacity-0 rounded md:border-[0.5px] border-custom-border-200 md:group-hover:opacity-100", {
block: isMenuOpen, block: isMenuOpen,
})} })}
> >

View File

@ -1,3 +1,4 @@
import { Placement } from "@popperjs/core";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
// components // components
@ -16,7 +17,7 @@ type Props = {
issues: TIssueMap | undefined; issues: TIssueMap | undefined;
groupedIssueIds: TGroupedIssues; groupedIssueIds: TGroupedIssues;
week: ICalendarWeek | undefined; week: ICalendarWeek | undefined;
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode; quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
enableQuickIssueCreate?: boolean; enableQuickIssueCreate?: boolean;
disableIssueCreation?: boolean; disableIssueCreation?: boolean;
quickAddCallback?: ( quickAddCallback?: (

View File

@ -25,6 +25,17 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
d.name.toLowerCase().includes(searchQuery.toLowerCase()) 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 ( return (
<> <>
{isDateFilterModalOpen && ( {isDateFilterModalOpen && (
@ -53,7 +64,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
multiple multiple
/> />
))} ))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple /> <FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</> </>
) : ( ) : (
<p className="text-xs italic text-custom-text-400">No matches found</p> <p className="text-xs italic text-custom-text-400">No matches found</p>

View File

@ -25,6 +25,17 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
d.name.toLowerCase().includes(searchQuery.toLowerCase()) 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 ( return (
<> <>
{isDateFilterModalOpen && ( {isDateFilterModalOpen && (
@ -53,7 +64,7 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
multiple multiple
/> />
))} ))}
<FilterOption isChecked={false} onClick={() => setIsDateFilterModalOpen(true)} title="Custom" multiple /> <FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</> </>
) : ( ) : (
<p className="text-xs italic text-custom-text-400">No matches found</p> <p className="text-xs italic text-custom-text-400">No matches found</p>

View File

@ -143,7 +143,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
setToast({ setToast({
title: "Error", title: "Error",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
message: err.detail ?? "Failed to perform this action", message: err?.detail ?? "Failed to perform this action",
}); });
}); });
} }

View File

@ -25,7 +25,7 @@ import {
} from "@/hooks/store"; } from "@/hooks/store";
// types // types
// parent components // parent components
import { getGroupByColumns } from "../utils"; import { getGroupByColumns, isWorkspaceLevel } from "../utils";
// components // components
import { KanbanStoreType } from "./base-kanban-root"; import { KanbanStoreType } from "./base-kanban-root";
import { HeaderGroupByCard } from "./headers/group-by-card"; import { HeaderGroupByCard } from "./headers/group-by-card";
@ -102,7 +102,9 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
moduleInfo, moduleInfo,
label, label,
projectState, projectState,
member member,
true,
isWorkspaceLevel(storeType)
); );
if (!list) return null; if (!list) return null;

View File

@ -1,11 +1,11 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
// components // components
import { ProjectIssueQuickActions } from "@/components/issues"; import { DraftIssueQuickActions } from "@/components/issues";
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { BaseKanBanRoot } from "../base-kanban-root"; import { BaseKanBanRoot } from "../base-kanban-root";
export interface IKanBanLayout {} export interface IKanBanLayout {}
export const DraftKanBanLayout: React.FC = observer(() => ( export const DraftKanBanLayout: React.FC = observer(() => (
<BaseKanBanRoot showLoader QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.DRAFT} /> <BaseKanBanRoot showLoader QuickActions={DraftIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />
)); ));

View File

@ -13,7 +13,7 @@ import {
} from "@plane/types"; } from "@plane/types";
// components // components
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; 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 { KanbanStoreType } from "./base-kanban-root";
import { KanBan } from "./default"; import { KanBan } from "./default";
import { HeaderGroupByCard } from "./headers/group-by-card"; import { HeaderGroupByCard } from "./headers/group-by-card";
@ -291,7 +291,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
projectModule, projectModule,
label, label,
projectState, projectState,
member member,
true,
isWorkspaceLevel(storeType)
); );
const subGroupByList = getGroupByColumns( const subGroupByList = getGroupByColumns(
sub_group_by as GroupByColumnTypes, sub_group_by as GroupByColumnTypes,
@ -300,7 +302,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
projectModule, projectModule,
label, label,
projectState, projectState,
member member,
true,
isWorkspaceLevel(storeType)
); );
if (!groupByList || !subGroupByList) return null; if (!groupByList || !subGroupByList) return null;

View File

@ -13,9 +13,8 @@ import { IssueBlocksList, ListQuickAddIssueForm } from "@/components/issues";
// hooks // hooks
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
// constants // utils
// types import { getGroupByColumns, isWorkspaceLevel } from "../utils";
import { getGroupByColumns } from "../utils";
import { HeaderGroupByCard } from "./headers/group-by-card"; import { HeaderGroupByCard } from "./headers/group-by-card";
export interface IGroupByList { export interface IGroupByList {
@ -78,7 +77,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
projectState, projectState,
member, member,
true, true,
true isWorkspaceLevel(storeType)
); );
if (!groups) return null; if (!groups) return null;

View File

@ -70,8 +70,8 @@ export const HeaderGroupByCard = observer(
{icon ? icon : <CircleDashed className="h-3.5 w-3.5" strokeWidth={2} />} {icon ? icon : <CircleDashed className="h-3.5 w-3.5" strokeWidth={2} />}
</div> </div>
<div className="flex w-full flex-row items-center gap-1"> <div className="relative flex w-full flex-row items-center gap-1 overflow-hidden">
<div className="line-clamp-1 font-medium text-custom-text-100">{title}</div> <div className="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
<div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div> <div className="pl-2 text-sm font-medium text-custom-text-300">{count || 0}</div>
</div> </div>

View File

@ -1,3 +1,4 @@
import { Placement } from "@popperjs/core";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
export interface IQuickActionProps { export interface IQuickActionProps {
@ -10,4 +11,5 @@ export interface IQuickActionProps {
customActionButton?: React.ReactElement; customActionButton?: React.ReactElement;
portalElement?: HTMLDivElement | null; portalElement?: HTMLDivElement | null;
readOnly?: boolean; readOnly?: boolean;
placements?: Placement;
} }

View File

@ -2,7 +2,7 @@ import { FC } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
// hooks // hooks
import { ProjectIssueQuickActions } from "@/components/issues"; import { DraftIssueQuickActions } from "@/components/issues";
import { EIssuesStoreType } from "@/constants/issue"; import { EIssuesStoreType } from "@/constants/issue";
// components // components
// types // types
@ -15,5 +15,5 @@ export const DraftIssueListLayout: FC = observer(() => {
if (!workspaceSlug || !projectId) return null; if (!workspaceSlug || !projectId) return null;
return <BaseListRoot QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />; return <BaseListRoot QuickActions={DraftIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />;
}); });

View File

@ -257,8 +257,9 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
{/* basic properties */} {/* basic properties */}
{/* state */} {/* state */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
<div className="h-5 truncate"> <div className="h-5">
<StateDropdown <StateDropdown
buttonContainerClassName="truncate max-w-40"
value={issue.state_id} value={issue.state_id}
onChange={handleState} onChange={handleState}
projectId={issue.project_id} projectId={issue.project_id}
@ -340,6 +341,9 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
multiple multiple
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"} buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""} buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
showTooltip={issue?.assignee_ids?.length === 0}
placeholder="Assignees"
tooltipContent=""
/> />
</div> </div>
</WithDisplayPropertiesHOC> </WithDisplayPropertiesHOC>
@ -348,6 +352,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
<div className="h-5"> <div className="h-5">
<ModuleDropdown <ModuleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id} projectId={issue?.project_id}
value={issue?.module_ids ?? []} value={issue?.module_ids ?? []}
onChange={handleModule} onChange={handleModule}
@ -362,8 +367,9 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
{/* cycles */} {/* cycles */}
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle"> <WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
<div className="h-5 truncate"> <div className="h-5">
<CycleDropdown <CycleDropdown
buttonContainerClassName="truncate max-w-40"
projectId={issue?.project_id} projectId={issue?.project_id}
value={issue?.cycle_id} value={issue?.cycle_id}
onChange={handleCycle} onChange={handleCycle}

View File

@ -31,6 +31,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start",
} = props; } = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
@ -107,7 +108,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
/> />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
placement="bottom-start" placement={placements}
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" maxHeight="lg"

View File

@ -0,0 +1,121 @@
import { useState } from "react";
import omit from "lodash/omit";
import { observer } from "mobx-react";
// hooks
import { Copy, Pencil, Trash2 } from "lucide-react";
import { TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { CreateUpdateIssueModal, DeleteIssueModal } from "@/components/issues";
import { EIssuesStoreType } from "@/constants/issue";
import { EUserProjectRoles } from "@/constants/project";
import { useEventTracker, useIssues, useUser } from "@/hooks/store";
// ui
// components
// helpers
// types
import { IQuickActionProps } from "../list/list-view-types";
// constant
export const DraftIssueQuickActions: React.FC<IQuickActionProps> = observer((props) => {
const { issue, handleDelete, handleUpdate, customActionButton, portalElement, readOnly = false } = props;
// states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
const [issueToEdit, setIssueToEdit] = useState<TIssue | undefined>(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 (
<>
<DeleteIssueModal
data={issue}
isOpen={deleteIssueModal}
handleClose={() => setDeleteIssueModal(false)}
onSubmit={handleDelete}
/>
<CreateUpdateIssueModal
isOpen={createUpdateIssueModal}
onClose={() => {
setCreateUpdateIssueModal(false);
setIssueToEdit(undefined);
}}
data={issueToEdit ?? duplicateIssuePayload}
onSubmit={async (data) => {
if (issueToEdit && handleUpdate) await handleUpdate(data);
}}
storeType={EIssuesStoreType.PROJECT}
isDraft
/>
<CustomMenu
menuItemsClassName="z-[14]"
placement="bottom-start"
customButton={customActionButton}
portalElement={portalElement}
maxHeight="lg"
closeOnSelect
ellipsis
>
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setIssueToEdit(issue);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Pencil className="h-3 w-3" />
Edit
</div>
</CustomMenu.MenuItem>
)}
{isEditingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setCreateUpdateIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Copy className="h-3 w-3" />
Make a copy
</div>
</CustomMenu.MenuItem>
)}
{isDeletingAllowed && (
<CustomMenu.MenuItem
onClick={() => {
setTrackElement(activeLayout);
setDeleteIssueModal(true);
}}
>
<div className="flex items-center gap-2">
<Trash2 className="h-3 w-3" />
Delete
</div>
</CustomMenu.MenuItem>
)}
</CustomMenu>
</>
);
});

View File

@ -2,4 +2,5 @@ export * from "./cycle-issue";
export * from "./module-issue"; export * from "./module-issue";
export * from "./project-issue"; export * from "./project-issue";
export * from "./archived-issue"; export * from "./archived-issue";
export * from "./draft-issue";
export * from "./all-issue"; export * from "./all-issue";

View File

@ -30,6 +30,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start",
} = props; } = props;
// states // states
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false); const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
@ -106,7 +107,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
/> />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
placement="bottom-start" placement={placements}
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" maxHeight="lg"

View File

@ -28,6 +28,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
customActionButton, customActionButton,
portalElement, portalElement,
readOnly = false, readOnly = false,
placements = "bottom-start",
} = props; } = props;
// router // router
const router = useRouter(); const router = useRouter();
@ -107,7 +108,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
/> />
<CustomMenu <CustomMenu
menuItemsClassName="z-[14]" menuItemsClassName="z-[14]"
placement="bottom-start" placement={placements}
customButton={customActionButton} customButton={customActionButton}
portalElement={portalElement} portalElement={portalElement}
maxHeight="lg" maxHeight="lg"

View File

@ -4,7 +4,7 @@ import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "
// components // components
import { ProjectLogo } from "@/components/project"; import { ProjectLogo } from "@/components/project";
// stores // stores
import { ISSUE_PRIORITIES } from "@/constants/issue"; import { ISSUE_PRIORITIES, EIssuesStoreType } from "@/constants/issue";
import { STATE_GROUPS } from "@/constants/state"; import { STATE_GROUPS } from "@/constants/state";
import { ICycleStore } from "@/store/cycle.store"; import { ICycleStore } from "@/store/cycle.store";
import { ILabelStore } from "@/store/label.store"; import { ILabelStore } from "@/store/label.store";
@ -16,6 +16,9 @@ import { IStateStore } from "@/store/state.store";
// constants // constants
// types // types
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
export const getGroupByColumns = ( export const getGroupByColumns = (
groupBy: GroupByColumnTypes | null, groupBy: GroupByColumnTypes | null,
project: IProjectStore, project: IProjectStore,

View File

@ -1,4 +1,5 @@
import React, { useState } from "react"; import React, { useState } from "react";
import isEmpty from "lodash/isEmpty";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
@ -6,12 +7,10 @@ import type { TIssue } from "@plane/types";
import { TOAST_TYPE, setToast } from "@plane/ui"; import { TOAST_TYPE, setToast } from "@plane/ui";
import { ConfirmIssueDiscard } from "@/components/issues"; import { ConfirmIssueDiscard } from "@/components/issues";
import { IssueFormRoot } from "@/components/issues/issue-modal/form"; import { IssueFormRoot } from "@/components/issues/issue-modal/form";
import { isEmptyHtmlString } from "@/helpers/string.helper";
import { useEventTracker } from "@/hooks/store"; import { useEventTracker } from "@/hooks/store";
// services // services
import { IssueDraftService } from "@/services/issue"; import { IssueDraftService } from "@/services/issue";
// ui
// components
// types
export interface DraftIssueProps { export interface DraftIssueProps {
changesMade: Partial<TIssue> | null; changesMade: Partial<TIssue> | null;
@ -50,8 +49,34 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const { captureIssueEvent } = useEventTracker(); const { captureIssueEvent } = useEventTracker();
const handleClose = () => { const handleClose = () => {
if (changesMade) setIssueDiscardModal(true); if (data?.id) {
else onClose(false); 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 () => { const handleCreateDraftIssue = async () => {
@ -59,7 +84,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
const payload = { const payload = {
...changesMade, ...changesMade,
name: changesMade.name?.trim() === "" ? "Untitled" : changesMade.name?.trim(), name: changesMade?.name && changesMade?.name?.trim() === "" ? changesMade.name?.trim() : "Untitled",
}; };
await issueDraftService await issueDraftService

View File

@ -178,6 +178,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
id: data.id, id: data.id,
description_html: formData.description_html ?? "<p></p>", description_html: formData.description_html ?? "<p></p>",
}; };
// 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); await onSubmit(submitData, is_draft_issue);
setGptAssistantModal(false); setGptAssistantModal(false);
@ -597,6 +601,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
onChange(cycleId); onChange(cycleId);
handleFormChange(); handleFormChange();
}} }}
placeholder="Cycle"
value={value} value={value}
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={getTabIndex("cycle_id")} tabIndex={getTabIndex("cycle_id")}
@ -618,6 +623,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
onChange(moduleIds); onChange(moduleIds);
handleFormChange(); handleFormChange();
}} }}
placeholder="Modules"
buttonVariant="border-with-text" buttonVariant="border-with-text"
tabIndex={getTabIndex("module_ids")} tabIndex={getTabIndex("module_ids")}
multiple multiple
@ -716,19 +722,24 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
</div> </div>
</div> </div>
<div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-100 px-5 pt-5"> <div className="-mx-5 mt-5 flex items-center justify-between gap-2 border-t border-custom-border-100 px-5 pt-5">
<div <div>
className="flex cursor-default items-center gap-1.5" {!data?.id && (
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} <div
onKeyDown={(e) => { className="inline-flex cursor-default items-center gap-1.5"
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
}} onKeyDown={(e) => {
tabIndex={getTabIndex("create_more")} if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
> }}
<div className="flex cursor-pointer items-center justify-center"> tabIndex={getTabIndex("create_more")}
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" /> >
</div> <div className="flex cursor-pointer items-center justify-center">
<span className="text-xs">Create more</span> <ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
</div>
<span className="text-xs">Create more</span>
</div>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={getTabIndex("discard_button")}> <Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={getTabIndex("discard_button")}>
Discard Discard

View File

@ -171,7 +171,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
path: router.asPath, path: router.asPath,
}); });
!createMore && handleClose(); !createMore && handleClose();
if (createMore) issueTitleRef && issueTitleRef?.current?.focus(); if (createMore) {
issueTitleRef && issueTitleRef?.current?.focus();
setChangesMade(null);
}
return response; return response;
} catch (error) { } catch (error) {
setToast({ setToast({

View File

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { observer } from "mobx-react";
import { RefreshCw } from "lucide-react"; import { RefreshCw } from "lucide-react";
import { TIssue } from "@plane/types"; import { TIssue } from "@plane/types";
// types // types
@ -9,7 +10,7 @@ type Props = {
issueDetail?: TIssue; issueDetail?: TIssue;
}; };
export const IssueUpdateStatus: React.FC<Props> = (props) => { export const IssueUpdateStatus: React.FC<Props> = observer((props) => {
const { isSubmitting, issueDetail } = props; const { isSubmitting, issueDetail } = props;
// hooks // hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
@ -33,4 +34,4 @@ export const IssueUpdateStatus: React.FC<Props> = (props) => {
</div> </div>
</> </>
); );
}; });

View File

@ -52,8 +52,10 @@ export const IssuesMobileHeader = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -53,7 +53,7 @@ export type PeekOverviewHeaderProps = {
issueId: string; issueId: string;
isArchived: boolean; isArchived: boolean;
disabled: boolean; disabled: boolean;
toggleDeleteIssueModal: (value: boolean) => void; toggleDeleteIssueModal: (issueId: string | null) => void;
toggleArchiveIssueModal: (value: boolean) => void; toggleArchiveIssueModal: (value: boolean) => void;
handleRestoreIssue: () => void; handleRestoreIssue: () => void;
isSubmitting: "submitting" | "submitted" | "saved"; isSubmitting: "submitting" | "submitted" | "saved";
@ -188,7 +188,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
)} )}
{!disabled && ( {!disabled && (
<Tooltip tooltipContent="Delete" isMobile={isMobile}> <Tooltip tooltipContent="Delete" isMobile={isMobile}>
<button type="button" onClick={() => toggleDeleteIssueModal(true)}> <button type="button" onClick={() => toggleDeleteIssueModal(issueId)}>
<Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" /> <Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
</Tooltip> </Tooltip>

View File

@ -91,23 +91,14 @@ export const IssueView: FC<IIssueView> = observer((props) => {
/> />
)} )}
{issue && !is_archived && ( {issue && isDeleteIssueModalOpen === issue.id && (
<DeleteIssueModal <DeleteIssueModal
isOpen={isDeleteIssueModalOpen} isOpen={!!isDeleteIssueModalOpen}
handleClose={() => { handleClose={() => {
toggleDeleteIssueModal(false); toggleDeleteIssueModal(null);
}} }}
data={issue} data={issue}
onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)} onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId).then(() => removeRoutePeekId())}
/>
)}
{issue && is_archived && (
<DeleteIssueModal
data={issue}
isOpen={isDeleteIssueModalOpen}
handleClose={() => toggleDeleteIssueModal(false)}
onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)}
/> />
)} )}

View File

@ -158,7 +158,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={() => { onClick={() => {
handleIssueCrudState("delete", parentIssueId, issue); handleIssueCrudState("delete", parentIssueId, issue);
toggleDeleteIssueModal(true); toggleDeleteIssueModal(issue.id);
}} }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -523,7 +523,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
isOpen={issueCrudState?.delete?.toggle} isOpen={issueCrudState?.delete?.toggle}
handleClose={() => { handleClose={() => {
handleIssueCrudState("delete", null, null); handleIssueCrudState("delete", null, null);
toggleDeleteIssueModal(false); toggleDeleteIssueModal(null);
}} }}
data={issueCrudState?.delete?.issue as TIssue} data={issueCrudState?.delete?.issue as TIssue}
onSubmit={async () => onSubmit={async () =>

View File

@ -31,7 +31,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
handleClose(); handleClose();
}; };
const handleArchiveIssue = async () => { const handleArchiveModule = async () => {
setIsArchiving(true); setIsArchiving(true);
await archiveModule(workspaceSlug, projectId, moduleId) await archiveModule(workspaceSlug, projectId, moduleId)
.then(() => { .then(() => {
@ -89,7 +89,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
<Button variant="neutral-primary" size="sm" onClick={onClose}> <Button variant="neutral-primary" size="sm" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}> <Button size="sm" tabIndex={1} onClick={handleArchiveModule} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"} {isArchiving ? "Archiving" : "Archive"}
</Button> </Button>
</div> </div>

View File

@ -68,7 +68,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "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({ captureModuleEvent({
eventName: MODULE_CREATED, eventName: MODULE_CREATED,
@ -99,7 +99,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "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({ captureModuleEvent({
eventName: MODULE_UPDATED, eventName: MODULE_UPDATED,

View File

@ -101,10 +101,18 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
e.preventDefault(); e.preventDefault();
const { query } = router; const { query } = router;
router.push({ if (query.peekModule) {
pathname: router.pathname, delete query.peekModule;
query: { ...query, peekModule: moduleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekModule: moduleId },
});
}
}; };
if (!moduleDetails) return null; if (!moduleDetails) return null;

View File

@ -102,10 +102,18 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
e.preventDefault(); e.preventDefault();
const { query } = router; const { query } = router;
router.push({ if (query.peekModule) {
pathname: router.pathname, delete query.peekModule;
query: { ...query, peekModule: moduleId }, router.push({
}); pathname: router.pathname,
query: { ...query },
});
} else {
router.push({
pathname: router.pathname,
query: { ...query, peekModule: moduleId },
});
}
}; };
if (!moduleDetails) return null; if (!moduleDetails) return null;
@ -177,7 +185,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
<div className="relative flex w-full items-center justify-between gap-2.5 overflow-hidden sm:w-auto sm:flex-shrink-0 sm:justify-end "> <div className="relative flex w-full items-center justify-between gap-2.5 sm:w-auto sm:flex-shrink-0 sm:justify-end ">
<div className="text-xs text-custom-text-300"> <div className="text-xs text-custom-text-300">
{renderDate && ( {renderDate && (
<span className=" text-xs text-custom-text-300"> <span className=" text-xs text-custom-text-300">

View File

@ -54,8 +54,10 @@ export const ModuleMobileHeader = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -38,7 +38,7 @@ export const ModulesListView: React.FC = observer(() => {
</> </>
); );
if (totalFilters > 0 || searchQuery.trim() !== "") if (totalFilters > 0 && filteredModuleIds.length === 0)
return ( return (
<div className="h-full w-full grid place-items-center"> <div className="h-full w-full grid place-items-center">
<div className="text-center"> <div className="text-center">

View File

@ -44,8 +44,10 @@ export const ProfileIssuesFilter = observer(() => {
const newValues = issueFilters?.filters?.[key] ?? []; const newValues = issueFilters?.filters?.[key] ?? [];
if (Array.isArray(value)) { if (Array.isArray(value)) {
// this validation is majorly for the filter start_date, target_date custom
value.forEach((val) => { value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val); if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
}); });
} else { } else {
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);

View File

@ -1,4 +1,5 @@
export * from "./access"; export * from "./access";
export * from "./date"; export * from "./date";
export * from "./members"; export * from "./members";
export * from "./project-display-filters";
export * from "./root"; export * from "./root";

View File

@ -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<Props> = observer((props) => {
const { handleRemove, values, editable } = props;
return (
<>
{values.map((key) => {
const filterLabel = PROJECT_DISPLAY_FILTER_OPTIONS.find((s) => s.key === key)?.label;
return (
<div key={key} className="flex items-center gap-1 rounded p-1 text-xs bg-custom-background-80">
{filterLabel}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(key)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@ -1,17 +1,24 @@
import { X } from "lucide-react"; import { X } from "lucide-react";
import { TProjectFilters } from "@plane/types"; // types
// components import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
import { Tooltip } from "@plane/ui";
import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "@/components/project";
// ui // ui
import { Tooltip } from "@plane/ui";
// components
import {
AppliedAccessFilters,
AppliedDateFilters,
AppliedMembersFilters,
AppliedProjectDisplayFilters,
} from "@/components/project";
// helpers // helpers
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper"; import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
// types
type Props = { type Props = {
appliedFilters: TProjectFilters; appliedFilters: TProjectFilters;
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
handleClearAllFilters: () => void; handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void; handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
alwaysAllowEditing?: boolean; alwaysAllowEditing?: boolean;
filteredProjects: number; filteredProjects: number;
totalProjects: number; totalProjects: number;
@ -23,21 +30,24 @@ const DATE_FILTERS = ["created_at"];
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => { export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
const { const {
appliedFilters, appliedFilters,
appliedDisplayFilters,
handleClearAllFilters, handleClearAllFilters,
handleRemoveFilter, handleRemoveFilter,
handleRemoveDisplayFilter,
alwaysAllowEditing, alwaysAllowEditing,
filteredProjects, filteredProjects,
totalProjects, totalProjects,
} = props; } = props;
if (!appliedFilters) return null; if (!appliedFilters && !appliedDisplayFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null; if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
const isEditingAllowed = alwaysAllowEditing; const isEditingAllowed = alwaysAllowEditing;
return ( return (
<div className="flex items-start justify-between gap-1.5"> <div className="flex items-start justify-between gap-1.5">
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100"> <div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
{/* Applied filters */}
{Object.entries(appliedFilters).map(([key, value]) => { {Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TProjectFilters; const filterKey = key as keyof TProjectFilters;
@ -85,6 +95,22 @@ export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
</div> </div>
); );
})} })}
{/* Applied display filters */}
{appliedDisplayFilters.length > 0 && (
<div
key="project_display_filters"
className="flex flex-wrap items-center gap-2 rounded-md border border-custom-border-200 px-2 py-1 capitalize"
>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-custom-text-300">Projects</span>
<AppliedProjectDisplayFilters
editable={isEditingAllowed}
values={appliedDisplayFilters}
handleRemove={(key) => handleRemoveDisplayFilter(key)}
/>
</div>
</div>
)}
{isEditingAllowed && ( {isEditingAllowed && (
<button <button
type="button" type="button"

View File

@ -17,9 +17,9 @@ export const ProjectCardList = observer(() => {
const { commandPalette: commandPaletteStore } = useApplication(); const { commandPalette: commandPaletteStore } = useApplication();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject(); const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
const { searchQuery } = useProjectFilter(); const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter();
if (workspaceProjectIds?.length === 0) if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
return ( return (
<EmptyState <EmptyState
type={EmptyStateType.WORKSPACE_PROJECTS} type={EmptyStateType.WORKSPACE_PROJECTS}

View File

@ -2,12 +2,13 @@ import React, { useState } from "react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { Check, LinkIcon, Lock, Pencil, Star } from "lucide-react"; import { ArchiveRestoreIcon, Check, LinkIcon, Lock, Pencil, Star, Trash2 } from "lucide-react";
import { cn } from "@plane/editor-core";
import type { IProject } from "@plane/types"; import type { IProject } from "@plane/types";
// ui // ui
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui"; import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
// components // components
import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project"; import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
// helpers // helpers
import { EUserProjectRoles } from "@/constants/project"; import { EUserProjectRoles } from "@/constants/project";
import { renderFormattedDate } from "@/helpers/date-time.helper"; import { renderFormattedDate } from "@/helpers/date-time.helper";
@ -28,6 +29,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
// states // states
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false); const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
const [joinProjectModalOpen, setJoinProjectModal] = useState(false); const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
const [restoreProject, setRestoreProject] = useState(false);
// router // router
const router = useRouter(); const router = useRouter();
const { workspaceSlug } = router.query; const { workspaceSlug } = router.query;
@ -41,6 +43,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
// auth // auth
const isOwner = project.member_role === EUserProjectRoles.ADMIN; const isOwner = project.member_role === EUserProjectRoles.ADMIN;
const isMember = project.member_role === EUserProjectRoles.MEMBER; const isMember = project.member_role === EUserProjectRoles.MEMBER;
// archive
const isArchived = !!project.archived_at;
const handleAddToFavorites = () => { const handleAddToFavorites = () => {
if (!workspaceSlug) return; if (!workspaceSlug) return;
@ -102,13 +106,23 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
handleClose={() => setJoinProjectModal(false)} handleClose={() => setJoinProjectModal(false)}
/> />
)} )}
{/* Restore project modal */}
{workspaceSlug && project && (
<ArchiveRestoreProjectModal
workspaceSlug={workspaceSlug.toString()}
projectId={project.id}
isOpen={restoreProject}
onClose={() => setRestoreProject(false)}
archive={false}
/>
)}
<Link <Link
href={`/${workspaceSlug}/projects/${project.id}/issues`} href={`/${workspaceSlug}/projects/${project.id}/issues`}
onClick={(e) => { onClick={(e) => {
if (!project.is_member) { if (!project.is_member || isArchived) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setJoinProjectModal(true); if (!isArchived) setJoinProjectModal(true);
} }
}} }}
className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100" className="flex flex-col rounded border border-custom-border-200 bg-custom-background-100"
@ -140,96 +154,137 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
</div> </div>
</div> </div>
<div className="flex h-full flex-shrink-0 items-center gap-2"> {!isArchived && (
<button <div className="flex h-full flex-shrink-0 items-center gap-2">
className="flex h-6 w-6 items-center justify-center rounded bg-white/10" <button
onClick={(e) => { className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
e.stopPropagation(); onClick={(e) => {
e.preventDefault(); e.stopPropagation();
handleCopyText(); e.preventDefault();
}} handleCopyText();
> }}
<LinkIcon className="h-3 w-3 text-white" /> >
</button> <LinkIcon className="h-3 w-3 text-white" />
<button </button>
className="flex h-6 w-6 items-center justify-center rounded bg-white/10" <button
onClick={(e) => { className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.preventDefault();
if (project.is_favorite) handleRemoveFromFavorites(); e.stopPropagation();
else handleAddToFavorites(); if (project.is_favorite) handleRemoveFromFavorites();
}} else handleAddToFavorites();
> }}
<Star >
className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `} <Star
/> className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `}
</button> />
</div> </button>
</div>
)}
</div> </div>
</div> </div>
<div className="flex h-[104px] w-full flex-col justify-between rounded-b p-4"> <div
className={cn("flex h-[104px] w-full flex-col justify-between rounded-b p-4", {
"opacity-90": isArchived,
})}
>
<p className="line-clamp-2 break-words text-sm text-custom-text-300"> <p className="line-clamp-2 break-words text-sm text-custom-text-300">
{project.description && project.description.trim() !== "" {project.description && project.description.trim() !== ""
? project.description ? project.description
: `Created on ${renderFormattedDate(project.created_at)}`} : `Created on ${renderFormattedDate(project.created_at)}`}
</p> </p>
<div className="item-center flex justify-between"> <div className="item-center flex justify-between">
<Tooltip <div className="flex items-center justify-center gap-2">
isMobile={isMobile} <Tooltip
tooltipHeading="Members" isMobile={isMobile}
tooltipContent={ tooltipHeading="Members"
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member" tooltipContent={
} project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
position="top" }
> position="top"
{projectMembersIds && projectMembersIds.length > 0 ? ( >
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200"> {projectMembersIds && projectMembersIds.length > 0 ? (
<AvatarGroup showTooltip={false}> <div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
{projectMembersIds.map((memberId) => { <AvatarGroup showTooltip={false}>
const member = project.members?.find((m) => m.member_id === memberId); {projectMembersIds.map((memberId) => {
const member = project.members?.find((m) => m.member_id === memberId);
if (!member) return null; if (!member) return null;
return (
return <Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />; <Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />
})} );
</AvatarGroup> })}
</AvatarGroup>
</div>
) : (
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
)}
</Tooltip>
{isArchived && <div className="text-xs text-custom-text-400 font-medium">Archived</div>}
</div>
{isArchived ? (
isOwner && (
<div className="flex items-center justify-center gap-2">
<div
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setRestoreProject(true);
}}
>
<div className="flex items-center gap-1.5">
<ArchiveRestoreIcon className="h-3.5 w-3.5" />
Restore
</div>
</div>
<div
className="flex items-center justify-center text-xs text-custom-text-400 font-medium hover:text-custom-text-200"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDeleteProjectModal(true);
}}
>
<Trash2 className="h-3.5 w-3.5" />
</div>
</div> </div>
) : ( )
<span className="text-sm italic text-custom-text-400">No Member Yet</span> ) : (
)} <>
</Tooltip> {project.is_member &&
{project.is_member && (isOwner || isMember ? (
(isOwner || isMember ? ( <Link
<Link className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200"
className="flex items-center justify-center rounded p-1 text-custom-text-400 hover:bg-custom-background-80 hover:text-custom-text-200" onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); }}
}} href={`/${workspaceSlug}/projects/${project.id}/settings`}
href={`/${workspaceSlug}/projects/${project.id}/settings`} >
> <Pencil className="h-3.5 w-3.5" />
<Pencil className="h-3.5 w-3.5" /> </Link>
</Link> ) : (
) : ( <span className="flex items-center gap-1 text-custom-text-400 text-sm">
<span className="flex items-center gap-1 text-custom-text-400 text-sm"> <Check className="h-3.5 w-3.5" />
<Check className="h-3.5 w-3.5" /> Joined
Joined </span>
</span> ))}
))} {!project.is_member && (
{!project.is_member && ( <div className="flex items-center">
<div className="flex items-center"> <Button
<Button variant="link-primary"
variant="link-primary" className="!p-0 font-semibold"
className="!p-0 font-semibold" onClick={(e) => {
onClick={(e) => { e.preventDefault();
e.preventDefault(); e.stopPropagation();
e.stopPropagation(); setJoinProjectModal(true);
setJoinProjectModal(true); }}
}} >
> Join
Join </Button>
</Button> </div>
</div> )}
</>
)} )}
</div> </div>
</div> </div>

View File

@ -209,8 +209,10 @@ export const CreateProjectForm: FC<Props> = observer((props) => {
[val.type]: logoValue, [val.type]: logoValue,
}); });
}} }}
defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined} defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON} defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
/> />
)} )}
/> />

View File

@ -51,6 +51,15 @@ export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
} }
title="My projects" title="My projects"
/> />
<FilterOption
isChecked={!!displayFilters.archived_projects}
onClick={() =>
handleDisplayFiltersUpdate({
archived_projects: !displayFilters.archived_projects,
})
}
title="Archived"
/>
</div> </div>
{/* access */} {/* access */}

View File

@ -40,7 +40,8 @@ export const ProjectOrderByDropdown: React.FC<Props> = (props) => {
key={option.key} key={option.key}
className="flex items-center justify-between gap-2" className="flex items-center justify-between gap-2"
onClick={() => { 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); else onChange(option.key);
}} }}
> >

View File

@ -166,8 +166,10 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
[val.type]: logoValue, [val.type]: logoValue,
}); });
}} }}
defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined} defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON} defaultOpen={
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
}
disabled={!isAdmin} disabled={!isAdmin}
/> />
)} )}

Some files were not shown because too many files have changed in this diff Show More