mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
Merge branch 'develop' of github.com:makeplane/plane into update-file-uploads
This commit is contained in:
commit
6cc29dca52
@ -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"
|
3
.github/workflows/codeql.yml
vendored
3
.github/workflows/codeql.yml
vendored
@ -1,8 +1,9 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: ["master"]
|
||||
branches: ["develop", "preview", "master"]
|
||||
pull_request:
|
||||
branches: ["develop", "preview", "master"]
|
||||
schedule:
|
||||
|
22
README.md
22
README.md
@ -17,10 +17,10 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="http://www.plane.so"><b>Website</b></a> •
|
||||
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
|
||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||
<a href="https://dub.sh/plane-website-readme"><b>Website</b></a> •
|
||||
<a href="https://git.new/releases"><b>Releases</b></a> •
|
||||
<a href="https://dub.sh/planepowershq"><b>Twitter</b></a> •
|
||||
<a href="https://dub.sh/planedocs"><b>Documentation</b></a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
@ -40,15 +40,15 @@
|
||||
</a>
|
||||
</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
|
||||
|
||||
The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account where we offer a hosted solution for users.
|
||||
|
||||
If you want more control over your data prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||
If you want more control over your data, prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/docker-compose).
|
||||
|
||||
| Installation Methods | Documentation Link |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
@ -59,9 +59,9 @@ If you want more control over your data prefer to self-host Plane, please refer
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
- **Issues**: Quickly create issues and add details using a powerful, rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking.
|
||||
- **Issues**: Quickly create issues and add details using a powerful rich text editor that supports file uploads. Add sub-properties and references to problems for better organization and tracking.
|
||||
|
||||
- **Cycles**
|
||||
- **Cycles**:
|
||||
Keep up your team's momentum with Cycles. Gain insights into your project's progress with burn-down charts and other valuable features.
|
||||
|
||||
- **Modules**: Break down your large projects into smaller, more manageable modules. Assign modules between teams to track and plan your project's progress easily.
|
||||
@ -74,11 +74,11 @@ If you want more control over your data prefer to self-host Plane, please refer
|
||||
|
||||
- **Drive** (_coming soon_): The drive helps you share documents, images, videos, or any other files that make sense to you or your team and align on the problem/solution.
|
||||
|
||||
## 🛠️ Contributors Quick Start
|
||||
## 🛠️ Quick start for contributors
|
||||
|
||||
> Development system must have docker engine installed and running.
|
||||
|
||||
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute
|
||||
Setting up local environment is extremely easy and straight forward. Follow the below step and you will be ready to contribute -
|
||||
|
||||
1. Clone the code locally using:
|
||||
```
|
||||
|
@ -182,7 +182,7 @@ def update_label_color():
|
||||
labels = Label.objects.filter(color="")
|
||||
updated_labels = []
|
||||
for label in labels:
|
||||
label.color = "#" + "%06x" % random.randint(0, 0xFFFFFF)
|
||||
label.color = f"#{random.randint(0, 0xFFFFFF+1):06X}"
|
||||
updated_labels.append(label)
|
||||
|
||||
Label.objects.bulk_update(updated_labels, ["color"], batch_size=100)
|
||||
|
@ -1,31 +1,32 @@
|
||||
from lxml import html
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import URLValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
from lxml import html
|
||||
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import (
|
||||
User,
|
||||
Issue,
|
||||
State,
|
||||
IssueActivity,
|
||||
IssueAssignee,
|
||||
Label,
|
||||
IssueComment,
|
||||
IssueLabel,
|
||||
IssueLink,
|
||||
IssueComment,
|
||||
IssueActivity,
|
||||
Label,
|
||||
ProjectMember,
|
||||
State,
|
||||
User,
|
||||
)
|
||||
|
||||
from .base import BaseSerializer
|
||||
from .cycle import CycleSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
from .cycle import CycleLiteSerializer, CycleSerializer
|
||||
from .module import ModuleLiteSerializer, ModuleSerializer
|
||||
from .state import StateLiteSerializer
|
||||
from .user import UserLiteSerializer
|
||||
|
||||
|
||||
class IssueSerializer(BaseSerializer):
|
||||
@ -78,7 +79,7 @@ class IssueSerializer(BaseSerializer):
|
||||
data["description_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
|
||||
# Validate assignees are from project
|
||||
if data.get("assignees", []):
|
||||
@ -293,7 +294,7 @@ class IssueLinkSerializer(BaseSerializer):
|
||||
raise serializers.ValidationError("Invalid URL format.")
|
||||
|
||||
# Check URL scheme
|
||||
if not value.startswith(('http://', 'https://')):
|
||||
if not value.startswith(("http://", "https://")):
|
||||
raise serializers.ValidationError("Invalid URL scheme.")
|
||||
|
||||
return value
|
||||
@ -349,7 +350,7 @@ class IssueCommentSerializer(BaseSerializer):
|
||||
data["comment_html"] = parsed_str
|
||||
|
||||
except Exception as e:
|
||||
raise serializers.ValidationError(f"Invalid HTML: {str(e)}")
|
||||
raise serializers.ValidationError("Invalid HTML passed")
|
||||
return data
|
||||
|
||||
|
||||
|
@ -504,8 +504,8 @@ class IssueReactionLiteSerializer(DynamicBaseSerializer):
|
||||
model = IssueReaction
|
||||
fields = [
|
||||
"id",
|
||||
"actor_id",
|
||||
"issue_id",
|
||||
"actor",
|
||||
"issue",
|
||||
"reaction",
|
||||
]
|
||||
|
||||
|
@ -101,59 +101,69 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(is_favorite=Exists(favorite_subquery))
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="completed",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="cancelled",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="started",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="unstarted",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_cycle__issue__state__group",
|
||||
"issue_cycle__issue__id",
|
||||
distinct=True,
|
||||
filter=Q(
|
||||
issue_cycle__issue__state__group="backlog",
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
@ -182,9 +192,6 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
distinct=True,
|
||||
filter=~Q(
|
||||
issue_cycle__issue__assignees__id__isnull=True
|
||||
)
|
||||
& Q(
|
||||
issue_cycle__issue__assignees__member_project__is_active=True
|
||||
),
|
||||
),
|
||||
Value([], output_field=ArrayField(UUIDField())),
|
||||
@ -195,19 +202,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
|
||||
def list(self, request, slug, project_id):
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(archived_at__isnull=True)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
queryset = self.get_queryset().filter(archived_at__isnull=True)
|
||||
cycle_view = request.GET.get("cycle_view", "all")
|
||||
|
||||
# Update the order by
|
||||
@ -361,8 +356,8 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"external_id",
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"total_issues",
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@ -409,6 +404,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"total_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
@ -484,6 +480,7 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
"progress_snapshot",
|
||||
# meta fields
|
||||
"is_favorite",
|
||||
"total_issues",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
@ -497,32 +494,11 @@ class CycleViewSet(WebhookMixin, BaseViewSet):
|
||||
|
||||
def retrieve(self, request, slug, project_id, pk):
|
||||
queryset = (
|
||||
self.get_queryset()
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_cycle",
|
||||
filter=Q(
|
||||
issue_cycle__issue__archived_at__isnull=True,
|
||||
issue_cycle__issue__is_draft=False,
|
||||
),
|
||||
)
|
||||
)
|
||||
self.get_queryset().filter(archived_at__isnull=True).filter(pk=pk)
|
||||
)
|
||||
data = (
|
||||
self.get_queryset()
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=True,
|
||||
issue_cycle__cycle_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
@ -880,7 +856,10 @@ class CycleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
cycle.archived_at = timezone.now()
|
||||
cycle.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(
|
||||
{"archived_at": str(cycle.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, cycle_id):
|
||||
cycle = Cycle.objects.get(
|
||||
|
@ -87,7 +87,7 @@ class BulkCreateIssueLabelsEndpoint(BaseAPIView):
|
||||
Label(
|
||||
name=label.get("name", "Migrated"),
|
||||
description=label.get("description", "Migrated Issue"),
|
||||
color="#" + "%06x" % random.randint(0, 0xFFFFFF),
|
||||
color=f"#{random.randint(0, 0xFFFFFF+1):06X}",
|
||||
project_id=project_id,
|
||||
workspace_id=project.workspace_id,
|
||||
created_by=request.user,
|
||||
|
@ -8,9 +8,11 @@ from django.db.models import (
|
||||
Exists,
|
||||
F,
|
||||
Func,
|
||||
IntegerField,
|
||||
OuterRef,
|
||||
Prefetch,
|
||||
Q,
|
||||
Subquery,
|
||||
UUIDField,
|
||||
Value,
|
||||
)
|
||||
@ -72,6 +74,59 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
)
|
||||
cancelled_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="cancelled",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
completed_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="completed",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
started_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="started",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
unstarted_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="unstarted",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
backlog_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
state__group="backlog",
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
total_issues = (
|
||||
Issue.issue_objects.filter(
|
||||
issue_module__module_id=OuterRef("pk"),
|
||||
)
|
||||
.values("issue_module__module_id")
|
||||
.annotate(cnt=Count("pk"))
|
||||
.values("cnt")
|
||||
)
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
@ -91,68 +146,39 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Count(
|
||||
"issue_module",
|
||||
filter=Q(
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
completed_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="completed",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
completed_issues=Coalesce(
|
||||
Subquery(completed_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
cancelled_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="cancelled",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
cancelled_issues=Coalesce(
|
||||
Subquery(cancelled_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
started_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="started",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
started_issues=Coalesce(
|
||||
Subquery(started_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
unstarted_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="unstarted",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
unstarted_issues=Coalesce(
|
||||
Subquery(unstarted_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
backlog_issues=Count(
|
||||
"issue_module__issue__state__group",
|
||||
filter=Q(
|
||||
issue_module__issue__state__group="backlog",
|
||||
issue_module__issue__archived_at__isnull=True,
|
||||
issue_module__issue__is_draft=False,
|
||||
),
|
||||
distinct=True,
|
||||
backlog_issues=Coalesce(
|
||||
Subquery(backlog_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
total_issues=Coalesce(
|
||||
Subquery(total_issues[:1]),
|
||||
Value(0, output_field=IntegerField()),
|
||||
)
|
||||
)
|
||||
.annotate(
|
||||
@ -202,6 +228,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"is_favorite",
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"total_issues",
|
||||
"started_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
@ -257,16 +284,6 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
self.get_queryset()
|
||||
.filter(archived_at__isnull=True)
|
||||
.filter(pk=pk)
|
||||
.annotate(
|
||||
total_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
parent__isnull=True,
|
||||
issue_module__module_id=pk,
|
||||
)
|
||||
.order_by()
|
||||
.annotate(count=Func(F("id"), function="Count"))
|
||||
.values("count")
|
||||
)
|
||||
.annotate(
|
||||
sub_issues=Issue.issue_objects.filter(
|
||||
project_id=self.kwargs.get("project_id"),
|
||||
@ -378,9 +395,11 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"completion_chart": {},
|
||||
}
|
||||
|
||||
if queryset.first().start_date and queryset.first().target_date:
|
||||
# Fetch the modules
|
||||
modules = queryset.first()
|
||||
if modules and modules.start_date and modules.target_date:
|
||||
data["distribution"]["completion_chart"] = burndown_plot(
|
||||
queryset=queryset.first(),
|
||||
queryset=modules,
|
||||
slug=slug,
|
||||
project_id=project_id,
|
||||
module_id=pk,
|
||||
@ -429,6 +448,7 @@ class ModuleViewSet(WebhookMixin, BaseViewSet):
|
||||
"cancelled_issues",
|
||||
"completed_issues",
|
||||
"started_issues",
|
||||
"total_issues",
|
||||
"unstarted_issues",
|
||||
"backlog_issues",
|
||||
"created_at",
|
||||
@ -642,7 +662,10 @@ class ModuleArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
)
|
||||
module.archived_at = timezone.now()
|
||||
module.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(
|
||||
{"archived_at": str(module.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id, module_id):
|
||||
module = Module.objects.get(
|
||||
|
@ -441,7 +441,10 @@ class ProjectArchiveUnarchiveEndpoint(BaseAPIView):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
project.archived_at = timezone.now()
|
||||
project.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
return Response(
|
||||
{"archived_at": str(project.archived_at)},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
def delete(self, request, slug, project_id):
|
||||
project = Project.objects.get(pk=project_id, workspace__slug=slug)
|
||||
|
@ -3,15 +3,10 @@ from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
# Module imports
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
from plane.app.serializers import WorkspaceEstimateSerializer
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.db.models import Project, Estimate
|
||||
from plane.app.permissions import WorkspaceEntityPermission
|
||||
|
||||
# Django imports
|
||||
from django.db.models import (
|
||||
Prefetch,
|
||||
)
|
||||
from plane.db.models import Estimate, Project
|
||||
from plane.utils.cache import cache_response
|
||||
|
||||
|
||||
@ -25,15 +20,11 @@ class WorkspaceEstimatesEndpoint(BaseAPIView):
|
||||
estimate_ids = Project.objects.filter(
|
||||
workspace__slug=slug, estimate__isnull=False
|
||||
).values_list("estimate_id", flat=True)
|
||||
estimates = Estimate.objects.filter(
|
||||
pk__in=estimate_ids
|
||||
).prefetch_related(
|
||||
Prefetch(
|
||||
"points",
|
||||
queryset=Project.objects.select_related(
|
||||
"estimate", "workspace", "project"
|
||||
),
|
||||
)
|
||||
estimates = (
|
||||
Estimate.objects.filter(pk__in=estimate_ids, workspace__slug=slug)
|
||||
.prefetch_related("points")
|
||||
.select_related("workspace", "project")
|
||||
)
|
||||
|
||||
serializer = WorkspaceEstimateSerializer(estimates, many=True)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
@ -1,6 +1,6 @@
|
||||
# base requirements
|
||||
|
||||
Django==4.2.10
|
||||
Django==4.2.11
|
||||
psycopg==3.1.12
|
||||
djangorestframework==3.14.0
|
||||
redis==4.6.0
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### Manage your Plane instance
|
||||
|
||||
Use `plane-app` [OPERATOR] to manage your Plane instance easily. Get a list of all operators with `plane-app ---help`.
|
||||
|
||||

|
||||
|
||||
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 |
@ -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
|
@ -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
|
@ -9,9 +9,14 @@ export type TProjectOrderByOptions =
|
||||
|
||||
export type TProjectDisplayFilters = {
|
||||
my_projects?: boolean;
|
||||
archived_projects?: boolean;
|
||||
order_by?: TProjectOrderByOptions;
|
||||
};
|
||||
|
||||
export type TProjectAppliedDisplayFilterKeys =
|
||||
| "my_projects"
|
||||
| "archived_projects";
|
||||
|
||||
export type TProjectFilters = {
|
||||
access?: string[] | null;
|
||||
lead?: string[] | null;
|
||||
|
1
packages/types/src/project/projects.d.ts
vendored
1
packages/types/src/project/projects.d.ts
vendored
@ -23,6 +23,7 @@ export type TProjectLogoProps = {
|
||||
|
||||
export interface IProject {
|
||||
archive_in: number;
|
||||
archived_at: string | null;
|
||||
archived_issues: number;
|
||||
archived_sub_issues: number;
|
||||
close_in: number;
|
||||
|
@ -131,6 +131,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
ref={setReferenceElement}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isOpen ? closeDropdown() : openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
@ -157,6 +158,7 @@ const CustomMenu = (props: ICustomMenuDropdownProps) => {
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isOpen ? closeDropdown() : openDropdown();
|
||||
if (menuButtonOnClick) menuButtonOnClick();
|
||||
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
/** @type {import('next').NextConfig} */
|
||||
require("dotenv").config({ path: ".env" });
|
||||
const { withSentryConfig } = require("@sentry/nextjs");
|
||||
@ -26,8 +27,11 @@ const nextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0")) {
|
||||
module.exports = withSentryConfig(nextConfig, { silent: true }, { hideSourceMaps: true });
|
||||
if (parseInt(process.env.NEXT_PUBLIC_ENABLE_SENTRY || "0"), 10) {
|
||||
module.exports = withSentryConfig(nextConfig,
|
||||
{ silent: true, authToken: process.env.SENTRY_AUTH_TOKEN },
|
||||
{ hideSourceMaps: true }
|
||||
);
|
||||
} else {
|
||||
module.exports = nextConfig;
|
||||
}
|
||||
|
@ -22,7 +22,7 @@
|
||||
"@plane/rich-text-editor": "*",
|
||||
"@plane/types": "*",
|
||||
"@plane/ui": "*",
|
||||
"@sentry/nextjs": "^7.85.0",
|
||||
"@sentry/nextjs": "^7.108.0",
|
||||
"axios": "^1.3.4",
|
||||
"clsx": "^2.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
|
24
turbo.json
24
turbo.json
@ -17,37 +17,25 @@
|
||||
"NEXT_PUBLIC_POSTHOG_KEY",
|
||||
"NEXT_PUBLIC_POSTHOG_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_DEBUG",
|
||||
"JITSU_TRACKER_ACCESS_KEY",
|
||||
"JITSU_TRACKER_HOST"
|
||||
"SENTRY_AUTH_TOKEN"
|
||||
],
|
||||
"pipeline": {
|
||||
"build": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"outputs": [
|
||||
".next/**",
|
||||
"dist/**"
|
||||
]
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "dist/**"]
|
||||
},
|
||||
"develop": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true,
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
]
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"test": {
|
||||
"dependsOn": [
|
||||
"^build"
|
||||
],
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
},
|
||||
"lint": {
|
||||
|
@ -98,6 +98,8 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
|
||||
hasError={Boolean(errors.close_in)}
|
||||
placeholder="Enter Months"
|
||||
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>
|
||||
</div>
|
||||
@ -130,6 +132,8 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
|
||||
hasError={Boolean(errors.archive_in)}
|
||||
placeholder="Enter Months"
|
||||
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>
|
||||
</div>
|
||||
|
@ -113,8 +113,6 @@ export const CommandPalette: FC = observer(() => {
|
||||
const canPerformWorkspaceCreateActions = useCallback(
|
||||
(showToast: boolean = true) => {
|
||||
const isAllowed = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
console.log("currentWorkspaceRole", currentWorkspaceRole);
|
||||
console.log("isAllowed", isAllowed);
|
||||
if (!isAllowed && showToast)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
|
@ -40,13 +40,14 @@ type Props = {
|
||||
onChange: (data: string) => void;
|
||||
disabled?: boolean;
|
||||
tabIndex?: number;
|
||||
isProfileCover?: boolean;
|
||||
};
|
||||
|
||||
// services
|
||||
const fileService = new FileService();
|
||||
|
||||
export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
const { label, value, control, onChange, disabled = false, tabIndex } = props;
|
||||
const { label, value, control, onChange, disabled = false, tabIndex, isProfileCover = false } = props;
|
||||
// states
|
||||
const [image, setImage] = useState<File | null>(null);
|
||||
const [isImageUploading, setIsImageUploading] = useState(false);
|
||||
@ -97,37 +98,53 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
const handleSubmit = async () => {
|
||||
setIsImageUploading(true);
|
||||
|
||||
if (!image || !workspaceSlug) return;
|
||||
if (!image) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("asset", image);
|
||||
formData.append("attributes", JSON.stringify({}));
|
||||
|
||||
fileService
|
||||
.uploadFile(workspaceSlug.toString(), formData)
|
||||
.then((res) => {
|
||||
const oldValue = value;
|
||||
const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
|
||||
const oldValue = value;
|
||||
const isUnsplashImage = oldValue?.split("/")[2] === "images.unsplash.com";
|
||||
|
||||
const imageUrl = res.asset;
|
||||
onChange(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
setImage(null);
|
||||
setIsOpen(false);
|
||||
const uploadCallback = (res: any) => {
|
||||
const imageUrl = res.asset;
|
||||
onChange(imageUrl);
|
||||
setIsImageUploading(false);
|
||||
setImage(null);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
if (isUnsplashImage) return;
|
||||
|
||||
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
if (isProfileCover) {
|
||||
fileService
|
||||
.uploadUserFile(formData)
|
||||
.then((res) => {
|
||||
uploadCallback(res);
|
||||
if (isUnsplashImage) return;
|
||||
if (oldValue && currentWorkspace) fileService.deleteUserFile(oldValue);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
} else {
|
||||
if (!workspaceSlug) return;
|
||||
fileService
|
||||
.uploadFile(workspaceSlug.toString(), formData)
|
||||
.then((res) => {
|
||||
uploadCallback(res);
|
||||
if (isUnsplashImage) return;
|
||||
if (oldValue && currentWorkspace) fileService.deleteFile(currentWorkspace.id, oldValue);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!unsplashImages || value !== null) return;
|
||||
|
||||
onChange(unsplashImages[0].urls.regular);
|
||||
onChange(unsplashImages[0]?.urls.regular);
|
||||
}, [value, onChange, unsplashImages]);
|
||||
|
||||
const handleClose = () => {
|
||||
@ -149,7 +166,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
useOutsideClickDetector(ref, handleClose);
|
||||
|
||||
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
|
||||
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}
|
||||
@ -160,7 +177,7 @@ export const ImagePickerPopover: React.FC<Props> = observer((props) => {
|
||||
|
||||
{isOpen && (
|
||||
<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
|
||||
>
|
||||
<div
|
||||
|
@ -11,7 +11,9 @@ import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
|
||||
// components
|
||||
import { SingleProgressStats } from "@/components/core";
|
||||
import { StateDropdown } from "@/components/dropdowns";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
import { CYCLE_ISSUES_WITH_PARAMS } from "@/constants/fetch-keys";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// helper
|
||||
@ -177,8 +179,12 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
</Link>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-center text-center h-full text-sm text-custom-text-200">
|
||||
<span>There are no high priority issues present in this cycle.</span>
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState
|
||||
type={EmptyStateType.ACTIVE_CYCLE_PRIORITY_ISSUE_EMPTY_STATE}
|
||||
layout="screen-simple"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
@ -195,63 +201,75 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||
{cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
|
||||
cycle.distribution?.assignees?.map((assignee, index) => {
|
||||
if (assignee.assignee_id)
|
||||
return (
|
||||
<SingleProgressStats
|
||||
key={assignee.assignee_id}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar name={assignee?.display_name ?? undefined} src={assignee?.avatar ?? undefined} />
|
||||
|
||||
<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" />
|
||||
<span>{assignee.display_name}</span>
|
||||
</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
|
||||
as="div"
|
||||
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
|
||||
>
|
||||
{cycle.distribution?.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
/>
|
||||
))}
|
||||
{cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
|
||||
cycle.distribution.labels?.map((label, index) => (
|
||||
<SingleProgressStats
|
||||
key={label.label_id ?? `no-label-${index}`}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: label.color ?? "#000000",
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{label.label_name ?? "No labels"}</span>
|
||||
</div>
|
||||
}
|
||||
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.Panels>
|
||||
</Tab.Group>
|
||||
|
@ -3,6 +3,9 @@ import { FC } from "react";
|
||||
import { ICycle } from "@plane/types";
|
||||
// components
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
|
||||
export type ActiveCycleProductivityProps = {
|
||||
cycle: ICycle;
|
||||
@ -16,31 +19,40 @@ export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = (props)
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-base text-custom-text-300 font-semibold">Issue burndown</h3>
|
||||
</div>
|
||||
|
||||
<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="flex items-center gap-3 text-custom-text-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#A9BBD0]" />
|
||||
<span>Ideal</span>
|
||||
{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="flex items-center gap-3 text-custom-text-300">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<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 className="flex items-center justify-center gap-1">
|
||||
<span className="h-2 w-2 rounded-full bg-[#4C8FFF]" />
|
||||
<span>Current</span>
|
||||
<div className="relative h-full">
|
||||
<ProgressChart
|
||||
className="h-full"
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span>{`Pending issues - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
|
||||
</div>
|
||||
<div className="relative h-full">
|
||||
<ProgressChart
|
||||
className="h-full"
|
||||
distribution={cycle.distribution?.completion_chart ?? {}}
|
||||
startDate={cycle.start_date ?? ""}
|
||||
endDate={cycle.end_date ?? ""}
|
||||
totalIssues={cycle.total_issues}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-center h-full w-full">
|
||||
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_CHART_EMPTY_STATE} layout="screen-simple" size="sm" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -3,8 +3,11 @@ import { FC } from "react";
|
||||
import { ICycle } from "@plane/types";
|
||||
// ui
|
||||
import { LinearProgressIndicator } from "@plane/ui";
|
||||
// components
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
// constants
|
||||
import { CYCLE_STATE_GROUPS_DETAILS } from "@/constants/cycle";
|
||||
import { EmptyStateType } from "@/constants/empty-state";
|
||||
|
||||
export type ActiveCycleProgressProps = {
|
||||
cycle: ICycle;
|
||||
@ -32,48 +35,56 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<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.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
|
||||
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
|
||||
} closed`}
|
||||
</span>
|
||||
{cycle.total_issues > 0 && (
|
||||
<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}/${cycle.total_issues - cycle.cancelled_issues} ${
|
||||
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Issues" : "Issue"
|
||||
} closed`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<LinearProgressIndicator size="lg" data={progressIndicatorData} />
|
||||
{cycle.total_issues > 0 && <LinearProgressIndicator size="lg" data={progressIndicatorData} />}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<>
|
||||
{groupedIssues[group] > 0 && (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<span className="text-custom-text-300 capitalize font-medium w-16">{group}</span>
|
||||
{cycle.total_issues > 0 ? (
|
||||
<div className="flex flex-col gap-5">
|
||||
{Object.keys(groupedIssues).map((group, index) => (
|
||||
<>
|
||||
{groupedIssues[group] > 0 && (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between gap-2 text-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="block h-3 w-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: CYCLE_STATE_GROUPS_DETAILS[index].color,
|
||||
}}
|
||||
/>
|
||||
<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>
|
||||
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
|
||||
groupedIssues[group] > 1 ? "Issues" : "Issue"
|
||||
}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{cycle.cancelled_issues > 0 && (
|
||||
<span className="flex items-center gap-2 text-sm text-custom-text-300">
|
||||
<span>
|
||||
{`${cycle.cancelled_issues} cancelled ${
|
||||
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
|
||||
} excluded from this report.`}{" "}
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{cycle.cancelled_issues > 0 && (
|
||||
<span className="flex items-center gap-2 text-sm text-custom-text-300">
|
||||
<span>
|
||||
{`${cycle.cancelled_issues} cancelled ${
|
||||
cycle.cancelled_issues > 1 ? "issues are" : "issue is"
|
||||
} excluded from this report.`}{" "}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useTheme } from "next-themes";
|
||||
// components
|
||||
import { UpcomingCycleListItem } from "@/components/cycles";
|
||||
// hooks
|
||||
@ -14,6 +16,11 @@ export const UpcomingCyclesList: FC<Props> = observer((props) => {
|
||||
// store hooks
|
||||
const { currentProjectUpcomingCycleIds } = useCycle();
|
||||
|
||||
// theme
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const resolvedEmptyStatePath = `/empty-state/active-cycle/cycle-${resolvedTheme === "light" ? "light" : "dark"}.webp`;
|
||||
|
||||
if (!currentProjectUpcomingCycleIds) return null;
|
||||
|
||||
return (
|
||||
@ -28,8 +35,18 @@ export const UpcomingCyclesList: FC<Props> = observer((props) => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full grid place-items-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center h-full w-full py-20">
|
||||
<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>
|
||||
<p className="text-custom-text-400 text-base">
|
||||
Create new cycles to find them here or check
|
||||
|
@ -31,7 +31,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
const handleArchiveCycle = async () => {
|
||||
setIsArchiving(true);
|
||||
await archiveCycle(workspaceSlug, projectId, cycleId)
|
||||
.then(() => {
|
||||
@ -89,7 +89,7 @@ export const ArchiveCycleModal: React.FC<Props> = (props) => {
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
|
||||
<Button size="sm" tabIndex={1} onClick={handleArchiveCycle} loading={isArchiving}>
|
||||
{isArchiving ? "Archiving" : "Archive"}
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -69,8 +69,8 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
? cycleTotalIssues === 0
|
||||
? "0 Issue"
|
||||
: cycleTotalIssues === cycleDetails.completed_issues
|
||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||
? `${cycleTotalIssues} Issue${cycleTotalIssues > 1 ? "s" : ""}`
|
||||
: `${cycleDetails.completed_issues}/${cycleTotalIssues} Issues`
|
||||
: "0 Issue";
|
||||
|
||||
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
@ -134,10 +134,18 @@ export const CyclesBoardCard: FC<ICyclesBoardCard> = observer((props) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekCycle: cycleId },
|
||||
});
|
||||
if (query.peekCycle) {
|
||||
delete query.peekCycle;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekCycle: cycleId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
|
||||
|
@ -106,10 +106,18 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekCycle: cycleId },
|
||||
});
|
||||
if (query.peekCycle) {
|
||||
delete query.peekCycle;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekCycle: cycleId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const cycleDetails = getCycleById(cycleId);
|
||||
@ -190,7 +198,7 @@ export const CyclesListItem: FC<TCyclesListItem> = observer((props) => {
|
||||
{renderDate && `${renderFormattedDate(startDate) ?? `_ _`} - ${renderFormattedDate(endDate) ?? `_ _`}`}
|
||||
</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 && (
|
||||
<div
|
||||
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"
|
||||
|
@ -29,7 +29,7 @@ export const CyclesList: FC<ICyclesList> = observer((props) => {
|
||||
isArchived={isArchived}
|
||||
/>
|
||||
{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">
|
||||
{({ open }) => (
|
||||
<>
|
||||
|
@ -56,7 +56,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Error in creating cycle. Please try again.",
|
||||
message: err?.detail ?? "Error in creating cycle. Please try again.",
|
||||
});
|
||||
captureCycleEvent({
|
||||
eventName: CYCLE_CREATED,
|
||||
@ -90,7 +90,7 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Error in updating cycle. Please try again.",
|
||||
message: err?.detail ?? "Error in updating cycle. Please try again.",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
@ -41,7 +41,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
hideIcon = false,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Cycle",
|
||||
placeholder = "",
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
@ -132,7 +132,7 @@ export const CycleDropdown: React.FC<Props> = observer((props) => {
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!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>
|
||||
)}
|
||||
{dropdownArrow && (
|
||||
|
@ -37,6 +37,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Members",
|
||||
tooltipContent,
|
||||
placement,
|
||||
projectId,
|
||||
showTooltip = false,
|
||||
@ -123,7 +124,7 @@ export const MemberDropdown: React.FC<Props> = observer((props) => {
|
||||
className={buttonClassName}
|
||||
isActive={isOpen}
|
||||
tooltipHeading={placeholder}
|
||||
tooltipContent={`${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
tooltipContent={tooltipContent ?? `${value?.length ?? 0} assignee${value?.length !== 1 ? "s" : ""}`}
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
|
1
web/components/dropdowns/member/types.d.ts
vendored
1
web/components/dropdowns/member/types.d.ts
vendored
@ -5,6 +5,7 @@ export type MemberDropdownProps = TDropdownProps & {
|
||||
dropdownArrow?: boolean;
|
||||
dropdownArrowClassName?: string;
|
||||
placeholder?: string;
|
||||
tooltipContent?: string;
|
||||
onClose?: () => void;
|
||||
} & (
|
||||
| {
|
||||
|
@ -46,7 +46,7 @@ type ButtonContentProps = {
|
||||
hideIcon: boolean;
|
||||
hideText: boolean;
|
||||
onChange: (moduleIds: string[]) => void;
|
||||
placeholder: string;
|
||||
placeholder?: string;
|
||||
showCount: boolean;
|
||||
showTooltip?: boolean;
|
||||
value: string | string[] | null;
|
||||
@ -73,15 +73,17 @@ const ButtonContent: React.FC<ButtonContentProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
{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" />}
|
||||
<div className="max-w-40 flex-grow truncate">
|
||||
{value.length > 0
|
||||
? value.length === 1
|
||||
? `${getModuleById(value[0])?.name || "module"}`
|
||||
: `${value.length} Module${value.length === 1 ? "" : "s"}`
|
||||
: placeholder}
|
||||
</div>
|
||||
{(value.length > 0 || !!placeholder) && (
|
||||
<div className="max-w-40 flex-grow truncate">
|
||||
{value.length > 0
|
||||
? value.length === 1
|
||||
? `${getModuleById(value[0])?.name || "module"}`
|
||||
: `${value.length} Module${value.length === 1 ? "" : "s"}`
|
||||
: placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : value.length > 0 ? (
|
||||
<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,
|
||||
onChange,
|
||||
onClose,
|
||||
placeholder = "Module",
|
||||
placeholder = "",
|
||||
placement,
|
||||
projectId,
|
||||
showCount = false,
|
||||
|
@ -4,7 +4,7 @@ import { usePopper } from "react-popper";
|
||||
import { Check, ChevronDown, Search } from "lucide-react";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// hooks
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
import { Spinner, StateGroupIcon } from "@plane/ui";
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { useApplication, useProjectState } from "@/hooks/store";
|
||||
import { useDropdownKeyDown } from "@/hooks/use-dropdown-key-down";
|
||||
@ -50,6 +50,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [stateLoader, setStateLoader] = useState(false);
|
||||
// refs
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
@ -74,6 +75,8 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
} = useApplication();
|
||||
const { fetchProjectStates, getProjectStates, getStateById } = useProjectState();
|
||||
const statesList = getProjectStates(projectId);
|
||||
const defaultStateList = statesList?.find((state) => state.default);
|
||||
const stateValue = value ? value : defaultStateList?.id;
|
||||
|
||||
const options = statesList?.map((state) => ({
|
||||
value: state.id,
|
||||
@ -89,11 +92,19 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
const filteredOptions =
|
||||
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
||||
|
||||
const selectedState = getStateById(value);
|
||||
const selectedState = stateValue ? getStateById(stateValue) : undefined;
|
||||
|
||||
const onOpen = () => {
|
||||
if (!statesList && workspaceSlug) fetchProjectStates(workspaceSlug, projectId);
|
||||
const onOpen = async () => {
|
||||
if (!statesList && workspaceSlug) {
|
||||
setStateLoader(true);
|
||||
await fetchProjectStates(workspaceSlug, projectId);
|
||||
setStateLoader(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (projectId) onOpen();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [projectId]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isOpen) return;
|
||||
@ -141,7 +152,7 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
ref={dropdownRef}
|
||||
tabIndex={tabIndex}
|
||||
className={cn("h-full", className)}
|
||||
value={value}
|
||||
value={stateValue}
|
||||
onChange={dropdownOnChange}
|
||||
disabled={disabled}
|
||||
onKeyDown={handleKeyDown}
|
||||
@ -178,18 +189,27 @@ export const StateDropdown: React.FC<Props> = observer((props) => {
|
||||
showTooltip={showTooltip}
|
||||
variant={buttonVariant}
|
||||
>
|
||||
{!hideIcon && (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedState?.group ?? "backlog"}
|
||||
color={selectedState?.color}
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{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" />
|
||||
{stateLoader ? (
|
||||
<Spinner className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<>
|
||||
{!hideIcon && (
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedState?.group ?? "backlog"}
|
||||
color={selectedState?.color}
|
||||
className="h-3 w-3 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
</button>
|
||||
|
@ -151,12 +151,12 @@ export const EmptyState: React.FC<EmptyStateProps> = (props) => {
|
||||
)}
|
||||
{layout === "screen-simple" && (
|
||||
<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
|
||||
src={resolvedEmptyStatePath}
|
||||
alt={key || "button image"}
|
||||
width={96}
|
||||
height={96}
|
||||
width={size === "sm" ? 78 : 96}
|
||||
height={size === "sm" ? 78 : 96}
|
||||
layout="responsive"
|
||||
lazyBoundary="100%"
|
||||
/>
|
||||
|
@ -109,8 +109,10 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
@ -224,7 +226,9 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
className="ml-1.5 flex-shrink-0 truncate"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{currentProjectCycleIds?.map((cycleId) => <CycleDropdownOption key={cycleId} cycleId={cycleId} />)}
|
||||
{currentProjectCycleIds?.map((cycleId) => (
|
||||
<CycleDropdownOption key={cycleId} cycleId={cycleId} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
|
@ -56,8 +56,10 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -110,8 +110,10 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
@ -225,7 +227,9 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
className="ml-1.5 flex-shrink-0"
|
||||
placement="bottom-start"
|
||||
>
|
||||
{projectModuleIds?.map((moduleId) => <ModuleDropdownOption key={moduleId} moduleId={moduleId} />)}
|
||||
{projectModuleIds?.map((moduleId) => (
|
||||
<ModuleDropdownOption key={moduleId} moduleId={moduleId} />
|
||||
))}
|
||||
</CustomMenu>
|
||||
}
|
||||
/>
|
||||
|
@ -38,8 +38,10 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -60,8 +60,10 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -74,8 +74,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -1,18 +1,19 @@
|
||||
import { FC, useState } from "react";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
// hooks
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
// icons
|
||||
import { getFileIcon } from "@/components/icons";
|
||||
// helper
|
||||
import { convertBytesToSize, getFileExtension, getFileName } from "@/helpers/attachment.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { truncateText } from "@/helpers/string.helper";
|
||||
import { useIssueDetail, useMember } from "@/hooks/store";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// hooks
|
||||
// ui
|
||||
// components
|
||||
// icons
|
||||
// helper
|
||||
import { IssueAttachmentDeleteModal } from "./delete-attachment-confirmation-modal";
|
||||
// types
|
||||
import { TAttachmentOperations } from "./root";
|
||||
@ -25,16 +26,17 @@ type TIssueAttachmentsDetail = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => {
|
||||
export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = observer((props) => {
|
||||
// props
|
||||
const { attachmentId, handleAttachmentOperations, disabled } = props;
|
||||
// store hooks
|
||||
const { getUserDetails } = useMember();
|
||||
const {
|
||||
attachment: { getAttachmentById },
|
||||
isDeleteAttachmentModalOpen,
|
||||
toggleDeleteAttachmentModal,
|
||||
} = useIssueDetail();
|
||||
// states
|
||||
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
|
||||
const { isMobile } = usePlatformOS();
|
||||
const attachment = attachmentId && getAttachmentById(attachmentId);
|
||||
|
||||
@ -42,8 +44,8 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<IssueAttachmentDeleteModal
|
||||
isOpen={attachmentDeleteModal}
|
||||
setIsOpen={setAttachmentDeleteModal}
|
||||
isOpen={isDeleteAttachmentModalOpen}
|
||||
setIsOpen={() => toggleDeleteAttachmentModal(false)}
|
||||
handleAttachmentOperations={handleAttachmentOperations}
|
||||
data={attachment}
|
||||
/>
|
||||
@ -81,15 +83,11 @@ export const IssueAttachmentsDetail: FC<TIssueAttachmentsDetail> = (props) => {
|
||||
</Link>
|
||||
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setAttachmentDeleteModal(true);
|
||||
}}
|
||||
>
|
||||
<button onClick={() => toggleDeleteAttachmentModal(true)}>
|
||||
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -1,14 +1,13 @@
|
||||
import { FC, useState, Fragment, useEffect } from "react";
|
||||
import { TwitterPicker } from "react-color";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Plus, X, Loader } from "lucide-react";
|
||||
import { Popover, Transition } from "@headlessui/react";
|
||||
import { Popover } from "@headlessui/react";
|
||||
import { IIssueLabel } from "@plane/types";
|
||||
// hooks
|
||||
import { Input, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// ui
|
||||
// types
|
||||
import { TLabelOperations } from "./root";
|
||||
@ -31,11 +30,12 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||
// hooks
|
||||
const {
|
||||
issue: { getIssueById },
|
||||
peekIssue,
|
||||
} = useIssueDetail();
|
||||
// state
|
||||
const [isCreateToggle, setIsCreateToggle] = useState(false);
|
||||
const handleIsCreateToggle = () => setIsCreateToggle(!isCreateToggle);
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
// react hook form
|
||||
const {
|
||||
handleSubmit,
|
||||
@ -47,6 +47,18 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
||||
placement: "bottom-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "preventOverflow",
|
||||
options: {
|
||||
padding: 12,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCreateToggle) return;
|
||||
|
||||
@ -93,36 +105,28 @@ export const LabelCreate: FC<ILabelCreate> = (props) => {
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Popover>
|
||||
<>
|
||||
<Popover.Button className="grid place-items-center outline-none">
|
||||
{value && value?.trim() !== "" && (
|
||||
<span
|
||||
className="h-6 w-6 rounded"
|
||||
style={{
|
||||
backgroundColor: value ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Popover.Button as={Fragment}>
|
||||
<button type="button" ref={setReferenceElement} className="grid place-items-center outline-none">
|
||||
{value && value?.trim() !== "" && (
|
||||
<span
|
||||
className="h-6 w-6 rounded"
|
||||
style={{
|
||||
backgroundColor: value ?? "black",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</Popover.Button>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-200"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
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" : "")}
|
||||
<Popover.Panel className="fixed z-10">
|
||||
<div
|
||||
className="p-2 max-w-xs sm:px-0"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<TwitterPicker
|
||||
triangle={!peekIssue ? "hide" : "top-left"}
|
||||
color={value}
|
||||
onChange={(value) => onChange(value.hex)}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
<TwitterPicker triangle={"hide"} color={value} onChange={(value) => onChange(value.hex)} />
|
||||
</div>
|
||||
</Popover.Panel>
|
||||
</>
|
||||
</Popover>
|
||||
)}
|
||||
|
@ -73,7 +73,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
setToast({
|
||||
title: "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}
|
||||
layout={displayFilters?.calendar?.layout}
|
||||
showWeekends={displayFilters?.calendar?.show_weekends ?? false}
|
||||
quickActions={(issue, customActionButton) => (
|
||||
quickActions={(issue, customActionButton, placement) => (
|
||||
<QuickActions
|
||||
customActionButton={customActionButton}
|
||||
issue={issue}
|
||||
@ -101,6 +101,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)}
|
||||
handleRestore={async () => restoreIssue && restoreIssue(issue.project_id, issue.id)}
|
||||
readOnly={!isEditingAllowed || isCompletedCycle}
|
||||
placements={placement}
|
||||
/>
|
||||
)}
|
||||
addIssuesToView={addIssuesToView}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import type {
|
||||
IIssueDisplayFilterOptions,
|
||||
@ -37,7 +38,7 @@ type Props = {
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
layout: "month" | "week" | undefined;
|
||||
showWeekends: boolean;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
quickAddCallback?: (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
@ -19,7 +20,7 @@ type Props = {
|
||||
date: ICalendarDate;
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
// components
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
import { CalendarIssueBlock } from "@/components/issues";
|
||||
@ -7,7 +8,7 @@ import { CalendarIssueBlock } from "@/components/issues";
|
||||
type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
issueId: string;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
@ -14,7 +15,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
issue: TIssue;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
isDragging?: boolean;
|
||||
};
|
||||
|
||||
@ -56,6 +57,11 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const isMenuActionRefAboveScreenBottom =
|
||||
menuActionRef?.current && menuActionRef?.current?.getBoundingClientRect().bottom < window.innerHeight - 220;
|
||||
|
||||
const placement = isMenuActionRefAboveScreenBottom ? "bottom-end" : "top-end";
|
||||
|
||||
return (
|
||||
<ControlLink
|
||||
href={`/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`}
|
||||
@ -104,7 +110,7 @@ export const CalendarIssueBlock: React.FC<Props> = observer((props) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{quickActions(issue, customActionButton)}
|
||||
{quickActions(issue, customActionButton, placement)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
@ -12,7 +13,7 @@ type Props = {
|
||||
date: Date;
|
||||
issues: TIssueMap | undefined;
|
||||
issueIdList: string[] | null;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
isDragDisabled?: boolean;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
|
@ -230,7 +230,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
|
||||
|
||||
{!isOpen && (
|
||||
<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,
|
||||
})}
|
||||
>
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types";
|
||||
// components
|
||||
@ -16,7 +17,7 @@ type Props = {
|
||||
issues: TIssueMap | undefined;
|
||||
groupedIssueIds: TGroupedIssues;
|
||||
week: ICalendarWeek | undefined;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
quickActions: (issue: TIssue, customActionButton?: React.ReactElement, placement?: Placement) => React.ReactNode;
|
||||
enableQuickIssueCreate?: boolean;
|
||||
disableIssueCreation?: boolean;
|
||||
quickAddCallback?: (
|
||||
|
@ -25,6 +25,17 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const isCustomDateSelected = () => {
|
||||
const isCustomFateApplied = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||
return isCustomFateApplied.length > 0 ? true : false;
|
||||
};
|
||||
const handleCustomDate = () => {
|
||||
if (isCustomDateSelected()) {
|
||||
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||
handleUpdate(updateAppliedFilters);
|
||||
} else setIsDateFilterModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDateFilterModalOpen && (
|
||||
@ -53,7 +64,7 @@ export const FilterStartDate: React.FC<Props> = observer((props) => {
|
||||
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>
|
||||
|
@ -25,6 +25,17 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
|
||||
d.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const isCustomDateSelected = () => {
|
||||
const isCustomFateApplied = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||
return isCustomFateApplied.length > 0 ? true : false;
|
||||
};
|
||||
const handleCustomDate = () => {
|
||||
if (isCustomDateSelected()) {
|
||||
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
|
||||
handleUpdate(updateAppliedFilters);
|
||||
} else setIsDateFilterModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isDateFilterModalOpen && (
|
||||
@ -53,7 +64,7 @@ export const FilterTargetDate: React.FC<Props> = observer((props) => {
|
||||
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>
|
||||
|
@ -143,7 +143,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
setToast({
|
||||
title: "Error",
|
||||
type: TOAST_TYPE.ERROR,
|
||||
message: err.detail ?? "Failed to perform this action",
|
||||
message: err?.detail ?? "Failed to perform this action",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ import {
|
||||
} from "@/hooks/store";
|
||||
// types
|
||||
// parent components
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
// components
|
||||
import { KanbanStoreType } from "./base-kanban-root";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
@ -102,7 +102,9 @@ const GroupByKanBan: React.FC<IGroupByKanBan> = observer((props) => {
|
||||
moduleInfo,
|
||||
label,
|
||||
projectState,
|
||||
member
|
||||
member,
|
||||
true,
|
||||
isWorkspaceLevel(storeType)
|
||||
);
|
||||
|
||||
if (!list) return null;
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
// components
|
||||
import { ProjectIssueQuickActions } from "@/components/issues";
|
||||
import { DraftIssueQuickActions } from "@/components/issues";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { BaseKanBanRoot } from "../base-kanban-root";
|
||||
|
||||
export interface IKanBanLayout {}
|
||||
|
||||
export const DraftKanBanLayout: React.FC = observer(() => (
|
||||
<BaseKanBanRoot showLoader QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />
|
||||
<BaseKanBanRoot showLoader QuickActions={DraftIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />
|
||||
));
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
} from "@plane/types";
|
||||
// components
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
import { getGroupByColumns } from "../utils";
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
import { KanbanStoreType } from "./base-kanban-root";
|
||||
import { KanBan } from "./default";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
@ -291,7 +291,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
projectModule,
|
||||
label,
|
||||
projectState,
|
||||
member
|
||||
member,
|
||||
true,
|
||||
isWorkspaceLevel(storeType)
|
||||
);
|
||||
const subGroupByList = getGroupByColumns(
|
||||
sub_group_by as GroupByColumnTypes,
|
||||
@ -300,7 +302,9 @@ export const KanBanSwimLanes: React.FC<IKanBanSwimLanes> = observer((props) => {
|
||||
projectModule,
|
||||
label,
|
||||
projectState,
|
||||
member
|
||||
member,
|
||||
true,
|
||||
isWorkspaceLevel(storeType)
|
||||
);
|
||||
|
||||
if (!groupByList || !subGroupByList) return null;
|
||||
|
@ -13,9 +13,8 @@ import { IssueBlocksList, ListQuickAddIssueForm } from "@/components/issues";
|
||||
// hooks
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store";
|
||||
// constants
|
||||
// types
|
||||
import { getGroupByColumns } from "../utils";
|
||||
// utils
|
||||
import { getGroupByColumns, isWorkspaceLevel } from "../utils";
|
||||
import { HeaderGroupByCard } from "./headers/group-by-card";
|
||||
|
||||
export interface IGroupByList {
|
||||
@ -78,7 +77,7 @@ const GroupByList: React.FC<IGroupByList> = (props) => {
|
||||
projectState,
|
||||
member,
|
||||
true,
|
||||
true
|
||||
isWorkspaceLevel(storeType)
|
||||
);
|
||||
|
||||
if (!groups) return null;
|
||||
|
@ -70,8 +70,8 @@ export const HeaderGroupByCard = observer(
|
||||
{icon ? icon : <CircleDashed className="h-3.5 w-3.5" strokeWidth={2} />}
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-row items-center gap-1">
|
||||
<div className="line-clamp-1 font-medium text-custom-text-100">{title}</div>
|
||||
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden">
|
||||
<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>
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Placement } from "@popperjs/core";
|
||||
import { TIssue } from "@plane/types";
|
||||
|
||||
export interface IQuickActionProps {
|
||||
@ -10,4 +11,5 @@ export interface IQuickActionProps {
|
||||
customActionButton?: React.ReactElement;
|
||||
portalElement?: HTMLDivElement | null;
|
||||
readOnly?: boolean;
|
||||
placements?: Placement;
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import { FC } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// hooks
|
||||
import { ProjectIssueQuickActions } from "@/components/issues";
|
||||
import { DraftIssueQuickActions } from "@/components/issues";
|
||||
import { EIssuesStoreType } from "@/constants/issue";
|
||||
// components
|
||||
// types
|
||||
@ -15,5 +15,5 @@ export const DraftIssueListLayout: FC = observer(() => {
|
||||
|
||||
if (!workspaceSlug || !projectId) return null;
|
||||
|
||||
return <BaseListRoot QuickActions={ProjectIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />;
|
||||
return <BaseListRoot QuickActions={DraftIssueQuickActions} storeType={EIssuesStoreType.DRAFT} />;
|
||||
});
|
||||
|
@ -257,8 +257,9 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
{/* basic properties */}
|
||||
{/* state */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="state">
|
||||
<div className="h-5 truncate">
|
||||
<div className="h-5">
|
||||
<StateDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
value={issue.state_id}
|
||||
onChange={handleState}
|
||||
projectId={issue.project_id}
|
||||
@ -340,6 +341,9 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
multiple
|
||||
buttonVariant={issue.assignee_ids?.length > 0 ? "transparent-without-text" : "border-without-text"}
|
||||
buttonClassName={issue.assignee_ids?.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
showTooltip={issue?.assignee_ids?.length === 0}
|
||||
placeholder="Assignees"
|
||||
tooltipContent=""
|
||||
/>
|
||||
</div>
|
||||
</WithDisplayPropertiesHOC>
|
||||
@ -348,6 +352,7 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="modules">
|
||||
<div className="h-5">
|
||||
<ModuleDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.module_ids ?? []}
|
||||
onChange={handleModule}
|
||||
@ -362,8 +367,9 @@ export const IssueProperties: React.FC<IIssueProperties> = observer((props) => {
|
||||
|
||||
{/* cycles */}
|
||||
<WithDisplayPropertiesHOC displayProperties={displayProperties} displayPropertyKey="cycle">
|
||||
<div className="h-5 truncate">
|
||||
<div className="h-5">
|
||||
<CycleDropdown
|
||||
buttonContainerClassName="truncate max-w-40"
|
||||
projectId={issue?.project_id}
|
||||
value={issue?.cycle_id}
|
||||
onChange={handleCycle}
|
||||
|
@ -31,6 +31,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -107,7 +108,7 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = observer((pro
|
||||
/>
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
@ -2,4 +2,5 @@ export * from "./cycle-issue";
|
||||
export * from "./module-issue";
|
||||
export * from "./project-issue";
|
||||
export * from "./archived-issue";
|
||||
export * from "./draft-issue";
|
||||
export * from "./all-issue";
|
||||
|
@ -30,6 +30,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
} = props;
|
||||
// states
|
||||
const [createUpdateIssueModal, setCreateUpdateIssueModal] = useState(false);
|
||||
@ -106,7 +107,7 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = observer((pr
|
||||
/>
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
|
@ -28,6 +28,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
customActionButton,
|
||||
portalElement,
|
||||
readOnly = false,
|
||||
placements = "bottom-start",
|
||||
} = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -107,7 +108,7 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = observer((p
|
||||
/>
|
||||
<CustomMenu
|
||||
menuItemsClassName="z-[14]"
|
||||
placement="bottom-start"
|
||||
placement={placements}
|
||||
customButton={customActionButton}
|
||||
portalElement={portalElement}
|
||||
maxHeight="lg"
|
||||
|
@ -4,7 +4,7 @@ import { Avatar, CycleGroupIcon, DiceIcon, PriorityIcon, StateGroupIcon } from "
|
||||
// components
|
||||
import { ProjectLogo } from "@/components/project";
|
||||
// stores
|
||||
import { ISSUE_PRIORITIES } from "@/constants/issue";
|
||||
import { ISSUE_PRIORITIES, EIssuesStoreType } from "@/constants/issue";
|
||||
import { STATE_GROUPS } from "@/constants/state";
|
||||
import { ICycleStore } from "@/store/cycle.store";
|
||||
import { ILabelStore } from "@/store/label.store";
|
||||
@ -16,6 +16,9 @@ import { IStateStore } from "@/store/state.store";
|
||||
// constants
|
||||
// types
|
||||
|
||||
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
|
||||
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
|
||||
|
||||
export const getGroupByColumns = (
|
||||
groupBy: GroupByColumnTypes | null,
|
||||
project: IProjectStore,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React, { useState } from "react";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
import type { TIssue } from "@plane/types";
|
||||
@ -6,12 +7,10 @@ import type { TIssue } from "@plane/types";
|
||||
import { TOAST_TYPE, setToast } from "@plane/ui";
|
||||
import { ConfirmIssueDiscard } from "@/components/issues";
|
||||
import { IssueFormRoot } from "@/components/issues/issue-modal/form";
|
||||
import { isEmptyHtmlString } from "@/helpers/string.helper";
|
||||
import { useEventTracker } from "@/hooks/store";
|
||||
// services
|
||||
import { IssueDraftService } from "@/services/issue";
|
||||
// ui
|
||||
// components
|
||||
// types
|
||||
|
||||
export interface DraftIssueProps {
|
||||
changesMade: Partial<TIssue> | null;
|
||||
@ -50,8 +49,34 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
||||
const { captureIssueEvent } = useEventTracker();
|
||||
|
||||
const handleClose = () => {
|
||||
if (changesMade) setIssueDiscardModal(true);
|
||||
else onClose(false);
|
||||
if (data?.id) {
|
||||
onClose(false);
|
||||
setIssueDiscardModal(false);
|
||||
} else {
|
||||
if (changesMade) {
|
||||
Object.entries(changesMade).forEach(([key, value]) => {
|
||||
const issueKey = key as keyof TIssue;
|
||||
if (value === null || value === undefined || value === "") delete changesMade[issueKey];
|
||||
if (typeof value === "object" && isEmpty(value)) delete changesMade[issueKey];
|
||||
if (Array.isArray(value) && value.length === 0) delete changesMade[issueKey];
|
||||
if (issueKey === "project_id") delete changesMade.project_id;
|
||||
if (issueKey === "priority" && value && value === "none") delete changesMade.priority;
|
||||
if (
|
||||
issueKey === "description_html" &&
|
||||
changesMade.description_html &&
|
||||
isEmptyHtmlString(changesMade.description_html)
|
||||
)
|
||||
delete changesMade.description_html;
|
||||
});
|
||||
if (isEmpty(changesMade)) {
|
||||
onClose(false);
|
||||
setIssueDiscardModal(false);
|
||||
} else setIssueDiscardModal(true);
|
||||
} else {
|
||||
onClose(false);
|
||||
setIssueDiscardModal(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDraftIssue = async () => {
|
||||
@ -59,7 +84,7 @@ export const DraftIssueLayout: React.FC<DraftIssueProps> = observer((props) => {
|
||||
|
||||
const payload = {
|
||||
...changesMade,
|
||||
name: changesMade.name?.trim() === "" ? "Untitled" : changesMade.name?.trim(),
|
||||
name: changesMade?.name && changesMade?.name?.trim() === "" ? changesMade.name?.trim() : "Untitled",
|
||||
};
|
||||
|
||||
await issueDraftService
|
||||
|
@ -178,6 +178,10 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
id: data.id,
|
||||
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);
|
||||
|
||||
setGptAssistantModal(false);
|
||||
@ -597,6 +601,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
onChange(cycleId);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder="Cycle"
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("cycle_id")}
|
||||
@ -618,6 +623,7 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
onChange(moduleIds);
|
||||
handleFormChange();
|
||||
}}
|
||||
placeholder="Modules"
|
||||
buttonVariant="border-with-text"
|
||||
tabIndex={getTabIndex("module_ids")}
|
||||
multiple
|
||||
@ -716,19 +722,24 @@ export const IssueFormRoot: FC<IssueFormProps> = observer((props) => {
|
||||
</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="flex cursor-default items-center gap-1.5"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={getTabIndex("create_more")}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center justify-center">
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
</div>
|
||||
<span className="text-xs">Create more</span>
|
||||
<div>
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex cursor-default items-center gap-1.5"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
}}
|
||||
tabIndex={getTabIndex("create_more")}
|
||||
>
|
||||
<div className="flex cursor-pointer items-center justify-center">
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
</div>
|
||||
<span className="text-xs">Create more</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose} tabIndex={getTabIndex("discard_button")}>
|
||||
Discard
|
||||
|
@ -171,7 +171,10 @@ export const CreateUpdateIssueModal: React.FC<IssuesModalProps> = observer((prop
|
||||
path: router.asPath,
|
||||
});
|
||||
!createMore && handleClose();
|
||||
if (createMore) issueTitleRef && issueTitleRef?.current?.focus();
|
||||
if (createMore) {
|
||||
issueTitleRef && issueTitleRef?.current?.focus();
|
||||
setChangesMade(null);
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
setToast({
|
||||
|
@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { TIssue } from "@plane/types";
|
||||
// types
|
||||
@ -9,7 +10,7 @@ type Props = {
|
||||
issueDetail?: TIssue;
|
||||
};
|
||||
|
||||
export const IssueUpdateStatus: React.FC<Props> = (props) => {
|
||||
export const IssueUpdateStatus: React.FC<Props> = observer((props) => {
|
||||
const { isSubmitting, issueDetail } = props;
|
||||
// hooks
|
||||
const { getProjectById } = useProject();
|
||||
@ -33,4 +34,4 @@ export const IssueUpdateStatus: React.FC<Props> = (props) => {
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -52,8 +52,10 @@ export const IssuesMobileHeader = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -53,7 +53,7 @@ export type PeekOverviewHeaderProps = {
|
||||
issueId: string;
|
||||
isArchived: boolean;
|
||||
disabled: boolean;
|
||||
toggleDeleteIssueModal: (value: boolean) => void;
|
||||
toggleDeleteIssueModal: (issueId: string | null) => void;
|
||||
toggleArchiveIssueModal: (value: boolean) => void;
|
||||
handleRestoreIssue: () => void;
|
||||
isSubmitting: "submitting" | "submitted" | "saved";
|
||||
@ -188,7 +188,7 @@ export const IssuePeekOverviewHeader: FC<PeekOverviewHeaderProps> = observer((pr
|
||||
)}
|
||||
{!disabled && (
|
||||
<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" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
@ -91,23 +91,14 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{issue && !is_archived && (
|
||||
{issue && isDeleteIssueModalOpen === issue.id && (
|
||||
<DeleteIssueModal
|
||||
isOpen={isDeleteIssueModalOpen}
|
||||
isOpen={!!isDeleteIssueModalOpen}
|
||||
handleClose={() => {
|
||||
toggleDeleteIssueModal(false);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issue}
|
||||
onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{issue && is_archived && (
|
||||
<DeleteIssueModal
|
||||
data={issue}
|
||||
isOpen={isDeleteIssueModalOpen}
|
||||
handleClose={() => toggleDeleteIssueModal(false)}
|
||||
onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId)}
|
||||
onSubmit={() => issueOperations.remove(workspaceSlug, projectId, issueId).then(() => removeRoutePeekId())}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
@ -158,7 +158,7 @@ export const IssueListItem: React.FC<ISubIssues> = observer((props) => {
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
handleIssueCrudState("delete", parentIssueId, issue);
|
||||
toggleDeleteIssueModal(true);
|
||||
toggleDeleteIssueModal(issue.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
@ -523,7 +523,7 @@ export const SubIssuesRoot: FC<ISubIssuesRoot> = observer((props) => {
|
||||
isOpen={issueCrudState?.delete?.toggle}
|
||||
handleClose={() => {
|
||||
handleIssueCrudState("delete", null, null);
|
||||
toggleDeleteIssueModal(false);
|
||||
toggleDeleteIssueModal(null);
|
||||
}}
|
||||
data={issueCrudState?.delete?.issue as TIssue}
|
||||
onSubmit={async () =>
|
||||
|
@ -31,7 +31,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
const handleArchiveIssue = async () => {
|
||||
const handleArchiveModule = async () => {
|
||||
setIsArchiving(true);
|
||||
await archiveModule(workspaceSlug, projectId, moduleId)
|
||||
.then(() => {
|
||||
@ -89,7 +89,7 @@ export const ArchiveModuleModal: React.FC<Props> = (props) => {
|
||||
<Button variant="neutral-primary" size="sm" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" tabIndex={1} onClick={handleArchiveIssue} loading={isArchiving}>
|
||||
<Button size="sm" tabIndex={1} onClick={handleArchiveModule} loading={isArchiving}>
|
||||
{isArchiving ? "Archiving" : "Archive"}
|
||||
</Button>
|
||||
</div>
|
||||
|
@ -68,7 +68,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Module could not be created. Please try again.",
|
||||
message: err?.detail ?? "Module could not be created. Please try again.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: MODULE_CREATED,
|
||||
@ -99,7 +99,7 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: err.detail ?? "Module could not be updated. Please try again.",
|
||||
message: err?.detail ?? "Module could not be updated. Please try again.",
|
||||
});
|
||||
captureModuleEvent({
|
||||
eventName: MODULE_UPDATED,
|
||||
|
@ -101,10 +101,18 @@ export const ModuleCardItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekModule: moduleId },
|
||||
});
|
||||
if (query.peekModule) {
|
||||
delete query.peekModule;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekModule: moduleId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!moduleDetails) return null;
|
||||
|
@ -102,10 +102,18 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
e.preventDefault();
|
||||
const { query } = router;
|
||||
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekModule: moduleId },
|
||||
});
|
||||
if (query.peekModule) {
|
||||
delete query.peekModule;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query },
|
||||
});
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekModule: moduleId },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (!moduleDetails) return null;
|
||||
@ -177,7 +185,7 @@ export const ModuleListItem: React.FC<Props> = observer((props) => {
|
||||
</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">
|
||||
{renderDate && (
|
||||
<span className=" text-xs text-custom-text-300">
|
||||
|
@ -54,8 +54,10 @@ export const ModuleMobileHeader = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -38,7 +38,7 @@ export const ModulesListView: React.FC = observer(() => {
|
||||
</>
|
||||
);
|
||||
|
||||
if (totalFilters > 0 || searchQuery.trim() !== "")
|
||||
if (totalFilters > 0 && filteredModuleIds.length === 0)
|
||||
return (
|
||||
<div className="h-full w-full grid place-items-center">
|
||||
<div className="text-center">
|
||||
|
@ -44,8 +44,10 @@ export const ProfileIssuesFilter = observer(() => {
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// this validation is majorly for the filter start_date, target_date custom
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
else newValues.splice(newValues.indexOf(val), 1);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./access";
|
||||
export * from "./date";
|
||||
export * from "./members";
|
||||
export * from "./project-display-filters";
|
||||
export * from "./root";
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
@ -1,17 +1,24 @@
|
||||
import { X } from "lucide-react";
|
||||
import { TProjectFilters } from "@plane/types";
|
||||
// components
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { AppliedAccessFilters, AppliedDateFilters, AppliedMembersFilters } from "@/components/project";
|
||||
// types
|
||||
import { TProjectAppliedDisplayFilterKeys, TProjectFilters } from "@plane/types";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import {
|
||||
AppliedAccessFilters,
|
||||
AppliedDateFilters,
|
||||
AppliedMembersFilters,
|
||||
AppliedProjectDisplayFilters,
|
||||
} from "@/components/project";
|
||||
// helpers
|
||||
import { replaceUnderscoreIfSnakeCase } from "@/helpers/string.helper";
|
||||
// types
|
||||
|
||||
type Props = {
|
||||
appliedFilters: TProjectFilters;
|
||||
appliedDisplayFilters: TProjectAppliedDisplayFilterKeys[];
|
||||
handleClearAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof TProjectFilters, value: string | null) => void;
|
||||
handleRemoveDisplayFilter: (key: TProjectAppliedDisplayFilterKeys) => void;
|
||||
alwaysAllowEditing?: boolean;
|
||||
filteredProjects: number;
|
||||
totalProjects: number;
|
||||
@ -23,21 +30,24 @@ const DATE_FILTERS = ["created_at"];
|
||||
export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
|
||||
const {
|
||||
appliedFilters,
|
||||
appliedDisplayFilters,
|
||||
handleClearAllFilters,
|
||||
handleRemoveFilter,
|
||||
handleRemoveDisplayFilter,
|
||||
alwaysAllowEditing,
|
||||
filteredProjects,
|
||||
totalProjects,
|
||||
} = props;
|
||||
|
||||
if (!appliedFilters) return null;
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
if (!appliedFilters && !appliedDisplayFilters) return null;
|
||||
if (Object.keys(appliedFilters).length === 0 && appliedDisplayFilters.length === 0) return null;
|
||||
|
||||
const isEditingAllowed = alwaysAllowEditing;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-1.5">
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
{/* Applied filters */}
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
const filterKey = key as keyof TProjectFilters;
|
||||
|
||||
@ -85,6 +95,22 @@ export const ProjectAppliedFiltersList: React.FC<Props> = (props) => {
|
||||
</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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
@ -17,9 +17,9 @@ export const ProjectCardList = observer(() => {
|
||||
const { commandPalette: commandPaletteStore } = useApplication();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { workspaceProjectIds, filteredProjectIds, getProjectById } = useProject();
|
||||
const { searchQuery } = useProjectFilter();
|
||||
const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter();
|
||||
|
||||
if (workspaceProjectIds?.length === 0)
|
||||
if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
|
||||
return (
|
||||
<EmptyState
|
||||
type={EmptyStateType.WORKSPACE_PROJECTS}
|
||||
|
@ -2,12 +2,13 @@ import React, { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import Link from "next/link";
|
||||
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";
|
||||
// ui
|
||||
import { Avatar, AvatarGroup, Button, Tooltip, TOAST_TYPE, setToast, setPromiseToast } from "@plane/ui";
|
||||
// components
|
||||
import { DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
|
||||
import { ArchiveRestoreProjectModal, DeleteProjectModal, JoinProjectModal, ProjectLogo } from "@/components/project";
|
||||
// helpers
|
||||
import { EUserProjectRoles } from "@/constants/project";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
@ -28,6 +29,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
// states
|
||||
const [deleteProjectModalOpen, setDeleteProjectModal] = useState(false);
|
||||
const [joinProjectModalOpen, setJoinProjectModal] = useState(false);
|
||||
const [restoreProject, setRestoreProject] = useState(false);
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
@ -41,6 +43,8 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
// auth
|
||||
const isOwner = project.member_role === EUserProjectRoles.ADMIN;
|
||||
const isMember = project.member_role === EUserProjectRoles.MEMBER;
|
||||
// archive
|
||||
const isArchived = !!project.archived_at;
|
||||
|
||||
const handleAddToFavorites = () => {
|
||||
if (!workspaceSlug) return;
|
||||
@ -102,13 +106,23 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
|
||||
handleClose={() => setJoinProjectModal(false)}
|
||||
/>
|
||||
)}
|
||||
{/* Restore project modal */}
|
||||
{workspaceSlug && project && (
|
||||
<ArchiveRestoreProjectModal
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={project.id}
|
||||
isOpen={restoreProject}
|
||||
onClose={() => setRestoreProject(false)}
|
||||
archive={false}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={`/${workspaceSlug}/projects/${project.id}/issues`}
|
||||
onClick={(e) => {
|
||||
if (!project.is_member) {
|
||||
if (!project.is_member || isArchived) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setJoinProjectModal(true);
|
||||
if (!isArchived) setJoinProjectModal(true);
|
||||
}
|
||||
}}
|
||||
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 className="flex h-full flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCopyText();
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="h-3 w-3 text-white" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (project.is_favorite) handleRemoveFromFavorites();
|
||||
else handleAddToFavorites();
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!isArchived && (
|
||||
<div className="flex h-full flex-shrink-0 items-center gap-2">
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
handleCopyText();
|
||||
}}
|
||||
>
|
||||
<LinkIcon className="h-3 w-3 text-white" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-6 w-6 items-center justify-center rounded bg-white/10"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (project.is_favorite) handleRemoveFromFavorites();
|
||||
else handleAddToFavorites();
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={`h-3 w-3 ${project.is_favorite ? "fill-amber-400 text-transparent" : "text-white"} `}
|
||||
/>
|
||||
</button>
|
||||
</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">
|
||||
{project.description && project.description.trim() !== ""
|
||||
? project.description
|
||||
: `Created on ${renderFormattedDate(project.created_at)}`}
|
||||
</p>
|
||||
<div className="item-center flex justify-between">
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipHeading="Members"
|
||||
tooltipContent={
|
||||
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
|
||||
}
|
||||
position="top"
|
||||
>
|
||||
{projectMembersIds && projectMembersIds.length > 0 ? (
|
||||
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{projectMembersIds.map((memberId) => {
|
||||
const member = project.members?.find((m) => m.member_id === memberId);
|
||||
|
||||
if (!member) return null;
|
||||
|
||||
return <Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />;
|
||||
})}
|
||||
</AvatarGroup>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Tooltip
|
||||
isMobile={isMobile}
|
||||
tooltipHeading="Members"
|
||||
tooltipContent={
|
||||
project.members && project.members.length > 0 ? `${project.members.length} Members` : "No Member"
|
||||
}
|
||||
position="top"
|
||||
>
|
||||
{projectMembersIds && projectMembersIds.length > 0 ? (
|
||||
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{projectMembersIds.map((memberId) => {
|
||||
const member = project.members?.find((m) => m.member_id === memberId);
|
||||
if (!member) return null;
|
||||
return (
|
||||
<Avatar key={member.id} name={member.member__display_name} src={member.member__avatar} />
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
) : (
|
||||
<span className="text-sm italic text-custom-text-400">No Member Yet</span>
|
||||
)}
|
||||
</Tooltip>
|
||||
{project.is_member &&
|
||||
(isOwner || isMember ? (
|
||||
<Link
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
href={`/${workspaceSlug}/projects/${project.id}/settings`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-custom-text-400 text-sm">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Joined
|
||||
</span>
|
||||
))}
|
||||
{!project.is_member && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="link-primary"
|
||||
className="!p-0 font-semibold"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setJoinProjectModal(true);
|
||||
}}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{project.is_member &&
|
||||
(isOwner || isMember ? (
|
||||
<Link
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
href={`/${workspaceSlug}/projects/${project.id}/settings`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-custom-text-400 text-sm">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Joined
|
||||
</span>
|
||||
))}
|
||||
{!project.is_member && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="link-primary"
|
||||
className="!p-0 font-semibold"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setJoinProjectModal(true);
|
||||
}}
|
||||
>
|
||||
Join
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -209,8 +209,10 @@ export const CreateProjectForm: FC<Props> = observer((props) => {
|
||||
[val.type]: logoValue,
|
||||
});
|
||||
}}
|
||||
defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined}
|
||||
defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
|
||||
defaultIconColor={value.in_use && value.in_use === "icon" ? value.icon?.color : undefined}
|
||||
defaultOpen={
|
||||
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
@ -51,6 +51,15 @@ export const ProjectFiltersSelection: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
title="My projects"
|
||||
/>
|
||||
<FilterOption
|
||||
isChecked={!!displayFilters.archived_projects}
|
||||
onClick={() =>
|
||||
handleDisplayFiltersUpdate({
|
||||
archived_projects: !displayFilters.archived_projects,
|
||||
})
|
||||
}
|
||||
title="Archived"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* access */}
|
||||
|
@ -40,7 +40,8 @@ export const ProjectOrderByDropdown: React.FC<Props> = (props) => {
|
||||
key={option.key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
onClick={() => {
|
||||
if (isDescending) onChange(`-${option.key}` as TProjectOrderByOptions);
|
||||
if (isDescending)
|
||||
onChange(option.key == "sort_order" ? option.key : (`-${option.key}` as TProjectOrderByOptions));
|
||||
else onChange(option.key);
|
||||
}}
|
||||
>
|
||||
|
@ -166,8 +166,10 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
|
||||
[val.type]: logoValue,
|
||||
});
|
||||
}}
|
||||
defaultIconColor={value.in_use === "icon" ? value.icon?.color : undefined}
|
||||
defaultOpen={value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON}
|
||||
defaultIconColor={value?.in_use && value.in_use === "icon" ? value?.icon?.color : undefined}
|
||||
defaultOpen={
|
||||
value.in_use && value.in_use === "emoji" ? EmojiIconPickerTypes.EMOJI : EmojiIconPickerTypes.ICON
|
||||
}
|
||||
disabled={!isAdmin}
|
||||
/>
|
||||
)}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user