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 Robin-Sch/develop
This commit is contained in:
commit
4e62abc6f0
25
.env.example
25
.env.example
@ -1,20 +1,41 @@
|
|||||||
# Replace with your instance Public IP
|
# Frontend
|
||||||
|
# Extra image domains that need to be added for Next Image
|
||||||
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS=
|
||||||
|
# Google Client ID for Google OAuth
|
||||||
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
NEXT_PUBLIC_GOOGLE_CLIENTID=""
|
||||||
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
# Github ID for Github OAuth
|
||||||
NEXT_PUBLIC_GITHUB_ID=""
|
NEXT_PUBLIC_GITHUB_ID=""
|
||||||
|
# Github App Name for GitHub Integration
|
||||||
|
NEXT_PUBLIC_GITHUB_APP_NAME=""
|
||||||
|
# Sentry DSN for error monitoring
|
||||||
NEXT_PUBLIC_SENTRY_DSN=""
|
NEXT_PUBLIC_SENTRY_DSN=""
|
||||||
|
# Enable/Disable OAUTH - default 0 for selfhosted instance
|
||||||
NEXT_PUBLIC_ENABLE_OAUTH=0
|
NEXT_PUBLIC_ENABLE_OAUTH=0
|
||||||
|
# Enable/Disable sentry
|
||||||
NEXT_PUBLIC_ENABLE_SENTRY=0
|
NEXT_PUBLIC_ENABLE_SENTRY=0
|
||||||
|
# Enable/Disable session recording
|
||||||
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
|
||||||
|
# Enable/Disable event tracking
|
||||||
NEXT_PUBLIC_TRACK_EVENTS=0
|
NEXT_PUBLIC_TRACK_EVENTS=0
|
||||||
|
# Slack for Slack Integration
|
||||||
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
NEXT_PUBLIC_SLACK_CLIENT_ID=""
|
||||||
|
|
||||||
|
# Backend
|
||||||
|
# Email Settings
|
||||||
EMAIL_HOST=""
|
EMAIL_HOST=""
|
||||||
EMAIL_HOST_USER=""
|
EMAIL_HOST_USER=""
|
||||||
EMAIL_HOST_PASSWORD=""
|
EMAIL_HOST_PASSWORD=""
|
||||||
|
EMAIL_PORT=587
|
||||||
|
|
||||||
|
# AWS Settings
|
||||||
AWS_REGION=""
|
AWS_REGION=""
|
||||||
AWS_ACCESS_KEY_ID=""
|
AWS_ACCESS_KEY_ID=""
|
||||||
AWS_SECRET_ACCESS_KEY=""
|
AWS_SECRET_ACCESS_KEY=""
|
||||||
AWS_S3_BUCKET_NAME=""
|
AWS_S3_BUCKET_NAME=""
|
||||||
|
AWS_S3_ENDPOINT_URL=""
|
||||||
|
|
||||||
|
# GPT settings
|
||||||
OPENAI_API_KEY=""
|
OPENAI_API_KEY=""
|
||||||
GPT_ENGINE=""
|
GPT_ENGINE=""
|
||||||
|
|
||||||
|
# Auto generated and Required
|
56
README.md
56
README.md
@ -15,11 +15,18 @@
|
|||||||
</a>
|
</a>
|
||||||
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||||
</p>
|
</p>
|
||||||
<br />
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/" target="_blank">
|
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680599798/Plane/plane_1_1_tnb32j.png"
|
src="https://ik.imagekit.io/killbluedog/Plane_Screen.png?updatedAt=1684942001069"
|
||||||
|
alt="Plane Screens"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<a href="https://app.plane.so/#gh-dark-mode-only" target="_blank">
|
||||||
|
<img
|
||||||
|
src="https://ik.imagekit.io/killbluedog/Plane_Screens_Dark_Mode.png?updatedAt=1684942388044"
|
||||||
alt="Plane Screens"
|
alt="Plane Screens"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
@ -85,41 +92,62 @@ docker compose up -d
|
|||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601719/Plane/plane_2_iqao52.png"
|
src="https://ik.imagekit.io/killbluedog/Plane_Views_Dark_Mode.png?updatedAt=1684943050275"
|
||||||
|
alt="Plane Views"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://plane.so" target="_blank">
|
||||||
|
<img
|
||||||
|
src="https://ik.imagekit.io/killbluedog/Plane_Issue_Detail_Dark_Mode.png?updatedAt=1684943050202"
|
||||||
alt="Plane Issue Details"
|
alt="Plane Issue Details"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680604273/Plane/plane_5_1_nwsl3a.png"
|
src="https://ik.imagekit.io/killbluedog/Plane_Cycles___Modules_Dark_Mode.png?updatedAt=1684943050281"
|
||||||
alt="Plane Cycles and Modules"
|
alt="Plane Cycles and Modules"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
|
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
|
||||||
alt="Plane Quick Lists"
|
alt="Plane Analytics"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="https://app.plane.so/" target="_blank">
|
<a href="https://plane.so" target="_blank">
|
||||||
<img
|
<img
|
||||||
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
|
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
|
||||||
alt="Plane Command K"
|
alt="Plane Pages"
|
||||||
width="100%"
|
width="100%"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="https://plane.so" target="_blank">
|
||||||
|
<img
|
||||||
|
src="https://ik.imagekit.io/killbluedog/Plane_Commad_K_Dark_Mode.png?updatedAt=1684943050312"
|
||||||
|
alt="Plane Command Menu"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
## 📚Documentation
|
## 📚Documentation
|
||||||
|
|
||||||
@ -131,7 +159,7 @@ To see how to Contribute, visit [here](https://github.com/makeplane/plane/blob/m
|
|||||||
|
|
||||||
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
The Plane community can be found on GitHub Discussions, where you can ask questions, voice ideas, and share your projects.
|
||||||
|
|
||||||
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/q9HKAdau).
|
To chat with other community members you can join the [Plane Discord](https://discord.com/invite/A92xrEGCge).
|
||||||
|
|
||||||
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
|
Our [Code of Conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) applies to all Plane community channels.
|
||||||
|
|
||||||
|
@ -204,7 +204,21 @@ def update_integration_verified():
|
|||||||
Integration.objects.bulk_update(
|
Integration.objects.bulk_update(
|
||||||
updated_integrations, ["verified"], batch_size=10
|
updated_integrations, ["verified"], batch_size=10
|
||||||
)
|
)
|
||||||
print("Sucess")
|
print("Success")
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
print("Failed")
|
||||||
|
|
||||||
|
|
||||||
|
def update_start_date():
|
||||||
|
try:
|
||||||
|
issues = Issue.objects.filter(state__group__in=["started", "completed"])
|
||||||
|
updated_issues = []
|
||||||
|
for issue in issues:
|
||||||
|
issue.start_date = issue.created_at.date()
|
||||||
|
updated_issues.append(issue)
|
||||||
|
Issue.objects.bulk_update(updated_issues, ["start_date"], batch_size=500)
|
||||||
|
print("Success")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
print("Failed")
|
print("Failed")
|
||||||
|
@ -70,3 +70,5 @@ from .importer import ImporterSerializer
|
|||||||
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
|
||||||
|
|
||||||
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
|
||||||
|
|
||||||
|
from .analytic import AnalyticViewSerializer
|
||||||
|
30
apiserver/plane/api/serializers/analytic.py
Normal file
30
apiserver/plane/api/serializers/analytic.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
from .base import BaseSerializer
|
||||||
|
from plane.db.models import AnalyticView
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticViewSerializer(BaseSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = AnalyticView
|
||||||
|
fields = "__all__"
|
||||||
|
read_only_fields = [
|
||||||
|
"workspace",
|
||||||
|
"query",
|
||||||
|
]
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
query_params = validated_data.get("query_dict", {})
|
||||||
|
if bool(query_params):
|
||||||
|
validated_data["query"] = issue_filters(query_params, "POST")
|
||||||
|
else:
|
||||||
|
validated_data["query"] = dict()
|
||||||
|
return AnalyticView.objects.create(**validated_data)
|
||||||
|
|
||||||
|
def update(self, instance, validated_data):
|
||||||
|
query_params = validated_data.get("query_data", {})
|
||||||
|
if bool(query_params):
|
||||||
|
validated_data["query"] = issue_filters(query_params, "POST")
|
||||||
|
else:
|
||||||
|
validated_data["query"] = dict()
|
||||||
|
validated_data["query"] = issue_filters(query_params, "PATCH")
|
||||||
|
return super().update(instance, validated_data)
|
@ -19,7 +19,10 @@ class CycleSerializer(BaseSerializer):
|
|||||||
started_issues = serializers.IntegerField(read_only=True)
|
started_issues = serializers.IntegerField(read_only=True)
|
||||||
unstarted_issues = serializers.IntegerField(read_only=True)
|
unstarted_issues = serializers.IntegerField(read_only=True)
|
||||||
backlog_issues = serializers.IntegerField(read_only=True)
|
backlog_issues = serializers.IntegerField(read_only=True)
|
||||||
assignees = serializers.SerializerMethodField()
|
assignees = serializers.SerializerMethodField(read_only=True)
|
||||||
|
total_estimates = serializers.IntegerField(read_only=True)
|
||||||
|
completed_estimates = serializers.IntegerField(read_only=True)
|
||||||
|
started_estimates = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
workspace_detail = WorkspaceLiteSerializer(read_only=True, source="workspace")
|
||||||
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
project_detail = ProjectLiteSerializer(read_only=True, source="project")
|
||||||
|
@ -82,6 +82,9 @@ class ProjectDetailSerializer(BaseSerializer):
|
|||||||
default_assignee = UserLiteSerializer(read_only=True)
|
default_assignee = UserLiteSerializer(read_only=True)
|
||||||
project_lead = UserLiteSerializer(read_only=True)
|
project_lead = UserLiteSerializer(read_only=True)
|
||||||
is_favorite = serializers.BooleanField(read_only=True)
|
is_favorite = serializers.BooleanField(read_only=True)
|
||||||
|
total_members = serializers.IntegerField(read_only=True)
|
||||||
|
total_cycles = serializers.IntegerField(read_only=True)
|
||||||
|
total_modules = serializers.IntegerField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Project
|
model = Project
|
||||||
|
@ -148,6 +148,13 @@ from plane.api.views import (
|
|||||||
# Release Notes
|
# Release Notes
|
||||||
ReleaseNotesEndpoint,
|
ReleaseNotesEndpoint,
|
||||||
## End Release Notes
|
## End Release Notes
|
||||||
|
# Analytics
|
||||||
|
AnalyticsEndpoint,
|
||||||
|
AnalyticViewViewset,
|
||||||
|
SavedAnalyticEndpoint,
|
||||||
|
ExportAnalyticsEndpoint,
|
||||||
|
DefaultAnalyticsEndpoint,
|
||||||
|
## End Analytics
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -308,7 +315,6 @@ urlpatterns = [
|
|||||||
"workspaces/<str:slug>/members/<uuid:pk>/",
|
"workspaces/<str:slug>/members/<uuid:pk>/",
|
||||||
WorkSpaceMemberViewSet.as_view(
|
WorkSpaceMemberViewSet.as_view(
|
||||||
{
|
{
|
||||||
"put": "update",
|
|
||||||
"patch": "partial_update",
|
"patch": "partial_update",
|
||||||
"delete": "destroy",
|
"delete": "destroy",
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
@ -418,7 +424,6 @@ urlpatterns = [
|
|||||||
ProjectMemberViewSet.as_view(
|
ProjectMemberViewSet.as_view(
|
||||||
{
|
{
|
||||||
"get": "retrieve",
|
"get": "retrieve",
|
||||||
"put": "update",
|
|
||||||
"patch": "partial_update",
|
"patch": "partial_update",
|
||||||
"delete": "destroy",
|
"delete": "destroy",
|
||||||
}
|
}
|
||||||
@ -1285,4 +1290,38 @@ urlpatterns = [
|
|||||||
name="release-notes",
|
name="release-notes",
|
||||||
),
|
),
|
||||||
## End Release Notes
|
## End Release Notes
|
||||||
|
# Analytics
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/analytics/",
|
||||||
|
AnalyticsEndpoint.as_view(),
|
||||||
|
name="plane-analytics",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/analytic-view/",
|
||||||
|
AnalyticViewViewset.as_view({"get": "list", "post": "create"}),
|
||||||
|
name="analytic-view",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/analytic-view/<uuid:pk>/",
|
||||||
|
AnalyticViewViewset.as_view(
|
||||||
|
{"get": "retrieve", "patch": "partial_update", "delete": "destroy"}
|
||||||
|
),
|
||||||
|
name="analytic-view",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/saved-analytic-view/<uuid:analytic_id>/",
|
||||||
|
SavedAnalyticEndpoint.as_view(),
|
||||||
|
name="saved-analytic-view",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/export-analytics/",
|
||||||
|
ExportAnalyticsEndpoint.as_view(),
|
||||||
|
name="export-analytics",
|
||||||
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/default-analytics/",
|
||||||
|
DefaultAnalyticsEndpoint.as_view(),
|
||||||
|
name="default-analytics",
|
||||||
|
),
|
||||||
|
## End Analytics
|
||||||
]
|
]
|
||||||
|
@ -140,3 +140,11 @@ from .estimate import (
|
|||||||
|
|
||||||
|
|
||||||
from .release import ReleaseNotesEndpoint
|
from .release import ReleaseNotesEndpoint
|
||||||
|
|
||||||
|
from .analytic import (
|
||||||
|
AnalyticsEndpoint,
|
||||||
|
AnalyticViewViewset,
|
||||||
|
SavedAnalyticEndpoint,
|
||||||
|
ExportAnalyticsEndpoint,
|
||||||
|
DefaultAnalyticsEndpoint,
|
||||||
|
)
|
||||||
|
295
apiserver/plane/api/views/analytic.py
Normal file
295
apiserver/plane/api/views/analytic.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
# Django imports
|
||||||
|
from django.db.models import (
|
||||||
|
Count,
|
||||||
|
Sum,
|
||||||
|
F,
|
||||||
|
)
|
||||||
|
from django.db.models.functions import ExtractMonth
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.api.views import BaseAPIView, BaseViewSet
|
||||||
|
from plane.api.permissions import WorkSpaceAdminPermission
|
||||||
|
from plane.db.models import Issue, AnalyticView, Workspace, State, Label
|
||||||
|
from plane.api.serializers import AnalyticViewSerializer
|
||||||
|
from plane.utils.analytics_plot import build_graph_plot
|
||||||
|
from plane.bgtasks.analytic_plot_export import analytic_export_task
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkSpaceAdminPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
|
x_axis = request.GET.get("x_axis", False)
|
||||||
|
y_axis = request.GET.get("y_axis", False)
|
||||||
|
|
||||||
|
if not x_axis or not y_axis:
|
||||||
|
return Response(
|
||||||
|
{"error": "x-axis and y-axis dimensions are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
segment = request.GET.get("segment", False)
|
||||||
|
filters = issue_filters(request.GET, "GET")
|
||||||
|
|
||||||
|
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||||
|
|
||||||
|
total_issues = queryset.count()
|
||||||
|
distribution = build_graph_plot(
|
||||||
|
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||||
|
)
|
||||||
|
|
||||||
|
colors = dict()
|
||||||
|
if x_axis in ["state__name", "state__group"] or segment in [
|
||||||
|
"state__name",
|
||||||
|
"state__group",
|
||||||
|
]:
|
||||||
|
if x_axis in ["state__name", "state__group"]:
|
||||||
|
key = "name" if x_axis == "state__name" else "group"
|
||||||
|
else:
|
||||||
|
key = "name" if segment == "state__name" else "group"
|
||||||
|
|
||||||
|
colors = (
|
||||||
|
State.objects.filter(
|
||||||
|
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||||
|
).values(key, "color")
|
||||||
|
if filters.get("project__in", False)
|
||||||
|
else State.objects.filter(workspace__slug=slug).values(key, "color")
|
||||||
|
)
|
||||||
|
|
||||||
|
if x_axis in ["labels__name"] or segment in ["labels__name"]:
|
||||||
|
colors = (
|
||||||
|
Label.objects.filter(
|
||||||
|
workspace__slug=slug, project_id__in=filters.get("project__in")
|
||||||
|
).values("name", "color")
|
||||||
|
if filters.get("project__in", False)
|
||||||
|
else Label.objects.filter(workspace__slug=slug).values(
|
||||||
|
"name", "color"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assignee_details = {}
|
||||||
|
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||||
|
assignee_details = (
|
||||||
|
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||||
|
.order_by("assignees__id")
|
||||||
|
.distinct("assignees__id")
|
||||||
|
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"total": total_issues,
|
||||||
|
"distribution": distribution,
|
||||||
|
"extras": {"colors": colors, "assignee_details": assignee_details},
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticViewViewset(BaseViewSet):
|
||||||
|
permission_classes = [
|
||||||
|
WorkSpaceAdminPermission,
|
||||||
|
]
|
||||||
|
model = AnalyticView
|
||||||
|
serializer_class = AnalyticViewSerializer
|
||||||
|
|
||||||
|
def perform_create(self, serializer):
|
||||||
|
workspace = Workspace.objects.get(slug=self.kwargs.get("slug"))
|
||||||
|
serializer.save(workspace_id=workspace.id)
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
return self.filter_queryset(
|
||||||
|
super().get_queryset().filter(workspace__slug=self.kwargs.get("slug"))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SavedAnalyticEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkSpaceAdminPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug, analytic_id):
|
||||||
|
try:
|
||||||
|
analytic_view = AnalyticView.objects.get(
|
||||||
|
pk=analytic_id, workspace__slug=slug
|
||||||
|
)
|
||||||
|
|
||||||
|
filter = analytic_view.query
|
||||||
|
queryset = Issue.objects.filter(**filter)
|
||||||
|
|
||||||
|
x_axis = analytic_view.query_dict.get("x_axis", False)
|
||||||
|
y_axis = analytic_view.query_dict.get("y_axis", False)
|
||||||
|
|
||||||
|
if not x_axis or not y_axis:
|
||||||
|
return Response(
|
||||||
|
{"error": "x-axis and y-axis dimensions are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
segment = request.GET.get("segment", False)
|
||||||
|
distribution = build_graph_plot(
|
||||||
|
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||||
|
)
|
||||||
|
total_issues = queryset.count()
|
||||||
|
return Response(
|
||||||
|
{"total": total_issues, "distribution": distribution},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except AnalyticView.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Analytic View Does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportAnalyticsEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkSpaceAdminPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def post(self, request, slug):
|
||||||
|
try:
|
||||||
|
x_axis = request.data.get("x_axis", False)
|
||||||
|
y_axis = request.data.get("y_axis", False)
|
||||||
|
|
||||||
|
if not x_axis or not y_axis:
|
||||||
|
return Response(
|
||||||
|
{"error": "x-axis and y-axis dimensions are required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
analytic_export_task.delay(
|
||||||
|
email=request.user.email, data=request.data, slug=slug
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"message": f"Once the export is ready it will be emailed to you at {str(request.user.email)}"
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultAnalyticsEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkSpaceAdminPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def get(self, request, slug):
|
||||||
|
try:
|
||||||
|
filters = issue_filters(request.GET, "GET")
|
||||||
|
|
||||||
|
queryset = Issue.objects.filter(workspace__slug=slug, **filters)
|
||||||
|
|
||||||
|
total_issues = queryset.count()
|
||||||
|
|
||||||
|
total_issues_classified = (
|
||||||
|
queryset.annotate(state_group=F("state__group"))
|
||||||
|
.values("state_group")
|
||||||
|
.annotate(state_count=Count("state_group"))
|
||||||
|
.order_by("state_group")
|
||||||
|
)
|
||||||
|
|
||||||
|
open_issues = queryset.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"]
|
||||||
|
).count()
|
||||||
|
|
||||||
|
open_issues_classified = (
|
||||||
|
queryset.filter(state__group__in=["backlog", "unstarted", "started"])
|
||||||
|
.annotate(state_group=F("state__group"))
|
||||||
|
.values("state_group")
|
||||||
|
.annotate(state_count=Count("state_group"))
|
||||||
|
.order_by("state_group")
|
||||||
|
)
|
||||||
|
|
||||||
|
issue_completed_month_wise = (
|
||||||
|
queryset.filter(completed_at__isnull=False)
|
||||||
|
.annotate(month=ExtractMonth("completed_at"))
|
||||||
|
.values("month")
|
||||||
|
.annotate(count=Count("*"))
|
||||||
|
.order_by("month")
|
||||||
|
)
|
||||||
|
most_issue_created_user = (
|
||||||
|
queryset.exclude(created_by=None)
|
||||||
|
.values("created_by__first_name", "created_by__last_name", "created_by__avatar", "created_by__email")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
)[:5]
|
||||||
|
|
||||||
|
most_issue_closed_user = (
|
||||||
|
queryset.filter(completed_at__isnull=False, assignees__isnull=False)
|
||||||
|
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
)[:5]
|
||||||
|
|
||||||
|
pending_issue_user = (
|
||||||
|
queryset.filter(completed_at__isnull=True)
|
||||||
|
.values("assignees__first_name", "assignees__last_name", "assignees__avatar", "assignees__email")
|
||||||
|
.annotate(count=Count("id"))
|
||||||
|
.order_by("-count")
|
||||||
|
)
|
||||||
|
|
||||||
|
open_estimate_sum = (
|
||||||
|
queryset.filter(
|
||||||
|
state__group__in=["backlog", "unstarted", "started"]
|
||||||
|
).aggregate(open_estimate_sum=Sum("estimate_point"))
|
||||||
|
)["open_estimate_sum"]
|
||||||
|
print(open_estimate_sum)
|
||||||
|
|
||||||
|
total_estimate_sum = queryset.aggregate(
|
||||||
|
total_estimate_sum=Sum("estimate_point")
|
||||||
|
)["total_estimate_sum"]
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"total_issues": total_issues,
|
||||||
|
"total_issues_classified": total_issues_classified,
|
||||||
|
"open_issues": open_issues,
|
||||||
|
"open_issues_classified": open_issues_classified,
|
||||||
|
"issue_completed_month_wise": issue_completed_month_wise,
|
||||||
|
"most_issue_created_user": most_issue_created_user,
|
||||||
|
"most_issue_closed_user": most_issue_closed_user,
|
||||||
|
"pending_issue_user": pending_issue_user,
|
||||||
|
"open_estimate_sum": open_estimate_sum,
|
||||||
|
"total_estimate_sum": total_estimate_sum,
|
||||||
|
},
|
||||||
|
status=status.HTTP_200_OK,
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
@ -3,7 +3,7 @@ from rest_framework import status
|
|||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.parsers import MultiPartParser, FormParser
|
from rest_framework.parsers import MultiPartParser, FormParser
|
||||||
from sentry_sdk import capture_exception
|
from sentry_sdk import capture_exception
|
||||||
|
from django.conf import settings
|
||||||
# Module imports
|
# Module imports
|
||||||
from .base import BaseAPIView
|
from .base import BaseAPIView
|
||||||
from plane.db.models import FileAsset
|
from plane.db.models import FileAsset
|
||||||
@ -34,7 +34,10 @@ class FileAssetEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
serializer.save(workspace_id=request.user.last_workspace_id)
|
serializer.save(workspace_id=request.user.last_workspace_id)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
response_data = serializer.data
|
||||||
|
if settings.DOCKERIZED and "minio:9000" in response_data["asset"]:
|
||||||
|
response_data["asset"] = response_data["asset"].replace("minio:9000", settings.WEB_URL)
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
@ -82,7 +85,10 @@ class UserAssetsEndpoint(BaseAPIView):
|
|||||||
serializer = FileAssetSerializer(data=request.data)
|
serializer = FileAssetSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save()
|
serializer.save()
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
response_data = serializer.data
|
||||||
|
if settings.DOCKERIZED and "minio:9000" in response_data["asset"]:
|
||||||
|
response_data["asset"] = response_data["asset"].replace("minio:9000", settings.WEB_URL)
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
|
@ -3,7 +3,17 @@ import json
|
|||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef, Count, Prefetch
|
from django.db.models import (
|
||||||
|
OuterRef,
|
||||||
|
Func,
|
||||||
|
F,
|
||||||
|
Q,
|
||||||
|
Exists,
|
||||||
|
OuterRef,
|
||||||
|
Count,
|
||||||
|
Prefetch,
|
||||||
|
Sum,
|
||||||
|
)
|
||||||
from django.core import serializers
|
from django.core import serializers
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@ -24,13 +34,13 @@ from plane.api.serializers import (
|
|||||||
)
|
)
|
||||||
from plane.api.permissions import ProjectEntityPermission
|
from plane.api.permissions import ProjectEntityPermission
|
||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
|
User,
|
||||||
Cycle,
|
Cycle,
|
||||||
CycleIssue,
|
CycleIssue,
|
||||||
Issue,
|
Issue,
|
||||||
CycleFavorite,
|
CycleFavorite,
|
||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
User,
|
|
||||||
)
|
)
|
||||||
from plane.bgtasks.issue_activites_task import issue_activity
|
from plane.bgtasks.issue_activites_task import issue_activity
|
||||||
from plane.utils.grouper import group_results
|
from plane.utils.grouper import group_results
|
||||||
@ -119,6 +129,25 @@ class CycleViewSet(BaseViewSet):
|
|||||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
"issue_cycle__issue__assignees",
|
||||||
|
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
||||||
|
)
|
||||||
|
)
|
||||||
.order_by("-is_favorite", "name")
|
.order_by("-is_favorite", "name")
|
||||||
.distinct()
|
.distinct()
|
||||||
)
|
)
|
||||||
@ -414,26 +443,22 @@ class CycleDateCheckEndpoint(BaseAPIView):
|
|||||||
try:
|
try:
|
||||||
start_date = request.data.get("start_date", False)
|
start_date = request.data.get("start_date", False)
|
||||||
end_date = request.data.get("end_date", False)
|
end_date = request.data.get("end_date", False)
|
||||||
cycle_id = request.data.get("cycle_id", False)
|
cycle_id = request.data.get("cycle_id")
|
||||||
|
|
||||||
if not start_date or not end_date:
|
if not start_date or not end_date:
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Start date and end date are required"},
|
{"error": "Start date and end date both are required"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
cycles = Cycle.objects.filter(
|
cycles = Cycle.objects.filter(
|
||||||
|
Q(workspace__slug=slug)
|
||||||
|
& Q(project_id=project_id)
|
||||||
|
& (
|
||||||
Q(start_date__lte=start_date, end_date__gte=start_date)
|
Q(start_date__lte=start_date, end_date__gte=start_date)
|
||||||
| Q(start_date__lte=end_date, end_date__gte=end_date)
|
| Q(start_date__lte=end_date, end_date__gte=end_date)
|
||||||
| Q(start_date__gte=start_date, end_date__lte=end_date),
|
| Q(start_date__gte=start_date, end_date__lte=end_date)
|
||||||
workspace__slug=slug,
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if cycle_id:
|
|
||||||
cycles = cycles.filter(
|
|
||||||
~Q(pk=cycle_id),
|
|
||||||
)
|
)
|
||||||
|
).exclude(pk=cycle_id)
|
||||||
|
|
||||||
if cycles.exists():
|
if cycles.exists():
|
||||||
return Response(
|
return Response(
|
||||||
@ -508,10 +533,25 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
|||||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"issue_cycle__issue__assignees",
|
"issue_cycle__issue__assignees",
|
||||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by("name", "-is_favorite")
|
.order_by("name", "-is_favorite")
|
||||||
@ -558,10 +598,25 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
|||||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"issue_cycle__issue__assignees",
|
"issue_cycle__issue__assignees",
|
||||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by("name", "-is_favorite")
|
.order_by("name", "-is_favorite")
|
||||||
@ -576,7 +631,7 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
capture_exception(e)
|
||||||
return Response(
|
return Response(
|
||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
@ -637,10 +692,25 @@ class CompletedCyclesEndpoint(BaseAPIView):
|
|||||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"issue_cycle__issue__assignees",
|
"issue_cycle__issue__assignees",
|
||||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by("name", "-is_favorite")
|
.order_by("name", "-is_favorite")
|
||||||
@ -718,10 +788,25 @@ class DraftCyclesEndpoint(BaseAPIView):
|
|||||||
filter=Q(issue_cycle__issue__state__group="backlog"),
|
filter=Q(issue_cycle__issue__state__group="backlog"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
.annotate(total_estimates=Sum("issue_cycle__issue__estimate_point"))
|
||||||
|
.annotate(
|
||||||
|
completed_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="completed"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
started_estimates=Sum(
|
||||||
|
"issue_cycle__issue__estimate_point",
|
||||||
|
filter=Q(issue_cycle__issue__state__group="started"),
|
||||||
|
)
|
||||||
|
)
|
||||||
.prefetch_related(
|
.prefetch_related(
|
||||||
Prefetch(
|
Prefetch(
|
||||||
"issue_cycle__issue__assignees",
|
"issue_cycle__issue__assignees",
|
||||||
queryset=User.objects.only("avatar", "first_name", "id").distinct(),
|
queryset=User.objects.only(
|
||||||
|
"avatar", "first_name", "id"
|
||||||
|
).distinct(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.order_by("name", "-is_favorite")
|
.order_by("name", "-is_favorite")
|
||||||
|
@ -363,6 +363,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
|||||||
start_date=issue_data.get("start_date", None),
|
start_date=issue_data.get("start_date", None),
|
||||||
target_date=issue_data.get("target_date", None),
|
target_date=issue_data.get("target_date", None),
|
||||||
priority=issue_data.get("priority", None),
|
priority=issue_data.get("priority", None),
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -400,7 +401,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for label_id in labels_list
|
for label_id in labels_list
|
||||||
]
|
]
|
||||||
@ -420,7 +420,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for assignee_id in assignees_list
|
for assignee_id in assignees_list
|
||||||
]
|
]
|
||||||
@ -439,6 +438,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
|||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
comment=f"{request.user.email} importer the issue from {service}",
|
comment=f"{request.user.email} importer the issue from {service}",
|
||||||
verb="created",
|
verb="created",
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
for issue in issues
|
for issue in issues
|
||||||
],
|
],
|
||||||
@ -457,7 +457,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for comment in comments_list
|
for comment in comments_list
|
||||||
]
|
]
|
||||||
@ -474,7 +473,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for issue, issue_data in zip(issues, issues_data)
|
for issue, issue_data in zip(issues, issues_data)
|
||||||
]
|
]
|
||||||
@ -512,7 +510,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for module in modules_data
|
for module in modules_data
|
||||||
],
|
],
|
||||||
@ -536,7 +533,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for module, module_data in zip(modules, modules_data)
|
for module, module_data in zip(modules, modules_data)
|
||||||
],
|
],
|
||||||
@ -554,7 +550,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
workspace_id=project.workspace_id,
|
workspace_id=project.workspace_id,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
updated_by=request.user,
|
|
||||||
)
|
)
|
||||||
for issue in module_issues_list
|
for issue in module_issues_list
|
||||||
]
|
]
|
||||||
|
@ -9,7 +9,7 @@ from django.core.serializers.json import DjangoJSONEncoder
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.gzip import gzip_page
|
from django.views.decorators.gzip import gzip_page
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
|
from django.conf import settings
|
||||||
# Third Party imports
|
# Third Party imports
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -788,6 +788,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||||||
serializer = IssueAttachmentSerializer(data=request.data)
|
serializer = IssueAttachmentSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
serializer.save(project_id=project_id, issue_id=issue_id)
|
serializer.save(project_id=project_id, issue_id=issue_id)
|
||||||
|
response_data = serializer.data
|
||||||
|
if settings.DOCKERIZED and "minio:9000" in response_data["asset"]:
|
||||||
|
response_data["asset"] = response_data["asset"].replace("minio:9000", settings.WEB_URL)
|
||||||
issue_activity.delay(
|
issue_activity.delay(
|
||||||
type="attachment.activity.created",
|
type="attachment.activity.created",
|
||||||
requested_data=None,
|
requested_data=None,
|
||||||
@ -799,7 +802,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
|||||||
cls=DjangoJSONEncoder,
|
cls=DjangoJSONEncoder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
return Response(response_data, status=status.HTTP_201_CREATED)
|
||||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
capture_exception(e)
|
capture_exception(e)
|
||||||
|
@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.db.models import Q, Exists, OuterRef
|
from django.db.models import Q, Exists, OuterRef, Func, F
|
||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
@ -46,6 +46,8 @@ from plane.db.models import (
|
|||||||
ProjectMemberInvite,
|
ProjectMemberInvite,
|
||||||
User,
|
User,
|
||||||
ProjectIdentifier,
|
ProjectIdentifier,
|
||||||
|
Cycle,
|
||||||
|
Module,
|
||||||
)
|
)
|
||||||
from plane.bgtasks.project_invitation_task import project_invitation
|
from plane.bgtasks.project_invitation_task import project_invitation
|
||||||
|
|
||||||
@ -92,6 +94,26 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
self.get_queryset()
|
self.get_queryset()
|
||||||
.annotate(is_favorite=Exists(subquery))
|
.annotate(is_favorite=Exists(subquery))
|
||||||
.order_by("-is_favorite", "name")
|
.order_by("-is_favorite", "name")
|
||||||
|
.annotate(
|
||||||
|
total_members=ProjectMember.objects.filter(
|
||||||
|
project_id=OuterRef("id")
|
||||||
|
)
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_cycles=Cycle.objects.filter(project_id=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
|
.annotate(
|
||||||
|
total_modules=Module.objects.filter(project_id=OuterRef("id"))
|
||||||
|
.order_by()
|
||||||
|
.annotate(count=Func(F("id"), function="Count"))
|
||||||
|
.values("count")
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return Response(ProjectDetailSerializer(projects, many=True).data)
|
return Response(ProjectDetailSerializer(projects, many=True).data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -161,6 +183,7 @@ class ProjectViewSet(BaseViewSet):
|
|||||||
workspace=serializer.instance.workspace,
|
workspace=serializer.instance.workspace,
|
||||||
group=state["group"],
|
group=state["group"],
|
||||||
default=state.get("default", False),
|
default=state.get("default", False),
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
for state in states
|
for state in states
|
||||||
]
|
]
|
||||||
@ -344,6 +367,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
|
|||||||
workspace=invitation.project.workspace,
|
workspace=invitation.project.workspace,
|
||||||
member=request.user,
|
member=request.user,
|
||||||
role=invitation.role,
|
role=invitation.role,
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
for invitation in project_invitations
|
for invitation in project_invitations
|
||||||
]
|
]
|
||||||
@ -385,6 +409,41 @@ class ProjectMemberViewSet(BaseViewSet):
|
|||||||
.select_related("workspace", "workspace__owner")
|
.select_related("workspace", "workspace__owner")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def partial_update(self, request, slug, project_id, pk):
|
||||||
|
try:
|
||||||
|
project_member = ProjectMember.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
|
||||||
|
if request.user.id == project_member.member_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "You cannot update your own role"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.data.get("role", 10) > project_member.role:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "You cannot update a role that is higher than your own role"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = ProjectMemberSerializer(
|
||||||
|
project_member, data=request.data, partial=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except ProjectMember.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Project Member does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AddMemberToProjectEndpoint(BaseAPIView):
|
class AddMemberToProjectEndpoint(BaseAPIView):
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
@ -465,6 +524,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
|
|||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
member_id=member,
|
member_id=member,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -612,6 +672,7 @@ class ProjectJoinEndpoint(BaseAPIView):
|
|||||||
if workspace_role >= 15
|
if workspace_role >= 15
|
||||||
else (15 if workspace_role == 10 else workspace_role),
|
else (15 if workspace_role == 10 else workspace_role),
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
for project_id in project_ids
|
for project_id in project_ids
|
||||||
],
|
],
|
||||||
|
@ -18,10 +18,6 @@ from plane.api.permissions import ProjectEntityPermission
|
|||||||
from plane.db.models import (
|
from plane.db.models import (
|
||||||
IssueView,
|
IssueView,
|
||||||
Issue,
|
Issue,
|
||||||
IssueBlocker,
|
|
||||||
IssueLink,
|
|
||||||
CycleIssue,
|
|
||||||
ModuleIssue,
|
|
||||||
IssueViewFavorite,
|
IssueViewFavorite,
|
||||||
)
|
)
|
||||||
from plane.utils.issue_filters import issue_filters
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
@ -223,6 +223,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
|
|||||||
algorithm="HS256",
|
algorithm="HS256",
|
||||||
),
|
),
|
||||||
role=email.get("role", 10),
|
role=email.get("role", 10),
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
@ -381,6 +382,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
|
|||||||
workspace=invitation.workspace,
|
workspace=invitation.workspace,
|
||||||
member=request.user,
|
member=request.user,
|
||||||
role=invitation.role,
|
role=invitation.role,
|
||||||
|
created_by=request.user,
|
||||||
)
|
)
|
||||||
for invitation in workspace_invitations
|
for invitation in workspace_invitations
|
||||||
],
|
],
|
||||||
@ -421,6 +423,43 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
|||||||
.select_related("member")
|
.select_related("member")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def partial_update(self, request, slug, pk):
|
||||||
|
try:
|
||||||
|
workspace_member = WorkspaceMember.objects.get(pk=pk, workspace__slug=slug)
|
||||||
|
if request.user.id == workspace_member.member_id:
|
||||||
|
return Response(
|
||||||
|
{"error": "You cannot update your own role"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
if request.data.get("role", 10) > workspace_member.role:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"error": "You cannot update a role that is higher than your own role"
|
||||||
|
},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
serializer = WorkSpaceMemberSerializer(
|
||||||
|
workspace_member, data=request.data, partial=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save()
|
||||||
|
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||||
|
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
except WorkspaceMember.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"error": "Workspace Member does not exist"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
capture_exception(e)
|
||||||
|
return Response(
|
||||||
|
{"error": "Something went wrong please try again later"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TeamMemberViewSet(BaseViewSet):
|
class TeamMemberViewSet(BaseViewSet):
|
||||||
serializer_class = TeamSerializer
|
serializer_class = TeamSerializer
|
||||||
@ -783,4 +822,3 @@ class WorkspaceThemeViewSet(BaseViewSet):
|
|||||||
{"error": "Something went wrong please try again later"},
|
{"error": "Something went wrong please try again later"},
|
||||||
status=status.HTTP_400_BAD_REQUEST,
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
174
apiserver/plane/bgtasks/analytic_plot_export.py
Normal file
174
apiserver/plane/bgtasks/analytic_plot_export.py
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# Python imports
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
# Django imports
|
||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# Third party imports
|
||||||
|
from celery import shared_task
|
||||||
|
from sentry_sdk import capture_exception
|
||||||
|
|
||||||
|
# Module imports
|
||||||
|
from plane.db.models import Issue
|
||||||
|
from plane.utils.analytics_plot import build_graph_plot
|
||||||
|
from plane.utils.issue_filters import issue_filters
|
||||||
|
|
||||||
|
row_mapping = {
|
||||||
|
"state__name": "State",
|
||||||
|
"state__group": "State Group",
|
||||||
|
"labels__name": "Label",
|
||||||
|
"assignees__email": "Assignee Name",
|
||||||
|
"start_date": "Start Date",
|
||||||
|
"target_date": "Due Date",
|
||||||
|
"completed_at": "Completed At",
|
||||||
|
"created_at": "Created At",
|
||||||
|
"issue_count": "Issue Count",
|
||||||
|
"priority": "Priority",
|
||||||
|
"estimate": "Estimate",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def analytic_export_task(email, data, slug):
|
||||||
|
try:
|
||||||
|
filters = issue_filters(data, "POST")
|
||||||
|
queryset = Issue.objects.filter(**filters, workspace__slug=slug)
|
||||||
|
|
||||||
|
x_axis = data.get("x_axis", False)
|
||||||
|
y_axis = data.get("y_axis", False)
|
||||||
|
segment = data.get("segment", False)
|
||||||
|
|
||||||
|
distribution = build_graph_plot(
|
||||||
|
queryset=queryset, x_axis=x_axis, y_axis=y_axis, segment=segment
|
||||||
|
)
|
||||||
|
|
||||||
|
key = "count" if y_axis == "issue_count" else "estimate"
|
||||||
|
|
||||||
|
segmented = segment
|
||||||
|
|
||||||
|
assignee_details = {}
|
||||||
|
if x_axis in ["assignees__email"] or segment in ["assignees__email"]:
|
||||||
|
assignee_details = (
|
||||||
|
Issue.objects.filter(workspace__slug=slug, **filters, assignees__avatar__isnull=False)
|
||||||
|
.order_by("assignees__id")
|
||||||
|
.distinct("assignees__id")
|
||||||
|
.values("assignees__avatar", "assignees__email", "assignees__first_name", "assignees__last_name")
|
||||||
|
)
|
||||||
|
|
||||||
|
if segment:
|
||||||
|
segment_zero = []
|
||||||
|
for item in distribution:
|
||||||
|
current_dict = distribution.get(item)
|
||||||
|
for current in current_dict:
|
||||||
|
segment_zero.append(current.get("segment"))
|
||||||
|
|
||||||
|
segment_zero = list(set(segment_zero))
|
||||||
|
row_zero = (
|
||||||
|
[
|
||||||
|
row_mapping.get(x_axis, "X-Axis"),
|
||||||
|
]
|
||||||
|
+ [
|
||||||
|
row_mapping.get(y_axis, "Y-Axis"),
|
||||||
|
]
|
||||||
|
+ segment_zero
|
||||||
|
)
|
||||||
|
rows = []
|
||||||
|
for item in distribution:
|
||||||
|
generated_row = [
|
||||||
|
item,
|
||||||
|
]
|
||||||
|
|
||||||
|
data = distribution.get(item)
|
||||||
|
# Add y axis values
|
||||||
|
generated_row.append(sum(obj.get(key) for obj in data if obj.get(key, None) is not None))
|
||||||
|
|
||||||
|
for segment in segment_zero:
|
||||||
|
value = [x for x in data if x.get("segment") == segment]
|
||||||
|
if len(value):
|
||||||
|
generated_row.append(value[0].get(key))
|
||||||
|
else:
|
||||||
|
generated_row.append("0")
|
||||||
|
# x-axis replacement for names
|
||||||
|
if x_axis in ["assignees__email"]:
|
||||||
|
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||||
|
if len(assignee):
|
||||||
|
generated_row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||||
|
rows.append(tuple(generated_row))
|
||||||
|
|
||||||
|
# If segment is ["assignees__email"] then replace segment_zero rows with first and last names
|
||||||
|
if segmented in ["assignees__email"]:
|
||||||
|
for index, segm in enumerate(row_zero[2:]):
|
||||||
|
# find the name of the user
|
||||||
|
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(segm)]
|
||||||
|
if len(assignee):
|
||||||
|
row_zero[index] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||||
|
|
||||||
|
rows = [tuple(row_zero)] + rows
|
||||||
|
csv_buffer = io.StringIO()
|
||||||
|
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||||
|
|
||||||
|
# Write CSV data to the buffer
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
subject = "Your Export is ready"
|
||||||
|
|
||||||
|
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||||
|
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
csv_buffer.seek(0)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject, text_content, settings.EMAIL_FROM, [email]
|
||||||
|
)
|
||||||
|
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
|
||||||
|
msg.send(fail_silently=False)
|
||||||
|
|
||||||
|
else:
|
||||||
|
row_zero = [
|
||||||
|
row_mapping.get(x_axis, "X-Axis"),
|
||||||
|
row_mapping.get(y_axis, "Y-Axis"),
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for item in distribution:
|
||||||
|
row = [
|
||||||
|
item,
|
||||||
|
distribution.get(item)[0].get("count")
|
||||||
|
if y_axis == "issue_count"
|
||||||
|
else distribution.get(item)[0].get("estimate "),
|
||||||
|
]
|
||||||
|
# x-axis replacement to names
|
||||||
|
if x_axis in ["assignees__email"]:
|
||||||
|
assignee = [user for user in assignee_details if str(user.get("assignees__email")) == str(item)]
|
||||||
|
if len(assignee):
|
||||||
|
row[0] = str(assignee[0].get("assignees__first_name")) + " " + str(assignee[0].get("assignees__last_name"))
|
||||||
|
|
||||||
|
rows.append(tuple(row))
|
||||||
|
rows = [tuple(row_zero)] + rows
|
||||||
|
csv_buffer = io.StringIO()
|
||||||
|
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||||
|
|
||||||
|
# Write CSV data to the buffer
|
||||||
|
for row in rows:
|
||||||
|
writer.writerow(row)
|
||||||
|
|
||||||
|
subject = "Your Export is ready"
|
||||||
|
|
||||||
|
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||||
|
|
||||||
|
text_content = strip_tags(html_content)
|
||||||
|
|
||||||
|
csv_buffer.seek(0)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject, text_content, settings.EMAIL_FROM, [email]
|
||||||
|
)
|
||||||
|
msg.attach(f"{slug}-analytics.csv", csv_buffer.read())
|
||||||
|
msg.send(fail_silently=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(e)
|
||||||
|
capture_exception(e)
|
||||||
|
return
|
@ -27,7 +27,7 @@ from plane.db.models import (
|
|||||||
User,
|
User,
|
||||||
)
|
)
|
||||||
from .workspace_invitation_task import workspace_invitation
|
from .workspace_invitation_task import workspace_invitation
|
||||||
from plane.bgtasks.user_welcome_task import send_welcome_email
|
from plane.bgtasks.user_welcome_task import send_welcome_slack
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@ -58,7 +58,7 @@ def service_importer(service, importer_id):
|
|||||||
)
|
)
|
||||||
|
|
||||||
[
|
[
|
||||||
send_welcome_email.delay(
|
send_welcome_slack.delay(
|
||||||
str(user.id),
|
str(user.id),
|
||||||
True,
|
True,
|
||||||
f"{user.email} was imported to Plane from {service}",
|
f"{user.email} was imported to Plane from {service}",
|
||||||
@ -78,7 +78,11 @@ def service_importer(service, importer_id):
|
|||||||
# Add new users to Workspace and project automatically
|
# Add new users to Workspace and project automatically
|
||||||
WorkspaceMember.objects.bulk_create(
|
WorkspaceMember.objects.bulk_create(
|
||||||
[
|
[
|
||||||
WorkspaceMember(member=user, workspace_id=importer.workspace_id)
|
WorkspaceMember(
|
||||||
|
member=user,
|
||||||
|
workspace_id=importer.workspace_id,
|
||||||
|
created_by=importer.created_by,
|
||||||
|
)
|
||||||
for user in workspace_users
|
for user in workspace_users
|
||||||
],
|
],
|
||||||
batch_size=100,
|
batch_size=100,
|
||||||
@ -91,6 +95,7 @@ def service_importer(service, importer_id):
|
|||||||
project_id=importer.project_id,
|
project_id=importer.project_id,
|
||||||
workspace_id=importer.workspace_id,
|
workspace_id=importer.workspace_id,
|
||||||
member=user,
|
member=user,
|
||||||
|
created_by=importer.created_by,
|
||||||
)
|
)
|
||||||
for user in workspace_users
|
for user in workspace_users
|
||||||
],
|
],
|
||||||
|
@ -136,7 +136,6 @@ def track_priority(
|
|||||||
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
print(issue_activities)
|
|
||||||
|
|
||||||
|
|
||||||
# Track chnages in state of the issue
|
# Track chnages in state of the issue
|
||||||
|
@ -1,8 +1,5 @@
|
|||||||
# Django imports
|
# Django imports
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import EmailMultiAlternatives
|
|
||||||
from django.template.loader import render_to_string
|
|
||||||
from django.utils.html import strip_tags
|
|
||||||
|
|
||||||
# Third party imports
|
# Third party imports
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
@ -15,31 +12,11 @@ from plane.db.models import User
|
|||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
def send_welcome_email(user_id, created, message):
|
def send_welcome_slack(user_id, created, message):
|
||||||
try:
|
try:
|
||||||
instance = User.objects.get(pk=user_id)
|
instance = User.objects.get(pk=user_id)
|
||||||
|
|
||||||
if created and not instance.is_bot:
|
if created and not instance.is_bot:
|
||||||
first_name = instance.first_name.capitalize()
|
|
||||||
to_email = instance.email
|
|
||||||
from_email_string = settings.EMAIL_FROM
|
|
||||||
|
|
||||||
subject = f"Welcome to Plane ✈️!"
|
|
||||||
|
|
||||||
context = {"first_name": first_name, "email": instance.email}
|
|
||||||
|
|
||||||
html_content = render_to_string(
|
|
||||||
"emails/auth/user_welcome_email.html", context
|
|
||||||
)
|
|
||||||
|
|
||||||
text_content = strip_tags(html_content)
|
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(
|
|
||||||
subject, text_content, from_email_string, [to_email]
|
|
||||||
)
|
|
||||||
msg.attach_alternative(html_content, "text/html")
|
|
||||||
msg.send()
|
|
||||||
|
|
||||||
# Send message on slack as well
|
# Send message on slack as well
|
||||||
if settings.SLACK_BOT_TOKEN:
|
if settings.SLACK_BOT_TOKEN:
|
||||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||||
|
37
apiserver/plane/db/migrations/0031_analyticview.py
Normal file
37
apiserver/plane/db/migrations/0031_analyticview.py
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# Generated by Django 3.2.18 on 2023-05-12 11:31
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0030_alter_estimatepoint_unique_together'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AnalyticView',
|
||||||
|
fields=[
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')),
|
||||||
|
('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('query', models.JSONField()),
|
||||||
|
('query_dict', models.JSONField(default=dict)),
|
||||||
|
('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||||
|
('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='analyticview_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')),
|
||||||
|
('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analytics', to='db.workspace')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Analytic',
|
||||||
|
'verbose_name_plural': 'Analytics',
|
||||||
|
'db_table': 'analytic_views',
|
||||||
|
'ordering': ('-created_at',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
23
apiserver/plane/db/migrations/0032_auto_20230520_2015.py
Normal file
23
apiserver/plane/db/migrations/0032_auto_20230520_2015.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 3.2.19 on 2023-05-20 14:45
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('db', '0031_analyticview'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='project',
|
||||||
|
old_name='icon',
|
||||||
|
new_name='emoji',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='project',
|
||||||
|
name='icon_prop',
|
||||||
|
field=models.JSONField(null=True),
|
||||||
|
),
|
||||||
|
]
|
@ -67,3 +67,5 @@ from .importer import Importer
|
|||||||
from .page import Page, PageBlock, PageFavorite, PageLabel
|
from .page import Page, PageBlock, PageFavorite, PageLabel
|
||||||
|
|
||||||
from .estimate import Estimate, EstimatePoint
|
from .estimate import Estimate, EstimatePoint
|
||||||
|
|
||||||
|
from .analytic import AnalyticView
|
25
apiserver/plane/db/models/analytic.py
Normal file
25
apiserver/plane/db/models/analytic.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Django models
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from .base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticView(BaseModel):
|
||||||
|
workspace = models.ForeignKey(
|
||||||
|
"db.Workspace", related_name="analytics", on_delete=models.CASCADE
|
||||||
|
)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
query = models.JSONField()
|
||||||
|
query_dict = models.JSONField(default=dict)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "Analytic"
|
||||||
|
verbose_name_plural = "Analytics"
|
||||||
|
db_table = "analytic_views"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
"""Return name of the analytic view"""
|
||||||
|
return f"{self.name} <{self.workspace.name}>"
|
@ -4,6 +4,7 @@ from uuid import uuid4
|
|||||||
# Django import
|
# Django import
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
# Module import
|
# Module import
|
||||||
from . import BaseModel
|
from . import BaseModel
|
||||||
@ -16,6 +17,8 @@ def get_upload_path(instance, filename):
|
|||||||
|
|
||||||
|
|
||||||
def file_size(value):
|
def file_size(value):
|
||||||
|
# File limit check is only for cloud hosted
|
||||||
|
if not settings.DOCKERIZED:
|
||||||
limit = 5 * 1024 * 1024
|
limit = 5 * 1024 * 1024
|
||||||
if value.size > limit:
|
if value.size > limit:
|
||||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||||
|
@ -85,8 +85,13 @@ class Issue(ProjectBaseModel):
|
|||||||
).first()
|
).first()
|
||||||
# if there is no default state assign any random state
|
# if there is no default state assign any random state
|
||||||
if default_state is None:
|
if default_state is None:
|
||||||
self.state = State.objects.filter(project=self.project).first()
|
random_state = State.objects.filter(project=self.project).first()
|
||||||
|
self.state = random_state
|
||||||
|
if random_state.group == "started":
|
||||||
|
self.start_date = timezone.now().date()
|
||||||
else:
|
else:
|
||||||
|
if default_state.group == "started":
|
||||||
|
self.start_date = timezone.now().date()
|
||||||
self.state = default_state
|
self.state = default_state
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
@ -94,18 +99,15 @@ class Issue(ProjectBaseModel):
|
|||||||
try:
|
try:
|
||||||
from plane.db.models import State, PageBlock
|
from plane.db.models import State, PageBlock
|
||||||
|
|
||||||
# Get the completed states of the project
|
|
||||||
completed_states = State.objects.filter(
|
|
||||||
group="completed", project=self.project
|
|
||||||
).values_list("pk", flat=True)
|
|
||||||
# Check if the current issue state and completed state id are same
|
# Check if the current issue state and completed state id are same
|
||||||
if self.state.id in completed_states:
|
if self.state.group == "completed":
|
||||||
self.completed_at = timezone.now()
|
self.completed_at = timezone.now()
|
||||||
# check if there are any page blocks
|
# check if there are any page blocks
|
||||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||||
completed_at=timezone.now()
|
completed_at=timezone.now()
|
||||||
)
|
)
|
||||||
|
elif self.state.group == "started":
|
||||||
|
self.start_date = timezone.now().date()
|
||||||
else:
|
else:
|
||||||
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
PageBlock.objects.filter(issue_id=self.id).filter().update(
|
||||||
completed_at=None
|
completed_at=None
|
||||||
@ -116,7 +118,6 @@ class Issue(ProjectBaseModel):
|
|||||||
pass
|
pass
|
||||||
if self._state.adding:
|
if self._state.adding:
|
||||||
# Get the maximum display_id value from the database
|
# Get the maximum display_id value from the database
|
||||||
|
|
||||||
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
|
||||||
largest=models.Max("sequence")
|
largest=models.Max("sequence")
|
||||||
)["largest"]
|
)["largest"]
|
||||||
@ -131,6 +132,9 @@ class Issue(ProjectBaseModel):
|
|||||||
if largest_sort_order is not None:
|
if largest_sort_order is not None:
|
||||||
self.sort_order = largest_sort_order + 10000
|
self.sort_order = largest_sort_order + 10000
|
||||||
|
|
||||||
|
# If adding it to started state
|
||||||
|
if self.state.group == "started":
|
||||||
|
self.start_date = timezone.now().date()
|
||||||
# Strip the html tags using html parser
|
# Strip the html tags using html parser
|
||||||
self.description_stripped = (
|
self.description_stripped = (
|
||||||
None
|
None
|
||||||
@ -206,6 +210,8 @@ def get_upload_path(instance, filename):
|
|||||||
|
|
||||||
|
|
||||||
def file_size(value):
|
def file_size(value):
|
||||||
|
# File limit check is only for cloud hosted
|
||||||
|
if not settings.DOCKERIZED:
|
||||||
limit = 5 * 1024 * 1024
|
limit = 5 * 1024 * 1024
|
||||||
if value.size > limit:
|
if value.size > limit:
|
||||||
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
raise ValidationError("File too large. Size should not exceed 5 MB.")
|
||||||
|
@ -63,7 +63,8 @@ class Project(BaseModel):
|
|||||||
null=True,
|
null=True,
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
icon = models.CharField(max_length=255, null=True, blank=True)
|
emoji = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
icon_prop = models.JSONField(null=True)
|
||||||
module_view = models.BooleanField(default=True)
|
module_view = models.BooleanField(default=True)
|
||||||
cycle_view = models.BooleanField(default=True)
|
cycle_view = models.BooleanField(default=True)
|
||||||
issue_views_view = models.BooleanField(default=True)
|
issue_views_view = models.BooleanField(default=True)
|
||||||
|
@ -104,29 +104,9 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def send_welcome_email(sender, instance, created, **kwargs):
|
def send_welcome_slack(sender, instance, created, **kwargs):
|
||||||
try:
|
try:
|
||||||
if created and not instance.is_bot:
|
if created and not instance.is_bot:
|
||||||
first_name = instance.first_name.capitalize()
|
|
||||||
to_email = instance.email
|
|
||||||
from_email_string = settings.EMAIL_FROM
|
|
||||||
|
|
||||||
subject = f"Welcome to Plane ✈️!"
|
|
||||||
|
|
||||||
context = {"first_name": first_name, "email": instance.email}
|
|
||||||
|
|
||||||
html_content = render_to_string(
|
|
||||||
"emails/auth/user_welcome_email.html", context
|
|
||||||
)
|
|
||||||
|
|
||||||
text_content = strip_tags(html_content)
|
|
||||||
|
|
||||||
msg = EmailMultiAlternatives(
|
|
||||||
subject, text_content, from_email_string, [to_email]
|
|
||||||
)
|
|
||||||
msg.attach_alternative(html_content, "text/html")
|
|
||||||
msg.send()
|
|
||||||
|
|
||||||
# Send message on slack as well
|
# Send message on slack as well
|
||||||
if settings.SLACK_BOT_TOKEN:
|
if settings.SLACK_BOT_TOKEN:
|
||||||
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
client = WebClient(token=settings.SLACK_BOT_TOKEN)
|
||||||
|
@ -25,7 +25,9 @@ DATABASES = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DOCKERIZED = os.environ.get("DOCKERIZED", False)
|
DOCKERIZED = int(os.environ.get(
|
||||||
|
"DOCKERIZED", 0
|
||||||
|
)) == 1
|
||||||
|
|
||||||
if DOCKERIZED:
|
if DOCKERIZED:
|
||||||
DATABASES["default"] = dj_database_url.config()
|
DATABASES["default"] = dj_database_url.config()
|
||||||
|
@ -29,9 +29,10 @@ DATABASES = {
|
|||||||
DATABASES["default"] = dj_database_url.config()
|
DATABASES["default"] = dj_database_url.config()
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
DOCKERIZED = os.environ.get(
|
# Set the variable true if running in docker environment
|
||||||
"DOCKERIZED", False
|
DOCKERIZED = int(os.environ.get(
|
||||||
) # Set the variable true if running in docker-compose environment
|
"DOCKERIZED", 0
|
||||||
|
)) == 1
|
||||||
|
|
||||||
# Enable Connection Pooling (if desired)
|
# Enable Connection Pooling (if desired)
|
||||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||||
@ -69,7 +70,7 @@ CORS_ALLOW_CREDENTIALS = True
|
|||||||
# Simplified static file serving.
|
# Simplified static file serving.
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
if os.environ.get("SENTRY_DSN", False):
|
if bool(os.environ.get("SENTRY_DSN", False)):
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=os.environ.get("SENTRY_DSN", ""),
|
dsn=os.environ.get("SENTRY_DSN", ""),
|
||||||
integrations=[DjangoIntegration(), RedisIntegration()],
|
integrations=[DjangoIntegration(), RedisIntegration()],
|
||||||
@ -80,12 +81,21 @@ if os.environ.get("SENTRY_DSN", False):
|
|||||||
environment="production",
|
environment="production",
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if DOCKERIZED:
|
||||||
os.environ.get("AWS_REGION", False)
|
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||||
and os.environ.get("AWS_ACCESS_KEY_ID", False)
|
# The AWS access key to use.
|
||||||
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
|
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
|
||||||
and os.environ.get("AWS_S3_BUCKET_NAME", False)
|
# The AWS secret access key to use.
|
||||||
):
|
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
|
||||||
|
# The name of the bucket to store files in.
|
||||||
|
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads")
|
||||||
|
# The full URL to the S3 endpoint. Leave blank to use the default region URL.
|
||||||
|
AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "http://minio:9000")
|
||||||
|
# Default permissions
|
||||||
|
AWS_DEFAULT_ACL = "public-read"
|
||||||
|
AWS_QUERYSTRING_AUTH = False
|
||||||
|
AWS_S3_FILE_OVERWRITE = False
|
||||||
|
else:
|
||||||
# The AWS region to connect to.
|
# The AWS region to connect to.
|
||||||
AWS_REGION = os.environ.get("AWS_REGION", "")
|
AWS_REGION = os.environ.get("AWS_REGION", "")
|
||||||
|
|
||||||
@ -99,7 +109,7 @@ if (
|
|||||||
# AWS_SESSION_TOKEN = ""
|
# AWS_SESSION_TOKEN = ""
|
||||||
|
|
||||||
# The name of the bucket to store files in.
|
# The name of the bucket to store files in.
|
||||||
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "")
|
AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME")
|
||||||
|
|
||||||
# How to construct S3 URLs ("auto", "path", "virtual").
|
# How to construct S3 URLs ("auto", "path", "virtual").
|
||||||
AWS_S3_ADDRESSING_STYLE = "auto"
|
AWS_S3_ADDRESSING_STYLE = "auto"
|
||||||
@ -166,14 +176,8 @@ if (
|
|||||||
# extra characters appended.
|
# extra characters appended.
|
||||||
AWS_S3_FILE_OVERWRITE = False
|
AWS_S3_FILE_OVERWRITE = False
|
||||||
|
|
||||||
# AWS Settings End
|
|
||||||
|
|
||||||
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
|
||||||
|
# AWS Settings End
|
||||||
else:
|
|
||||||
MEDIA_URL = "/uploads/"
|
|
||||||
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
|
|
||||||
|
|
||||||
|
|
||||||
# Enable Connection Pooling (if desired)
|
# Enable Connection Pooling (if desired)
|
||||||
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
|
||||||
@ -218,12 +222,6 @@ else:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RQ_QUEUES = {
|
|
||||||
"default": {
|
|
||||||
"USE_REDIS_CACHE": "default",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
WEB_URL = os.environ.get("WEB_URL")
|
WEB_URL = os.environ.get("WEB_URL")
|
||||||
|
|
||||||
|
@ -49,6 +49,10 @@ CORS_ALLOW_ALL_ORIGINS = True
|
|||||||
# Simplified static file serving.
|
# Simplified static file serving.
|
||||||
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
|
||||||
|
|
||||||
|
# Make true if running in a docker environment
|
||||||
|
DOCKERIZED = int(os.environ.get(
|
||||||
|
"DOCKERIZED", 0
|
||||||
|
)) == 1
|
||||||
|
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=os.environ.get("SENTRY_DSN"),
|
dsn=os.environ.get("SENTRY_DSN"),
|
||||||
|
@ -7,7 +7,7 @@ from django.urls import path
|
|||||||
from django.views.generic import TemplateView
|
from django.views.generic import TemplateView
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls import include, url
|
from django.conf.urls import include, url, static
|
||||||
|
|
||||||
# from django.conf.urls.static import static
|
# from django.conf.urls.static import static
|
||||||
|
|
||||||
@ -17,9 +17,8 @@ urlpatterns = [
|
|||||||
path("api/", include("plane.api.urls")),
|
path("api/", include("plane.api.urls")),
|
||||||
path("", include("plane.web.urls")),
|
path("", include("plane.web.urls")),
|
||||||
]
|
]
|
||||||
# + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
|
||||||
# + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
||||||
|
|
||||||
|
urlpatterns = urlpatterns + static.static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
import debug_toolbar
|
import debug_toolbar
|
||||||
|
76
apiserver/plane/utils/analytics_plot.py
Normal file
76
apiserver/plane/utils/analytics_plot.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Python imports
|
||||||
|
from itertools import groupby
|
||||||
|
|
||||||
|
# Django import
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Count, F, Sum, Value, Case, When, CharField
|
||||||
|
from django.db.models.functions import Coalesce, ExtractMonth, ExtractYear, Concat
|
||||||
|
|
||||||
|
|
||||||
|
def build_graph_plot(queryset, x_axis, y_axis, segment=None):
|
||||||
|
|
||||||
|
temp_axis = x_axis
|
||||||
|
|
||||||
|
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||||
|
year = ExtractYear(x_axis)
|
||||||
|
month = ExtractMonth(x_axis)
|
||||||
|
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||||
|
queryset = queryset.annotate(dimension=dimension)
|
||||||
|
x_axis = "dimension"
|
||||||
|
else:
|
||||||
|
queryset = queryset.annotate(dimension=F(x_axis))
|
||||||
|
x_axis = "dimension"
|
||||||
|
|
||||||
|
if x_axis in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||||
|
queryset = queryset.exclude(x_axis__is_null=True)
|
||||||
|
|
||||||
|
if segment in ["created_at", "start_date", "target_date", "completed_at"]:
|
||||||
|
year = ExtractYear(segment)
|
||||||
|
month = ExtractMonth(segment)
|
||||||
|
dimension = Concat(year, Value("-"), month, output_field=CharField())
|
||||||
|
queryset = queryset.annotate(segmented=dimension)
|
||||||
|
segment = "segmented"
|
||||||
|
|
||||||
|
queryset = queryset.values(x_axis)
|
||||||
|
|
||||||
|
# Group queryset by x_axis field
|
||||||
|
|
||||||
|
if y_axis == "issue_count":
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
is_null=Case(
|
||||||
|
When(dimension__isnull=True, then=Value("None")),
|
||||||
|
default=Value("not_null"),
|
||||||
|
output_field=models.CharField(max_length=8),
|
||||||
|
),
|
||||||
|
dimension_ex=Coalesce("dimension", Value("null")),
|
||||||
|
).values("dimension")
|
||||||
|
if segment:
|
||||||
|
queryset = queryset.annotate(segment=F(segment)).values(
|
||||||
|
"dimension", "segment"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queryset = queryset.values("dimension")
|
||||||
|
|
||||||
|
queryset = queryset.annotate(count=Count("*")).order_by("dimension")
|
||||||
|
|
||||||
|
if y_axis == "estimate":
|
||||||
|
queryset = queryset.annotate(estimate=Sum("estimate_point")).order_by(x_axis)
|
||||||
|
if segment:
|
||||||
|
queryset = queryset.annotate(segment=F(segment)).values(
|
||||||
|
"dimension", "segment", "estimate"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
queryset = queryset.values("dimension", "estimate")
|
||||||
|
|
||||||
|
result_values = list(queryset)
|
||||||
|
grouped_data = {}
|
||||||
|
for key, items in groupby(result_values, key=lambda x: x[str("dimension")]):
|
||||||
|
grouped_data[str(key)] = list(items)
|
||||||
|
|
||||||
|
sorted_data = grouped_data
|
||||||
|
if temp_axis == "priority":
|
||||||
|
order = ["low", "medium", "high", "urgent", "None"]
|
||||||
|
sorted_data = {key: grouped_data[key] for key in order if key in grouped_data}
|
||||||
|
else:
|
||||||
|
sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0])))
|
||||||
|
return sorted_data
|
@ -198,6 +198,39 @@ def filter_issue_state_type(params, filter, method):
|
|||||||
return filter
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_project(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
projects = params.get("project").split(",")
|
||||||
|
if len(projects) and "" not in projects:
|
||||||
|
filter["project__in"] = projects
|
||||||
|
else:
|
||||||
|
if params.get("project", None) and len(params.get("project")):
|
||||||
|
filter["project__in"] = params.get("project")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_cycle(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
cycles = params.get("cycle").split(",")
|
||||||
|
if len(cycles) and "" not in cycles:
|
||||||
|
filter["issue_cycle__cycle_id__in"] = cycles
|
||||||
|
else:
|
||||||
|
if params.get("cycle", None) and len(params.get("cycle")):
|
||||||
|
filter["issue_cycle__cycle_id__in"] = params.get("cycle")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
|
def filter_module(params, filter, method):
|
||||||
|
if method == "GET":
|
||||||
|
modules = params.get("module").split(",")
|
||||||
|
if len(modules) and "" not in modules:
|
||||||
|
filter["issue_module__module_id__in"] = modules
|
||||||
|
else:
|
||||||
|
if params.get("module", None) and len(params.get("module")):
|
||||||
|
filter["issue_module__module_id__in"] = params.get("module")
|
||||||
|
return filter
|
||||||
|
|
||||||
|
|
||||||
def issue_filters(query_params, method):
|
def issue_filters(query_params, method):
|
||||||
filter = dict()
|
filter = dict()
|
||||||
|
|
||||||
@ -216,6 +249,9 @@ def issue_filters(query_params, method):
|
|||||||
"target_date": filter_target_date,
|
"target_date": filter_target_date,
|
||||||
"completed_at": filter_completed_at,
|
"completed_at": filter_completed_at,
|
||||||
"type": filter_issue_state_type,
|
"type": filter_issue_state_type,
|
||||||
|
"project": filter_project,
|
||||||
|
"cycle": filter_cycle,
|
||||||
|
"module": filter_module,
|
||||||
}
|
}
|
||||||
|
|
||||||
for key, value in ISSUE_FILTER.items():
|
for key, value in ISSUE_FILTER.items():
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# base requirements
|
# base requirements
|
||||||
|
|
||||||
Django==3.2.18
|
Django==3.2.19
|
||||||
django-braces==1.15.0
|
django-braces==1.15.0
|
||||||
django-taggit==3.1.0
|
django-taggit==3.1.0
|
||||||
psycopg2==2.9.5
|
psycopg2==2.9.5
|
||||||
|
@ -4,7 +4,7 @@ dj-database-url==1.2.0
|
|||||||
gunicorn==20.1.0
|
gunicorn==20.1.0
|
||||||
whitenoise==6.3.0
|
whitenoise==6.3.0
|
||||||
django-storages==1.13.2
|
django-storages==1.13.2
|
||||||
boto==2.49.0
|
boto3==1.26.136
|
||||||
django-anymail==9.0
|
django-anymail==9.0
|
||||||
twilio==7.16.2
|
twilio==7.16.2
|
||||||
django-debug-toolbar==3.8.1
|
django-debug-toolbar==3.8.1
|
||||||
|
@ -1,481 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
|
||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
||||||
<head>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
||||||
<meta name="format-detection" content="telephone=no">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Welcome to Plane ✈️!</title>
|
|
||||||
<style type="text/css" emogrify="no">#outlook a { padding:0; } .ExternalClass { width:100%; } .ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height: 100%; } table td { border-collapse: collapse; mso-line-height-rule: exactly; } .editable.image { font-size: 0 !important; line-height: 0 !important; } .nl2go_preheader { display: none !important; mso-hide:all !important; mso-line-height-rule: exactly; visibility: hidden !important; line-height: 0px !important; font-size: 0px !important; } body { width:100% !important; -webkit-text-size-adjust:100%; -ms-text-size-adjust:100%; margin:0; padding:0; } img { outline:none; text-decoration:none; -ms-interpolation-mode: bicubic; } a img { border:none; } table { border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; } th { font-weight: normal; text-align: left; } *[class="gmail-fix"] { display: none !important; } </style>
|
|
||||||
<style type="text/css" emogrify="no"> @media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} } </style>
|
|
||||||
<style type="text/css" emogrify="no">@media (max-width: 600px) { .gmx-killpill { content: ' \03D1';} .r0-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 320px !important } .r1-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 320px !important } .r2-i { background-color: #ffffff !important } .r3-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 100% !important } .r4-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 100% !important } .r5-i { background-color: #f8f9fa !important; padding-bottom: 20px !important; padding-left: 10px !important; padding-right: 10px !important; padding-top: 20px !important } .r6-c { box-sizing: border-box !important; display: block !important; valign: top !important; width: 100% !important } .r7-o { border-style: solid !important; width: 100% !important } .r8-i { padding-left: 0px !important; padding-right: 0px !important } .r9-i { padding-bottom: 15px !important; padding-top: 15px !important } .r10-c { box-sizing: border-box !important; text-align: left !important; valign: top !important; width: 100% !important } .r11-o { border-style: solid !important; margin: 0 auto 0 0 !important; width: 100% !important } .r12-i { padding-left: 20px !important; padding-right: 20px !important; padding-top: 0px !important; text-align: left !important } .r13-i { padding-bottom: 15px !important; padding-left: 20px !important; padding-right: 20px !important; padding-top: 15px !important; text-align: left !important } .r14-o { border-style: solid !important; margin: 0 auto 0 auto !important; margin-bottom: 15px !important; margin-top: 15px !important; width: 100% !important } .r15-i { text-align: center !important } .r16-r { background-color: #ffffff !important; border-color: #3f76ff !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r17-r { background-color: #ffffff !important; border-color: #000000 !important; border-radius: 4px !important; border-width: 1px !important; box-sizing: border-box; height: initial !important; padding-bottom: 7px !important; padding-left: 5px !important; padding-right: 5px !important; padding-top: 7px !important; text-align: center !important; width: 100% !important } .r18-i { background-color: #eff2f7 !important; padding-bottom: 20px !important; padding-left: 15px !important; padding-right: 15px !important; padding-top: 20px !important } .r19-i { color: #3b3f44 !important; padding-bottom: 0px !important; padding-top: 0px !important; text-align: center !important } .r20-c { box-sizing: border-box !important; text-align: center !important; width: 100% !important } .r21-c { box-sizing: border-box !important; width: 100% !important } .r22-i { font-size: 0px !important; padding-bottom: 15px !important; padding-left: 65px !important; padding-right: 65px !important; padding-top: 15px !important } .r23-c { box-sizing: border-box !important; width: 32px !important } .r24-o { border-style: solid !important; margin-right: 8px !important; width: 32px !important } .r25-i { padding-bottom: 5px !important; padding-top: 5px !important } .r26-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important } .r27-i { color: #3b3f44 !important; padding-bottom: 15px !important; padding-top: 15px !important; text-align: center !important } .r28-i { padding-bottom: 15px !important; padding-left: 0px !important; padding-right: 0px !important; padding-top: 0px !important } .r29-c { box-sizing: border-box !important; text-align: center !important; valign: top !important; width: 129px !important } .r30-o { border-style: solid !important; margin: 0 auto 0 auto !important; width: 129px !important } body { -webkit-text-size-adjust: none } .nl2go-responsive-hide { display: none } .nl2go-body-table { min-width: unset !important } .mobshow { height: auto !important; overflow: visible !important; max-height: unset !important; visibility: visible !important; border: none !important } .resp-table { display: inline-table !important } .magic-resp { display: table-cell !important } } </style>
|
|
||||||
<!--[if !mso]><!-->
|
|
||||||
<style type="text/css" emogrify="no">@import url("https://fonts.googleapis.com/css2?family=Bitter&family=Roboto"); </style>
|
|
||||||
<!--<![endif]-->
|
|
||||||
<style type="text/css">p, h1, h2, h3, h4, ol, ul { margin: 0; } a, a:link { color: #0092ff; text-decoration: underline } .nl2go-default-textstyle { color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5 } .default-button { border-radius: 4px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; width: 50% } .default-heading1 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 36px } .default-heading2 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 32px } .default-heading3 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 24px } .default-heading4 { color: #1F2D3D; font-family: arial,helvetica,sans-serif; font-size: 18px } a[x-apple-data-detectors] { color: inherit !important; text-decoration: inherit !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; } .no-show-for-you { border: none; display: none; float: none; font-size: 0; height: 0; line-height: 0; max-height: 0; mso-hide: all; overflow: hidden; table-layout: fixed; visibility: hidden; width: 0; } </style>
|
|
||||||
<!--[if mso]>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings>
|
|
||||||
<o:AllowPNG/>
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
<![endif]-->
|
|
||||||
<style type="text/css">a:link{color: #0092ff; text-decoration: underline;}</style>
|
|
||||||
</head>
|
|
||||||
<body bgcolor="#ffffff" text="#3b3f44" link="#0092ff" yahoo="fix" style="background-color: #ffffff; padding-bottom: 0px; padding-top: 0px;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" class="nl2go-body-table" width="100%" style="background-color: #ffffff; width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="r0-c">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="600" class="r1-o" style="table-layout: fixed; width: 600px;">
|
|
||||||
<tr>
|
|
||||||
<td valign="top" class="r2-i" style="background-color: #ffffff;">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="r3-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r5-i" style="background-color: #f8f9fa;">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr>
|
|
||||||
<td valign="top" class="r8-i">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="r3-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="120" class="r4-o" style="table-layout: fixed; width: 120px;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/Plane_Logo_pIhtbyIoa.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670873447444" width="120" border="0" class="" style="display: block; width: 100%;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td align="left" valign="top" class="r12-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
|
||||||
<div>
|
|
||||||
<h3 class="default-heading3" style="margin: 0; color: #1f2d3d; font-family: arial,helvetica,sans-serif; font-size: 24px;">Welcome to Plane!</h3>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0;">We're thrilled you're here. We know this is the beginning of a long and exciting<br>journey, and we want to be there every step of the way.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0;"><strong>Plane is an open-source issue planning and tracking tool</strong> that allows teams to collaborate on projects and prioritize tasks. With Plane, you can easily create and assign issues, set deadlines, and track progress.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0;">We have put together some resources to help you get started. Please find them below:</p>
|
|
||||||
<p style="margin: 0;"> </p>
|
|
||||||
<ul style="margin: 0; margin-top:20px;">
|
|
||||||
<li><a href="https://docs.plane.so/quick-start" target="_blank" style="color: #0092ff; text-decoration: underline;">Getting started with Plane</a></li>
|
|
||||||
<li><a href="https://plane.so/changelog" target="_blank" style="color: #0092ff; text-decoration: underline;">Plane Changelog</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r3-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
|
||||||
<!--[if mso]>
|
|
||||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://app.plane.so/" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#3f76ff" strokeweight="1px" data-btn="1">
|
|
||||||
<w:anchorlock/>
|
|
||||||
<div style="display:none;">
|
|
||||||
<center class="default-button">
|
|
||||||
<p><span style="color:#3F76FF;font-size:14px;">Open Plane</span></p>
|
|
||||||
</center>
|
|
||||||
</div>
|
|
||||||
</v:roundrect>
|
|
||||||
<![endif]--> <!--[if !mso]><!-- -->
|
|
||||||
<a href="https://app.plane.so/" class="r16-r default-button" target="_blank" data-btn="1" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #3f76ff; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
|
|
||||||
<p style="margin: 0;"><span style="color: #3F76FF; font-size: 14px;">Open Plane</span></p>
|
|
||||||
</a>
|
|
||||||
<!--<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0;">Also, if you like Plane, please consider starring us on GitHub. This helps us to grow our community and make Plane even better.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r3-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="300" class="r14-o" style="table-layout: fixed; width: 300px;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td height="18" align="center" valign="top" class="r15-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5;">
|
|
||||||
<!--[if mso]>
|
|
||||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="https://github.com/makeplane/plane" style="v-text-anchor:middle; height: 33px; width: 301px;" arcsize="12%" fillcolor="#ffffff" strokecolor="#000000" strokeweight="1px" data-btn="2">
|
|
||||||
<w:anchorlock/>
|
|
||||||
<div style="display:none;">
|
|
||||||
<center class="default-button">
|
|
||||||
<p><span style="color:#000000;font-size:14px;">⭐ Star us on GitHub</span></p>
|
|
||||||
</center>
|
|
||||||
</div>
|
|
||||||
</v:roundrect>
|
|
||||||
<![endif]--> <!--[if !mso]><!-- -->
|
|
||||||
<a href="https://github.com/makeplane/plane" class="r17-r default-button" target="_blank" data-btn="2" style="font-style: normal; font-weight: bold; line-height: 1.15; text-decoration: none; border-style: solid; word-wrap: break-word; display: inline-block; -webkit-text-size-adjust: none; mso-hide: all; background-color: #ffffff; border-color: #000000; border-radius: 4px; border-width: 1px; color: #ffffff; font-family: arial,helvetica,sans-serif; font-size: 16px; height: 18px; padding-bottom: 7px; padding-left: 5px; padding-right: 5px; padding-top: 7px; width: 288px;">
|
|
||||||
<p style="margin: 0;"><span style="color: #000000; font-size: 14px;">⭐ Star us on GitHub</span></p>
|
|
||||||
</a>
|
|
||||||
<!--<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td align="left" valign="top" class="r13-i nl2go-default-textstyle" style="color: #3b3f44; font-family: arial,helvetica,sans-serif; font-size: 16px; line-height: 1.5; text-align: left;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0;"><span style="font-size: 12px;">Note: 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 </span><a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">Discord</span></a><span style="font-size: 12px;"> or </span><a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"><span style="font-size: 12px;">GitHub</span></a><span style="font-size: 12px;">, and we will use your feedback to improve on our upcoming releases.</span></p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="20" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="20" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #f8f9fa;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r3-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r4-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r18-i" style="background-color: #eff2f7;">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<th width="100%" valign="top" class="r6-c" style="font-weight: normal;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r7-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td valign="top" class="r8-i">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="r3-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="57" class="r4-o" style="table-layout: fixed; width: 57px;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r9-i" style="font-size: 0px; line-height: 0px;"> <img src="https://ik.imagekit.io/w2okwbtu2/115727700_n9t8rrnwT.png?ik-sdk-version=javascript-1.4.3&updatedAt=1670872429989" width="57" border="0" class="" style="display: block; width: 100%;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" class="r19-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0; font-size: 14px;">Proudly made on <strong>Planet Earth 🌍</strong>.</p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r20-c" align="center">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r4-o" style="table-layout: fixed; width: 570px;">
|
|
||||||
<!-- -->
|
|
||||||
<tr>
|
|
||||||
<td valign="top" class="">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td class="r21-c" style="display: inline-block;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="570" class="r7-o" style="table-layout: fixed; width: 570px;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
<td class="r22-i">
|
|
||||||
<table width="100%" cellspacing="0" cellpadding="0" border="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://github.com/makeplane/plane" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
|
||||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://discord.gg/A92xrEGCge" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
|
||||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
<th width="40" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r24-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://twitter.com/planepowers" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
|
||||||
<td class="nl2go-responsive-hide" width="8" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
<td height="5" width="8" style="font-size: 5px; line-height: 5px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
<th width="32" valign="" class="r23-c mobshow resp-table" style="font-weight: normal;">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r26-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<!-- -->
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r25-i" style="font-size: 0px; line-height: 0px;"> <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style="color: #0092ff; text-decoration: underline;"> <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style="display: block; width: 100%;"></a> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="5" style="font-size: 5px; line-height: 5px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="209" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
<td height="15" width="209" style="font-size: 15px; line-height: 15px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="r10-c" align="left">
|
|
||||||
<table cellspacing="0" cellpadding="0" border="0" role="presentation" width="100%" class="r11-o" style="table-layout: fixed; width: 100%;">
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" class="r27-i nl2go-default-textstyle" style="font-family: arial,helvetica,sans-serif; color: #3b3f44; font-size: 18px; line-height: 1.5; text-align: center;">
|
|
||||||
<div>
|
|
||||||
<p style="margin: 0; font-size: 14px;"><a href="{{ mirror }}" style="color: #0092ff; text-decoration: underline;">View in browser</a> | <a href="{{ unsubscribe }}" style="color: #0092ff; text-decoration: underline;">Unsubscribe</a></p>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="15" style="font-size: 15px; line-height: 15px;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
<td class="nl2go-responsive-hide" width="15" style="font-size: 0px; line-height: 1px;"> </td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr class="nl2go-responsive-hide">
|
|
||||||
<td height="20" style="font-size: 20px; line-height: 20px; background-color: #eff2f7;"></td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
8
apiserver/templates/emails/exports/analytics.html
Normal file
8
apiserver/templates/emails/exports/analytics.html
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
|
||||||
|
<html>
|
||||||
|
Hey there,<br/>
|
||||||
|
Your requested data export from Plane Analytics is now ready. The information has been compiled into a CSV format for your convenience.<br/>
|
||||||
|
Please find the attachment and download the CSV file. This file can easily be imported into any spreadsheet program for further analysis.<br/>
|
||||||
|
If you require any assistance or have any questions, please do not hesitate to contact us.<br/>
|
||||||
|
Thank you
|
||||||
|
</html>
|
@ -0,0 +1,158 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// headless ui
|
||||||
|
import { Dialog, Transition } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import analyticsService from "services/analytics.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, ISaveAnalyticsFormData } from "types";
|
||||||
|
|
||||||
|
// types
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
handleClose: () => void;
|
||||||
|
params?: IAnalyticsParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormValues = {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: FormValues = {
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CreateUpdateAnalyticsModal: React.FC<Props> = ({ isOpen, handleClose, params }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
handleClose();
|
||||||
|
reset(defaultValues);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (formData: FormValues) => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
const payload: ISaveAnalyticsFormData = {
|
||||||
|
name: formData.name,
|
||||||
|
description: formData.description,
|
||||||
|
query_dict: {
|
||||||
|
x_axis: "priority",
|
||||||
|
y_axis: "issue_count",
|
||||||
|
...params,
|
||||||
|
project: params?.project ?? [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await analyticsService
|
||||||
|
.saveAnalytics(workspaceSlug.toString(), payload)
|
||||||
|
.then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Analytics saved successfully.",
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Analytics could not be saved. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition.Root show={isOpen} as={React.Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-30" onClose={onClose}>
|
||||||
|
<Transition.Child
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
||||||
|
<Transition.Child
|
||||||
|
as={React.Fragment}
|
||||||
|
enter="ease-out duration-300"
|
||||||
|
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leave="ease-in duration-200"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel className="relative transform rounded-lg border border-brand-base bg-brand-base px-4 pt-5 pb-4 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div>
|
||||||
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
|
||||||
|
Save Analytics
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="mt-5">
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Title"
|
||||||
|
autoComplete="off"
|
||||||
|
error={errors.name}
|
||||||
|
register={register}
|
||||||
|
width="full"
|
||||||
|
validations={{
|
||||||
|
required: "Title is required",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<TextArea
|
||||||
|
id="description"
|
||||||
|
name="description"
|
||||||
|
placeholder="Description"
|
||||||
|
className="mt-3 h-32 resize-none text-sm"
|
||||||
|
error={errors.description}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<SecondaryButton onClick={onClose}>Cancel</SecondaryButton>
|
||||||
|
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||||
|
{isSubmitting ? "Saving..." : "Save Analytics"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition.Root>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,130 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { Control, UseFormSetValue } from "react-hook-form";
|
||||||
|
// hooks
|
||||||
|
import useProjects from "hooks/use-projects";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
AnalyticsGraph,
|
||||||
|
AnalyticsSelectBar,
|
||||||
|
AnalyticsSidebar,
|
||||||
|
AnalyticsTable,
|
||||||
|
} from "components/analytics";
|
||||||
|
// ui
|
||||||
|
import { Loader, PrimaryButton } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { convertResponseToBarGraphData } from "helpers/analytics.helper";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { ANALYTICS } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
analytics: IAnalyticsResponse | undefined;
|
||||||
|
analyticsError: any;
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
control: Control<IAnalyticsParams, any>;
|
||||||
|
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||||
|
fullScreen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomAnalytics: React.FC<Props> = ({
|
||||||
|
analytics,
|
||||||
|
analyticsError,
|
||||||
|
params,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
fullScreen,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const isProjectLevel = projectId ? true : false;
|
||||||
|
|
||||||
|
const yAxisKey = params.y_axis === "issue_count" ? "count" : "estimate";
|
||||||
|
const barGraphData = convertResponseToBarGraphData(analytics?.distribution, params);
|
||||||
|
|
||||||
|
const { projects } = useProjects();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`overflow-hidden flex flex-col-reverse ${
|
||||||
|
fullScreen ? "md:grid md:grid-cols-4 md:h-full" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="col-span-3 flex flex-col h-full overflow-hidden">
|
||||||
|
<AnalyticsSelectBar
|
||||||
|
control={control}
|
||||||
|
setValue={setValue}
|
||||||
|
projects={projects}
|
||||||
|
params={params}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
isProjectLevel={isProjectLevel}
|
||||||
|
/>
|
||||||
|
{!analyticsError ? (
|
||||||
|
analytics ? (
|
||||||
|
analytics.total > 0 ? (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
<AnalyticsGraph
|
||||||
|
analytics={analytics}
|
||||||
|
barGraphData={barGraphData}
|
||||||
|
params={params}
|
||||||
|
yAxisKey={yAxisKey}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
/>
|
||||||
|
<AnalyticsTable
|
||||||
|
analytics={analytics}
|
||||||
|
barGraphData={barGraphData}
|
||||||
|
params={params}
|
||||||
|
yAxisKey={yAxisKey}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full place-items-center p-5">
|
||||||
|
<div className="space-y-4 text-brand-secondary">
|
||||||
|
<p className="text-sm">No matching issues found. Try changing the parameters.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="space-y-6 p-5">
|
||||||
|
<Loader.Item height="300px" />
|
||||||
|
<Loader className="space-y-4">
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
<Loader.Item height="30px" />
|
||||||
|
</Loader>
|
||||||
|
</Loader>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full place-items-center p-5">
|
||||||
|
<div className="space-y-4 text-brand-secondary">
|
||||||
|
<p className="text-sm">There was some error in fetching the data.</p>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AnalyticsSidebar
|
||||||
|
analytics={analytics}
|
||||||
|
params={params}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
isProjectLevel={isProjectLevel}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,57 @@
|
|||||||
|
// nivo
|
||||||
|
import { BarTooltipProps } from "@nivo/bar";
|
||||||
|
import { DATE_KEYS } from "constants/analytics";
|
||||||
|
import { renderMonthAndYear } from "helpers/analytics.helper";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
datum: BarTooltipProps<any>;
|
||||||
|
analytics: IAnalyticsResponse;
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomTooltip: React.FC<Props> = ({ datum, analytics, params }) => {
|
||||||
|
let tooltipValue: string | number = "";
|
||||||
|
|
||||||
|
if (params.segment) {
|
||||||
|
if (DATE_KEYS.includes(params.segment)) tooltipValue = renderMonthAndYear(datum.id);
|
||||||
|
else if (params.segment === "assignees__email") {
|
||||||
|
const assignee = analytics.extras.assignee_details.find(
|
||||||
|
(a) => a.assignees__email === datum.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (assignee)
|
||||||
|
tooltipValue = assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||||
|
else tooltipValue = "No assignees";
|
||||||
|
} else tooltipValue = datum.id;
|
||||||
|
} else {
|
||||||
|
if (DATE_KEYS.includes(params.x_axis)) tooltipValue = datum.indexValue;
|
||||||
|
else tooltipValue = datum.id === "count" ? "Issue count" : "Estimate";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: datum.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-medium text-brand-secondary ${
|
||||||
|
params.segment
|
||||||
|
? params.segment === "priority" || params.segment === "state__group"
|
||||||
|
? "capitalize"
|
||||||
|
: ""
|
||||||
|
: params.x_axis === "priority" || params.x_axis === "state__group"
|
||||||
|
? "capitalize"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tooltipValue}:
|
||||||
|
</span>
|
||||||
|
<span>{datum.value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
119
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
119
apps/app/components/analytics/custom-analytics/graph/index.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
// nivo
|
||||||
|
import { BarDatum } from "@nivo/bar";
|
||||||
|
// components
|
||||||
|
import { CustomTooltip } from "./custom-tooltip";
|
||||||
|
// ui
|
||||||
|
import { BarGraph } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { findStringWithMostCharacters } from "helpers/array.helper";
|
||||||
|
import { generateBarColor } from "helpers/analytics.helper";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||||
|
// constants
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
analytics: IAnalyticsResponse;
|
||||||
|
barGraphData: {
|
||||||
|
data: BarDatum[];
|
||||||
|
xAxisKeys: string[];
|
||||||
|
};
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
yAxisKey: "count" | "estimate";
|
||||||
|
fullScreen: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsGraph: React.FC<Props> = ({
|
||||||
|
analytics,
|
||||||
|
barGraphData,
|
||||||
|
params,
|
||||||
|
yAxisKey,
|
||||||
|
fullScreen,
|
||||||
|
}) => {
|
||||||
|
const generateYAxisTickValues = () => {
|
||||||
|
if (!analytics) return [];
|
||||||
|
|
||||||
|
let data: number[] = [];
|
||||||
|
|
||||||
|
if (params.segment)
|
||||||
|
// find the total no of issues in each segment
|
||||||
|
data = Object.keys(analytics.distribution).map((segment) => {
|
||||||
|
let totalSegmentIssues = 0;
|
||||||
|
|
||||||
|
analytics.distribution[segment].map((s) => {
|
||||||
|
totalSegmentIssues += s[yAxisKey] as number;
|
||||||
|
});
|
||||||
|
|
||||||
|
return totalSegmentIssues;
|
||||||
|
});
|
||||||
|
else data = barGraphData.data.map((d) => d[yAxisKey] as number);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const longestXAxisLabel = findStringWithMostCharacters(barGraphData.data.map((d) => `${d.name}`));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BarGraph
|
||||||
|
data={barGraphData.data}
|
||||||
|
indexBy="name"
|
||||||
|
keys={barGraphData.xAxisKeys}
|
||||||
|
colors={(datum) =>
|
||||||
|
generateBarColor(
|
||||||
|
params.segment ? `${datum.id}` : `${datum.indexValue}`,
|
||||||
|
analytics,
|
||||||
|
params,
|
||||||
|
params.segment ? "segment" : "x_axis"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
customYAxisTickValues={generateYAxisTickValues()}
|
||||||
|
tooltip={(datum) => <CustomTooltip datum={datum} analytics={analytics} params={params} />}
|
||||||
|
height={fullScreen ? "400px" : "300px"}
|
||||||
|
margin={{
|
||||||
|
right: 20,
|
||||||
|
bottom: params.x_axis === "assignees__email" ? 50 : longestXAxisLabel.length * 5 + 20,
|
||||||
|
}}
|
||||||
|
axisBottom={{
|
||||||
|
tickSize: 0,
|
||||||
|
tickPadding: 10,
|
||||||
|
tickRotation: barGraphData.data.length > 7 ? -45 : 0,
|
||||||
|
renderTick:
|
||||||
|
params.x_axis === "assignees__email"
|
||||||
|
? (datum) => {
|
||||||
|
const avatar = analytics.extras.assignee_details?.find(
|
||||||
|
(a) => a?.assignees__email === datum?.value
|
||||||
|
)?.assignees__avatar;
|
||||||
|
|
||||||
|
if (avatar && avatar !== "")
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
|
<image
|
||||||
|
x={-8}
|
||||||
|
y={10}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
xlinkHref={avatar}
|
||||||
|
style={{ clipPath: "circle(50%)" }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
|
<circle cy={18} r={8} fill="#374151" />
|
||||||
|
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||||
|
{datum.value && datum.value !== "None"
|
||||||
|
? `${datum.value}`.toUpperCase()[0]
|
||||||
|
: "?"}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
background: "rgb(var(--color-bg-base))",
|
||||||
|
axis: {},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
6
apps/app/components/analytics/custom-analytics/index.ts
Normal file
6
apps/app/components/analytics/custom-analytics/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export * from "./graph";
|
||||||
|
export * from "./create-update-analytics-modal";
|
||||||
|
export * from "./custom-analytics";
|
||||||
|
export * from "./select-bar";
|
||||||
|
export * from "./sidebar";
|
||||||
|
export * from "./table";
|
@ -0,0 +1,80 @@
|
|||||||
|
// react-hook-form
|
||||||
|
import { Control, Controller, UseFormSetValue } from "react-hook-form";
|
||||||
|
// components
|
||||||
|
import { SelectProject, SelectSegment, SelectXAxis, SelectYAxis } from "components/analytics";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IProject } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
control: Control<IAnalyticsParams, any>;
|
||||||
|
setValue: UseFormSetValue<IAnalyticsParams>;
|
||||||
|
projects: IProject[];
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
fullScreen: boolean;
|
||||||
|
isProjectLevel: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsSelectBar: React.FC<Props> = ({
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
projects,
|
||||||
|
params,
|
||||||
|
fullScreen,
|
||||||
|
isProjectLevel,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`grid items-center gap-4 px-5 py-2.5 ${
|
||||||
|
isProjectLevel ? "grid-cols-3" : "grid-cols-2"
|
||||||
|
} ${fullScreen ? "lg:grid-cols-4 md:py-5" : ""}`}
|
||||||
|
>
|
||||||
|
{!isProjectLevel && (
|
||||||
|
<div>
|
||||||
|
<h6 className="text-xs text-brand-secondary">Project</h6>
|
||||||
|
<Controller
|
||||||
|
name="project"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<SelectProject value={value} onChange={onChange} projects={projects} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<h6 className="text-xs text-brand-secondary">Measure (y-axis)</h6>
|
||||||
|
<Controller
|
||||||
|
name="y_axis"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<SelectYAxis value={value} onChange={onChange} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 className="text-xs text-brand-secondary">Dimension (x-axis)</h6>
|
||||||
|
<Controller
|
||||||
|
name="x_axis"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<SelectXAxis
|
||||||
|
value={value}
|
||||||
|
onChange={(val: string) => {
|
||||||
|
if (params.segment === val) setValue("segment", null);
|
||||||
|
|
||||||
|
onChange(val);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 className="text-xs text-brand-secondary">Group</h6>
|
||||||
|
<Controller
|
||||||
|
name="segment"
|
||||||
|
control={control}
|
||||||
|
render={({ field: { value, onChange } }) => (
|
||||||
|
<SelectSegment value={value} onChange={onChange} params={params} />
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
331
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
331
apps/app/components/analytics/custom-analytics/sidebar.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import analyticsService from "services/analytics.service";
|
||||||
|
import projectService from "services/project.service";
|
||||||
|
import cyclesService from "services/cycles.service";
|
||||||
|
import modulesService from "services/modules.service";
|
||||||
|
// hooks
|
||||||
|
import useProjects from "hooks/use-projects";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import {
|
||||||
|
ArrowDownTrayIcon,
|
||||||
|
ArrowPathIcon,
|
||||||
|
CalendarDaysIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { ContrastIcon, LayerDiagonalIcon } from "components/icons";
|
||||||
|
// helpers
|
||||||
|
import { renderShortDate } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IAnalyticsResponse, IExportAnalyticsFormData, IProject } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||||
|
// constants
|
||||||
|
import { NETWORK_CHOICES } from "constants/project";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
analytics: IAnalyticsResponse | undefined;
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
fullScreen: boolean;
|
||||||
|
isProjectLevel: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsSidebar: React.FC<Props> = ({
|
||||||
|
analytics,
|
||||||
|
params,
|
||||||
|
fullScreen,
|
||||||
|
isProjectLevel = false,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
const { projects } = useProjects();
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { data: projectDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||||
|
? PROJECT_DETAILS(projectId.toString())
|
||||||
|
: null,
|
||||||
|
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||||
|
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: cycleDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
|
||||||
|
workspaceSlug && projectId && cycleId
|
||||||
|
? () =>
|
||||||
|
cyclesService.getCycleDetails(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
cycleId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: moduleDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
|
||||||
|
workspaceSlug && projectId && moduleId
|
||||||
|
? () =>
|
||||||
|
modulesService.getModuleDetails(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
moduleId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const exportAnalytics = () => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
const data: IExportAnalyticsFormData = {
|
||||||
|
x_axis: params.x_axis,
|
||||||
|
y_axis: params.y_axis,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.segment) data.segment = params.segment;
|
||||||
|
if (params.project) data.project = params.project;
|
||||||
|
|
||||||
|
analyticsService
|
||||||
|
.exportAnalytics(workspaceSlug.toString(), data)
|
||||||
|
.then((res) =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: res.message,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch(() =>
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "There was some error in exporting the analytics. Please try again.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedProjects =
|
||||||
|
params.project && params.project.length > 0 ? params.project : projects.map((p) => p.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`px-5 py-2.5 flex items-center justify-between space-y-2 ${
|
||||||
|
fullScreen
|
||||||
|
? "border-l border-brand-base md:h-full md:border-l md:border-brand-base md:space-y-4 overflow-hidden md:flex-col md:items-start md:py-5"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||||
|
<LayerDiagonalIcon height={14} width={14} />
|
||||||
|
{analytics ? analytics.total : "..."} Issues
|
||||||
|
</div>
|
||||||
|
{isProjectLevel && (
|
||||||
|
<div className="flex items-center gap-1 bg-brand-surface-2 rounded-md px-3 py-1 text-brand-secondary text-xs">
|
||||||
|
<CalendarDaysIcon className="h-3.5 w-3.5" />
|
||||||
|
{renderShortDate(
|
||||||
|
(cycleId
|
||||||
|
? cycleDetails?.created_at
|
||||||
|
: moduleId
|
||||||
|
? moduleDetails?.created_at
|
||||||
|
: projectDetails?.created_at) ?? ""
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="h-full overflow-hidden">
|
||||||
|
{fullScreen ? (
|
||||||
|
<>
|
||||||
|
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
|
||||||
|
<div className="hidden h-full overflow-hidden md:flex md:flex-col">
|
||||||
|
<h4 className="font-medium">Selected Projects</h4>
|
||||||
|
<div className="space-y-6 mt-4 h-full overflow-y-auto">
|
||||||
|
{selectedProjects.map((projectId) => {
|
||||||
|
const project: IProject = projects.find((p) => p.id === projectId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={project.id}>
|
||||||
|
<div className="text-sm flex items-center gap-1">
|
||||||
|
{project.emoji ? (
|
||||||
|
<span className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||||
|
{String.fromCodePoint(parseInt(project.emoji))}
|
||||||
|
</span>
|
||||||
|
) : project.icon_prop ? (
|
||||||
|
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
style={{ color: project.icon_prop.color }}
|
||||||
|
className="material-symbols-rounded text-lg"
|
||||||
|
>
|
||||||
|
{project.icon_prop.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||||
|
{project?.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h5 className="break-all">
|
||||||
|
{project.name}
|
||||||
|
<span className="text-brand-secondary text-xs ml-1">
|
||||||
|
({project.identifier})
|
||||||
|
</span>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-3 pl-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
<h6>Total members</h6>
|
||||||
|
</div>
|
||||||
|
<span className="text-brand-secondary">{project.total_members}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ContrastIcon height={16} width={16} />
|
||||||
|
<h6>Total cycles</h6>
|
||||||
|
</div>
|
||||||
|
<span className="text-brand-secondary">{project.total_cycles}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserGroupIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
<h6>Total modules</h6>
|
||||||
|
</div>
|
||||||
|
<span className="text-brand-secondary">{project.total_modules}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{projectId ? (
|
||||||
|
cycleId && cycleDetails ? (
|
||||||
|
<div className="hidden md:block h-full overflow-y-auto">
|
||||||
|
<h4 className="font-medium break-all">Analytics for {cycleDetails.name}</h4>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Lead</h6>
|
||||||
|
<span>
|
||||||
|
{cycleDetails.owned_by?.first_name} {cycleDetails.owned_by?.last_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Start Date</h6>
|
||||||
|
<span>
|
||||||
|
{cycleDetails.start_date && cycleDetails.start_date !== ""
|
||||||
|
? renderShortDate(cycleDetails.start_date)
|
||||||
|
: "No start date"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Target Date</h6>
|
||||||
|
<span>
|
||||||
|
{cycleDetails.end_date && cycleDetails.end_date !== ""
|
||||||
|
? renderShortDate(cycleDetails.end_date)
|
||||||
|
: "No end date"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : moduleId && moduleDetails ? (
|
||||||
|
<div className="hidden md:block h-full overflow-y-auto">
|
||||||
|
<h4 className="font-medium break-all">Analytics for {moduleDetails.name}</h4>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Lead</h6>
|
||||||
|
<span>
|
||||||
|
{moduleDetails.lead_detail?.first_name}{" "}
|
||||||
|
{moduleDetails.lead_detail?.last_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Start Date</h6>
|
||||||
|
<span>
|
||||||
|
{moduleDetails.start_date && moduleDetails.start_date !== ""
|
||||||
|
? renderShortDate(moduleDetails.start_date)
|
||||||
|
: "No start date"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Target Date</h6>
|
||||||
|
<span>
|
||||||
|
{moduleDetails.target_date && moduleDetails.target_date !== ""
|
||||||
|
? renderShortDate(moduleDetails.target_date)
|
||||||
|
: "No end date"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="hidden md:flex md:flex-col h-full overflow-y-auto">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{projectDetails?.emoji ? (
|
||||||
|
<div className="grid h-6 w-6 flex-shrink-0 place-items-center">
|
||||||
|
{String.fromCodePoint(parseInt(projectDetails.emoji))}
|
||||||
|
</div>
|
||||||
|
) : projectDetails?.icon_prop ? (
|
||||||
|
<div className="h-6 w-6 grid place-items-center flex-shrink-0">
|
||||||
|
<span
|
||||||
|
style={{ color: projectDetails.icon_prop.color }}
|
||||||
|
className="material-symbols-rounded text-lg"
|
||||||
|
>
|
||||||
|
{projectDetails.icon_prop.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="grid h-6 w-6 mr-1 flex-shrink-0 place-items-center rounded bg-gray-700 uppercase text-white">
|
||||||
|
{projectDetails?.name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<h4 className="font-medium break-all">{projectDetails?.name}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<h6 className="text-brand-secondary">Network</h6>
|
||||||
|
<span>
|
||||||
|
{
|
||||||
|
NETWORK_CHOICES[
|
||||||
|
`${projectDetails?.network}` as keyof typeof NETWORK_CHOICES
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap justify-self-end">
|
||||||
|
<SecondaryButton
|
||||||
|
onClick={() => {
|
||||||
|
if (!workspaceSlug) return;
|
||||||
|
|
||||||
|
mutate(ANALYTICS(workspaceSlug.toString(), params));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 -my-1">
|
||||||
|
<ArrowPathIcon className="h-3.5 w-3.5" />
|
||||||
|
Refresh
|
||||||
|
</div>
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton onClick={exportAnalytics}>
|
||||||
|
<div className="flex items-center gap-2 -my-1">
|
||||||
|
<ArrowDownTrayIcon className="h-3.5 w-3.5" />
|
||||||
|
Export as CSV
|
||||||
|
</div>
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
135
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
135
apps/app/components/analytics/custom-analytics/table.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// nivo
|
||||||
|
import { BarDatum } from "@nivo/bar";
|
||||||
|
// icons
|
||||||
|
import { getPriorityIcon } from "components/icons";
|
||||||
|
// helpers
|
||||||
|
import { addSpaceIfCamelCase } from "helpers/string.helper";
|
||||||
|
// helpers
|
||||||
|
import { generateBarColor, renderMonthAndYear } from "helpers/analytics.helper";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, IAnalyticsResponse } from "types";
|
||||||
|
// constants
|
||||||
|
import { ANALYTICS_X_AXIS_VALUES, ANALYTICS_Y_AXIS_VALUES, DATE_KEYS } from "constants/analytics";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
analytics: IAnalyticsResponse;
|
||||||
|
barGraphData: {
|
||||||
|
data: BarDatum[];
|
||||||
|
xAxisKeys: string[];
|
||||||
|
};
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
yAxisKey: "count" | "estimate";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsTable: React.FC<Props> = ({ analytics, barGraphData, params, yAxisKey }) => {
|
||||||
|
const renderAssigneeName = (email: string): string => {
|
||||||
|
const assignee = analytics.extras.assignee_details.find((a) => a.assignees__email === email);
|
||||||
|
|
||||||
|
if (!assignee) return "No assignee";
|
||||||
|
|
||||||
|
if (assignee.assignees__first_name !== "")
|
||||||
|
return assignee.assignees__first_name + " " + assignee.assignees__last_name;
|
||||||
|
|
||||||
|
return email;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flow-root">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<div className="inline-block min-w-full align-middle">
|
||||||
|
<table className="min-w-full divide-y divide-brand-base whitespace-nowrap border-y border-brand-base">
|
||||||
|
<thead className="bg-brand-surface-2">
|
||||||
|
<tr className="divide-x divide-brand-base text-sm text-brand-base">
|
||||||
|
<th scope="col" className="py-3 px-2.5 text-left font-medium">
|
||||||
|
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === params.x_axis)?.label}
|
||||||
|
</th>
|
||||||
|
{params.segment ? (
|
||||||
|
barGraphData.xAxisKeys.map((key) => (
|
||||||
|
<th
|
||||||
|
key={`segment-${key}`}
|
||||||
|
scope="col"
|
||||||
|
className={`px-2.5 py-3 text-left font-medium ${
|
||||||
|
params.segment === "priority" || params.segment === "state__group"
|
||||||
|
? "capitalize"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{params.segment === "priority" ? (
|
||||||
|
getPriorityIcon(key)
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 flex-shrink-0 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: generateBarColor(key, analytics, params, "segment"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{DATE_KEYS.includes(params.segment ?? "")
|
||||||
|
? renderMonthAndYear(key)
|
||||||
|
: params.segment === "assignees__email"
|
||||||
|
? renderAssigneeName(key)
|
||||||
|
: key}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<th scope="col" className="py-3 px-2.5 text-left font-medium sm:pr-0">
|
||||||
|
{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === params.y_axis)?.label}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-brand-base">
|
||||||
|
{barGraphData.data.map((item, index) => (
|
||||||
|
<tr
|
||||||
|
key={`table-row-${index}`}
|
||||||
|
className="divide-x divide-brand-base text-xs text-brand-secondary"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className={`flex items-center gap-2 whitespace-nowrap py-2 px-2.5 font-medium ${
|
||||||
|
params.x_axis === "priority" || params.x_axis === "state__group"
|
||||||
|
? "capitalize"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{params.x_axis === "priority" ? (
|
||||||
|
getPriorityIcon(`${item.name}`)
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: generateBarColor(
|
||||||
|
`${item.name}`,
|
||||||
|
analytics,
|
||||||
|
params,
|
||||||
|
"x_axis"
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{params.x_axis === "assignees__email"
|
||||||
|
? renderAssigneeName(`${item.name}`)
|
||||||
|
: addSpaceIfCamelCase(`${item.name}`)}
|
||||||
|
</td>
|
||||||
|
{params.segment ? (
|
||||||
|
barGraphData.xAxisKeys.map((key, index) => (
|
||||||
|
<td
|
||||||
|
key={`segment-value-${index}`}
|
||||||
|
className="whitespace-nowrap py-2 px-2.5 sm:pr-0"
|
||||||
|
>
|
||||||
|
{item[key] ?? 0}
|
||||||
|
</td>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<td className="whitespace-nowrap py-2 px-2.5 sm:pr-0">{item[yAxisKey]}</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
4
apps/app/components/analytics/index.ts
Normal file
4
apps/app/components/analytics/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./custom-analytics";
|
||||||
|
export * from "./scope-and-demand";
|
||||||
|
export * from "./select";
|
||||||
|
export * from "./project-modal";
|
174
apps/app/components/analytics/project-modal.tsx
Normal file
174
apps/app/components/analytics/project-modal.tsx
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import React, { Fragment, useState } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// react-hook-form
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
// headless ui
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import analyticsService from "services/analytics.service";
|
||||||
|
import projectService from "services/project.service";
|
||||||
|
import cyclesService from "services/cycles.service";
|
||||||
|
import modulesService from "services/modules.service";
|
||||||
|
// components
|
||||||
|
import { CustomAnalytics, ScopeAndDemand } from "components/analytics";
|
||||||
|
// icons
|
||||||
|
import {
|
||||||
|
ArrowsPointingInIcon,
|
||||||
|
ArrowsPointingOutIcon,
|
||||||
|
XMarkIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { ANALYTICS, CYCLE_DETAILS, MODULE_DETAILS, PROJECT_DETAILS } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultValues: IAnalyticsParams = {
|
||||||
|
x_axis: "priority",
|
||||||
|
y_axis: "issue_count",
|
||||||
|
segment: null,
|
||||||
|
project: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabsList = ["Scope and Demand", "Custom Analytics"];
|
||||||
|
|
||||||
|
export const AnalyticsProjectModal: React.FC<Props> = ({ isOpen, onClose }) => {
|
||||||
|
const [fullScreen, setFullScreen] = useState(false);
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
const { control, watch, setValue } = useForm<IAnalyticsParams>({ defaultValues });
|
||||||
|
|
||||||
|
const params: IAnalyticsParams = {
|
||||||
|
x_axis: watch("x_axis"),
|
||||||
|
y_axis: watch("y_axis"),
|
||||||
|
segment: watch("segment"),
|
||||||
|
project: projectId ? [projectId.toString()] : watch("project"),
|
||||||
|
cycle: cycleId ? cycleId.toString() : null,
|
||||||
|
module: moduleId ? moduleId.toString() : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: analytics, error: analyticsError } = useSWR(
|
||||||
|
workspaceSlug ? ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug ? () => analyticsService.getAnalytics(workspaceSlug.toString(), params) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||||
|
? PROJECT_DETAILS(projectId.toString())
|
||||||
|
: null,
|
||||||
|
workspaceSlug && projectId && !(cycleId || moduleId)
|
||||||
|
? () => projectService.getProject(workspaceSlug.toString(), projectId.toString())
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: cycleDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId && cycleId ? CYCLE_DETAILS(cycleId.toString()) : null,
|
||||||
|
workspaceSlug && projectId && cycleId
|
||||||
|
? () =>
|
||||||
|
cyclesService.getCycleDetails(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
cycleId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: moduleDetails } = useSWR(
|
||||||
|
workspaceSlug && projectId && moduleId ? MODULE_DETAILS(moduleId.toString()) : null,
|
||||||
|
workspaceSlug && projectId && moduleId
|
||||||
|
? () =>
|
||||||
|
modulesService.getModuleDetails(
|
||||||
|
workspaceSlug.toString(),
|
||||||
|
projectId.toString(),
|
||||||
|
moduleId.toString()
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute top-0 z-30 h-full bg-brand-surface-1 ${
|
||||||
|
fullScreen ? "p-2 w-full" : "w-1/2"
|
||||||
|
} ${isOpen ? "right-0" : "-right-full"} duration-300 transition-all`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-full flex-col overflow-hidden border-brand-base bg-brand-base text-left ${
|
||||||
|
fullScreen ? "rounded-lg border" : "border-l"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4 bg-brand-base px-5 py-4 text-sm">
|
||||||
|
<h3 className="break-all">
|
||||||
|
Analytics for{" "}
|
||||||
|
{cycleId ? cycleDetails?.name : moduleId ? moduleDetails?.name : projectDetails?.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
|
||||||
|
onClick={() => setFullScreen((prevData) => !prevData)}
|
||||||
|
>
|
||||||
|
{fullScreen ? (
|
||||||
|
<ArrowsPointingInIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ArrowsPointingOutIcon className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="grid place-items-center p-1 text-brand-secondary hover:text-brand-base"
|
||||||
|
onClick={handleClose}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tab.Group as={Fragment}>
|
||||||
|
<Tab.List as="div" className="space-x-2 border-b border-brand-base p-5 pt-0">
|
||||||
|
{tabsList.map((tab) => (
|
||||||
|
<Tab
|
||||||
|
key={tab}
|
||||||
|
className={({ selected }) =>
|
||||||
|
`rounded-3xl border border-brand-base px-4 py-2 text-xs hover:bg-brand-surface-2 ${
|
||||||
|
selected ? "bg-brand-surface-2" : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Tab>
|
||||||
|
))}
|
||||||
|
</Tab.List>
|
||||||
|
{/* <h4 className="p-5 pb-0">Analytics for</h4> */}
|
||||||
|
<Tab.Panels as={Fragment}>
|
||||||
|
<Tab.Panel as={Fragment}>
|
||||||
|
<ScopeAndDemand fullScreen={fullScreen} />
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel as={Fragment}>
|
||||||
|
<CustomAnalytics
|
||||||
|
analytics={analytics}
|
||||||
|
analyticsError={analyticsError}
|
||||||
|
params={params}
|
||||||
|
control={control}
|
||||||
|
setValue={setValue}
|
||||||
|
fullScreen={fullScreen}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
63
apps/app/components/analytics/scope-and-demand/demand.tsx
Normal file
63
apps/app/components/analytics/scope-and-demand/demand.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// icons
|
||||||
|
import { PlayIcon } from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import { IDefaultAnalyticsResponse } from "types";
|
||||||
|
// constants
|
||||||
|
import { STATE_GROUP_COLORS } from "constants/state";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsDemand: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||||
|
<div className="space-y-3 rounded-[10px] border border-brand-base p-3">
|
||||||
|
<h5 className="text-xs text-red-500">DEMAND</h5>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-brand-bas text-base font-medium">Total open tasks</h4>
|
||||||
|
<h3 className="mt-1 text-xl font-semibold">{defaultAnalytics.open_issues}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{defaultAnalytics?.open_issues_classified.map((group) => {
|
||||||
|
const percentage = ((group.state_count / defaultAnalytics.total_issues) * 100).toFixed(0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.state_group} className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<h6 className="capitalize">{group.state_group}</h6>
|
||||||
|
<span className="ml-1 rounded-3xl bg-brand-surface-2 px-2 py-0.5 text-[0.65rem] text-brand-secondary">
|
||||||
|
{group.state_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-brand-secondary">{percentage}%</p>
|
||||||
|
</div>
|
||||||
|
<div className="bar relative h-1 w-full rounded bg-brand-surface-2">
|
||||||
|
<div
|
||||||
|
className="absolute top-0 left-0 h-1 rounded duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${percentage}%`,
|
||||||
|
backgroundColor: STATE_GROUP_COLORS[group.state_group],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="!mt-6 flex w-min items-center gap-2 whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||||
|
<p className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<PlayIcon className="h-4 w-4 -rotate-90" aria-hidden="true" />
|
||||||
|
<span>Estimate Demand:</span>
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{defaultAnalytics.open_estimate_sum}/{defaultAnalytics.total_estimate_sum}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
5
apps/app/components/analytics/scope-and-demand/index.ts
Normal file
5
apps/app/components/analytics/scope-and-demand/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export * from "./demand";
|
||||||
|
export * from "./leaderboard";
|
||||||
|
export * from "./scope-and-demand";
|
||||||
|
export * from "./scope";
|
||||||
|
export * from "./year-wise-issues";
|
@ -0,0 +1,52 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
users: {
|
||||||
|
avatar: string | null;
|
||||||
|
email: string | null;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
count: number;
|
||||||
|
}[];
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsLeaderboard: React.FC<Props> = ({ users, title }) => (
|
||||||
|
<div className="p-3 border border-brand-base rounded-[10px]">
|
||||||
|
<h6 className="text-base font-medium">{title}</h6>
|
||||||
|
{users.length > 0 ? (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.email ?? "None"}
|
||||||
|
className="flex items-start justify-between gap-4 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{user && user.avatar && user.avatar !== "" ? (
|
||||||
|
<div className="rounded-full h-4 w-4 flex-shrink-0">
|
||||||
|
<Image
|
||||||
|
src={user.avatar}
|
||||||
|
height="100%"
|
||||||
|
width="100%"
|
||||||
|
className="rounded-full"
|
||||||
|
alt={user.email ?? "None"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid place-items-center flex-shrink-0 rounded-full bg-gray-700 text-[11px] capitalize text-white h-4 w-4">
|
||||||
|
{user.firstName !== "" ? user.firstName[0] : "?"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="break-all text-brand-secondary">
|
||||||
|
{user.firstName !== "" ? `${user.firstName} ${user.lastName}` : "No assignee"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="flex-shrink-0">{user.count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
@ -0,0 +1,101 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import analyticsService from "services/analytics.service";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
AnalyticsDemand,
|
||||||
|
AnalyticsLeaderboard,
|
||||||
|
AnalyticsScope,
|
||||||
|
AnalyticsYearWiseIssues,
|
||||||
|
} from "components/analytics";
|
||||||
|
// ui
|
||||||
|
import { Loader, PrimaryButton } from "components/ui";
|
||||||
|
// fetch-keys
|
||||||
|
import { DEFAULT_ANALYTICS } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fullScreen?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScopeAndDemand: React.FC<Props> = ({ fullScreen = true }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
const isProjectLevel = projectId ? true : false;
|
||||||
|
|
||||||
|
const params = isProjectLevel
|
||||||
|
? {
|
||||||
|
project: projectId ? [projectId.toString()] : null,
|
||||||
|
cycle: cycleId ? cycleId.toString() : null,
|
||||||
|
module: moduleId ? moduleId.toString() : null,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: defaultAnalytics,
|
||||||
|
error: defaultAnalyticsError,
|
||||||
|
mutate: mutateDefaultAnalytics,
|
||||||
|
} = useSWR(
|
||||||
|
workspaceSlug ? DEFAULT_ANALYTICS(workspaceSlug.toString(), params) : null,
|
||||||
|
workspaceSlug
|
||||||
|
? () => analyticsService.getDefaultAnalytics(workspaceSlug.toString(), params)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!defaultAnalyticsError ? (
|
||||||
|
defaultAnalytics ? (
|
||||||
|
<div className="h-full overflow-y-auto p-5 text-sm">
|
||||||
|
<div className={`grid grid-cols-1 gap-5 ${fullScreen ? "md:grid-cols-2" : ""}`}>
|
||||||
|
<AnalyticsDemand defaultAnalytics={defaultAnalytics} />
|
||||||
|
<AnalyticsScope defaultAnalytics={defaultAnalytics} />
|
||||||
|
<AnalyticsLeaderboard
|
||||||
|
users={defaultAnalytics.most_issue_created_user?.map((user) => ({
|
||||||
|
avatar: user?.created_by__avatar,
|
||||||
|
email: user?.created_by__email,
|
||||||
|
firstName: user?.created_by__first_name,
|
||||||
|
lastName: user?.created_by__last_name,
|
||||||
|
count: user?.count,
|
||||||
|
}))}
|
||||||
|
title="Most issues created"
|
||||||
|
/>
|
||||||
|
<AnalyticsLeaderboard
|
||||||
|
users={defaultAnalytics.most_issue_closed_user?.map((user) => ({
|
||||||
|
avatar: user?.assignees__avatar,
|
||||||
|
email: user?.assignees__email,
|
||||||
|
firstName: user?.assignees__first_name,
|
||||||
|
lastName: user?.assignees__last_name,
|
||||||
|
count: user?.count,
|
||||||
|
}))}
|
||||||
|
title="Most issues closed"
|
||||||
|
/>
|
||||||
|
<div className={fullScreen ? "md:col-span-2" : ""}>
|
||||||
|
<AnalyticsYearWiseIssues defaultAnalytics={defaultAnalytics} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Loader className="grid grid-cols-1 gap-5 p-5 lg:grid-cols-2">
|
||||||
|
<Loader.Item height="250px" />
|
||||||
|
<Loader.Item height="250px" />
|
||||||
|
<Loader.Item height="250px" />
|
||||||
|
<Loader.Item height="250px" />
|
||||||
|
</Loader>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="grid h-full place-items-center p-5">
|
||||||
|
<div className="space-y-4 text-brand-secondary">
|
||||||
|
<p className="text-sm">There was some error in fetching the data.</p>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<PrimaryButton onClick={() => mutateDefaultAnalytics()}>Refresh</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
84
apps/app/components/analytics/scope-and-demand/scope.tsx
Normal file
84
apps/app/components/analytics/scope-and-demand/scope.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
// ui
|
||||||
|
import { BarGraph } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { IDefaultAnalyticsResponse } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsScope: React.FC<Props> = ({ defaultAnalytics }) => (
|
||||||
|
<div className="rounded-[10px] border border-brand-base">
|
||||||
|
<h5 className="p-3 text-xs text-green-500">SCOPE</h5>
|
||||||
|
<div className="divide-y divide-brand-base">
|
||||||
|
<div>
|
||||||
|
<h6 className="px-3 text-base font-medium">Pending issues</h6>
|
||||||
|
{defaultAnalytics.pending_issue_user.length > 0 ? (
|
||||||
|
<BarGraph
|
||||||
|
data={defaultAnalytics.pending_issue_user}
|
||||||
|
indexBy="assignees__email"
|
||||||
|
keys={["count"]}
|
||||||
|
height="250px"
|
||||||
|
colors={() => `#f97316`}
|
||||||
|
customYAxisTickValues={defaultAnalytics.pending_issue_user.map((d) => d.count)}
|
||||||
|
tooltip={(datum) => {
|
||||||
|
const assignee = defaultAnalytics.pending_issue_user.find(
|
||||||
|
(a) => a.assignees__email === `${datum.indexValue}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-brand-base bg-brand-surface-2 p-2 text-xs">
|
||||||
|
<span className="font-medium text-brand-secondary">
|
||||||
|
{assignee
|
||||||
|
? assignee.assignees__first_name + " " + assignee.assignees__last_name
|
||||||
|
: "No assignee"}
|
||||||
|
:{" "}
|
||||||
|
</span>
|
||||||
|
{datum.value}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
axisBottom={{
|
||||||
|
renderTick: (datum) => {
|
||||||
|
const avatar =
|
||||||
|
defaultAnalytics.pending_issue_user[datum.tickIndex]?.assignees__avatar ?? "";
|
||||||
|
|
||||||
|
if (avatar && avatar !== "")
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
|
<image
|
||||||
|
x={-8}
|
||||||
|
y={10}
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
xlinkHref={avatar}
|
||||||
|
style={{ clipPath: "circle(50%)" }}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
else
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${datum.x},${datum.y})`}>
|
||||||
|
<circle cy={18} r={8} fill="#374151" />
|
||||||
|
<text x={0} y={21} textAnchor="middle" fontSize={9} fill="#ffffff">
|
||||||
|
{datum.value ? `${datum.value}`.toUpperCase()[0] : "?"}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
margin={{ top: 20 }}
|
||||||
|
theme={{
|
||||||
|
background: "rgb(var(--color-bg-base))",
|
||||||
|
axis: {},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-brand-secondary text-center text-sm py-8">
|
||||||
|
No matching data found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
@ -0,0 +1,54 @@
|
|||||||
|
// ui
|
||||||
|
import { LineGraph } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { IDefaultAnalyticsResponse } from "types";
|
||||||
|
// constants
|
||||||
|
import { MONTHS_LIST } from "constants/calendar";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultAnalytics: IDefaultAnalyticsResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnalyticsYearWiseIssues: React.FC<Props> = ({ defaultAnalytics }) => {
|
||||||
|
const currentMonth = new Date().getMonth();
|
||||||
|
const startMonth = Math.floor(currentMonth / 3) * 3 + 1;
|
||||||
|
const quarterMonthsList = [startMonth, startMonth + 1, startMonth + 2];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-3 border border-brand-base rounded-[10px]">
|
||||||
|
<h1 className="px-3 text-base font-medium">Issues closed in a year</h1>
|
||||||
|
{defaultAnalytics.issue_completed_month_wise.length > 0 ? (
|
||||||
|
<LineGraph
|
||||||
|
data={[
|
||||||
|
{
|
||||||
|
id: "issues_closed",
|
||||||
|
color: "rgb(var(--color-accent))",
|
||||||
|
data: MONTHS_LIST.map((month) => ({
|
||||||
|
x: month.label.substring(0, 3),
|
||||||
|
y:
|
||||||
|
defaultAnalytics.issue_completed_month_wise.find(
|
||||||
|
(data) => data.month === month.value
|
||||||
|
)?.count || 0,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
customYAxisTickValues={defaultAnalytics.issue_completed_month_wise.map((data) => {
|
||||||
|
if (quarterMonthsList.includes(data.month)) return data.count;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
})}
|
||||||
|
height="300px"
|
||||||
|
colors={(datum) => datum.color}
|
||||||
|
curve="monotoneX"
|
||||||
|
margin={{ top: 20 }}
|
||||||
|
theme={{
|
||||||
|
background: "rgb(var(--color-bg-base))",
|
||||||
|
}}
|
||||||
|
enableArea
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-brand-secondary text-center text-sm py-8">No matching data found.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
4
apps/app/components/analytics/select/index.ts
Normal file
4
apps/app/components/analytics/select/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./project";
|
||||||
|
export * from "./segment";
|
||||||
|
export * from "./x-axis";
|
||||||
|
export * from "./y-axis";
|
41
apps/app/components/analytics/select/project.tsx
Normal file
41
apps/app/components/analytics/select/project.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
// ui
|
||||||
|
import { CustomSearchSelect } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { IProject } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string[] | null | undefined;
|
||||||
|
onChange: (val: string[] | null) => void;
|
||||||
|
projects: IProject[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectProject: React.FC<Props> = ({ value, onChange, projects }) => {
|
||||||
|
const options = projects?.map((project) => ({
|
||||||
|
value: project.id,
|
||||||
|
query: project.name + project.identifier,
|
||||||
|
content: (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-brand-secondary text-[0.65rem]">{project.identifier}</span>
|
||||||
|
{project.name}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomSearchSelect
|
||||||
|
value={value ?? []}
|
||||||
|
onChange={(val: string[]) => onChange(val)}
|
||||||
|
options={options}
|
||||||
|
label={
|
||||||
|
value && value.length > 0
|
||||||
|
? projects
|
||||||
|
.filter((p) => value.includes(p.id))
|
||||||
|
.map((p) => p.identifier)
|
||||||
|
.join(", ")
|
||||||
|
: "All projects"
|
||||||
|
}
|
||||||
|
optionsClassName="min-w-full"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
48
apps/app/components/analytics/select/segment.tsx
Normal file
48
apps/app/components/analytics/select/segment.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { CustomSelect } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { IAnalyticsParams, TXAxisValues } from "types";
|
||||||
|
// constants
|
||||||
|
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: TXAxisValues | null | undefined;
|
||||||
|
onChange: () => void;
|
||||||
|
params: IAnalyticsParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectSegment: React.FC<Props> = ({ value, onChange, params }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomSelect
|
||||||
|
value={value}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label ?? (
|
||||||
|
<span className="text-brand-secondary">No value</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onChange={onChange}
|
||||||
|
width="w-full"
|
||||||
|
maxHeight="lg"
|
||||||
|
>
|
||||||
|
<CustomSelect.Option value={null}>No value</CustomSelect.Option>
|
||||||
|
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||||
|
if (params.x_axis === item.value) return null;
|
||||||
|
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
|
||||||
|
if (moduleId && item.value === "issue_module__module__name") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomSelect.Option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CustomSelect>
|
||||||
|
);
|
||||||
|
};
|
39
apps/app/components/analytics/select/x-axis.tsx
Normal file
39
apps/app/components/analytics/select/x-axis.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// ui
|
||||||
|
import { CustomSelect } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { TXAxisValues } from "types";
|
||||||
|
// constants
|
||||||
|
import { ANALYTICS_X_AXIS_VALUES } from "constants/analytics";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: TXAxisValues;
|
||||||
|
onChange: (val: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectXAxis: React.FC<Props> = ({ value, onChange }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { cycleId, moduleId } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomSelect
|
||||||
|
value={value}
|
||||||
|
label={<span>{ANALYTICS_X_AXIS_VALUES.find((v) => v.value === value)?.label}</span>}
|
||||||
|
onChange={onChange}
|
||||||
|
width="w-full"
|
||||||
|
maxHeight="lg"
|
||||||
|
>
|
||||||
|
{ANALYTICS_X_AXIS_VALUES.map((item) => {
|
||||||
|
if (cycleId && item.value === "issue_cycle__cycle__name") return null;
|
||||||
|
if (moduleId && item.value === "issue_module__module__name") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CustomSelect.Option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CustomSelect>
|
||||||
|
);
|
||||||
|
};
|
26
apps/app/components/analytics/select/y-axis.tsx
Normal file
26
apps/app/components/analytics/select/y-axis.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// ui
|
||||||
|
import { CustomSelect } from "components/ui";
|
||||||
|
// types
|
||||||
|
import { TYAxisValues } from "types";
|
||||||
|
// constants
|
||||||
|
import { ANALYTICS_Y_AXIS_VALUES } from "constants/analytics";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: TYAxisValues;
|
||||||
|
onChange: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SelectYAxis: React.FC<Props> = ({ value, onChange }) => (
|
||||||
|
<CustomSelect
|
||||||
|
value={value}
|
||||||
|
label={<span>{ANALYTICS_Y_AXIS_VALUES.find((v) => v.value === value)?.label ?? "None"}</span>}
|
||||||
|
onChange={onChange}
|
||||||
|
width="w-full"
|
||||||
|
>
|
||||||
|
{ANALYTICS_Y_AXIS_VALUES.map((item) => (
|
||||||
|
<CustomSelect.Option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</CustomSelect.Option>
|
||||||
|
))}
|
||||||
|
</CustomSelect>
|
||||||
|
);
|
@ -27,7 +27,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
|||||||
description: "You are not authorized to view this page",
|
description: "You are not authorized to view this page",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||||
<div className="h-44 w-72">
|
<div className="h-44 w-72">
|
||||||
<Image
|
<Image
|
||||||
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
|
||||||
@ -36,7 +36,9 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
|
|||||||
alt="ProjectSettingImg"
|
alt="ProjectSettingImg"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-medium">Oops! You are not authorized to view this page</h1>
|
<h1 className="text-xl font-medium text-brand-base">
|
||||||
|
Oops! You are not authorized to view this page
|
||||||
|
</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||||
{user ? (
|
{user ? (
|
||||||
|
@ -41,11 +41,11 @@ export const JoinProject: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 text-center">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-y-5 bg-brand-surface-1 text-center">
|
||||||
<div className="h-44 w-72">
|
<div className="h-44 w-72">
|
||||||
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
|
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-medium">You are not a member of this project</h1>
|
<h1 className="text-xl font-medium text-brand-base">You are not a member of this project</h1>
|
||||||
|
|
||||||
<div className="w-full max-w-md text-base text-brand-secondary">
|
<div className="w-full max-w-md text-base text-brand-secondary">
|
||||||
<p className="mx-auto w-full text-sm md:w-3/4">
|
<p className="mx-auto w-full text-sm md:w-3/4">
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
@ -60,14 +60,8 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
|
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentState?.group === "completed" || currentState?.group === "cancelled")
|
|
||||||
setIsCollapsed(false);
|
|
||||||
}, [currentState]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96"}`}>
|
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
|
||||||
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
|
|
||||||
<BoardHeader
|
<BoardHeader
|
||||||
addIssueToState={addIssueToState}
|
addIssueToState={addIssueToState}
|
||||||
currentState={currentState}
|
currentState={currentState}
|
||||||
@ -80,9 +74,9 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
|
||||||
{(provided, snapshot) => (
|
{(provided, snapshot) => (
|
||||||
<div
|
<div
|
||||||
className={`relative h-full overflow-y-auto p-1 ${
|
className={`relative h-full ${
|
||||||
snapshot.isDraggingOver ? "bg-brand-base/20" : ""
|
orderBy !== "sort_order" && snapshot.isDraggingOver ? "bg-brand-base/20" : ""
|
||||||
} ${!isCollapsed ? "hidden" : "block"}`}
|
} ${!isCollapsed ? "hidden" : "flex flex-col"}`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
>
|
>
|
||||||
@ -105,6 +99,7 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="pt-3 overflow-hidden overflow-y-scroll">
|
||||||
{groupedByIssues?.[groupTitle].map((issue, index) => (
|
{groupedByIssues?.[groupTitle].map((issue, index) => (
|
||||||
<Draggable
|
<Draggable
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
@ -146,10 +141,12 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
{provided.placeholder}
|
{provided.placeholder}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
{type === "issue" ? (
|
{type === "issue" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex items-center gap-2 font-medium text-brand-accent outline-none"
|
className="flex items-center gap-2 font-medium text-brand-accent outline-none p-1"
|
||||||
onClick={addIssueToState}
|
onClick={addIssueToState}
|
||||||
>
|
>
|
||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
@ -167,7 +164,7 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
Add Issue
|
Add Issue
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
optionsPosition="left"
|
position="left"
|
||||||
noBorder
|
noBorder
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem onClick={addIssueToState}>
|
<CustomMenu.MenuItem onClick={addIssueToState}>
|
||||||
@ -182,10 +179,10 @@ export const SingleBoard: React.FC<Props> = ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</StrictModeDroppable>
|
</StrictModeDroppable>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useState } from "react";
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
@ -17,6 +17,7 @@ import issuesService from "services/issues.service";
|
|||||||
// hooks
|
// hooks
|
||||||
import useIssuesView from "hooks/use-issues-view";
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
// components
|
// components
|
||||||
import {
|
import {
|
||||||
ViewAssigneeSelect,
|
ViewAssigneeSelect,
|
||||||
@ -36,7 +37,9 @@ import {
|
|||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
PaperClipIcon,
|
PaperClipIcon,
|
||||||
|
EllipsisHorizontalIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { LayerDiagonalIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { handleIssuesMutation } from "constants/issue";
|
import { handleIssuesMutation } from "constants/issue";
|
||||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||||
@ -89,6 +92,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
// context menu
|
// context menu
|
||||||
const [contextMenu, setContextMenu] = useState(false);
|
const [contextMenu, setContextMenu] = useState(false);
|
||||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||||
|
|
||||||
|
const actionSectionRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { orderBy, params } = useIssuesView();
|
const { orderBy, params } = useIssuesView();
|
||||||
|
|
||||||
@ -98,7 +104,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const partialUpdateIssue = useCallback(
|
const partialUpdateIssue = useCallback(
|
||||||
(formData: Partial<IIssue>) => {
|
(formData: Partial<IIssue>, issueId: string) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
if (cycleId)
|
if (cycleId)
|
||||||
@ -164,7 +170,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
issuesService
|
issuesService
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (cycleId) {
|
if (cycleId) {
|
||||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||||
@ -178,18 +184,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
console.log(error);
|
console.log(error);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
cycleId,
|
|
||||||
moduleId,
|
|
||||||
issue,
|
|
||||||
groupTitle,
|
|
||||||
index,
|
|
||||||
selectedGroup,
|
|
||||||
orderBy,
|
|
||||||
params,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const getStyle = (
|
const getStyle = (
|
||||||
@ -217,6 +212,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
title: "Link Copied!",
|
title: "Link Copied!",
|
||||||
message: "Issue link copied to clipboard.",
|
message: "Issue link copied to clipboard.",
|
||||||
});
|
});
|
||||||
|
setIsMenuActive(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -224,6 +220,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
|
||||||
}, [snapshot, handleTrashBox]);
|
}, [snapshot, handleTrashBox]);
|
||||||
|
|
||||||
|
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
|
||||||
|
|
||||||
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -276,9 +274,23 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<div className="group/card relative select-none p-3.5">
|
<div className="group/card relative select-none p-3.5">
|
||||||
{!isNotAllowed && (
|
{!isNotAllowed && (
|
||||||
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
|
<div
|
||||||
|
ref={actionSectionRef}
|
||||||
|
className={`z-1 absolute top-1.5 right-1.5 hidden group-hover/card:!flex ${
|
||||||
|
isMenuActive ? "!flex" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{type && !isNotAllowed && (
|
{type && !isNotAllowed && (
|
||||||
<CustomMenu width="auto" ellipsis>
|
<CustomMenu
|
||||||
|
customButton={
|
||||||
|
<button
|
||||||
|
className="flex w-full cursor-pointer items-center justify-between gap-1 rounded p-1 text-left text-xs duration-300 hover:bg-brand-surface-2"
|
||||||
|
onClick={() => setIsMenuActive(!isMenuActive)}
|
||||||
|
>
|
||||||
|
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<CustomMenu.MenuItem onClick={editIssue}>
|
<CustomMenu.MenuItem onClick={editIssue}>
|
||||||
<div className="flex items-center justify-start gap-2">
|
<div className="flex items-center justify-start gap-2">
|
||||||
<PencilIcon className="h-4 w-4" />
|
<PencilIcon className="h-4 w-4" />
|
||||||
@ -348,11 +360,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.sub_issue_count && (
|
|
||||||
<div className="flex flex-shrink-0 items-center gap-1 rounded-md border border-brand-base px-2 py-1 text-xs text-brand-secondary shadow-sm">
|
|
||||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{properties.labels && issue.label_details.length > 0 && (
|
{properties.labels && issue.label_details.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{issue.label_details.map((label) => (
|
{issue.label_details.map((label) => (
|
||||||
@ -388,6 +395,16 @@ export const SingleBoardIssue: React.FC<Props> = ({
|
|||||||
selfPositioned
|
selfPositioned
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{properties.sub_issue_count && (
|
||||||
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
|
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||||
|
<div className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<LayerDiagonalIcon className="h-3.5 w-3.5" />
|
||||||
|
{issue.sub_issues_count}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{properties.link && (
|
{properties.link && (
|
||||||
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
|
<Tooltip tooltipHeading="Link" tooltipContent={`${issue.link_count}`}>
|
||||||
|
@ -121,7 +121,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
|||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 divide-opacity-10 rounded-xl bg-brand-surface-2 shadow-2xl ring-1 ring-black ring-opacity-5 transition-all">
|
<Dialog.Panel className="relative mx-auto max-w-2xl transform divide-y divide-gray-500 rounded-xl border border-brand-base bg-brand-base shadow-2xl transition-all">
|
||||||
<form>
|
<form>
|
||||||
<Combobox
|
<Combobox
|
||||||
onChange={(val: string) => {
|
onChange={(val: string) => {
|
||||||
@ -149,7 +149,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
|||||||
|
|
||||||
<Combobox.Options
|
<Combobox.Options
|
||||||
static
|
static
|
||||||
className="max-h-80 scroll-py-2 divide-y divide-gray-500 divide-opacity-10 overflow-y-auto"
|
className="max-h-80 scroll-py-2 divide-y divide-brand-base overflow-y-auto"
|
||||||
>
|
>
|
||||||
{filteredIssues.length > 0 ? (
|
{filteredIssues.length > 0 ? (
|
||||||
<li className="p-2">
|
<li className="p-2">
|
||||||
@ -158,15 +158,15 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
|||||||
Select issues to delete
|
Select issues to delete
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<ul className="text-sm text-gray-700">
|
<ul className="text-sm text-brand-secondary">
|
||||||
{filteredIssues.map((issue) => (
|
{filteredIssues.map((issue) => (
|
||||||
<Combobox.Option
|
<Combobox.Option
|
||||||
key={issue.id}
|
key={issue.id}
|
||||||
as="div"
|
as="div"
|
||||||
value={issue.id}
|
value={issue.id}
|
||||||
className={({ active }) =>
|
className={({ active, selected }) =>
|
||||||
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
|
`flex cursor-pointer select-none items-center justify-between rounded-md px-3 py-2 ${
|
||||||
active ? "bg-gray-900 bg-opacity-5 text-brand-base" : ""
|
active ? "bg-brand-surface-2 text-brand-base" : ""
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@ -182,7 +182,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
|
|||||||
backgroundColor: issue.state_detail.color,
|
backgroundColor: issue.state_detail.color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
<span className="flex-shrink-0 text-xs">
|
||||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||||
</span>
|
</span>
|
||||||
<span>{issue.name}</span>
|
<span>{issue.name}</span>
|
||||||
|
220
apps/app/components/core/calendar-view/calendar-header.tsx
Normal file
220
apps/app/components/core/calendar-view/calendar-header.tsx
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// headless ui
|
||||||
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
// ui
|
||||||
|
import { CustomMenu, ToggleSwitch } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
// helpers
|
||||||
|
import {
|
||||||
|
addMonths,
|
||||||
|
addSevenDaysToDate,
|
||||||
|
formatDate,
|
||||||
|
getCurrentWeekEndDate,
|
||||||
|
getCurrentWeekStartDate,
|
||||||
|
isSameMonth,
|
||||||
|
isSameYear,
|
||||||
|
lastDayOfWeek,
|
||||||
|
startOfWeek,
|
||||||
|
subtract7DaysToDate,
|
||||||
|
subtractMonths,
|
||||||
|
updateDateWithMonth,
|
||||||
|
updateDateWithYear,
|
||||||
|
} from "helpers/calendar.helper";
|
||||||
|
// constants
|
||||||
|
import { MONTHS_LIST, YEARS_LIST } from "constants/calendar";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isMonthlyView: boolean;
|
||||||
|
setIsMonthlyView: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
currentDate: Date;
|
||||||
|
setCurrentDate: React.Dispatch<React.SetStateAction<Date>>;
|
||||||
|
showWeekEnds: boolean;
|
||||||
|
setShowWeekEnds: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
changeDateRange: (startDate: Date, endDate: Date) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CalendarHeader: React.FC<Props> = ({
|
||||||
|
setIsMonthlyView,
|
||||||
|
isMonthlyView,
|
||||||
|
currentDate,
|
||||||
|
setCurrentDate,
|
||||||
|
showWeekEnds,
|
||||||
|
setShowWeekEnds,
|
||||||
|
changeDateRange,
|
||||||
|
}) => {
|
||||||
|
const updateDate = (date: Date) => {
|
||||||
|
setCurrentDate(date);
|
||||||
|
|
||||||
|
changeDateRange(startOfWeek(date), lastDayOfWeek(date));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
|
||||||
|
<Popover className="flex h-full items-center justify-start rounded-lg">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button>
|
||||||
|
<div className="flex items-center justify-center gap-2 text-2xl font-semibold text-brand-base">
|
||||||
|
<span>{formatDate(currentDate, "Month")}</span>{" "}
|
||||||
|
<span>{formatDate(currentDate, "yyyy")}</span>
|
||||||
|
</div>
|
||||||
|
</Popover.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.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="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg">
|
||||||
|
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
|
||||||
|
{YEARS_LIST.map((year) => (
|
||||||
|
<button
|
||||||
|
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
|
||||||
|
className={` ${
|
||||||
|
isSameYear(year.value, currentDate)
|
||||||
|
? "text-sm font-medium text-brand-base"
|
||||||
|
: "text-xs text-brand-secondary "
|
||||||
|
} hover:text-sm hover:font-medium hover:text-brand-base`}
|
||||||
|
>
|
||||||
|
{year.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 border-t border-brand-base px-2">
|
||||||
|
{MONTHS_LIST.map((month) => (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
updateDate(updateDateWithMonth(`${month.value}`, currentDate))
|
||||||
|
}
|
||||||
|
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
|
||||||
|
isSameMonth(`${month.value}`, currentDate)
|
||||||
|
? "font-medium text-brand-base"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{month.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMonthlyView) {
|
||||||
|
updateDate(subtractMonths(currentDate, 1));
|
||||||
|
} else {
|
||||||
|
setCurrentDate(subtract7DaysToDate(currentDate));
|
||||||
|
changeDateRange(
|
||||||
|
getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
|
||||||
|
getCurrentWeekEndDate(subtract7DaysToDate(currentDate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMonthlyView) {
|
||||||
|
updateDate(addMonths(currentDate, 1));
|
||||||
|
} else {
|
||||||
|
setCurrentDate(addSevenDaysToDate(currentDate));
|
||||||
|
changeDateRange(
|
||||||
|
getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
|
||||||
|
getCurrentWeekEndDate(addSevenDaysToDate(currentDate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none"
|
||||||
|
onClick={() => {
|
||||||
|
if (isMonthlyView) {
|
||||||
|
updateDate(new Date());
|
||||||
|
} else {
|
||||||
|
setCurrentDate(new Date());
|
||||||
|
changeDateRange(
|
||||||
|
getCurrentWeekStartDate(new Date()),
|
||||||
|
getCurrentWeekEndDate(new Date())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<CustomMenu
|
||||||
|
customButton={
|
||||||
|
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none ">
|
||||||
|
{isMonthlyView ? "Monthly" : "Weekly"}
|
||||||
|
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsMonthlyView(true);
|
||||||
|
changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate));
|
||||||
|
}}
|
||||||
|
className="w-52 text-sm text-brand-secondary"
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-2">Monthly View</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-100" : "opacity-0"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setIsMonthlyView(false);
|
||||||
|
changeDateRange(
|
||||||
|
getCurrentWeekStartDate(currentDate),
|
||||||
|
getCurrentWeekEndDate(currentDate)
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className="w-52 text-sm text-brand-secondary"
|
||||||
|
>
|
||||||
|
<div className="flex w-full items-center justify-between gap-2">
|
||||||
|
<span className="flex items-center gap-2">Weekly View</span>
|
||||||
|
<CheckIcon
|
||||||
|
className={`h-4 w-4 flex-shrink-0 ${isMonthlyView ? "opacity-0" : "opacity-100"}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<div className="mt-1 flex w-52 items-center justify-between border-t border-brand-base py-2 px-1 text-sm text-brand-secondary">
|
||||||
|
<h4>Show weekends</h4>
|
||||||
|
<ToggleSwitch value={showWeekEnds} onChange={() => setShowWeekEnds(!showWeekEnds)} />
|
||||||
|
</div>
|
||||||
|
</CustomMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CalendarHeader;
|
@ -1,9 +1,20 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import useSWR, { mutate } from "swr";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
// helper
|
import { mutate } from "swr";
|
||||||
|
|
||||||
|
// react-beautiful-dnd
|
||||||
|
import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
// hooks
|
||||||
|
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||||
|
// components
|
||||||
|
import { SingleCalendarDate, CalendarHeader } from "components/core";
|
||||||
|
// ui
|
||||||
|
import { Spinner } from "components/ui";
|
||||||
|
// helpers
|
||||||
import { renderDateFormat } from "helpers/date-time.helper";
|
import { renderDateFormat } from "helpers/date-time.helper";
|
||||||
import {
|
import {
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
@ -11,156 +22,61 @@ import {
|
|||||||
eachDayOfInterval,
|
eachDayOfInterval,
|
||||||
weekDayInterval,
|
weekDayInterval,
|
||||||
formatDate,
|
formatDate,
|
||||||
getCurrentWeekStartDate,
|
|
||||||
getCurrentWeekEndDate,
|
|
||||||
subtractMonths,
|
|
||||||
addMonths,
|
|
||||||
updateDateWithYear,
|
|
||||||
updateDateWithMonth,
|
|
||||||
isSameMonth,
|
|
||||||
isSameYear,
|
|
||||||
subtract7DaysToDate,
|
|
||||||
addSevenDaysToDate,
|
|
||||||
} from "helpers/calendar.helper";
|
} from "helpers/calendar.helper";
|
||||||
// ui
|
// types
|
||||||
import { Popover, Transition } from "@headlessui/react";
|
import { ICalendarRange, IIssue, UserAuth } from "types";
|
||||||
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
|
// fetch-keys
|
||||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
|
||||||
import { CustomMenu, Spinner, ToggleSwitch } from "components/ui";
|
|
||||||
// icon
|
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CYCLE_ISSUES_WITH_PARAMS,
|
||||||
ChevronDownIcon,
|
MODULE_ISSUES_WITH_PARAMS,
|
||||||
ChevronLeftIcon,
|
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||||
ChevronRightIcon,
|
VIEW_ISSUES,
|
||||||
PlusIcon,
|
|
||||||
} from "@heroicons/react/24/outline";
|
|
||||||
// hooks
|
|
||||||
import useIssuesView from "hooks/use-issues-view";
|
|
||||||
// services
|
|
||||||
import issuesService from "services/issues.service";
|
|
||||||
import cyclesService from "services/cycles.service";
|
|
||||||
// fetch key
|
|
||||||
import {
|
|
||||||
CYCLE_CALENDAR_ISSUES,
|
|
||||||
MODULE_CALENDAR_ISSUES,
|
|
||||||
PROJECT_CALENDAR_ISSUES,
|
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
// type
|
|
||||||
import { IIssue } from "types";
|
|
||||||
// constant
|
|
||||||
import { monthOptions, yearOptions } from "constants/calendar";
|
|
||||||
import modulesService from "services/modules.service";
|
|
||||||
import { getStateGroupIcon } from "components/icons";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
addIssueToDate: (date: string) => void;
|
addIssueToDate: (date: string) => void;
|
||||||
|
isCompleted: boolean;
|
||||||
|
userAuth: UserAuth;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ICalendarRange {
|
export const CalendarView: React.FC<Props> = ({
|
||||||
startDate: Date;
|
handleEditIssue,
|
||||||
endDate: Date;
|
handleDeleteIssue,
|
||||||
}
|
addIssueToDate,
|
||||||
|
isCompleted = false,
|
||||||
export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
|
userAuth,
|
||||||
|
}) => {
|
||||||
const [showWeekEnds, setShowWeekEnds] = useState(false);
|
const [showWeekEnds, setShowWeekEnds] = useState(false);
|
||||||
const [currentDate, setCurrentDate] = useState(new Date());
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
const [isMonthlyView, setIsMonthlyView] = useState(true);
|
const [isMonthlyView, setIsMonthlyView] = useState(true);
|
||||||
const [showAllIssues, setShowAllIssues] = useState(false);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const [calendarDates, setCalendarDates] = useState<ICalendarRange>({
|
||||||
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
|
|
||||||
|
|
||||||
const { params } = useIssuesView();
|
|
||||||
|
|
||||||
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
|
|
||||||
startDate: startOfWeek(currentDate),
|
startDate: startOfWeek(currentDate),
|
||||||
endDate: lastDayOfWeek(currentDate),
|
endDate: lastDayOfWeek(currentDate),
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetDateFilter = {
|
const router = useRouter();
|
||||||
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
|
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||||
calendarDateRange.endDate
|
|
||||||
)};before`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { data: projectCalendarIssues } = useSWR(
|
const { calendarIssues, params, setCalendarDateRange } = useCalendarIssuesView();
|
||||||
workspaceSlug && projectId ? PROJECT_CALENDAR_ISSUES(projectId as string) : null,
|
|
||||||
workspaceSlug && projectId
|
|
||||||
? () =>
|
|
||||||
issuesService.getIssuesWithParams(workspaceSlug as string, projectId as string, {
|
|
||||||
...params,
|
|
||||||
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
|
|
||||||
calendarDateRange.endDate
|
|
||||||
)};before`,
|
|
||||||
group_by: null,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: cycleCalendarIssues } = useSWR(
|
|
||||||
workspaceSlug && projectId && cycleId
|
|
||||||
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
|
|
||||||
: null,
|
|
||||||
workspaceSlug && projectId && cycleId
|
|
||||||
? () =>
|
|
||||||
cyclesService.getCycleIssuesWithParams(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
cycleId as string,
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
target_date: `${renderDateFormat(
|
|
||||||
calendarDateRange.startDate
|
|
||||||
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
|
|
||||||
group_by: null,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: moduleCalendarIssues } = useSWR(
|
|
||||||
workspaceSlug && projectId && moduleId
|
|
||||||
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
|
|
||||||
: null,
|
|
||||||
workspaceSlug && projectId && moduleId
|
|
||||||
? () =>
|
|
||||||
modulesService.getModuleIssuesWithParams(
|
|
||||||
workspaceSlug as string,
|
|
||||||
projectId as string,
|
|
||||||
moduleId as string,
|
|
||||||
{
|
|
||||||
...params,
|
|
||||||
target_date: `${renderDateFormat(
|
|
||||||
calendarDateRange.startDate
|
|
||||||
)};after,${renderDateFormat(calendarDateRange.endDate)};before`,
|
|
||||||
group_by: null,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
);
|
|
||||||
|
|
||||||
const totalDate = eachDayOfInterval({
|
const totalDate = eachDayOfInterval({
|
||||||
start: calendarDateRange.startDate,
|
start: calendarDates.startDate,
|
||||||
end: calendarDateRange.endDate,
|
end: calendarDates.endDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onlyWeekDays = weekDayInterval({
|
const onlyWeekDays = weekDayInterval({
|
||||||
start: calendarDateRange.startDate,
|
start: calendarDates.startDate,
|
||||||
end: calendarDateRange.endDate,
|
end: calendarDates.endDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
|
const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays;
|
||||||
|
|
||||||
const calendarIssues = cycleId
|
|
||||||
? (cycleCalendarIssues as IIssue[])
|
|
||||||
: moduleId
|
|
||||||
? (moduleCalendarIssues as IIssue[])
|
|
||||||
: (projectCalendarIssues as IIssue[]);
|
|
||||||
|
|
||||||
const currentViewDaysData = currentViewDays.map((date: Date) => {
|
const currentViewDaysData = currentViewDays.map((date: Date) => {
|
||||||
const filterIssue =
|
const filterIssue =
|
||||||
calendarIssues && calendarIssues.length > 0
|
calendarIssues.length > 0
|
||||||
? calendarIssues.filter(
|
? calendarIssues.filter(
|
||||||
(issue) =>
|
(issue) =>
|
||||||
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
|
issue.target_date && renderDateFormat(issue.target_date) === renderDateFormat(date)
|
||||||
@ -191,13 +107,16 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
|
|||||||
const { source, destination, draggableId } = result;
|
const { source, destination, draggableId } = result;
|
||||||
|
|
||||||
if (!destination || !workspaceSlug || !projectId) return;
|
if (!destination || !workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
if (source.droppableId === destination.droppableId) return;
|
if (source.droppableId === destination.droppableId) return;
|
||||||
|
|
||||||
const fetchKey = cycleId
|
const fetchKey = cycleId
|
||||||
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
|
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
|
||||||
: moduleId
|
: moduleId
|
||||||
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
|
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
|
||||||
: PROJECT_CALENDAR_ISSUES(projectId as string);
|
: viewId
|
||||||
|
? VIEW_ISSUES(viewId.toString(), params)
|
||||||
|
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
|
||||||
|
|
||||||
mutate<IIssue[]>(
|
mutate<IIssue[]>(
|
||||||
fetchKey,
|
fetchKey,
|
||||||
@ -208,196 +127,53 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
|
|||||||
...p,
|
...p,
|
||||||
target_date: destination.droppableId,
|
target_date: destination.droppableId,
|
||||||
};
|
};
|
||||||
|
|
||||||
return p;
|
return p;
|
||||||
}),
|
}),
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
|
issuesService
|
||||||
|
.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
|
||||||
target_date: destination?.droppableId,
|
target_date: destination?.droppableId,
|
||||||
});
|
})
|
||||||
|
.then(() => mutate(fetchKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateDate = (date: Date) => {
|
const changeDateRange = (startDate: Date, endDate: Date) => {
|
||||||
setCurrentDate(date);
|
setCalendarDates({
|
||||||
|
startDate,
|
||||||
setCalendarDateRange({
|
endDate,
|
||||||
startDate: startOfWeek(date),
|
|
||||||
endDate: lastDayOfWeek(date),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setCalendarDateRange(
|
||||||
|
`${renderDateFormat(startDate)};after,${renderDateFormat(endDate)};before`
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCalendarDateRange(
|
||||||
|
`${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat(
|
||||||
|
lastDayOfWeek(currentDate)
|
||||||
|
)};before`
|
||||||
|
);
|
||||||
|
}, [currentDate]);
|
||||||
|
|
||||||
|
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
|
||||||
|
|
||||||
return calendarIssues ? (
|
return calendarIssues ? (
|
||||||
|
<div className="h-full">
|
||||||
<DragDropContext onDragEnd={onDragEnd}>
|
<DragDropContext onDragEnd={onDragEnd}>
|
||||||
<div className="-m-2 h-full overflow-y-auto rounded-lg p-8 text-brand-secondary">
|
<div className="h-full rounded-lg p-8 text-brand-secondary">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<CalendarHeader
|
||||||
<div className="relative flex h-full w-full items-center justify-start gap-2 text-sm ">
|
isMonthlyView={isMonthlyView}
|
||||||
<Popover className="flex h-full items-center justify-start rounded-lg">
|
setIsMonthlyView={setIsMonthlyView}
|
||||||
{({ open }) => (
|
showWeekEnds={showWeekEnds}
|
||||||
<>
|
setShowWeekEnds={setShowWeekEnds}
|
||||||
<Popover.Button className={`group flex h-full items-start gap-1 text-brand-base`}>
|
currentDate={currentDate}
|
||||||
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
|
setCurrentDate={setCurrentDate}
|
||||||
<span>{formatDate(currentDate, "Month")}</span>{" "}
|
changeDateRange={changeDateRange}
|
||||||
<span>{formatDate(currentDate, "yyyy")}</span>
|
|
||||||
</div>
|
|
||||||
</Popover.Button>
|
|
||||||
|
|
||||||
<Transition
|
|
||||||
as={React.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="absolute top-10 left-0 z-20 flex w-full max-w-xs transform flex-col overflow-hidden rounded-[10px] bg-brand-surface-2 shadow-lg">
|
|
||||||
<div className="flex items-center justify-center gap-5 px-2 py-2 text-sm">
|
|
||||||
{yearOptions.map((year) => (
|
|
||||||
<button
|
|
||||||
onClick={() => updateDate(updateDateWithYear(year.label, currentDate))}
|
|
||||||
className={` ${
|
|
||||||
isSameYear(year.value, currentDate)
|
|
||||||
? "text-sm font-medium text-brand-base"
|
|
||||||
: "text-xs text-brand-secondary "
|
|
||||||
} hover:text-sm hover:font-medium hover:text-brand-base`}
|
|
||||||
>
|
|
||||||
{year.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-4 border-t border-brand-base px-2">
|
|
||||||
{monthOptions.map((month) => (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
updateDate(updateDateWithMonth(month.value, currentDate))
|
|
||||||
}
|
|
||||||
className={`px-2 py-2 text-xs text-brand-secondary hover:font-medium hover:text-brand-base ${
|
|
||||||
isSameMonth(month.value, currentDate)
|
|
||||||
? "font-medium text-brand-base"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{month.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popover.Panel>
|
|
||||||
</Transition>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
if (isMonthlyView) {
|
|
||||||
updateDate(subtractMonths(currentDate, 1));
|
|
||||||
} else {
|
|
||||||
setCurrentDate(subtract7DaysToDate(currentDate));
|
|
||||||
setCalendarDateRange({
|
|
||||||
startDate: getCurrentWeekStartDate(subtract7DaysToDate(currentDate)),
|
|
||||||
endDate: getCurrentWeekEndDate(subtract7DaysToDate(currentDate)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronLeftIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => {
|
|
||||||
if (isMonthlyView) {
|
|
||||||
updateDate(addMonths(currentDate, 1));
|
|
||||||
} else {
|
|
||||||
setCurrentDate(addSevenDaysToDate(currentDate));
|
|
||||||
setCalendarDateRange({
|
|
||||||
startDate: getCurrentWeekStartDate(addSevenDaysToDate(currentDate)),
|
|
||||||
endDate: getCurrentWeekEndDate(addSevenDaysToDate(currentDate)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ChevronRightIcon className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none"
|
|
||||||
onClick={() => {
|
|
||||||
if (isMonthlyView) {
|
|
||||||
updateDate(new Date());
|
|
||||||
} else {
|
|
||||||
setCurrentDate(new Date());
|
|
||||||
setCalendarDateRange({
|
|
||||||
startDate: getCurrentWeekStartDate(new Date()),
|
|
||||||
endDate: getCurrentWeekEndDate(new Date()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Today
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<CustomMenu
|
|
||||||
customButton={
|
|
||||||
<div className="group flex cursor-pointer items-center gap-2 rounded-md border border-brand-base px-3 py-1 text-sm hover:bg-brand-surface-2 hover:text-brand-base focus:outline-none ">
|
|
||||||
{isMonthlyView ? "Monthly" : "Weekly"}
|
|
||||||
<ChevronDownIcon className="h-3 w-3" aria-hidden="true" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setIsMonthlyView(true);
|
|
||||||
setCalendarDateRange({
|
|
||||||
startDate: startOfWeek(currentDate),
|
|
||||||
endDate: lastDayOfWeek(currentDate),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-52 text-sm text-brand-secondary"
|
|
||||||
>
|
|
||||||
<div className="flex w-full max-w-[260px] items-center justify-between gap-2">
|
|
||||||
<span className="flex items-center gap-2">Monthly View</span>
|
|
||||||
<CheckIcon
|
|
||||||
className={`h-4 w-4 flex-shrink-0 ${
|
|
||||||
isMonthlyView ? "opacity-100" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<CustomMenu.MenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setIsMonthlyView(false);
|
|
||||||
setCalendarDateRange({
|
|
||||||
startDate: getCurrentWeekStartDate(currentDate),
|
|
||||||
endDate: getCurrentWeekEndDate(currentDate),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
className="w-52 text-sm text-brand-secondary"
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
|
||||||
<span className="flex items-center gap-2">Weekly View</span>
|
|
||||||
<CheckIcon
|
|
||||||
className={`h-4 w-4 flex-shrink-0 ${
|
|
||||||
isMonthlyView ? "opacity-0" : "opacity-100"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CustomMenu.MenuItem>
|
|
||||||
<div className="mt-1 flex w-52 items-center justify-between border-t border-brand-base py-2 px-1 text-sm text-brand-secondary">
|
|
||||||
<h4>Show weekends</h4>
|
|
||||||
<ToggleSwitch
|
|
||||||
value={showWeekEnds}
|
|
||||||
onChange={() => setShowWeekEnds(!showWeekEnds)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CustomMenu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
|
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
|
||||||
@ -420,7 +196,9 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
|
{isMonthlyView
|
||||||
|
? formatDate(date, "eee").substring(0, 3)
|
||||||
|
: formatDate(date, "eee")}
|
||||||
</span>
|
</span>
|
||||||
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
|
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
|
||||||
</div>
|
</div>
|
||||||
@ -428,94 +206,26 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
|
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
|
||||||
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
|
||||||
} `}
|
} `}
|
||||||
>
|
>
|
||||||
{currentViewDaysData.map((date, index) => {
|
{currentViewDaysData.map((date, index) => (
|
||||||
const totalIssues = date.issues.length;
|
<SingleCalendarDate
|
||||||
|
index={index}
|
||||||
return (
|
date={date}
|
||||||
<StrictModeDroppable droppableId={date.date}>
|
handleEditIssue={handleEditIssue}
|
||||||
{(provided) => (
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
<div
|
addIssueToDate={addIssueToDate}
|
||||||
key={index}
|
isMonthlyView={isMonthlyView}
|
||||||
ref={provided.innerRef}
|
showWeekEnds={showWeekEnds}
|
||||||
{...provided.droppableProps}
|
isNotAllowed={isNotAllowed}
|
||||||
className={`group relative flex flex-col gap-1.5 border-t border-brand-base p-2.5 text-left text-sm font-medium hover:bg-brand-surface-1 ${
|
/>
|
||||||
showWeekEnds
|
|
||||||
? (index + 1) % 7 === 0
|
|
||||||
? ""
|
|
||||||
: "border-r"
|
|
||||||
: (index + 1) % 5 === 0
|
|
||||||
? ""
|
|
||||||
: "border-r"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
|
|
||||||
{totalIssues > 0 &&
|
|
||||||
date.issues
|
|
||||||
.slice(0, showAllIssues ? totalIssues : 4)
|
|
||||||
.map((issue: IIssue, index) => (
|
|
||||||
<Draggable draggableId={issue.id} index={index}>
|
|
||||||
{(provided, snapshot) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
ref={provided.innerRef}
|
|
||||||
{...provided.draggableProps}
|
|
||||||
{...provided.dragHandleProps}
|
|
||||||
className={`w-full cursor-pointer truncate rounded border border-brand-base px-1.5 py-1 text-xs duration-300 hover:cursor-move hover:bg-brand-surface-2 ${
|
|
||||||
snapshot.isDragging ? "bg-brand-surface-2 shadow-lg" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`}
|
|
||||||
>
|
|
||||||
<a className="flex w-full items-center gap-2">
|
|
||||||
{getStateGroupIcon(
|
|
||||||
issue.state_detail.group,
|
|
||||||
"12",
|
|
||||||
"12",
|
|
||||||
issue.state_detail.color
|
|
||||||
)}
|
|
||||||
{issue.name}
|
|
||||||
</a>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Draggable>
|
|
||||||
))}
|
))}
|
||||||
{totalIssues > 4 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="w-min whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 px-1.5 py-1 text-xs"
|
|
||||||
onClick={() => setShowAllIssues((prevData) => !prevData)}
|
|
||||||
>
|
|
||||||
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={`absolute ${
|
|
||||||
isMonthlyView ? "bottom-2" : "top-2"
|
|
||||||
} right-2 flex items-center justify-center rounded-md bg-brand-surface-2 p-1 text-xs text-brand-secondary opacity-0 group-hover:opacity-100`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
className="flex items-center justify-center gap-1 text-center"
|
|
||||||
onClick={() => addIssueToDate(date.date)}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-3 w-3 text-brand-secondary" />
|
|
||||||
Add issue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{provided.placeholder}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</StrictModeDroppable>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DragDropContext>
|
</DragDropContext>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center">
|
<div className="flex h-full w-full items-center justify-center">
|
||||||
<Spinner />
|
<Spinner />
|
||||||
|
@ -1 +1,4 @@
|
|||||||
export * from "./calendar"
|
export * from "./calendar-header";
|
||||||
|
export * from "./calendar";
|
||||||
|
export * from "./single-date";
|
||||||
|
export * from "./single-issue";
|
||||||
|
107
apps/app/components/core/calendar-view/single-date.tsx
Normal file
107
apps/app/components/core/calendar-view/single-date.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import React, { useState } from "react";
|
||||||
|
|
||||||
|
// react-beautiful-dnd
|
||||||
|
import { Draggable } from "react-beautiful-dnd";
|
||||||
|
// component
|
||||||
|
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||||
|
import { SingleCalendarIssue } from "./single-issue";
|
||||||
|
// icons
|
||||||
|
import { PlusSmallIcon } from "@heroicons/react/24/outline";
|
||||||
|
// helper
|
||||||
|
import { formatDate } from "helpers/calendar.helper";
|
||||||
|
// types
|
||||||
|
import { IIssue } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
|
index: number;
|
||||||
|
date: {
|
||||||
|
date: string;
|
||||||
|
issues: IIssue[];
|
||||||
|
};
|
||||||
|
addIssueToDate: (date: string) => void;
|
||||||
|
isMonthlyView: boolean;
|
||||||
|
showWeekEnds: boolean;
|
||||||
|
isNotAllowed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SingleCalendarDate: React.FC<Props> = ({
|
||||||
|
handleEditIssue,
|
||||||
|
handleDeleteIssue,
|
||||||
|
date,
|
||||||
|
index,
|
||||||
|
addIssueToDate,
|
||||||
|
isMonthlyView,
|
||||||
|
showWeekEnds,
|
||||||
|
isNotAllowed,
|
||||||
|
}) => {
|
||||||
|
const [showAllIssues, setShowAllIssues] = useState(false);
|
||||||
|
|
||||||
|
const totalIssues = date.issues.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StrictModeDroppable droppableId={date.date}>
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-brand-base p-2.5 text-left text-sm font-medium hover:bg-brand-surface-1 ${
|
||||||
|
isMonthlyView ? "" : "pt-9"
|
||||||
|
} ${
|
||||||
|
showWeekEnds
|
||||||
|
? (index + 1) % 7 === 0
|
||||||
|
? ""
|
||||||
|
: "border-r"
|
||||||
|
: (index + 1) % 5 === 0
|
||||||
|
? ""
|
||||||
|
: "border-r"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isMonthlyView && <span>{formatDate(new Date(date.date), "d")}</span>}
|
||||||
|
{totalIssues > 0 &&
|
||||||
|
date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => (
|
||||||
|
<Draggable key={issue.id} draggableId={issue.id} index={index}>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<SingleCalendarIssue
|
||||||
|
key={index}
|
||||||
|
index={index}
|
||||||
|
provided={provided}
|
||||||
|
snapshot={snapshot}
|
||||||
|
issue={issue}
|
||||||
|
handleEditIssue={handleEditIssue}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
isNotAllowed={isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{totalIssues > 4 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="w-min whitespace-nowrap rounded-md border border-brand-base bg-brand-surface-2 px-1.5 py-1 text-xs"
|
||||||
|
onClick={() => setShowAllIssues((prevData) => !prevData)}
|
||||||
|
>
|
||||||
|
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute top-2 right-2 flex items-center justify-center rounded-md bg-brand-surface-2 p-1 text-xs text-brand-secondary opacity-0 group-hover:opacity-100`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="flex items-center justify-center gap-1 text-center"
|
||||||
|
onClick={() => addIssueToDate(date.date)}
|
||||||
|
>
|
||||||
|
<PlusSmallIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
Add issue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</StrictModeDroppable>
|
||||||
|
);
|
||||||
|
};
|
276
apps/app/components/core/calendar-view/single-issue.tsx
Normal file
276
apps/app/components/core/calendar-view/single-issue.tsx
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
import React, { useCallback } from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import { mutate } from "swr";
|
||||||
|
|
||||||
|
// react-beautiful-dnd
|
||||||
|
import { DraggableProvided, DraggableStateSnapshot } from "react-beautiful-dnd";
|
||||||
|
// services
|
||||||
|
import issuesService from "services/issues.service";
|
||||||
|
// hooks
|
||||||
|
import useCalendarIssuesView from "hooks/use-calendar-issues-view";
|
||||||
|
import useIssuesProperties from "hooks/use-issue-properties";
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// components
|
||||||
|
import { CustomMenu, Tooltip } from "components/ui";
|
||||||
|
import {
|
||||||
|
ViewAssigneeSelect,
|
||||||
|
ViewDueDateSelect,
|
||||||
|
ViewEstimateSelect,
|
||||||
|
ViewPrioritySelect,
|
||||||
|
ViewStateSelect,
|
||||||
|
} from "components/issues";
|
||||||
|
// icons
|
||||||
|
import { LinkIcon, PaperClipIcon, PencilIcon, TrashIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { LayerDiagonalIcon } from "components/icons";
|
||||||
|
// helper
|
||||||
|
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||||
|
// type
|
||||||
|
import { IIssue } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import {
|
||||||
|
CYCLE_ISSUES_WITH_PARAMS,
|
||||||
|
MODULE_ISSUES_WITH_PARAMS,
|
||||||
|
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||||
|
VIEW_ISSUES,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
handleEditIssue: (issue: IIssue) => void;
|
||||||
|
handleDeleteIssue: (issue: IIssue) => void;
|
||||||
|
index: number;
|
||||||
|
provided: DraggableProvided;
|
||||||
|
snapshot: DraggableStateSnapshot;
|
||||||
|
issue: IIssue;
|
||||||
|
isNotAllowed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SingleCalendarIssue: React.FC<Props> = ({
|
||||||
|
handleEditIssue,
|
||||||
|
handleDeleteIssue,
|
||||||
|
index,
|
||||||
|
provided,
|
||||||
|
snapshot,
|
||||||
|
issue,
|
||||||
|
isNotAllowed,
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const { params } = useCalendarIssuesView();
|
||||||
|
|
||||||
|
const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string);
|
||||||
|
|
||||||
|
const partialUpdateIssue = useCallback(
|
||||||
|
(formData: Partial<IIssue>, issueId: string) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
const fetchKey = cycleId
|
||||||
|
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
|
||||||
|
: moduleId
|
||||||
|
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
|
||||||
|
: viewId
|
||||||
|
? VIEW_ISSUES(viewId.toString(), params)
|
||||||
|
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
|
||||||
|
|
||||||
|
mutate<IIssue[]>(
|
||||||
|
fetchKey,
|
||||||
|
(prevData) =>
|
||||||
|
(prevData ?? []).map((p) => {
|
||||||
|
if (p.id === issueId) {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
...formData,
|
||||||
|
assignees: formData?.assignees_list ?? p.assignees,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return p;
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
issuesService
|
||||||
|
.patchIssue(workspaceSlug as string, projectId as string, issueId as string, formData)
|
||||||
|
.then(() => {
|
||||||
|
mutate(fetchKey);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.log(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[workspaceSlug, projectId, cycleId, moduleId, params]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCopyText = () => {
|
||||||
|
const originURL =
|
||||||
|
typeof window !== "undefined" && window.location.origin ? window.location.origin : "";
|
||||||
|
copyTextToClipboard(
|
||||||
|
`${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}`
|
||||||
|
).then(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Link Copied!",
|
||||||
|
message: "Issue link copied to clipboard.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayProperties = properties
|
||||||
|
? Object.values(properties).some((value) => value === true)
|
||||||
|
: false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className={`w-full relative cursor-pointer rounded border border-brand-base px-1.5 py-1.5 text-xs duration-300 hover:cursor-move hover:bg-brand-surface-2 ${
|
||||||
|
snapshot.isDragging ? "bg-brand-surface-2 shadow-lg" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="group/card flex w-full flex-col items-start justify-center gap-1.5 text-xs sm:w-auto ">
|
||||||
|
{!isNotAllowed && (
|
||||||
|
<div className="z-1 absolute top-1.5 right-1.5 opacity-0 group-hover/card:opacity-100">
|
||||||
|
<CustomMenu width="auto" ellipsis>
|
||||||
|
<CustomMenu.MenuItem onClick={() => handleEditIssue(issue)}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
<span>Edit issue</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={() => handleDeleteIssue(issue)}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<span>Delete issue</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
<CustomMenu.MenuItem onClick={handleCopyText}>
|
||||||
|
<div className="flex items-center justify-start gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
<span>Copy issue Link</span>
|
||||||
|
</div>
|
||||||
|
</CustomMenu.MenuItem>
|
||||||
|
</CustomMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail.id}/issues/${issue.id}`}>
|
||||||
|
<a className="flex w-full cursor-pointer flex-col items-start justify-center gap-1.5">
|
||||||
|
{properties.key && (
|
||||||
|
<Tooltip
|
||||||
|
tooltipHeading="Issue ID"
|
||||||
|
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||||
|
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip position="top-left" tooltipHeading="Title" tooltipContent={issue.name}>
|
||||||
|
<span className="text-xs text-brand-base">{truncateText(issue.name, 25)}</span>
|
||||||
|
</Tooltip>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
{displayProperties && (
|
||||||
|
<div className="relative mt-1.5 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{properties.priority && (
|
||||||
|
<ViewPrioritySelect
|
||||||
|
issue={issue}
|
||||||
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
|
position="left"
|
||||||
|
isNotAllowed={isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{properties.state && (
|
||||||
|
<ViewStateSelect
|
||||||
|
issue={issue}
|
||||||
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
|
position="left"
|
||||||
|
isNotAllowed={isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{properties.due_date && (
|
||||||
|
<ViewDueDateSelect
|
||||||
|
issue={issue}
|
||||||
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
|
isNotAllowed={isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{properties.labels && issue.label_details.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{issue.label_details.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor: label?.color && label.color !== "" ? label.color : "#000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
{properties.assignee && (
|
||||||
|
<ViewAssigneeSelect
|
||||||
|
issue={issue}
|
||||||
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
|
position="left"
|
||||||
|
isNotAllowed={isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{properties.estimate && (
|
||||||
|
<ViewEstimateSelect
|
||||||
|
issue={issue}
|
||||||
|
partialUpdateIssue={partialUpdateIssue}
|
||||||
|
position="left"
|
||||||
|
isNotAllowed={isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{properties.sub_issue_count && (
|
||||||
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
|
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||||
|
<div className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<LayerDiagonalIcon className="h-3.5 w-3.5" />
|
||||||
|
{issue.sub_issues_count}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{properties.link && (
|
||||||
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
|
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||||
|
<div className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<LinkIcon className="h-3.5 w-3.5" />
|
||||||
|
{issue.link_count}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{properties.attachment_count && (
|
||||||
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
|
<Tooltip tooltipHeading="Attachments" tooltipContent={`${issue.attachment_count}`}>
|
||||||
|
<div className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<PaperClipIcon className="h-3.5 w-3.5 -rotate-45" />
|
||||||
|
{issue.attachment_count}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
121
apps/app/components/core/color-picker-input.tsx
Normal file
121
apps/app/components/core/color-picker-input.tsx
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
// react-form
|
||||||
|
import {
|
||||||
|
FieldError,
|
||||||
|
FieldErrorsImpl,
|
||||||
|
Merge,
|
||||||
|
UseFormRegister,
|
||||||
|
UseFormSetValue,
|
||||||
|
UseFormWatch,
|
||||||
|
} from "react-hook-form";
|
||||||
|
// react-color
|
||||||
|
import { ColorResult, SketchPicker } from "react-color";
|
||||||
|
// component
|
||||||
|
import { Popover, Transition } from "@headlessui/react";
|
||||||
|
import { Input } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { ColorPickerIcon } from "components/icons";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
name: string;
|
||||||
|
watch: UseFormWatch<any>;
|
||||||
|
setValue: UseFormSetValue<any>;
|
||||||
|
error: FieldError | Merge<FieldError, FieldErrorsImpl<any>> | undefined;
|
||||||
|
register: UseFormRegister<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorPickerInput: React.FC<Props> = ({ name, watch, setValue, error, register }) => {
|
||||||
|
const handleColorChange = (newColor: ColorResult) => {
|
||||||
|
const { hex } = newColor;
|
||||||
|
setValue(name, hex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getColorText = (colorName: string) => {
|
||||||
|
switch (colorName) {
|
||||||
|
case "accent":
|
||||||
|
return "Accent";
|
||||||
|
case "bgBase":
|
||||||
|
return "Background";
|
||||||
|
case "bgSurface1":
|
||||||
|
return "Background surface 1";
|
||||||
|
case "bgSurface2":
|
||||||
|
return "Background surface 2";
|
||||||
|
case "border":
|
||||||
|
return "Border";
|
||||||
|
case "sidebar":
|
||||||
|
return "Sidebar";
|
||||||
|
case "textBase":
|
||||||
|
return "Text primary";
|
||||||
|
case "textSecondary":
|
||||||
|
return "Text secondary";
|
||||||
|
default:
|
||||||
|
return "Color";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative ">
|
||||||
|
<Input
|
||||||
|
id={name}
|
||||||
|
name={name}
|
||||||
|
type="name"
|
||||||
|
placeholder="#FFFFFF"
|
||||||
|
autoComplete="off"
|
||||||
|
error={error}
|
||||||
|
value={watch(name)}
|
||||||
|
register={register}
|
||||||
|
validations={{
|
||||||
|
required: `${getColorText(name)} color is required`,
|
||||||
|
pattern: {
|
||||||
|
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
||||||
|
message: `${getColorText(name)} color should be hex format`,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-4 top-2.5">
|
||||||
|
<Popover className="relative grid place-items-center">
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<Popover.Button
|
||||||
|
type="button"
|
||||||
|
className={`group inline-flex items-center outline-none ${
|
||||||
|
open ? "text-brand-base" : "text-brand-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{watch(name) && watch(name) !== "" ? (
|
||||||
|
<span
|
||||||
|
className="h-4 w-4 rounded border border-brand-base"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${watch(name)}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ColorPickerIcon
|
||||||
|
height={14}
|
||||||
|
width={14}
|
||||||
|
className="fill-current text-brand-base"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Popover.Button>
|
||||||
|
|
||||||
|
<Transition
|
||||||
|
as={React.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="absolute bottom-8 right-0 z-20 mt-1 max-w-xs px-2 sm:px-0">
|
||||||
|
<SketchPicker color={watch(name)} onChange={handleColorChange} />
|
||||||
|
</Popover.Panel>
|
||||||
|
</Transition>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,267 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
// react-hook-form
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
// ui
|
|
||||||
import { Input, PrimaryButton, SecondaryButton } from "components/ui";
|
|
||||||
|
|
||||||
const defaultValues = {
|
|
||||||
palette: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ThemeForm: React.FC<any> = ({ handleFormSubmit, handleClose, status, data }) => {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors, isSubmitting },
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
} = useForm<any>({
|
|
||||||
defaultValues,
|
|
||||||
});
|
|
||||||
const [darkPalette, setDarkPalette] = useState(false);
|
|
||||||
|
|
||||||
const handleUpdateTheme = async (formData: any) => {
|
|
||||||
await handleFormSubmit({ ...formData, darkPalette });
|
|
||||||
|
|
||||||
reset({
|
|
||||||
...defaultValues,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reset({
|
|
||||||
...defaultValues,
|
|
||||||
...data,
|
|
||||||
});
|
|
||||||
}, [data, reset]);
|
|
||||||
|
|
||||||
// --color-bg-base: 25, 27, 27;
|
|
||||||
// --color-bg-surface-1: 31, 32, 35;
|
|
||||||
// --color-bg-surface-2: 39, 42, 45;
|
|
||||||
|
|
||||||
// --color-border: 46, 50, 52;
|
|
||||||
// --color-bg-sidebar: 19, 20, 22;
|
|
||||||
// --color-accent: 60, 133, 217;
|
|
||||||
|
|
||||||
// --color-text-base: 255, 255, 255;
|
|
||||||
// --color-text-secondary: 142, 148, 146;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
|
||||||
<div className="space-y-5">
|
|
||||||
<h3 className="text-lg font-medium leading-6 text-brand-base">Customize your theme</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="mt-6 grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-6">
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Input
|
|
||||||
id="bgBase"
|
|
||||||
label="Background"
|
|
||||||
name="bgBase"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.bgBase}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Background color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Background color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Input
|
|
||||||
id="bgSurface1"
|
|
||||||
label="Background surface 1"
|
|
||||||
name="bgSurface1"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.bgSurface1}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Background surface 1 color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Background surface 1 color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Input
|
|
||||||
id="bgSurface2"
|
|
||||||
label="Background surface 2"
|
|
||||||
name="bgSurface1"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.bgSurface1}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Background surface 2 color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Background surface 2 color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Input
|
|
||||||
id="border"
|
|
||||||
label="Border"
|
|
||||||
name="border"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.border}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Border color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Border color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Input
|
|
||||||
id="sidebar"
|
|
||||||
label="Sidebar"
|
|
||||||
name="sidebar"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.sidebar}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Sidebar color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Sidebar color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-2">
|
|
||||||
<Input
|
|
||||||
id="accent"
|
|
||||||
label="Accent"
|
|
||||||
name="accent"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.accent}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Accent color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Accent color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-3">
|
|
||||||
<Input
|
|
||||||
id="textBase"
|
|
||||||
label="Text primary"
|
|
||||||
name="textBase"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.textBase}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Text primary color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Text primary color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="sm:col-span-3">
|
|
||||||
<Input
|
|
||||||
id="textSecondary"
|
|
||||||
label="Text secondary"
|
|
||||||
name="textSecondary"
|
|
||||||
type="name"
|
|
||||||
placeholder="#FFFFFF"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.textSecondary}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Text secondary color is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Text secondary color should be hex format",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Input
|
|
||||||
id="palette"
|
|
||||||
label="All colors"
|
|
||||||
name="palette"
|
|
||||||
type="name"
|
|
||||||
placeholder="Enter comma separated hex colors"
|
|
||||||
autoComplete="off"
|
|
||||||
error={errors.palette}
|
|
||||||
register={register}
|
|
||||||
validations={{
|
|
||||||
required: "Color values is required",
|
|
||||||
pattern: {
|
|
||||||
value: /^(#(?:[0-9a-fA-F]{3}){1,2},){7}#(?:[0-9a-fA-F]{3}){1,2}$/g,
|
|
||||||
message: "Color values should be hex format, separated by commas",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="flex cursor-pointer items-center gap-1"
|
|
||||||
onClick={() => setDarkPalette((prevData) => !prevData)}
|
|
||||||
>
|
|
||||||
<span className="text-xs">Dark palette</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`pointer-events-none relative inline-flex h-4 w-7 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent ${
|
|
||||||
darkPalette ? "bg-brand-accent" : "bg-gray-300"
|
|
||||||
} transition-colors duration-300 ease-in-out focus:outline-none`}
|
|
||||||
role="switch"
|
|
||||||
aria-checked="false"
|
|
||||||
>
|
|
||||||
<span className="sr-only">Dark palette</span>
|
|
||||||
<span
|
|
||||||
aria-hidden="true"
|
|
||||||
className={`pointer-events-none inline-block h-3 w-3 ${
|
|
||||||
darkPalette ? "translate-x-3" : "translate-x-0"
|
|
||||||
} transform rounded-full bg-brand-surface-2 shadow ring-0 transition duration-300 ease-in-out`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-5 flex justify-end gap-2">
|
|
||||||
<SecondaryButton onClick={handleClose}>Cancel</SecondaryButton>
|
|
||||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
|
||||||
{status
|
|
||||||
? isSubmitting
|
|
||||||
? "Updating Theme..."
|
|
||||||
: "Update Theme"
|
|
||||||
: isSubmitting
|
|
||||||
? "Creating Theme..."
|
|
||||||
: "Set Theme"}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1,65 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
// headless ui
|
|
||||||
import { Dialog, Transition } from "@headlessui/react";
|
|
||||||
// components
|
|
||||||
import { ThemeForm } from "./custom-theme-form";
|
|
||||||
// helpers
|
|
||||||
import { applyTheme } from "helpers/theme.helper";
|
|
||||||
// fetch-keys
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
isOpen: boolean;
|
|
||||||
handleClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomThemeModal: React.FC<Props> = ({ isOpen, handleClose }) => {
|
|
||||||
const onClose = () => {
|
|
||||||
handleClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFormSubmit = async (formData: any) => {
|
|
||||||
applyTheme(formData.palette, formData.darkPalette);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Transition.Root show={isOpen} as={React.Fragment}>
|
|
||||||
<Dialog as="div" className="relative z-20" onClose={handleClose}>
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
|
||||||
</Transition.Child>
|
|
||||||
|
|
||||||
<div className="fixed inset-0 z-20 overflow-y-auto">
|
|
||||||
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
|
|
||||||
<Transition.Child
|
|
||||||
as={React.Fragment}
|
|
||||||
enter="ease-out duration-300"
|
|
||||||
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
enterTo="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leave="ease-in duration-200"
|
|
||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
|
||||||
>
|
|
||||||
<Dialog.Panel className="relative transform rounded-lg bg-brand-surface-1 px-5 py-8 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-2xl sm:p-6">
|
|
||||||
<ThemeForm
|
|
||||||
handleClose={handleClose}
|
|
||||||
handleFormSubmit={handleFormSubmit}
|
|
||||||
status={false}
|
|
||||||
/>
|
|
||||||
</Dialog.Panel>
|
|
||||||
</Transition.Child>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition.Root>
|
|
||||||
);
|
|
||||||
};
|
|
198
apps/app/components/core/custom-theme-selector.tsx
Normal file
198
apps/app/components/core/custom-theme-selector.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
// hooks
|
||||||
|
import useUser from "hooks/use-user";
|
||||||
|
// ui
|
||||||
|
import { PrimaryButton } from "components/ui";
|
||||||
|
import { ColorPickerInput } from "components/core";
|
||||||
|
// services
|
||||||
|
import userService from "services/user.service";
|
||||||
|
// helper
|
||||||
|
import { applyTheme } from "helpers/theme.helper";
|
||||||
|
// types
|
||||||
|
import { ICustomTheme } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
preLoadedData?: Partial<ICustomTheme> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CustomThemeSelector: React.FC<Props> = ({ preLoadedData }) => {
|
||||||
|
const [darkPalette, setDarkPalette] = useState(false);
|
||||||
|
|
||||||
|
const defaultValues = {
|
||||||
|
accent: preLoadedData?.accent ?? "#FE5050",
|
||||||
|
bgBase: preLoadedData?.bgBase ?? "#FFF7F7",
|
||||||
|
bgSurface1: preLoadedData?.bgSurface1 ?? "#FFE0E0",
|
||||||
|
bgSurface2: preLoadedData?.bgSurface2 ?? "#FFF7F7",
|
||||||
|
border: preLoadedData?.border ?? "#FFC9C9",
|
||||||
|
darkPalette: preLoadedData?.darkPalette ?? false,
|
||||||
|
palette: preLoadedData?.palette ?? "",
|
||||||
|
sidebar: preLoadedData?.sidebar ?? "#FFFFFF",
|
||||||
|
textBase: preLoadedData?.textBase ?? "#430000",
|
||||||
|
textSecondary: preLoadedData?.textSecondary ?? "#323232",
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, isSubmitting },
|
||||||
|
handleSubmit,
|
||||||
|
watch,
|
||||||
|
setValue,
|
||||||
|
reset,
|
||||||
|
} = useForm<any>({
|
||||||
|
defaultValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
const { mutateUser } = useUser();
|
||||||
|
|
||||||
|
const handleFormSubmit = async (formData: any) => {
|
||||||
|
await userService
|
||||||
|
.updateUser({
|
||||||
|
theme: {
|
||||||
|
accent: formData.accent,
|
||||||
|
bgBase: formData.bgBase,
|
||||||
|
bgSurface1: formData.bgSurface1,
|
||||||
|
bgSurface2: formData.bgSurface2,
|
||||||
|
border: formData.border,
|
||||||
|
darkPalette: darkPalette,
|
||||||
|
palette: `${formData.bgBase},${formData.bgSurface1},${formData.bgSurface2},${formData.border},${formData.sidebar},${formData.accent},${formData.textBase},${formData.textSecondary}`,
|
||||||
|
sidebar: formData.sidebar,
|
||||||
|
textBase: formData.textBase,
|
||||||
|
textSecondary: formData.textSecondary,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
mutateUser((prevData) => {
|
||||||
|
if (!prevData) return prevData;
|
||||||
|
return { ...prevData, user: res };
|
||||||
|
}, false);
|
||||||
|
applyTheme(formData.palette, darkPalette);
|
||||||
|
setTheme("custom");
|
||||||
|
})
|
||||||
|
.catch((err) => console.log(err));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTheme = async (formData: any) => {
|
||||||
|
await handleFormSubmit({ ...formData, darkPalette });
|
||||||
|
|
||||||
|
reset({
|
||||||
|
...defaultValues,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reset({
|
||||||
|
...defaultValues,
|
||||||
|
...preLoadedData,
|
||||||
|
});
|
||||||
|
}, [preLoadedData, reset]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(handleUpdateTheme)}>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h3 className="text-lg font-semibold text-brand-base">Customize your theme</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Background</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="bgBase"
|
||||||
|
error={errors.bgBase}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Background surface 1</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="bgSurface1"
|
||||||
|
error={errors.bgSurface1}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Background surface 2</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="bgSurface2"
|
||||||
|
error={errors.bgSurface2}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Border</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="border"
|
||||||
|
error={errors.border}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Sidebar</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="sidebar"
|
||||||
|
error={errors.sidebar}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Accent</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="accent"
|
||||||
|
error={errors.accent}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Text primary</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="textBase"
|
||||||
|
error={errors.textBase}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<h3 className="text-left text-base text-brand-secondary">Text secondary</h3>
|
||||||
|
<ColorPickerInput
|
||||||
|
name="textSecondary"
|
||||||
|
error={errors.textSecondary}
|
||||||
|
watch={watch}
|
||||||
|
setValue={setValue}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex justify-end gap-2">
|
||||||
|
<PrimaryButton type="submit" loading={isSubmitting}>
|
||||||
|
{isSubmitting ? "Creating Theme..." : "Set Theme"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
@ -79,7 +79,7 @@ const activityDetails: {
|
|||||||
},
|
},
|
||||||
estimate_point: {
|
estimate_point: {
|
||||||
message: "set the estimate point to",
|
message: "set the estimate point to",
|
||||||
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-gray-500" aria-hidden="true" />,
|
icon: <PlayIcon className="h-3 w-3 -rotate-90 text-brand-secondary" aria-hidden="true" />,
|
||||||
},
|
},
|
||||||
target_date: {
|
target_date: {
|
||||||
message: "set the due date to",
|
message: "set the due date to",
|
||||||
|
26
apps/app/components/core/gantt-chart-view/index.tsx
Normal file
26
apps/app/components/core/gantt-chart-view/index.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { CycleIssuesGanttChartView } from "components/cycles";
|
||||||
|
import { IssueGanttChartView } from "components/issues/gantt-chart";
|
||||||
|
import { ModuleIssuesGanttChartView } from "components/modules";
|
||||||
|
import { ViewIssuesGanttChartView } from "components/views";
|
||||||
|
|
||||||
|
export const GanttChartView = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { cycleId, moduleId, viewId } = router.query;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{cycleId ? (
|
||||||
|
<CycleIssuesGanttChartView />
|
||||||
|
) : moduleId ? (
|
||||||
|
<ModuleIssuesGanttChartView />
|
||||||
|
) : viewId ? (
|
||||||
|
<ViewIssuesGanttChartView />
|
||||||
|
) : (
|
||||||
|
<IssueGanttChartView />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, forwardRef, useRef } from "react";
|
||||||
|
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
@ -35,6 +35,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
|
|||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { IRemirrorRichTextEditor } from "components/rich-text-editor";
|
||||||
|
|
||||||
|
const WrappedRemirrorRichTextEditor = forwardRef<IRemirrorRichTextEditor, IRemirrorRichTextEditor>(
|
||||||
|
(props, ref) => <RemirrorRichTextEditor {...props} forwardedRef={ref} />
|
||||||
|
);
|
||||||
|
|
||||||
|
WrappedRemirrorRichTextEditor.displayName = "WrappedRemirrorRichTextEditor";
|
||||||
|
|
||||||
export const GptAssistantModal: React.FC<Props> = ({
|
export const GptAssistantModal: React.FC<Props> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
handleClose,
|
handleClose,
|
||||||
@ -52,6 +60,8 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
|
const editorRef = useRef<any>(null);
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -119,21 +129,26 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
if (isOpen) setFocus("task");
|
if (isOpen) setFocus("task");
|
||||||
}, [isOpen, setFocus]);
|
}, [isOpen, setFocus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
editorRef.current?.setEditorValue(htmlContent ?? `<p>${content}</p>`);
|
||||||
|
}, [htmlContent, editorRef, content]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-base p-4 shadow ${
|
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border border-brand-base bg-brand-base p-4 shadow ${
|
||||||
isOpen ? "block" : "hidden"
|
isOpen ? "block" : "hidden"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{((content && content !== "") || htmlContent !== "<p></p>") && (
|
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
|
||||||
<div className="remirror-section text-sm">
|
<div className="remirror-section text-sm">
|
||||||
Content:
|
Content:
|
||||||
<RemirrorRichTextEditor
|
<WrappedRemirrorRichTextEditor
|
||||||
value={htmlContent ?? <p>{content}</p>}
|
value={htmlContent ?? <p>{content}</p>}
|
||||||
customClassName="-m-3"
|
customClassName="-m-3"
|
||||||
noBorder
|
noBorder
|
||||||
borderOnFocus={false}
|
borderOnFocus={false}
|
||||||
editable={false}
|
editable={false}
|
||||||
|
ref={editorRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
@ -110,7 +110,7 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-[#131313] bg-opacity-50 transition-opacity" />
|
<div className="fixed inset-0 bg-brand-backdrop bg-opacity-50 transition-opacity" />
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
|
|
||||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||||
@ -124,7 +124,7 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-brand-surface-2 px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6">
|
<Dialog.Panel className="relative transform overflow-hidden rounded-lg border border-brand-base bg-brand-base px-5 py-8 text-left shadow-xl transition-all sm:w-full sm:max-w-xl sm:p-6">
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
|
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
|
||||||
Upload Image
|
Upload Image
|
||||||
@ -133,9 +133,9 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`relative block h-80 w-full rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 ${
|
className={`relative grid h-80 w-full cursor-pointer place-items-center rounded-lg p-12 text-center focus:outline-none focus:ring-2 focus:ring-brand-accent focus:ring-offset-2 ${
|
||||||
(image === null && isDragActive) || !value
|
(image === null && isDragActive) || !value
|
||||||
? "border-2 border-dashed border-brand-base hover:border-gray-400"
|
? "border-2 border-dashed border-brand-base hover:bg-brand-surface-1"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@ -143,7 +143,7 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-brand-surface-1 px-2 py-0.5 text-xs font-medium text-gray-600"
|
className="absolute top-0 right-0 z-40 translate-x-1/2 -translate-y-1/2 rounded bg-brand-surface-1 px-2 py-0.5 text-xs font-medium text-brand-secondary"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@ -152,17 +152,18 @@ export const ImageUploadModal: React.FC<Props> = ({
|
|||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
src={image ? URL.createObjectURL(image) : value ? value : ""}
|
||||||
alt="image"
|
alt="image"
|
||||||
|
className="rounded-lg"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div>
|
||||||
<UserCircleIcon className="mx-auto h-16 w-16 text-gray-400" />
|
<UserCircleIcon className="mx-auto h-16 w-16 text-brand-secondary" />
|
||||||
<span className="mt-2 block text-sm font-medium text-brand-base">
|
<span className="mt-2 block text-sm font-medium text-brand-secondary">
|
||||||
{isDragActive
|
{isDragActive
|
||||||
? "Drop image here to upload"
|
? "Drop image here to upload"
|
||||||
: "Drag & drop image here"}
|
: "Drag & drop image here"}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<input {...getInputProps()} type="text" />
|
<input {...getInputProps()} type="text" />
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
export * from "./board-view";
|
export * from "./board-view";
|
||||||
|
export * from "./calendar-view";
|
||||||
|
export * from "./gantt-chart-view";
|
||||||
export * from "./list-view";
|
export * from "./list-view";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
export * from "./bulk-delete-issues-modal";
|
export * from "./bulk-delete-issues-modal";
|
||||||
export * from "./existing-issues-list-modal";
|
export * from "./existing-issues-list-modal";
|
||||||
|
export * from "./filters-list";
|
||||||
export * from "./gpt-assistant-modal";
|
export * from "./gpt-assistant-modal";
|
||||||
export * from "./image-upload-modal";
|
export * from "./image-upload-modal";
|
||||||
export * from "./issues-view-filter";
|
export * from "./issues-view-filter";
|
||||||
export * from "./issues-view";
|
export * from "./issues-view";
|
||||||
export * from "./link-modal";
|
export * from "./link-modal";
|
||||||
export * from "./image-picker-popover";
|
export * from "./image-picker-popover";
|
||||||
export * from "./filter-list";
|
|
||||||
export * from "./feeds";
|
export * from "./feeds";
|
||||||
export * from "./theme-switch";
|
export * from "./theme-switch";
|
||||||
|
export * from "./custom-theme-selector";
|
||||||
|
export * from "./color-picker-input";
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
ListBulletIcon,
|
ListBulletIcon,
|
||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
|
ChartBarIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// helpers
|
// helpers
|
||||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||||
@ -82,6 +83,17 @@ export const IssuesFilterView: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
|
<CalendarDaysIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${
|
||||||
|
issueView === "gantt_chart" ? "bg-brand-surface-2" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setIssueView("gantt_chart")}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-rounded text-brand-secondary text-[18px] rotate-90">
|
||||||
|
waterfall_chart
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<SelectFilters
|
<SelectFilters
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@ -233,7 +245,7 @@ export const IssuesFilterView: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{issueView !== "calendar" && (
|
|
||||||
<div className="space-y-2 py-3">
|
<div className="space-y-2 py-3">
|
||||||
<h4 className="text-sm text-brand-secondary">Display Properties</h4>
|
<h4 className="text-sm text-brand-secondary">Display Properties</h4>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@ -257,7 +269,6 @@ export const IssuesFilterView: React.FC = () => {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
@ -6,6 +6,7 @@ import useSWR, { mutate } from "swr";
|
|||||||
|
|
||||||
// react-beautiful-dnd
|
// react-beautiful-dnd
|
||||||
import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
import { DragDropContext, DropResult } from "react-beautiful-dnd";
|
||||||
|
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
||||||
// services
|
// services
|
||||||
import issuesService from "services/issues.service";
|
import issuesService from "services/issues.service";
|
||||||
import stateService from "services/state.service";
|
import stateService from "services/state.service";
|
||||||
@ -17,14 +18,13 @@ import { useProjectMyMembership } from "contexts/project-member.context";
|
|||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
import useIssuesView from "hooks/use-issues-view";
|
import useIssuesView from "hooks/use-issues-view";
|
||||||
// components
|
// components
|
||||||
import { AllLists, AllBoards, FilterList } from "components/core";
|
import { AllLists, AllBoards, FilterList, CalendarView, GanttChartView } from "components/core";
|
||||||
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
|
||||||
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
|
|
||||||
import { CreateUpdateViewModal } from "components/views";
|
import { CreateUpdateViewModal } from "components/views";
|
||||||
import { TransferIssues, TransferIssuesModal } from "components/cycles";
|
import { CycleIssuesGanttChartView, TransferIssues, TransferIssuesModal } from "components/cycles";
|
||||||
|
import { IssueGanttChartView } from "components/issues/gantt-chart";
|
||||||
// ui
|
// ui
|
||||||
import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui";
|
import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui";
|
||||||
import { CalendarView } from "./calendar-view";
|
|
||||||
// icons
|
// icons
|
||||||
import {
|
import {
|
||||||
ListBulletIcon,
|
ListBulletIcon,
|
||||||
@ -48,7 +48,7 @@ import {
|
|||||||
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
PROJECT_ISSUES_LIST_WITH_PARAMS,
|
||||||
STATES_LIST,
|
STATES_LIST,
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
// image
|
import { ModuleIssuesGanttChartView } from "components/modules";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
type?: "issue" | "cycle" | "module";
|
type?: "issue" | "cycle" | "module";
|
||||||
@ -446,7 +446,6 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
|
||||||
<FilterList filters={filters} setFilters={setFilters} />
|
<FilterList filters={filters} setFilters={setFilters} />
|
||||||
{areFiltersApplied && (
|
|
||||||
<PrimaryButton
|
<PrimaryButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (viewId) {
|
if (viewId) {
|
||||||
@ -466,7 +465,6 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
{!viewId && <PlusIcon className="h-4 w-4" />}
|
{!viewId && <PlusIcon className="h-4 w-4" />}
|
||||||
{viewId ? "Update" : "Save"} view
|
{viewId ? "Update" : "Save"} view
|
||||||
</PrimaryButton>
|
</PrimaryButton>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{<div className="mt-3 border-t border-brand-base" />}
|
{<div className="mt-3 border-t border-brand-base" />}
|
||||||
</>
|
</>
|
||||||
@ -478,14 +476,14 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
<div
|
<div
|
||||||
className={`${
|
className={`${
|
||||||
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
trashBox ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"
|
||||||
} fixed top-9 right-9 z-30 flex h-28 w-96 flex-col items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-red-500/20 p-3 text-xs font-medium italic text-red-500 ${
|
} fixed top-4 left-1/2 -translate-x-1/2 z-40 w-72 flex items-center justify-center gap-2 rounded border-2 border-red-500/20 bg-brand-base px-3 py-5 text-xs font-medium italic text-red-500 ${
|
||||||
snapshot.isDraggingOver ? "bg-red-500/100 text-white" : ""
|
snapshot.isDraggingOver ? "bg-red-500 blur-2xl opacity-70" : ""
|
||||||
} duration-200`}
|
} transition duration-300`}
|
||||||
ref={provided.innerRef}
|
ref={provided.innerRef}
|
||||||
{...provided.droppableProps}
|
{...provided.droppableProps}
|
||||||
>
|
>
|
||||||
<TrashIcon className="h-4 w-4" />
|
<TrashIcon className="h-4 w-4" />
|
||||||
Drop issue here to delete
|
Drop here to delete the issue.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</StrictModeDroppable>
|
</StrictModeDroppable>
|
||||||
@ -532,8 +530,16 @@ export const IssuesView: React.FC<Props> = ({
|
|||||||
isCompleted={isCompleted}
|
isCompleted={isCompleted}
|
||||||
userAuth={memberRole}
|
userAuth={memberRole}
|
||||||
/>
|
/>
|
||||||
|
) : issueView === "calendar" ? (
|
||||||
|
<CalendarView
|
||||||
|
handleEditIssue={handleEditIssue}
|
||||||
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
|
addIssueToDate={addIssueToDate}
|
||||||
|
isCompleted={isCompleted}
|
||||||
|
userAuth={memberRole}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CalendarView addIssueToDate={addIssueToDate} />
|
issueView === "gantt_chart" && <GanttChartView />
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : type === "issue" ? (
|
) : type === "issue" ? (
|
||||||
|
@ -31,6 +31,7 @@ import {
|
|||||||
ArrowTopRightOnSquareIcon,
|
ArrowTopRightOnSquareIcon,
|
||||||
PaperClipIcon,
|
PaperClipIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { LayerDiagonalIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
|
||||||
import { handleIssuesMutation } from "constants/issue";
|
import { handleIssuesMutation } from "constants/issue";
|
||||||
@ -84,7 +85,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
|
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
|
||||||
|
|
||||||
const partialUpdateIssue = useCallback(
|
const partialUpdateIssue = useCallback(
|
||||||
(formData: Partial<IIssue>) => {
|
(formData: Partial<IIssue>, issueId: string) => {
|
||||||
if (!workspaceSlug || !projectId) return;
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
if (cycleId)
|
if (cycleId)
|
||||||
@ -140,7 +141,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
issuesService
|
issuesService
|
||||||
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
|
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (cycleId) {
|
if (cycleId) {
|
||||||
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
|
||||||
@ -151,18 +152,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
} else mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId as string, params));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
|
||||||
workspaceSlug,
|
|
||||||
projectId,
|
|
||||||
cycleId,
|
|
||||||
moduleId,
|
|
||||||
issue,
|
|
||||||
groupTitle,
|
|
||||||
index,
|
|
||||||
selectedGroup,
|
|
||||||
orderBy,
|
|
||||||
params,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyText = () => {
|
const handleCopyText = () => {
|
||||||
@ -216,7 +206,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
</a>
|
</a>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
<div
|
<div
|
||||||
className="flex flex-wrap items-center justify-between gap-2 border-b border-brand-base bg-brand-base last:border-b-0"
|
className="flex flex-wrap items-center justify-between px-4 py-2.5 gap-2 border-b border-brand-base bg-brand-base last:border-b-0"
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setContextMenu(true);
|
setContextMenu(true);
|
||||||
@ -224,7 +214,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
<Link href={`/${workspaceSlug}/projects/${issue?.project_detail?.id}/issues/${issue.id}`}>
|
||||||
<div className="flex-grow cursor-pointer px-4 pt-2.5 md:py-2.5">
|
<div className="flex-grow cursor-pointer">
|
||||||
<a className="group relative flex items-center gap-2">
|
<a className="group relative flex items-center gap-2">
|
||||||
{properties.key && (
|
{properties.key && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@ -245,7 +235,7 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex w-full flex-shrink flex-wrap items-center gap-2 px-4 pb-2.5 text-xs sm:w-auto md:px-0 md:py-2.5 md:pr-4">
|
<div className="flex w-full flex-shrink flex-wrap items-center gap-2 text-xs sm:w-auto">
|
||||||
{properties.priority && (
|
{properties.priority && (
|
||||||
<ViewPrioritySelect
|
<ViewPrioritySelect
|
||||||
issue={issue}
|
issue={issue}
|
||||||
@ -269,11 +259,6 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{properties.sub_issue_count && (
|
|
||||||
<div className="flex items-center gap-1 rounded-md border border-brand-base px-2 py-1 text-xs text-brand-secondary shadow-sm">
|
|
||||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{properties.labels && issue.label_details.length > 0 ? (
|
{properties.labels && issue.label_details.length > 0 ? (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{issue.label_details.map((label) => (
|
{issue.label_details.map((label) => (
|
||||||
@ -310,6 +295,16 @@ export const SingleListIssue: React.FC<Props> = ({
|
|||||||
isNotAllowed={isNotAllowed}
|
isNotAllowed={isNotAllowed}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{properties.sub_issue_count && (
|
||||||
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
|
<Tooltip tooltipHeading="Sub-issue" tooltipContent={`${issue.sub_issues_count}`}>
|
||||||
|
<div className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<LayerDiagonalIcon className="h-3.5 w-3.5" />
|
||||||
|
{issue.sub_issues_count}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{properties.link && (
|
{properties.link && (
|
||||||
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
<div className="flex cursor-default items-center rounded-md border border-brand-base px-2.5 py-1 text-xs shadow-sm">
|
||||||
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
<Tooltip tooltipHeading="Links" tooltipContent={`${issue.link_count}`}>
|
||||||
|
@ -168,7 +168,7 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
<PlusIcon className="h-4 w-4" />
|
<PlusIcon className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
optionsPosition="right"
|
position="right"
|
||||||
noBorder
|
noBorder
|
||||||
>
|
>
|
||||||
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
|
||||||
@ -204,7 +204,8 @@ export const SingleList: React.FC<Props> = ({
|
|||||||
makeIssueCopy={() => makeIssueCopy(issue)}
|
makeIssueCopy={() => makeIssueCopy(issue)}
|
||||||
handleDeleteIssue={handleDeleteIssue}
|
handleDeleteIssue={handleDeleteIssue}
|
||||||
removeIssue={() => {
|
removeIssue={() => {
|
||||||
if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id, issue.id);
|
if (removeIssue !== null && issue.bridge_id)
|
||||||
|
removeIssue(issue.bridge_id, issue.id);
|
||||||
}}
|
}}
|
||||||
isCompleted={isCompleted}
|
isCompleted={isCompleted}
|
||||||
userAuth={userAuth}
|
userAuth={userAuth}
|
||||||
|
@ -12,9 +12,11 @@ type Props = {
|
|||||||
issues: IIssue[];
|
issues: IIssue[];
|
||||||
start: string;
|
start: string;
|
||||||
end: string;
|
end: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
const ProgressChart: React.FC<Props> = ({ issues, start, end, width = 360, height = 160 }) => {
|
||||||
const startDate = new Date(start);
|
const startDate = new Date(start);
|
||||||
const endDate = new Date(end);
|
const endDate = new Date(end);
|
||||||
const getChartData = () => {
|
const getChartData = () => {
|
||||||
@ -51,8 +53,8 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
|
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
|
||||||
<AreaChart
|
<AreaChart
|
||||||
width={360}
|
width={width}
|
||||||
height={160}
|
height={height}
|
||||||
data={ChartData}
|
data={ChartData}
|
||||||
margin={{
|
margin={{
|
||||||
top: 12,
|
top: 12,
|
||||||
|
@ -29,6 +29,8 @@ type Props = {
|
|||||||
issues: IIssue[];
|
issues: IIssue[];
|
||||||
module?: IModule;
|
module?: IModule;
|
||||||
userAuth?: UserAuth;
|
userAuth?: UserAuth;
|
||||||
|
roundedTab?: boolean;
|
||||||
|
noBackground?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const stateGroupColours: {
|
const stateGroupColours: {
|
||||||
@ -46,6 +48,8 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
issues,
|
issues,
|
||||||
module,
|
module,
|
||||||
userAuth,
|
userAuth,
|
||||||
|
roundedTab,
|
||||||
|
noBackground,
|
||||||
}) => {
|
}) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug, projectId } = router.query;
|
||||||
@ -100,12 +104,16 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<Tab.List
|
<Tab.List
|
||||||
as="div"
|
as="div"
|
||||||
className={`flex w-full items-center justify-between rounded-md bg-brand-surface-1 px-1 py-1.5
|
className={`flex w-full items-center gap-2 justify-between rounded-md ${
|
||||||
|
noBackground ? "" : "bg-brand-surface-1"
|
||||||
|
} px-1 py-1.5
|
||||||
${module ? "text-xs" : "text-sm"} `}
|
${module ? "text-xs" : "text-sm"} `}
|
||||||
>
|
>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`w-full rounded px-3 py-1 text-brand-base ${
|
`w-full ${
|
||||||
|
roundedTab ? "rounded-3xl border border-brand-base" : "rounded"
|
||||||
|
} px-3 py-1 text-brand-base ${
|
||||||
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@ -114,7 +122,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`w-full rounded px-3 py-1 text-brand-base ${
|
`w-full ${
|
||||||
|
roundedTab ? "rounded-3xl border border-brand-base" : "rounded"
|
||||||
|
} px-3 py-1 text-brand-base ${
|
||||||
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@ -123,7 +133,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab
|
||||||
className={({ selected }) =>
|
className={({ selected }) =>
|
||||||
`w-full rounded px-3 py-1 text-brand-base ${
|
`w-full ${
|
||||||
|
roundedTab ? "rounded-3xl border border-brand-base" : "rounded"
|
||||||
|
} px-3 py-1 text-brand-base ${
|
||||||
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
@ -131,10 +143,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
States
|
States
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tab.List>
|
</Tab.List>
|
||||||
<Tab.Panels className="flex w-full items-center justify-between pt-1">
|
<Tab.Panels className="flex w-full items-center justify-between pt-1 text-brand-secondary">
|
||||||
<Tab.Panel as="div" className="flex w-full flex-col text-xs">
|
<Tab.Panel as="div" className="flex w-full flex-col text-xs">
|
||||||
{members?.map((member, index) => {
|
{members?.map((member, index) => {
|
||||||
const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id));
|
const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id));
|
||||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||||
|
|
||||||
if (totalArray.length > 0) {
|
if (totalArray.length > 0) {
|
||||||
@ -150,19 +162,19 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
completed={completeArray.length}
|
completed={completeArray.length}
|
||||||
total={totalArray.length}
|
total={totalArray.length}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (filters.assignees?.includes(member.member.id))
|
if (filters?.assignees?.includes(member.member.id))
|
||||||
setFilters({
|
setFilters({
|
||||||
assignees: filters.assignees?.filter((a) => a !== member.member.id),
|
assignees: filters?.assignees?.filter((a) => a !== member.member.id),
|
||||||
});
|
});
|
||||||
else
|
else
|
||||||
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] });
|
setFilters({ assignees: [...(filters?.assignees ?? []), member.member.id] });
|
||||||
}}
|
}}
|
||||||
selected={filters.assignees?.includes(member.member.id)}
|
selected={filters?.assignees?.includes(member.member.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
{issues?.filter((i) => i.assignees?.length === 0).length > 0 ? (
|
{issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? (
|
||||||
<SingleProgressStats
|
<SingleProgressStats
|
||||||
title={
|
title={
|
||||||
<>
|
<>
|
||||||
@ -180,10 +192,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
completed={
|
completed={
|
||||||
issues?.filter(
|
issues?.filter(
|
||||||
(i) => i.state_detail.group === "completed" && i.assignees?.length === 0
|
(i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
|
||||||
).length
|
).length
|
||||||
}
|
}
|
||||||
total={issues?.filter((i) => i.assignees?.length === 0).length}
|
total={issues?.filter((i) => i?.assignees?.length === 0).length}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
@ -191,8 +203,8 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
</Tab.Panel>
|
</Tab.Panel>
|
||||||
<Tab.Panel as="div" className="w-full space-y-1">
|
<Tab.Panel as="div" className="w-full space-y-1">
|
||||||
{issueLabels?.map((label, index) => {
|
{issueLabels?.map((label, index) => {
|
||||||
const totalArray = issues?.filter((i) => i.labels?.includes(label.id));
|
const totalArray = issues?.filter((i) => i?.labels?.includes(label.id));
|
||||||
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed");
|
||||||
|
|
||||||
if (totalArray.length > 0) {
|
if (totalArray.length > 0) {
|
||||||
return (
|
return (
|
||||||
@ -207,7 +219,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
label.color && label.color !== "" ? label.color : "#000000",
|
label.color && label.color !== "" ? label.color : "#000000",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<span className="text-xs capitalize">{label.name}</span>
|
<span className="text-xs capitalize">{label?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
completed={completeArray.length}
|
completed={completeArray.length}
|
||||||
@ -215,11 +227,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (filters.labels?.includes(label.id))
|
if (filters.labels?.includes(label.id))
|
||||||
setFilters({
|
setFilters({
|
||||||
labels: filters.labels?.filter((l) => l !== label.id),
|
labels: filters?.labels?.filter((l) => l !== label.id),
|
||||||
});
|
});
|
||||||
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
|
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
|
||||||
}}
|
}}
|
||||||
selected={filters.labels?.includes(label.id)}
|
selected={filters?.labels?.includes(label.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
|||||||
selected = false,
|
selected = false,
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={`flex w-full items-center justify-between rounded p-2 text-xs ${
|
className={`flex w-full items-center gap-4 justify-between rounded-sm p-1 text-xs ${
|
||||||
onClick ? "cursor-pointer hover:bg-brand-surface-1" : ""
|
onClick ? "cursor-pointer hover:bg-brand-surface-1" : ""
|
||||||
} ${selected ? "bg-brand-surface-1" : ""}`}
|
} ${selected ? "bg-brand-surface-1" : ""}`}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
@ -29,7 +29,12 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
|
|||||||
<span className="h-4 w-4 ">
|
<span className="h-4 w-4 ">
|
||||||
<ProgressBar value={completed} maxValue={total} />
|
<ProgressBar value={completed} maxValue={total} />
|
||||||
</span>
|
</span>
|
||||||
<span className="w-8 text-right">{Math.floor((completed / total) * 100)}%</span>
|
<span className="w-8 text-right">
|
||||||
|
{isNaN(Math.floor((completed / total) * 100))
|
||||||
|
? "0"
|
||||||
|
: Math.floor((completed / total) * 100)}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span>of</span>
|
<span>of</span>
|
||||||
<span>{total}</span>
|
<span>{total}</span>
|
||||||
|
@ -1,12 +1,30 @@
|
|||||||
import { useState, useEffect, ChangeEvent } from "react";
|
import { useState, useEffect, Dispatch, SetStateAction } from "react";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { THEMES_OBJ } from "constants/themes";
|
|
||||||
import { CustomSelect } from "components/ui";
|
|
||||||
import { CustomThemeModal } from "./custom-theme-modal";
|
|
||||||
|
|
||||||
export const ThemeSwitch = () => {
|
import { useTheme } from "next-themes";
|
||||||
|
|
||||||
|
// constants
|
||||||
|
import { THEMES_OBJ } from "constants/themes";
|
||||||
|
// ui
|
||||||
|
import { CustomSelect } from "components/ui";
|
||||||
|
// helper
|
||||||
|
import { applyTheme } from "helpers/theme.helper";
|
||||||
|
// types
|
||||||
|
import { ICustomTheme, IUser } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
user: IUser | undefined;
|
||||||
|
setPreLoadedData: Dispatch<SetStateAction<ICustomTheme | null>>;
|
||||||
|
customThemeSelectorOptions: boolean;
|
||||||
|
setCustomThemeSelectorOptions: Dispatch<SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ThemeSwitch: React.FC<Props> = ({
|
||||||
|
user,
|
||||||
|
setPreLoadedData,
|
||||||
|
customThemeSelectorOptions,
|
||||||
|
setCustomThemeSelectorOptions,
|
||||||
|
}) => {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [customThemeModal, setCustomThemeModal] = useState(false);
|
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
// useEffect only runs on the client, so now we can safely show the UI
|
// useEffect only runs on the client, so now we can safely show the UI
|
||||||
@ -18,15 +36,49 @@ export const ThemeSwitch = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
value={theme}
|
value={theme}
|
||||||
label={theme ? THEMES_OBJ.find((t) => t.value === theme)?.label : "Select your theme"}
|
label={
|
||||||
|
currentThemeObj ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||||
|
style={{
|
||||||
|
borderColor: currentThemeObj.icon.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-1/2 rounded-l-full"
|
||||||
|
style={{
|
||||||
|
background: currentThemeObj.icon.color1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full w-1/2 rounded-r-full border-l"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: currentThemeObj.icon.border,
|
||||||
|
background: currentThemeObj.icon.color2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{currentThemeObj.label}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"Select your theme"
|
||||||
|
)
|
||||||
|
}
|
||||||
onChange={({ value, type }: { value: string; type: string }) => {
|
onChange={({ value, type }: { value: string; type: string }) => {
|
||||||
if (value === "custom") {
|
if (value === "custom") {
|
||||||
if (!customThemeModal) setCustomThemeModal(true);
|
if (user?.theme.palette) {
|
||||||
|
setPreLoadedData(user.theme);
|
||||||
|
}
|
||||||
|
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
|
||||||
} else {
|
} else {
|
||||||
|
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
|
||||||
const cssVars = [
|
const cssVars = [
|
||||||
"--color-bg-base",
|
"--color-bg-base",
|
||||||
"--color-bg-surface-1",
|
"--color-bg-surface-1",
|
||||||
@ -41,20 +93,41 @@ export const ThemeSwitch = () => {
|
|||||||
];
|
];
|
||||||
cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar));
|
cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar));
|
||||||
}
|
}
|
||||||
document.documentElement.style.setProperty("color-scheme", type);
|
|
||||||
setTheme(value);
|
setTheme(value);
|
||||||
|
document.documentElement.style.setProperty("color-scheme", type);
|
||||||
}}
|
}}
|
||||||
input
|
input
|
||||||
width="w-full"
|
width="w-full"
|
||||||
position="right"
|
position="right"
|
||||||
>
|
>
|
||||||
{THEMES_OBJ.map(({ value, label, type }) => (
|
{THEMES_OBJ.map(({ value, label, type, icon }) => (
|
||||||
<CustomSelect.Option key={value} value={{ value, type }}>
|
<CustomSelect.Option key={value} value={{ value, type }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
|
||||||
|
style={{
|
||||||
|
borderColor: icon.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full w-1/2 rounded-l-full"
|
||||||
|
style={{
|
||||||
|
background: icon.color1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full w-1/2 rounded-r-full border-l"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: icon.border,
|
||||||
|
background: icon.color2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{label}
|
{label}
|
||||||
|
</div>
|
||||||
</CustomSelect.Option>
|
</CustomSelect.Option>
|
||||||
))}
|
))}
|
||||||
</CustomSelect>
|
</CustomSelect>
|
||||||
{/* <CustomThemeModal isOpen={customThemeModal} handleClose={() => setCustomThemeModal(false)} /> */}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
623
apps/app/components/cycles/active-cycle-details.tsx
Normal file
623
apps/app/components/cycles/active-cycle-details.tsx
Normal file
@ -0,0 +1,623 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR, { mutate } from "swr";
|
||||||
|
|
||||||
|
// services
|
||||||
|
import cyclesService from "services/cycles.service";
|
||||||
|
// hooks
|
||||||
|
import useToast from "hooks/use-toast";
|
||||||
|
// ui
|
||||||
|
import { LinearProgressIndicator, Tooltip } from "components/ui";
|
||||||
|
import { AssigneesList } from "components/ui/avatar";
|
||||||
|
import { SingleProgressStats } from "components/core";
|
||||||
|
// components
|
||||||
|
import ProgressChart from "components/core/sidebar/progress-chart";
|
||||||
|
import { ActiveCycleProgressStats } from "components/cycles";
|
||||||
|
|
||||||
|
// icons
|
||||||
|
import { CalendarDaysIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { getPriorityIcon } from "components/icons/priority-icon";
|
||||||
|
import {
|
||||||
|
TargetIcon,
|
||||||
|
ContrastIcon,
|
||||||
|
PersonRunningIcon,
|
||||||
|
ArrowRightIcon,
|
||||||
|
TriangleExclamationIcon,
|
||||||
|
AlarmClockIcon,
|
||||||
|
LayerDiagonalIcon,
|
||||||
|
CompletedStateIcon,
|
||||||
|
} from "components/icons";
|
||||||
|
import { StarIcon } from "@heroicons/react/24/outline";
|
||||||
|
// helpers
|
||||||
|
import {
|
||||||
|
getDateRangeStatus,
|
||||||
|
renderShortDateWithYearFormat,
|
||||||
|
findHowManyDaysLeft,
|
||||||
|
} from "helpers/date-time.helper";
|
||||||
|
import { truncateText } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import {
|
||||||
|
CompletedCyclesResponse,
|
||||||
|
CurrentAndUpcomingCyclesResponse,
|
||||||
|
DraftCyclesResponse,
|
||||||
|
ICycle,
|
||||||
|
IIssue,
|
||||||
|
} from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import {
|
||||||
|
CYCLE_COMPLETE_LIST,
|
||||||
|
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
||||||
|
CYCLE_DETAILS,
|
||||||
|
CYCLE_DRAFT_LIST,
|
||||||
|
CYCLE_ISSUES,
|
||||||
|
} from "constants/fetch-keys";
|
||||||
|
|
||||||
|
type TSingleStatProps = {
|
||||||
|
cycle: ICycle;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateGroups = [
|
||||||
|
{
|
||||||
|
key: "backlog_issues",
|
||||||
|
title: "Backlog",
|
||||||
|
color: "#dee2e6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "unstarted_issues",
|
||||||
|
title: "Unstarted",
|
||||||
|
color: "#26b5ce",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "started_issues",
|
||||||
|
title: "Started",
|
||||||
|
color: "#f7ae59",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "cancelled_issues",
|
||||||
|
title: "Cancelled",
|
||||||
|
color: "#d687ff",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "completed_issues",
|
||||||
|
title: "Completed",
|
||||||
|
color: "#09a953",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ActiveCycleDetails: React.FC<TSingleStatProps> = ({ cycle, isCompleted = false }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
|
const endDate = new Date(cycle.end_date ?? "");
|
||||||
|
const startDate = new Date(cycle.start_date ?? "");
|
||||||
|
|
||||||
|
const groupedIssues: any = {
|
||||||
|
backlog: cycle.backlog_issues,
|
||||||
|
unstarted: cycle.unstarted_issues,
|
||||||
|
started: cycle.started_issues,
|
||||||
|
completed: cycle.completed_issues,
|
||||||
|
cancelled: cycle.cancelled_issues,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date);
|
||||||
|
|
||||||
|
const handleAddToFavorites = () => {
|
||||||
|
if (!workspaceSlug || !projectId || !cycle) return;
|
||||||
|
|
||||||
|
switch (cycleStatus) {
|
||||||
|
case "current":
|
||||||
|
case "upcoming":
|
||||||
|
mutate<CurrentAndUpcomingCyclesResponse>(
|
||||||
|
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
|
||||||
|
(prevData) => ({
|
||||||
|
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||||
|
})),
|
||||||
|
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
mutate<CompletedCyclesResponse>(
|
||||||
|
CYCLE_COMPLETE_LIST(projectId as string),
|
||||||
|
(prevData) => ({
|
||||||
|
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "draft":
|
||||||
|
mutate<DraftCyclesResponse>(
|
||||||
|
CYCLE_DRAFT_LIST(projectId as string),
|
||||||
|
(prevData) => ({
|
||||||
|
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
mutate(
|
||||||
|
CYCLE_DETAILS(projectId as string),
|
||||||
|
(prevData: any) =>
|
||||||
|
(prevData ?? []).map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? true : c.is_favorite,
|
||||||
|
})),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
cyclesService
|
||||||
|
.addCycleToFavorites(workspaceSlug as string, projectId as string, {
|
||||||
|
cycle: cycle.id,
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Couldn't add the cycle to favorites. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFavorites = () => {
|
||||||
|
if (!workspaceSlug || !projectId || !cycle) return;
|
||||||
|
|
||||||
|
switch (cycleStatus) {
|
||||||
|
case "current":
|
||||||
|
case "upcoming":
|
||||||
|
mutate<CurrentAndUpcomingCyclesResponse>(
|
||||||
|
CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string),
|
||||||
|
(prevData) => ({
|
||||||
|
current_cycle: (prevData?.current_cycle ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||||
|
})),
|
||||||
|
upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "completed":
|
||||||
|
mutate<CompletedCyclesResponse>(
|
||||||
|
CYCLE_COMPLETE_LIST(projectId as string),
|
||||||
|
(prevData) => ({
|
||||||
|
completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "draft":
|
||||||
|
mutate<DraftCyclesResponse>(
|
||||||
|
CYCLE_DRAFT_LIST(projectId as string),
|
||||||
|
(prevData) => ({
|
||||||
|
draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
mutate(
|
||||||
|
CYCLE_DETAILS(projectId as string),
|
||||||
|
(prevData: any) =>
|
||||||
|
(prevData ?? []).map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
is_favorite: c.id === cycle.id ? false : c.is_favorite,
|
||||||
|
})),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
cyclesService
|
||||||
|
.removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id)
|
||||||
|
.catch(() => {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Couldn't remove the cycle from favorites. Please try again.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data: issues } = useSWR<IIssue[]>(
|
||||||
|
workspaceSlug && projectId && cycle.id ? CYCLE_ISSUES(cycle.id as string) : null,
|
||||||
|
workspaceSlug && projectId && cycle.id
|
||||||
|
? () =>
|
||||||
|
cyclesService.getCycleIssues(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
cycle.id as string
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const progressIndicatorData = stateGroups.map((group, index) => ({
|
||||||
|
id: index,
|
||||||
|
name: group.title,
|
||||||
|
value:
|
||||||
|
cycle.total_issues > 0
|
||||||
|
? ((cycle[group.key as keyof ICycle] as number) / cycle.total_issues) * 100
|
||||||
|
: 0,
|
||||||
|
color: group.color,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid-row-2 grid rounded-[10px] shadow divide-y bg-brand-base border border-brand-base">
|
||||||
|
<div className="grid grid-cols-1 divide-y border-brand-base lg:divide-y-0 lg:divide-x lg:grid-cols-3">
|
||||||
|
<div className="flex flex-col text-xs">
|
||||||
|
<a className="h-full w-full">
|
||||||
|
<div className="flex h-60 flex-col gap-5 justify-between rounded-b-[10px] p-4">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="h-5 w-5">
|
||||||
|
<ContrastIcon
|
||||||
|
className="h-5 w-5"
|
||||||
|
color={`${
|
||||||
|
cycleStatus === "current"
|
||||||
|
? "#09A953"
|
||||||
|
: cycleStatus === "upcoming"
|
||||||
|
? "#F7AE59"
|
||||||
|
: cycleStatus === "completed"
|
||||||
|
? "#3F76FF"
|
||||||
|
: cycleStatus === "draft"
|
||||||
|
? "#858E96"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<Tooltip tooltipContent={cycle.name} position="top-left">
|
||||||
|
<h3 className="break-all text-lg font-semibold">
|
||||||
|
{truncateText(cycle.name, 70)}
|
||||||
|
</h3>
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 capitalize">
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 py-0.5
|
||||||
|
${
|
||||||
|
cycleStatus === "current"
|
||||||
|
? "bg-green-600/5 text-green-600"
|
||||||
|
: cycleStatus === "upcoming"
|
||||||
|
? "bg-orange-300/5 text-orange-300"
|
||||||
|
: cycleStatus === "completed"
|
||||||
|
? "bg-blue-500/5 text-blue-500"
|
||||||
|
: cycleStatus === "draft"
|
||||||
|
? "bg-neutral-400/5 text-neutral-400"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cycleStatus === "current" ? (
|
||||||
|
<span className="flex gap-1 whitespace-nowrap">
|
||||||
|
<PersonRunningIcon className="h-4 w-4" />
|
||||||
|
{findHowManyDaysLeft(cycle.end_date ?? new Date())} Days Left
|
||||||
|
</span>
|
||||||
|
) : cycleStatus === "upcoming" ? (
|
||||||
|
<span className="flex gap-1 whitespace-nowrap">
|
||||||
|
<AlarmClockIcon className="h-4 w-4" />
|
||||||
|
{findHowManyDaysLeft(cycle.start_date ?? new Date())} Days Left
|
||||||
|
</span>
|
||||||
|
) : cycleStatus === "completed" ? (
|
||||||
|
<span className="flex gap-1 whitespace-nowrap">
|
||||||
|
{cycle.total_issues - cycle.completed_issues > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
tooltipContent={`${
|
||||||
|
cycle.total_issues - cycle.completed_issues
|
||||||
|
} more pending ${
|
||||||
|
cycle.total_issues - cycle.completed_issues === 1 ? "issue" : "issues"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<TriangleExclamationIcon className="h-3.5 w-3.5 fill-current" />
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
)}{" "}
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
cycleStatus
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{cycle.is_favorite ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRemoveFromFavorites();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StarIcon className="h-4 w-4 text-orange-400" fill="#f6ad55" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddToFavorites();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StarIcon className="h-4 w-4 " color="#858E96" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-start gap-5 text-brand-secondary">
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<CalendarDaysIcon className="h-4 w-4" />
|
||||||
|
<span>{renderShortDateWithYearFormat(startDate)}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRightIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
<div className="flex items-start gap-1">
|
||||||
|
<TargetIcon className="h-4 w-4" />
|
||||||
|
<span>{renderShortDateWithYearFormat(endDate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-2.5 text-brand-secondary">
|
||||||
|
{cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? (
|
||||||
|
<Image
|
||||||
|
src={cycle.owned_by.avatar}
|
||||||
|
height={16}
|
||||||
|
width={16}
|
||||||
|
className="rounded-full"
|
||||||
|
alt={cycle.owned_by.first_name}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="bg-brand-secondary flex h-5 w-5 items-center justify-center rounded-full bg-brand-base capitalize">
|
||||||
|
{cycle.owned_by.first_name.charAt(0)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-brand-secondary">{cycle.owned_by.first_name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cycle.assignees.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 text-brand-secondary">
|
||||||
|
<AssigneesList users={cycle.assignees} length={4} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-brand-secondary">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<LayerDiagonalIcon className="h-4 w-4 flex-shrink-0" />
|
||||||
|
{cycle.total_issues} issues
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<CompletedStateIcon width={16} height={16} color="#438AF3" />
|
||||||
|
{cycle.completed_issues} issues
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`}>
|
||||||
|
<a className="bg-brand-accent text-white px-4 rounded-md py-2 text-center text-sm font-medium w-full hover:bg-brand-accent/90">
|
||||||
|
View Cycle
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="grid col-span-2 grid-cols-1 divide-y border-brand-base md:divide-y-0 md:divide-x md:grid-cols-2">
|
||||||
|
<div className="flex h-60 flex-col border-brand-base">
|
||||||
|
<div className="flex h-full w-full flex-col text-brand-secondary p-4">
|
||||||
|
<div className="flex w-full items-center gap-2 py-1">
|
||||||
|
<span>Progress</span>
|
||||||
|
<LinearProgressIndicator data={progressIndicatorData} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col mt-2 gap-1 items-center">
|
||||||
|
{Object.keys(groupedIssues).map((group, index) => (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full "
|
||||||
|
style={{
|
||||||
|
backgroundColor: stateGroups[index].color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs capitalize">{group}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={groupedIssues[group]}
|
||||||
|
total={cycle.total_issues}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-brand-base h-60 overflow-y-scroll">
|
||||||
|
<ActiveCycleProgressStats issues={issues ?? []} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 divide-y border-brand-base lg:divide-y-0 lg:divide-x lg:grid-cols-2">
|
||||||
|
<div className="flex flex-col justify-between p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-brand-primary mb-2">High Priority Issues</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex max-h-[240px] min-h-[240px] flex-col gap-2.5 overflow-y-scroll rounded-md">
|
||||||
|
{issues
|
||||||
|
?.filter((issue) => issue.priority === "urgent" || issue.priority === "high")
|
||||||
|
.map((issue) => (
|
||||||
|
<div
|
||||||
|
key={issue.id}
|
||||||
|
className="flex flex-wrap rounded-md items-center justify-between gap-2 border border-brand-base bg-brand-surface-1 px-3 py-1.5"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div>
|
||||||
|
<Tooltip
|
||||||
|
tooltipHeading="Issue ID"
|
||||||
|
tooltipContent={`${issue.project_detail?.identifier}-${issue.sequence_id}`}
|
||||||
|
>
|
||||||
|
<span className="flex-shrink-0 text-xs text-brand-secondary">
|
||||||
|
{issue.project_detail?.identifier}-{issue.sequence_id}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
position="top-left"
|
||||||
|
tooltipHeading="Title"
|
||||||
|
tooltipContent={issue.name}
|
||||||
|
>
|
||||||
|
<span className="text-[0.825rem] text-brand-base">
|
||||||
|
{truncateText(issue.name, 30)}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className={`grid h-6 w-6 place-items-center items-center rounded border shadow-sm ${
|
||||||
|
issue.priority === "urgent"
|
||||||
|
? "border-red-500/20 bg-red-500/20 text-red-500"
|
||||||
|
: issue.priority === "high"
|
||||||
|
? "border-orange-500/20 bg-orange-500/20 text-orange-500"
|
||||||
|
: issue.priority === "medium"
|
||||||
|
? "border-yellow-500/20 bg-yellow-500/20 text-yellow-500"
|
||||||
|
: issue.priority === "low"
|
||||||
|
? "border-green-500/20 bg-green-500/20 text-green-500"
|
||||||
|
: "border-brand-base"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getPriorityIcon(issue.priority, "text-sm")}
|
||||||
|
</div>
|
||||||
|
{issue.label_details.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{issue.label_details.map((label) => (
|
||||||
|
<span
|
||||||
|
key={label.id}
|
||||||
|
className="group flex items-center gap-1 rounded-2xl border border-brand-base px-2 py-0.5 text-xs text-brand-secondary"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="h-1.5 w-1.5 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
label?.color && label.color !== "" ? label.color : "#000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{label.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center gap-2 text-brand-secondary`}>
|
||||||
|
{issue.assignees &&
|
||||||
|
issue.assignees.length > 0 &&
|
||||||
|
Array.isArray(issue.assignees) ? (
|
||||||
|
<div className="-my-0.5 flex items-center justify-center gap-2">
|
||||||
|
<AssigneesList
|
||||||
|
userIds={issue.assignees}
|
||||||
|
length={3}
|
||||||
|
showLength={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="h-1 w-full rounded-full bg-brand-surface-2">
|
||||||
|
<div
|
||||||
|
className="h-1 rounded-full bg-green-600"
|
||||||
|
style={{
|
||||||
|
width:
|
||||||
|
issues &&
|
||||||
|
`${
|
||||||
|
(issues?.filter(
|
||||||
|
(issue) =>
|
||||||
|
issue?.state_detail?.group === "completed" &&
|
||||||
|
(issue?.priority === "urgent" || issue?.priority === "high")
|
||||||
|
)?.length /
|
||||||
|
issues?.filter(
|
||||||
|
(issue) => issue?.priority === "urgent" || issue?.priority === "high"
|
||||||
|
)?.length) *
|
||||||
|
100 ?? 0
|
||||||
|
}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-16 text-end text-xs text-brand-secondary">
|
||||||
|
{
|
||||||
|
issues?.filter(
|
||||||
|
(issue) =>
|
||||||
|
issue?.state_detail?.group === "completed" &&
|
||||||
|
(issue?.priority === "urgent" || issue?.priority === "high")
|
||||||
|
)?.length
|
||||||
|
}{" "}
|
||||||
|
of{" "}
|
||||||
|
{
|
||||||
|
issues?.filter(
|
||||||
|
(issue) => issue?.priority === "urgent" || issue?.priority === "high"
|
||||||
|
)?.length
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col justify-between border-brand-base p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4 py-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-3 text-brand-base">
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#A9BBD0]" />
|
||||||
|
<span>Ideal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<span className="h-2.5 w-2.5 rounded-full bg-[#4C8FFF]" />
|
||||||
|
<span>Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span>
|
||||||
|
<LayerDiagonalIcon className="h-5 w-5 flex-shrink-0 text-brand-secondary" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Pending Issues -{" "}
|
||||||
|
{cycle.total_issues - (cycle.completed_issues + cycle.cancelled_issues)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative h-64">
|
||||||
|
<ProgressChart
|
||||||
|
issues={issues ?? []}
|
||||||
|
start={cycle?.start_date ?? ""}
|
||||||
|
end={cycle?.end_date ?? ""}
|
||||||
|
width={475}
|
||||||
|
height={256}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
185
apps/app/components/cycles/active-cycle-stats.tsx
Normal file
185
apps/app/components/cycles/active-cycle-stats.tsx
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
|
||||||
|
import useSWR from "swr";
|
||||||
|
|
||||||
|
// headless ui
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// services
|
||||||
|
import issuesServices from "services/issues.service";
|
||||||
|
import projectService from "services/project.service";
|
||||||
|
// hooks
|
||||||
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
|
// components
|
||||||
|
import { SingleProgressStats } from "components/core";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import User from "public/user.png";
|
||||||
|
// types
|
||||||
|
import { IIssue, IIssueLabels } from "types";
|
||||||
|
// fetch-keys
|
||||||
|
import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys";
|
||||||
|
// types
|
||||||
|
type Props = {
|
||||||
|
issues: IIssue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActiveCycleProgressStats: React.FC<Props> = ({ issues }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
|
||||||
|
|
||||||
|
const { data: issueLabels } = useSWR<IIssueLabels[]>(
|
||||||
|
workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => issuesServices.getIssueLabels(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: members } = useSWR(
|
||||||
|
workspaceSlug && projectId ? PROJECT_MEMBERS(workspaceSlug as string) : null,
|
||||||
|
workspaceSlug && projectId
|
||||||
|
? () => projectService.projectMembers(workspaceSlug as string, projectId as string)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentValue = (tab: string | null) => {
|
||||||
|
switch (tab) {
|
||||||
|
case "Assignees":
|
||||||
|
return 0;
|
||||||
|
case "Labels":
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tab.Group
|
||||||
|
defaultIndex={currentValue(tab)}
|
||||||
|
onChange={(i) => {
|
||||||
|
switch (i) {
|
||||||
|
case 0:
|
||||||
|
return setTab("Assignees");
|
||||||
|
case 1:
|
||||||
|
return setTab("Labels");
|
||||||
|
|
||||||
|
default:
|
||||||
|
return setTab("Assignees");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tab.List
|
||||||
|
as="div"
|
||||||
|
className="flex sticky top-0 z-10 bg-brand-base w-full px-4 pt-4 pb-1 flex-wrap items-center justify-start gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<Tab
|
||||||
|
className={({ selected }) =>
|
||||||
|
`px-3 py-1 text-brand-base rounded-3xl border border-brand-base ${
|
||||||
|
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Assignees
|
||||||
|
</Tab>
|
||||||
|
<Tab
|
||||||
|
className={({ selected }) =>
|
||||||
|
`px-3 py-1 text-brand-base rounded-3xl border border-brand-base ${
|
||||||
|
selected ? " bg-brand-accent text-white" : " hover:bg-brand-surface-2"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Labels
|
||||||
|
</Tab>
|
||||||
|
</Tab.List>
|
||||||
|
<Tab.Panels className="flex w-full px-4 pb-4">
|
||||||
|
<Tab.Panel
|
||||||
|
as="div"
|
||||||
|
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary"
|
||||||
|
>
|
||||||
|
{members?.map((member, index) => {
|
||||||
|
const totalArray = issues?.filter((i) => i?.assignees?.includes(member.member.id));
|
||||||
|
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
|
||||||
|
|
||||||
|
if (totalArray.length > 0) {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Avatar user={member.member} />
|
||||||
|
<span>{member.member.first_name}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={completeArray.length}
|
||||||
|
total={totalArray.length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{issues?.filter((i) => i?.assignees?.length === 0).length > 0 ? (
|
||||||
|
<SingleProgressStats
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-5 w-5 rounded-full border-2 border-white bg-brand-surface-2">
|
||||||
|
<Image
|
||||||
|
src={User}
|
||||||
|
height="100%"
|
||||||
|
width="100%"
|
||||||
|
className="rounded-full"
|
||||||
|
alt="User"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span>No assignee</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={
|
||||||
|
issues?.filter(
|
||||||
|
(i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
|
||||||
|
).length
|
||||||
|
}
|
||||||
|
total={issues?.filter((i) => i?.assignees?.length === 0).length}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel
|
||||||
|
as="div"
|
||||||
|
className="flex flex-col w-full mt-2 gap-1 overflow-y-scroll items-center text-brand-secondary"
|
||||||
|
>
|
||||||
|
{issueLabels?.map((label, index) => {
|
||||||
|
const totalArray = issues?.filter((i) => i?.labels?.includes(label.id));
|
||||||
|
const completeArray = totalArray?.filter((i) => i?.state_detail.group === "completed");
|
||||||
|
|
||||||
|
if (totalArray.length > 0) {
|
||||||
|
return (
|
||||||
|
<SingleProgressStats
|
||||||
|
key={index}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="block h-3 w-3 rounded-full"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
label.color && label.color !== "" ? label.color : "#000000",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs capitalize">{label?.name}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
completed={completeArray.length}
|
||||||
|
total={totalArray.length}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Tab.Panel>
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
);
|
||||||
|
};
|
@ -17,7 +17,7 @@ type TCycleStatsViewProps = {
|
|||||||
type: "current" | "upcoming" | "draft";
|
type: "current" | "upcoming" | "draft";
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CyclesList: React.FC<TCycleStatsViewProps> = ({
|
export const AllCyclesBoard: React.FC<TCycleStatsViewProps> = ({
|
||||||
cycles,
|
cycles,
|
||||||
setCreateUpdateCycleModal,
|
setCreateUpdateCycleModal,
|
||||||
setSelectedCycle,
|
setSelectedCycle,
|
||||||
@ -61,7 +61,7 @@ export const CyclesList: React.FC<TCycleStatsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : type === "current" ? (
|
) : type === "current" ? (
|
||||||
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
|
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
|
||||||
<h3 className="text-base font-medium text-brand-base ">No current cycle is present.</h3>
|
<h3 className="text-base font-medium text-brand-base ">No cycle is present.</h3>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
86
apps/app/components/cycles/all-cycles-list.tsx
Normal file
86
apps/app/components/cycles/all-cycles-list.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
// components
|
||||||
|
import { DeleteCycleModal, SingleCycleList } from "components/cycles";
|
||||||
|
import { EmptyState, Loader } from "components/ui";
|
||||||
|
// image
|
||||||
|
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||||
|
// icon
|
||||||
|
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
|
// types
|
||||||
|
import { ICycle, SelectCycleType } from "types";
|
||||||
|
|
||||||
|
type TCycleStatsViewProps = {
|
||||||
|
cycles: ICycle[] | undefined;
|
||||||
|
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
||||||
|
type: "current" | "upcoming" | "draft";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllCyclesList: React.FC<TCycleStatsViewProps> = ({
|
||||||
|
cycles,
|
||||||
|
setCreateUpdateCycleModal,
|
||||||
|
setSelectedCycle,
|
||||||
|
type,
|
||||||
|
}) => {
|
||||||
|
const [cycleDeleteModal, setCycleDeleteModal] = useState(false);
|
||||||
|
const [selectedCycleForDelete, setSelectedCycleForDelete] = useState<SelectCycleType>();
|
||||||
|
|
||||||
|
const handleDeleteCycle = (cycle: ICycle) => {
|
||||||
|
setSelectedCycleForDelete({ ...cycle, actionType: "delete" });
|
||||||
|
setCycleDeleteModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditCycle = (cycle: ICycle) => {
|
||||||
|
setSelectedCycle({ ...cycle, actionType: "edit" });
|
||||||
|
setCreateUpdateCycleModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DeleteCycleModal
|
||||||
|
isOpen={
|
||||||
|
cycleDeleteModal &&
|
||||||
|
!!selectedCycleForDelete &&
|
||||||
|
selectedCycleForDelete.actionType === "delete"
|
||||||
|
}
|
||||||
|
setIsOpen={setCycleDeleteModal}
|
||||||
|
data={selectedCycleForDelete}
|
||||||
|
/>
|
||||||
|
{cycles ? (
|
||||||
|
cycles.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
{cycles.map((cycle) => (
|
||||||
|
<div className="hover:bg-brand-surface-2">
|
||||||
|
<div className="flex flex-col border-brand-base">
|
||||||
|
<SingleCycleList
|
||||||
|
key={cycle.id}
|
||||||
|
cycle={cycle}
|
||||||
|
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||||
|
handleEditCycle={() => handleEditCycle(cycle)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : type === "current" ? (
|
||||||
|
<div className="flex w-full items-center justify-start rounded-[10px] bg-brand-surface-2 px-6 py-4">
|
||||||
|
<h3 className="text-base font-medium text-brand-base ">No cycle is present.</h3>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
type="cycle"
|
||||||
|
title="Create New Cycle"
|
||||||
|
description="Sprint more effectively with Cycles by confining your project
|
||||||
|
to a fixed amount of time. Create new cycle now."
|
||||||
|
imgURL={emptyCycle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Loader className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Loader.Item height="200px" />
|
||||||
|
</Loader>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -7,9 +7,9 @@ import useSWR from "swr";
|
|||||||
// services
|
// services
|
||||||
import cyclesService from "services/cycles.service";
|
import cyclesService from "services/cycles.service";
|
||||||
// components
|
// components
|
||||||
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
|
import { DeleteCycleModal, SingleCycleCard, SingleCycleList } from "components/cycles";
|
||||||
// icons
|
// icons
|
||||||
import { CompletedCycleIcon, ExclamationIcon } from "components/icons";
|
import { ExclamationIcon } from "components/icons";
|
||||||
// types
|
// types
|
||||||
import { ICycle, SelectCycleType } from "types";
|
import { ICycle, SelectCycleType } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
@ -19,11 +19,13 @@ import { EmptyState, Loader } from "components/ui";
|
|||||||
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||||
|
|
||||||
export interface CompletedCyclesListProps {
|
export interface CompletedCyclesListProps {
|
||||||
|
cycleView: string;
|
||||||
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
|
export const CompletedCycles: React.FC<CompletedCyclesListProps> = ({
|
||||||
|
cycleView,
|
||||||
setCreateUpdateCycleModal,
|
setCreateUpdateCycleModal,
|
||||||
setSelectedCycle,
|
setSelectedCycle,
|
||||||
}) => {
|
}) => {
|
||||||
@ -72,6 +74,24 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
|
|||||||
/>
|
/>
|
||||||
<span>Completed cycles are not editable.</span>
|
<span>Completed cycles are not editable.</span>
|
||||||
</div>
|
</div>
|
||||||
|
{cycleView === "list" && (
|
||||||
|
<div>
|
||||||
|
{completedCycles.completed_cycles.map((cycle) => (
|
||||||
|
<div className="hover:bg-brand-surface-2">
|
||||||
|
<div className="flex flex-col border-brand-base">
|
||||||
|
<SingleCycleList
|
||||||
|
key={cycle.id}
|
||||||
|
cycle={cycle}
|
||||||
|
handleDeleteCycle={() => handleDeleteCycle(cycle)}
|
||||||
|
handleEditCycle={() => handleEditCycle(cycle)}
|
||||||
|
isCompleted
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cycleView === "board" && (
|
||||||
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{completedCycles.completed_cycles.map((cycle) => (
|
{completedCycles.completed_cycles.map((cycle) => (
|
||||||
<SingleCycleCard
|
<SingleCycleCard
|
||||||
@ -83,6 +103,7 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<EmptyState
|
74
apps/app/components/cycles/cycles-list-gantt-chart.tsx
Normal file
74
apps/app/components/cycles/cycles-list-gantt-chart.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
// next imports
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// components
|
||||||
|
import { GanttChartRoot } from "components/gantt-chart";
|
||||||
|
// types
|
||||||
|
import { ICycle } from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cycles: ICycle[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CyclesListGanttChartView: FC<Props> = ({ cycles }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId } = router.query;
|
||||||
|
|
||||||
|
// rendering issues on gantt sidebar
|
||||||
|
const GanttSidebarBlockView = ({ data }: any) => (
|
||||||
|
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||||
|
<div
|
||||||
|
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||||
|
style={{ backgroundColor: "#858e96" }}
|
||||||
|
/>
|
||||||
|
<div className="text-brand-base text-sm">{data?.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// rendering issues on gantt card
|
||||||
|
const GanttBlockView = ({ data }: { data: ICycle }) => (
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${data?.id}`}>
|
||||||
|
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
|
||||||
|
<div className="flex-shrink-0 w-[4px] h-full" style={{ backgroundColor: "#858e96" }} />
|
||||||
|
<div className="w-full text-brand-base text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden">
|
||||||
|
{data?.name}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// handle gantt issue start date and target date
|
||||||
|
const handleUpdateDates = async (data: any) => {
|
||||||
|
const payload = {
|
||||||
|
id: data?.id,
|
||||||
|
start_date: data?.start_date,
|
||||||
|
target_date: data?.target_date,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockFormat = (blocks: any) =>
|
||||||
|
blocks && blocks.length > 0
|
||||||
|
? blocks.map((_block: any) => {
|
||||||
|
if (_block?.start_date && _block.target_date) console.log("_block", _block);
|
||||||
|
return {
|
||||||
|
start_date: new Date(_block.created_at),
|
||||||
|
target_date: new Date(_block.updated_at),
|
||||||
|
data: _block,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full overflow-y-auto">
|
||||||
|
<GanttChartRoot
|
||||||
|
title={"Cycles"}
|
||||||
|
loaderTitle="Cycles"
|
||||||
|
blocks={cycles ? blockFormat(cycles) : null}
|
||||||
|
blockUpdateHandler={handleUpdateDates}
|
||||||
|
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||||
|
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
249
apps/app/components/cycles/cycles-view.tsx
Normal file
249
apps/app/components/cycles/cycles-view.tsx
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
// headless ui
|
||||||
|
import { Tab } from "@headlessui/react";
|
||||||
|
// hooks
|
||||||
|
import useLocalStorage from "hooks/use-local-storage";
|
||||||
|
// components
|
||||||
|
import {
|
||||||
|
ActiveCycleDetails,
|
||||||
|
CompletedCyclesListProps,
|
||||||
|
AllCyclesBoard,
|
||||||
|
AllCyclesList,
|
||||||
|
CyclesListGanttChartView,
|
||||||
|
} from "components/cycles";
|
||||||
|
// ui
|
||||||
|
import { EmptyState, Loader } from "components/ui";
|
||||||
|
// icons
|
||||||
|
import { ChartBarIcon, ListBulletIcon, Squares2X2Icon } from "@heroicons/react/24/outline";
|
||||||
|
import emptyCycle from "public/empty-state/empty-cycle.svg";
|
||||||
|
// types
|
||||||
|
import {
|
||||||
|
SelectCycleType,
|
||||||
|
ICycle,
|
||||||
|
CurrentAndUpcomingCyclesResponse,
|
||||||
|
DraftCyclesResponse,
|
||||||
|
} from "types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
|
||||||
|
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
cyclesCompleteList: ICycle[] | undefined;
|
||||||
|
currentAndUpcomingCycles: CurrentAndUpcomingCyclesResponse | undefined;
|
||||||
|
draftCycles: DraftCyclesResponse | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CyclesView: React.FC<Props> = ({
|
||||||
|
setSelectedCycle,
|
||||||
|
setCreateUpdateCycleModal,
|
||||||
|
cyclesCompleteList,
|
||||||
|
currentAndUpcomingCycles,
|
||||||
|
draftCycles,
|
||||||
|
}) => {
|
||||||
|
const { storedValue: cycleTab, setValue: setCycleTab } = useLocalStorage("cycleTab", "All");
|
||||||
|
const { storedValue: cyclesView, setValue: setCyclesView } = useLocalStorage("cycleView", "list");
|
||||||
|
|
||||||
|
const currentTabValue = (tab: string | null) => {
|
||||||
|
switch (tab) {
|
||||||
|
case "All":
|
||||||
|
return 0;
|
||||||
|
case "Active":
|
||||||
|
return 1;
|
||||||
|
case "Upcoming":
|
||||||
|
return 2;
|
||||||
|
case "Completed":
|
||||||
|
return 3;
|
||||||
|
case "Drafts":
|
||||||
|
return 4;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const CompletedCycles = dynamic<CompletedCyclesListProps>(
|
||||||
|
() => import("components/cycles").then((a) => a.CompletedCycles),
|
||||||
|
{
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<Loader className="mb-5">
|
||||||
|
<Loader.Item height="12rem" width="100%" />
|
||||||
|
</Loader>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 justify-between">
|
||||||
|
<h3 className="text-2xl font-semibold text-brand-base">Cycles</h3>
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
|
||||||
|
cyclesView === "list" ? "bg-brand-surface-2" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setCyclesView("list")}
|
||||||
|
>
|
||||||
|
<ListBulletIcon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-brand-surface-2 ${
|
||||||
|
cyclesView === "board" ? "bg-brand-surface-2" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => setCyclesView("board")}
|
||||||
|
>
|
||||||
|
<Squares2X2Icon className="h-4 w-4 text-brand-secondary" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`grid h-7 w-7 place-items-center rounded outline-none duration-300 hover:bg-brand-surface-2 ${
|
||||||
|
cyclesView === "gantt_chart" ? "bg-brand-surface-2" : ""
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
setCyclesView("gantt_chart");
|
||||||
|
setCycleTab("All");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-rounded text-brand-secondary text-[18px] rotate-90">
|
||||||
|
waterfall_chart
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tab.Group
|
||||||
|
as={React.Fragment}
|
||||||
|
defaultIndex={currentTabValue(cycleTab)}
|
||||||
|
selectedIndex={currentTabValue(cycleTab)}
|
||||||
|
onChange={(i) => {
|
||||||
|
switch (i) {
|
||||||
|
case 0:
|
||||||
|
return setCycleTab("All");
|
||||||
|
case 1:
|
||||||
|
return setCycleTab("Active");
|
||||||
|
case 2:
|
||||||
|
return setCycleTab("Upcoming");
|
||||||
|
case 3:
|
||||||
|
return setCycleTab("Completed");
|
||||||
|
case 4:
|
||||||
|
return setCycleTab("Drafts");
|
||||||
|
default:
|
||||||
|
return setCycleTab("All");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Tab.List as="div" className="flex flex-wrap items-center justify-start gap-4 text-base">
|
||||||
|
{["All", "Active", "Upcoming", "Completed", "Drafts"].map((tab, index) => {
|
||||||
|
if (
|
||||||
|
cyclesView === "gantt_chart" &&
|
||||||
|
(tab === "Active" || tab === "Drafts" || tab === "Completed")
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tab
|
||||||
|
key={index}
|
||||||
|
className={({ selected }) =>
|
||||||
|
`rounded-3xl border px-6 py-1 outline-none ${
|
||||||
|
selected
|
||||||
|
? "border-brand-accent bg-brand-accent text-white font-medium"
|
||||||
|
: "border-brand-base bg-brand-base hover:bg-brand-surface-2"
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</Tab>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Tab.List>
|
||||||
|
</div>
|
||||||
|
<Tab.Panels as={React.Fragment}>
|
||||||
|
<Tab.Panel as="div" className="mt-7 space-y-5 h-full overflow-y-auto">
|
||||||
|
{cyclesView === "list" && (
|
||||||
|
<AllCyclesList
|
||||||
|
cycles={cyclesCompleteList}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
type="current"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cyclesView === "board" && (
|
||||||
|
<AllCyclesBoard
|
||||||
|
cycles={cyclesCompleteList}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
type="current"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cyclesView === "gantt_chart" && (
|
||||||
|
<CyclesListGanttChartView cycles={cyclesCompleteList ?? []} />
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
{cyclesView !== "gantt_chart" && (
|
||||||
|
<Tab.Panel as="div" className="mt-7 space-y-5">
|
||||||
|
{currentAndUpcomingCycles?.current_cycle?.[0] ? (
|
||||||
|
<ActiveCycleDetails cycle={currentAndUpcomingCycles?.current_cycle?.[0]} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
type="cycle"
|
||||||
|
title="Create New Cycle"
|
||||||
|
description="Sprint more effectively with Cycles by confining your project to a fixed amount of time. Create new cycle now."
|
||||||
|
imgURL={emptyCycle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
|
<Tab.Panel as="div" className="mt-7 space-y-5 h-full overflow-y-auto">
|
||||||
|
{cyclesView === "list" && (
|
||||||
|
<AllCyclesList
|
||||||
|
cycles={currentAndUpcomingCycles?.upcoming_cycle}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
type="upcoming"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cyclesView === "board" && (
|
||||||
|
<AllCyclesBoard
|
||||||
|
cycles={currentAndUpcomingCycles?.upcoming_cycle}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
type="upcoming"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cyclesView === "gantt_chart" && (
|
||||||
|
<CyclesListGanttChartView cycles={currentAndUpcomingCycles?.upcoming_cycle ?? []} />
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
<Tab.Panel as="div" className="mt-7 space-y-5">
|
||||||
|
<CompletedCycles
|
||||||
|
cycleView={cyclesView ?? "list"}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
/>
|
||||||
|
</Tab.Panel>
|
||||||
|
{cyclesView !== "gantt_chart" && (
|
||||||
|
<Tab.Panel as="div" className="mt-7 space-y-5">
|
||||||
|
{cyclesView === "list" && (
|
||||||
|
<AllCyclesList
|
||||||
|
cycles={draftCycles?.draft_cycles}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
type="draft"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{cyclesView === "board" && (
|
||||||
|
<AllCyclesBoard
|
||||||
|
cycles={draftCycles?.draft_cycles}
|
||||||
|
setCreateUpdateCycleModal={setCreateUpdateCycleModal}
|
||||||
|
setSelectedCycle={setSelectedCycle}
|
||||||
|
type="draft"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Tab.Panel>
|
||||||
|
)}
|
||||||
|
</Tab.Panels>
|
||||||
|
</Tab.Group>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -29,6 +29,7 @@ type TConfirmCycleDeletionProps = {
|
|||||||
import {
|
import {
|
||||||
CYCLE_COMPLETE_LIST,
|
CYCLE_COMPLETE_LIST,
|
||||||
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
||||||
|
CYCLE_DETAILS,
|
||||||
CYCLE_DRAFT_LIST,
|
CYCLE_DRAFT_LIST,
|
||||||
CYCLE_LIST,
|
CYCLE_LIST,
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
@ -114,6 +115,14 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
|
|||||||
false
|
false
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
mutate(
|
||||||
|
CYCLE_DETAILS(projectId as string),
|
||||||
|
(prevData: any) => {
|
||||||
|
if (!prevData) return;
|
||||||
|
return prevData.filter((cycle: any) => cycle.id !== data?.id);
|
||||||
|
},
|
||||||
|
false
|
||||||
|
);
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
|
83
apps/app/components/cycles/gantt-chart.tsx
Normal file
83
apps/app/components/cycles/gantt-chart.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { FC } from "react";
|
||||||
|
// next imports
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// components
|
||||||
|
import { GanttChartRoot } from "components/gantt-chart";
|
||||||
|
// hooks
|
||||||
|
import useGanttChartCycleIssues from "hooks/gantt-chart/cycle-issues-view";
|
||||||
|
|
||||||
|
type Props = {};
|
||||||
|
|
||||||
|
export const CycleIssuesGanttChartView: FC<Props> = ({}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||||
|
|
||||||
|
const { ganttIssues, mutateGanttIssues } = useGanttChartCycleIssues(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
cycleId as string
|
||||||
|
);
|
||||||
|
|
||||||
|
// rendering issues on gantt sidebar
|
||||||
|
const GanttSidebarBlockView = ({ data }: any) => (
|
||||||
|
<div className="relative flex w-full h-full items-center p-1 overflow-hidden gap-1">
|
||||||
|
<div
|
||||||
|
className="rounded-sm flex-shrink-0 w-[10px] h-[10px] flex justify-center items-center"
|
||||||
|
style={{ backgroundColor: data?.state_detail?.color || "#858e96" }}
|
||||||
|
/>
|
||||||
|
<div className="text-brand-base text-sm">{data?.name}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// rendering issues on gantt card
|
||||||
|
const GanttBlockView = ({ data }: any) => (
|
||||||
|
<Link href={`/${workspaceSlug}/projects/${projectId}/issues/${data?.id}`}>
|
||||||
|
<a className="relative flex items-center w-full h-full overflow-hidden shadow-sm">
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 w-[4px] h-full"
|
||||||
|
style={{ backgroundColor: data?.state_detail?.color || "#858e96" }}
|
||||||
|
/>
|
||||||
|
<div className="w-full text-brand-base text-[15px] whitespace-nowrap py-[4px] px-2.5 overflow-hidden">
|
||||||
|
{data?.name}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// handle gantt issue start date and target date
|
||||||
|
const handleUpdateDates = async (data: any) => {
|
||||||
|
const payload = {
|
||||||
|
id: data?.id,
|
||||||
|
start_date: data?.start_date,
|
||||||
|
target_date: data?.target_date,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("payload", payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockFormat = (blocks: any) =>
|
||||||
|
blocks && blocks.length > 0
|
||||||
|
? blocks.map((_block: any) => {
|
||||||
|
if (_block?.start_date && _block.target_date) console.log("_block", _block);
|
||||||
|
return {
|
||||||
|
start_date: new Date(_block.created_at),
|
||||||
|
target_date: new Date(_block.updated_at),
|
||||||
|
data: _block,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full p-3">
|
||||||
|
<GanttChartRoot
|
||||||
|
title="Cycles"
|
||||||
|
loaderTitle="Cycles"
|
||||||
|
blocks={ganttIssues ? blockFormat(ganttIssues) : null}
|
||||||
|
blockUpdateHandler={handleUpdateDates}
|
||||||
|
sidebarBlockRender={(data: any) => <GanttSidebarBlockView data={data} />}
|
||||||
|
blockRender={(data: any) => <GanttBlockView data={data} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -1,11 +1,18 @@
|
|||||||
export * from "./completed-cycles-list";
|
export * from "./active-cycle-details";
|
||||||
export * from "./cycles-list";
|
export * from "./cycles-view";
|
||||||
|
export * from "./completed-cycles";
|
||||||
|
export * from "./cycles-list-gantt-chart";
|
||||||
|
export * from "./all-cycles-board";
|
||||||
|
export * from "./all-cycles-list";
|
||||||
export * from "./delete-cycle-modal";
|
export * from "./delete-cycle-modal";
|
||||||
export * from "./form";
|
export * from "./form";
|
||||||
|
export * from "./gantt-chart";
|
||||||
export * from "./modal";
|
export * from "./modal";
|
||||||
export * from "./select";
|
export * from "./select";
|
||||||
export * from "./sidebar";
|
export * from "./sidebar";
|
||||||
|
export * from "./single-cycle-list";
|
||||||
export * from "./single-cycle-card";
|
export * from "./single-cycle-card";
|
||||||
export * from "./empty-cycle";
|
export * from "./empty-cycle";
|
||||||
export * from "./transfer-issues-modal";
|
export * from "./transfer-issues-modal";
|
||||||
export * from "./transfer-issues";
|
export * from "./transfer-issues";
|
||||||
|
export * from "./active-cycle-stats";
|
||||||
|
@ -20,6 +20,7 @@ import type { ICycle } from "types";
|
|||||||
import {
|
import {
|
||||||
CYCLE_COMPLETE_LIST,
|
CYCLE_COMPLETE_LIST,
|
||||||
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
CYCLE_CURRENT_AND_UPCOMING_LIST,
|
||||||
|
CYCLE_DETAILS,
|
||||||
CYCLE_DRAFT_LIST,
|
CYCLE_DRAFT_LIST,
|
||||||
CYCLE_INCOMPLETE_LIST,
|
CYCLE_INCOMPLETE_LIST,
|
||||||
} from "constants/fetch-keys";
|
} from "constants/fetch-keys";
|
||||||
@ -58,6 +59,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
|||||||
mutate(CYCLE_DRAFT_LIST(projectId as string));
|
mutate(CYCLE_DRAFT_LIST(projectId as string));
|
||||||
}
|
}
|
||||||
mutate(CYCLE_INCOMPLETE_LIST(projectId as string));
|
mutate(CYCLE_INCOMPLETE_LIST(projectId as string));
|
||||||
|
mutate(CYCLE_DETAILS(projectId as string));
|
||||||
handleClose();
|
handleClose();
|
||||||
|
|
||||||
setToastAlert({
|
setToastAlert({
|
||||||
@ -92,6 +94,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
|||||||
default:
|
default:
|
||||||
mutate(CYCLE_DRAFT_LIST(projectId as string));
|
mutate(CYCLE_DRAFT_LIST(projectId as string));
|
||||||
}
|
}
|
||||||
|
mutate(CYCLE_DETAILS(projectId as string));
|
||||||
if (
|
if (
|
||||||
getDateRangeStatus(data?.start_date, data?.end_date) !=
|
getDateRangeStatus(data?.start_date, data?.end_date) !=
|
||||||
getDateRangeStatus(res.start_date, res.end_date)
|
getDateRangeStatus(res.start_date, res.end_date)
|
||||||
@ -157,14 +160,10 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
|||||||
title: "Error!",
|
title: "Error!",
|
||||||
message: "Unable to create cycle in past date. Please enter a valid date.",
|
message: "Unable to create cycle in past date. Please enter a valid date.",
|
||||||
});
|
});
|
||||||
|
handleClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDateValid = await dateChecker({
|
|
||||||
start_date: payload.start_date,
|
|
||||||
end_date: payload.end_date,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data?.start_date && data?.end_date) {
|
if (data?.start_date && data?.end_date) {
|
||||||
const isDateValidForExistingCycle = await dateChecker({
|
const isDateValidForExistingCycle = await dateChecker({
|
||||||
start_date: payload.start_date,
|
start_date: payload.start_date,
|
||||||
@ -182,10 +181,16 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
|||||||
message:
|
message:
|
||||||
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||||
});
|
});
|
||||||
|
handleClose();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isDateValid = await dateChecker({
|
||||||
|
start_date: payload.start_date,
|
||||||
|
end_date: payload.end_date,
|
||||||
|
});
|
||||||
|
|
||||||
if (isDateValid) {
|
if (isDateValid) {
|
||||||
if (data) {
|
if (data) {
|
||||||
await updateCycle(data.id, payload);
|
await updateCycle(data.id, payload);
|
||||||
@ -199,6 +204,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
|
|||||||
message:
|
message:
|
||||||
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||||
});
|
});
|
||||||
|
handleClose();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (data) {
|
if (data) {
|
||||||
|
@ -8,7 +8,6 @@ import useSWR, { mutate } from "swr";
|
|||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
import { Disclosure, Popover, Transition } from "@headlessui/react";
|
||||||
import DatePicker from "react-datepicker";
|
|
||||||
// icons
|
// icons
|
||||||
import {
|
import {
|
||||||
CalendarDaysIcon,
|
CalendarDaysIcon,
|
||||||
@ -21,7 +20,7 @@ import {
|
|||||||
LinkIcon,
|
LinkIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Loader, ProgressBar } from "components/ui";
|
import { CustomMenu, CustomRangeDatePicker, Loader, ProgressBar } from "components/ui";
|
||||||
// hooks
|
// hooks
|
||||||
import useToast from "hooks/use-toast";
|
import useToast from "hooks/use-toast";
|
||||||
// services
|
// services
|
||||||
@ -34,7 +33,11 @@ import { DeleteCycleModal } from "components/cycles";
|
|||||||
import { ExclamationIcon } from "components/icons";
|
import { ExclamationIcon } from "components/icons";
|
||||||
// helpers
|
// helpers
|
||||||
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
|
import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper";
|
||||||
import { isDateRangeValid, renderDateFormat, renderShortDate } from "helpers/date-time.helper";
|
import {
|
||||||
|
isDateGreaterThanToday,
|
||||||
|
renderDateFormat,
|
||||||
|
renderShortDate,
|
||||||
|
} from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { ICycle, IIssue } from "types";
|
import { ICycle, IIssue } from "types";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
@ -77,7 +80,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
const { reset, watch } = useForm({
|
const { setValue, reset, watch } = useForm({
|
||||||
defaultValues,
|
defaultValues,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -122,6 +125,166 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
});
|
});
|
||||||
}, [cycle, reset]);
|
}, [cycle, reset]);
|
||||||
|
|
||||||
|
const dateChecker = async (payload: any) => {
|
||||||
|
try {
|
||||||
|
const res = await cyclesService.cycleDateCheck(
|
||||||
|
workspaceSlug as string,
|
||||||
|
projectId as string,
|
||||||
|
payload
|
||||||
|
);
|
||||||
|
return res.status;
|
||||||
|
} catch (err) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartDateChange = async (date: string) => {
|
||||||
|
setValue("start_date", date);
|
||||||
|
if (
|
||||||
|
watch("start_date") &&
|
||||||
|
watch("end_date") &&
|
||||||
|
watch("start_date") !== "" &&
|
||||||
|
watch("end_date") &&
|
||||||
|
watch("start_date") !== ""
|
||||||
|
) {
|
||||||
|
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Unable to create cycle in past date. Please enter a valid date.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle?.start_date && cycle?.end_date) {
|
||||||
|
const isDateValidForExistingCycle = await dateChecker({
|
||||||
|
start_date: `${watch("start_date")}`,
|
||||||
|
end_date: `${watch("end_date")}`,
|
||||||
|
cycle_id: cycle.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDateValidForExistingCycle) {
|
||||||
|
await submitChanges({
|
||||||
|
start_date: renderDateFormat(`${watch("start_date")}`),
|
||||||
|
end_date: renderDateFormat(`${watch("end_date")}`),
|
||||||
|
});
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Cycle updated successfully.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message:
|
||||||
|
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDateValid = await dateChecker({
|
||||||
|
start_date: `${watch("start_date")}`,
|
||||||
|
end_date: `${watch("end_date")}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDateValid) {
|
||||||
|
submitChanges({
|
||||||
|
start_date: renderDateFormat(`${watch("start_date")}`),
|
||||||
|
end_date: renderDateFormat(`${watch("end_date")}`),
|
||||||
|
});
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Cycle updated successfully.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message:
|
||||||
|
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndDateChange = async (date: string) => {
|
||||||
|
setValue("end_date", date);
|
||||||
|
|
||||||
|
if (
|
||||||
|
watch("start_date") &&
|
||||||
|
watch("end_date") &&
|
||||||
|
watch("start_date") !== "" &&
|
||||||
|
watch("end_date") &&
|
||||||
|
watch("start_date") !== ""
|
||||||
|
) {
|
||||||
|
if (!isDateGreaterThanToday(`${watch("end_date")}`)) {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message: "Unable to create cycle in past date. Please enter a valid date.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cycle?.start_date && cycle?.end_date) {
|
||||||
|
const isDateValidForExistingCycle = await dateChecker({
|
||||||
|
start_date: `${watch("start_date")}`,
|
||||||
|
end_date: `${watch("end_date")}`,
|
||||||
|
cycle_id: cycle.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDateValidForExistingCycle) {
|
||||||
|
await submitChanges({
|
||||||
|
start_date: renderDateFormat(`${watch("start_date")}`),
|
||||||
|
end_date: renderDateFormat(`${watch("end_date")}`),
|
||||||
|
});
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Cycle updated successfully.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message:
|
||||||
|
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDateValid = await dateChecker({
|
||||||
|
start_date: `${watch("start_date")}`,
|
||||||
|
end_date: `${watch("end_date")}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDateValid) {
|
||||||
|
submitChanges({
|
||||||
|
start_date: renderDateFormat(`${watch("start_date")}`),
|
||||||
|
end_date: renderDateFormat(`${watch("end_date")}`),
|
||||||
|
});
|
||||||
|
setToastAlert({
|
||||||
|
type: "success",
|
||||||
|
title: "Success!",
|
||||||
|
message: "Cycle updated successfully.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setToastAlert({
|
||||||
|
type: "error",
|
||||||
|
title: "Error!",
|
||||||
|
message:
|
||||||
|
"You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const isStartValid = new Date(`${cycle?.start_date}`) <= new Date();
|
const isStartValid = new Date(`${cycle?.start_date}`) <= new Date();
|
||||||
const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`);
|
const isEndValid = new Date(`${cycle?.end_date}`) >= new Date(`${cycle?.start_date}`);
|
||||||
|
|
||||||
@ -133,9 +296,9 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
<>
|
<>
|
||||||
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
|
<DeleteCycleModal isOpen={cycleDeleteModal} setIsOpen={setCycleDeleteModal} data={cycle} />
|
||||||
<div
|
<div
|
||||||
className={`fixed top-0 ${
|
className={`fixed top-[66px] ${
|
||||||
isOpen ? "right-0" : "-right-[24rem]"
|
isOpen ? "right-0" : "-right-[24rem]"
|
||||||
} z-20 h-full w-[24rem] overflow-y-auto border-l border-brand-base bg-brand-sidebar py-5 duration-300`}
|
} h-full w-[24rem] overflow-y-auto border-l border-brand-base bg-brand-sidebar pt-5 pb-10 duration-300`}
|
||||||
>
|
>
|
||||||
{cycle ? (
|
{cycle ? (
|
||||||
<>
|
<>
|
||||||
@ -158,7 +321,12 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<CalendarDaysIcon className="h-3 w-3" />
|
<CalendarDaysIcon className="h-3 w-3" />
|
||||||
<span>
|
<span>
|
||||||
{renderShortDate(new Date(`${cycle?.start_date}`), "Start date")}
|
{renderShortDate(
|
||||||
|
new Date(
|
||||||
|
`${watch("start_date") ? watch("start_date") : cycle?.start_date}`
|
||||||
|
),
|
||||||
|
"Start date"
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
|
|
||||||
@ -172,36 +340,17 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-1"
|
leaveTo="opacity-0 translate-y-1"
|
||||||
>
|
>
|
||||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||||
<DatePicker
|
<CustomRangeDatePicker
|
||||||
selected={
|
value={watch("start_date") ? watch("start_date") : cycle?.start_date}
|
||||||
watch("start_date")
|
onChange={(val) => {
|
||||||
? new Date(`${watch("start_date")}`)
|
if (val) {
|
||||||
: new Date()
|
handleStartDateChange(val);
|
||||||
}
|
|
||||||
onChange={(date) => {
|
|
||||||
if (date && watch("end_date")) {
|
|
||||||
if (
|
|
||||||
isDateRangeValid(renderDateFormat(date), `${watch("end_date")}`)
|
|
||||||
) {
|
|
||||||
submitChanges({
|
|
||||||
start_date: renderDateFormat(date),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message:
|
|
||||||
"The date you have entered is invalid. Please check and enter a valid date.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
selectsStart
|
startDate={watch("start_date") ? `${watch("start_date")}` : null}
|
||||||
startDate={new Date(`${watch("start_date")}`)}
|
endDate={watch("end_date") ? `${watch("end_date")}` : null}
|
||||||
endDate={new Date(`${watch("end_date")}`)}
|
|
||||||
maxDate={new Date(`${watch("end_date")}`)}
|
maxDate={new Date(`${watch("end_date")}`)}
|
||||||
shouldCloseOnSelect
|
selectsStart
|
||||||
inline
|
|
||||||
/>
|
/>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
@ -222,7 +371,14 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
>
|
>
|
||||||
<CalendarDaysIcon className="h-3 w-3" />
|
<CalendarDaysIcon className="h-3 w-3" />
|
||||||
|
|
||||||
<span>{renderShortDate(new Date(`${cycle?.end_date}`), "End date")}</span>
|
<span>
|
||||||
|
{renderShortDate(
|
||||||
|
new Date(
|
||||||
|
`${watch("end_date") ? watch("end_date") : cycle?.end_date}`
|
||||||
|
),
|
||||||
|
"End date"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</Popover.Button>
|
</Popover.Button>
|
||||||
|
|
||||||
<Transition
|
<Transition
|
||||||
@ -235,37 +391,17 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
|
|||||||
leaveTo="opacity-0 translate-y-1"
|
leaveTo="opacity-0 translate-y-1"
|
||||||
>
|
>
|
||||||
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
|
||||||
<DatePicker
|
<CustomRangeDatePicker
|
||||||
selected={
|
value={watch("end_date") ? watch("end_date") : cycle?.end_date}
|
||||||
watch("end_date") ? new Date(`${watch("end_date")}`) : new Date()
|
onChange={(val) => {
|
||||||
}
|
if (val) {
|
||||||
onChange={(date) => {
|
handleEndDateChange(val);
|
||||||
if (watch("start_date") && date) {
|
|
||||||
if (
|
|
||||||
isDateRangeValid(
|
|
||||||
`${watch("start_date")}`,
|
|
||||||
renderDateFormat(date)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
submitChanges({
|
|
||||||
end_date: renderDateFormat(date),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setToastAlert({
|
|
||||||
type: "error",
|
|
||||||
title: "Error!",
|
|
||||||
message:
|
|
||||||
"The date you have entered is invalid. Please check and enter a valid date.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
selectsEnd
|
startDate={watch("start_date") ? `${watch("start_date")}` : null}
|
||||||
startDate={new Date(`${watch("start_date")}`)}
|
endDate={watch("end_date") ? `${watch("end_date")}` : null}
|
||||||
endDate={new Date(`${watch("end_date")}`)}
|
|
||||||
minDate={new Date(`${watch("start_date")}`)}
|
minDate={new Date(`${watch("start_date")}`)}
|
||||||
shouldCloseOnSelect
|
selectsEnd
|
||||||
inline
|
|
||||||
/>
|
/>
|
||||||
</Popover.Panel>
|
</Popover.Panel>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user