Merge branch 'develop' of github.com:makeplane/plane into Robin-Sch/develop

This commit is contained in:
pablohashescobar 2023-05-25 10:40:03 +05:30
commit 4e62abc6f0
295 changed files with 15980 additions and 6427 deletions

View File

@ -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=
# Google Client ID for Google OAuth
NEXT_PUBLIC_GOOGLE_CLIENTID=""
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Github ID for Github OAuth
NEXT_PUBLIC_GITHUB_ID=""
# Github App Name for GitHub Integration
NEXT_PUBLIC_GITHUB_APP_NAME=""
# Sentry DSN for error monitoring
NEXT_PUBLIC_SENTRY_DSN=""
# Enable/Disable OAUTH - default 0 for selfhosted instance
NEXT_PUBLIC_ENABLE_OAUTH=0
# Enable/Disable sentry
NEXT_PUBLIC_ENABLE_SENTRY=0
# Enable/Disable session recording
NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0
# Enable/Disable event tracking
NEXT_PUBLIC_TRACK_EVENTS=0
# Slack for Slack Integration
NEXT_PUBLIC_SLACK_CLIENT_ID=""
# Backend
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
# AWS Settings
AWS_REGION=""
AWS_ACCESS_KEY_ID=""
AWS_SECRET_ACCESS_KEY=""
AWS_S3_BUCKET_NAME=""
AWS_S3_ENDPOINT_URL=""
# GPT settings
OPENAI_API_KEY=""
GPT_ENGINE=""
# Auto generated and Required

View File

@ -15,11 +15,18 @@
</a>
<img alt="Discord" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
</p>
<br />
<p>
<a href="https://app.plane.so/" target="_blank">
<a href="https://app.plane.so/#gh-light-mode-only" target="_blank">
<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"
width="100%"
/>
@ -85,41 +92,62 @@ docker compose up -d
## 📸 Screenshots
<p>
<a href="https://app.plane.so/" target="_blank">
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<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"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601713/Plane/plane_4_cqm0g8.png"
alt="Plane Quick Lists"
src="https://ik.imagekit.io/killbluedog/Plane_Analytics_Dark_Mode.png?updatedAt=1684944596824"
alt="Plane Analytics"
width="100%"
/>
</a>
</p>
<p>
<a href="https://app.plane.so/" target="_blank">
</p>
<p>
<a href="https://plane.so" target="_blank">
<img
src="https://res.cloudinary.com/toolspacedev/image/upload/v1680601712/Plane/plane_3_1_cu4fsc.png"
alt="Plane Command K"
src="https://ik.imagekit.io/killbluedog/Plane_Pages_Dark_Mode.png?updatedAt=1684943050202"
alt="Plane Pages"
width="100%"
/>
</a>
</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
@ -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.
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.

View File

@ -204,7 +204,21 @@ def update_integration_verified():
Integration.objects.bulk_update(
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:
print(e)
print("Failed")

View File

@ -70,3 +70,5 @@ from .importer import ImporterSerializer
from .page import PageSerializer, PageBlockSerializer, PageFavoriteSerializer
from .estimate import EstimateSerializer, EstimatePointSerializer, EstimateReadSerializer
from .analytic import AnalyticViewSerializer

View 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)

View File

@ -19,7 +19,10 @@ class CycleSerializer(BaseSerializer):
started_issues = serializers.IntegerField(read_only=True)
unstarted_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")
project_detail = ProjectLiteSerializer(read_only=True, source="project")

View File

@ -82,6 +82,9 @@ class ProjectDetailSerializer(BaseSerializer):
default_assignee = UserLiteSerializer(read_only=True)
project_lead = UserLiteSerializer(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:
model = Project

View File

@ -148,6 +148,13 @@ from plane.api.views import (
# Release Notes
ReleaseNotesEndpoint,
## End Release Notes
# Analytics
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
## End Analytics
)
@ -308,7 +315,6 @@ urlpatterns = [
"workspaces/<str:slug>/members/<uuid:pk>/",
WorkSpaceMemberViewSet.as_view(
{
"put": "update",
"patch": "partial_update",
"delete": "destroy",
"get": "retrieve",
@ -418,7 +424,6 @@ urlpatterns = [
ProjectMemberViewSet.as_view(
{
"get": "retrieve",
"put": "update",
"patch": "partial_update",
"delete": "destroy",
}
@ -1285,4 +1290,38 @@ urlpatterns = [
name="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
]

View File

@ -140,3 +140,11 @@ from .estimate import (
from .release import ReleaseNotesEndpoint
from .analytic import (
AnalyticsEndpoint,
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint,
)

View 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,
)

View File

@ -3,7 +3,7 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.parsers import MultiPartParser, FormParser
from sentry_sdk import capture_exception
from django.conf import settings
# Module imports
from .base import BaseAPIView
from plane.db.models import FileAsset
@ -34,7 +34,10 @@ class FileAssetEndpoint(BaseAPIView):
)
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)
except Exception as e:
capture_exception(e)
@ -82,7 +85,10 @@ class UserAssetsEndpoint(BaseAPIView):
serializer = FileAssetSerializer(data=request.data)
if serializer.is_valid():
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)
except Exception as e:
capture_exception(e)

View File

@ -3,7 +3,17 @@ import json
# Django imports
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.utils import timezone
from django.utils.decorators import method_decorator
@ -24,13 +34,13 @@ from plane.api.serializers import (
)
from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
User,
Cycle,
CycleIssue,
Issue,
CycleFavorite,
IssueLink,
IssueAttachment,
User,
)
from plane.bgtasks.issue_activites_task import issue_activity
from plane.utils.grouper import group_results
@ -119,6 +129,25 @@ class CycleViewSet(BaseViewSet):
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")
.distinct()
)
@ -414,26 +443,22 @@ class CycleDateCheckEndpoint(BaseAPIView):
try:
start_date = request.data.get("start_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:
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,
)
cycles = Cycle.objects.filter(
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__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),
Q(workspace__slug=slug)
& Q(project_id=project_id)
& (
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__gte=start_date, end_date__lte=end_date)
)
).exclude(pk=cycle_id)
if cycles.exists():
return Response(
@ -508,10 +533,25 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
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(),
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
@ -558,10 +598,25 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
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(),
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
@ -576,7 +631,7 @@ class CurrentUpcomingCyclesEndpoint(BaseAPIView):
)
except Exception as e:
print(e)
capture_exception(e)
return Response(
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
@ -637,10 +692,25 @@ class CompletedCyclesEndpoint(BaseAPIView):
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(),
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")
@ -718,10 +788,25 @@ class DraftCyclesEndpoint(BaseAPIView):
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(),
queryset=User.objects.only(
"avatar", "first_name", "id"
).distinct(),
)
)
.order_by("name", "-is_favorite")

View File

@ -363,6 +363,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
start_date=issue_data.get("start_date", None),
target_date=issue_data.get("target_date", None),
priority=issue_data.get("priority", None),
created_by=request.user,
)
)
@ -400,7 +401,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for label_id in labels_list
]
@ -420,7 +420,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for assignee_id in assignees_list
]
@ -439,6 +438,7 @@ class BulkImportIssuesEndpoint(BaseAPIView):
workspace_id=project.workspace_id,
comment=f"{request.user.email} importer the issue from {service}",
verb="created",
created_by=request.user,
)
for issue in issues
],
@ -457,7 +457,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for comment in comments_list
]
@ -474,7 +473,6 @@ class BulkImportIssuesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue, issue_data in zip(issues, issues_data)
]
@ -512,7 +510,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module in modules_data
],
@ -536,7 +533,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for module, module_data in zip(modules, modules_data)
],
@ -554,7 +550,6 @@ class BulkImportModulesEndpoint(BaseAPIView):
project_id=project_id,
workspace_id=project.workspace_id,
created_by=request.user,
updated_by=request.user,
)
for issue in module_issues_list
]

View File

@ -9,7 +9,7 @@ from django.core.serializers.json import DjangoJSONEncoder
from django.utils.decorators import method_decorator
from django.views.decorators.gzip import gzip_page
from django.db.models.functions import Coalesce
from django.conf import settings
# Third Party imports
from rest_framework.response import Response
from rest_framework import status
@ -788,6 +788,9 @@ class IssueAttachmentEndpoint(BaseAPIView):
serializer = IssueAttachmentSerializer(data=request.data)
if serializer.is_valid():
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(
type="attachment.activity.created",
requested_data=None,
@ -799,7 +802,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
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)
except Exception as e:
capture_exception(e)

View File

@ -5,7 +5,7 @@ from datetime import datetime
# Django imports
from django.core.exceptions import ValidationError
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.conf import settings
@ -46,6 +46,8 @@ from plane.db.models import (
ProjectMemberInvite,
User,
ProjectIdentifier,
Cycle,
Module,
)
from plane.bgtasks.project_invitation_task import project_invitation
@ -92,6 +94,26 @@ class ProjectViewSet(BaseViewSet):
self.get_queryset()
.annotate(is_favorite=Exists(subquery))
.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)
except Exception as e:
@ -161,6 +183,7 @@ class ProjectViewSet(BaseViewSet):
workspace=serializer.instance.workspace,
group=state["group"],
default=state.get("default", False),
created_by=request.user,
)
for state in states
]
@ -344,6 +367,7 @@ class UserProjectInvitationsViewset(BaseViewSet):
workspace=invitation.project.workspace,
member=request.user,
role=invitation.role,
created_by=request.user,
)
for invitation in project_invitations
]
@ -385,6 +409,41 @@ class ProjectMemberViewSet(BaseViewSet):
.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):
permission_classes = [
@ -465,6 +524,7 @@ class AddTeamToProjectEndpoint(BaseAPIView):
project_id=project_id,
member_id=member,
workspace=workspace,
created_by=request.user,
)
)
@ -612,6 +672,7 @@ class ProjectJoinEndpoint(BaseAPIView):
if workspace_role >= 15
else (15 if workspace_role == 10 else workspace_role),
workspace=workspace,
created_by=request.user,
)
for project_id in project_ids
],

View File

@ -18,10 +18,6 @@ from plane.api.permissions import ProjectEntityPermission
from plane.db.models import (
IssueView,
Issue,
IssueBlocker,
IssueLink,
CycleIssue,
ModuleIssue,
IssueViewFavorite,
)
from plane.utils.issue_filters import issue_filters

View File

@ -223,6 +223,7 @@ class InviteWorkspaceEndpoint(BaseAPIView):
algorithm="HS256",
),
role=email.get("role", 10),
created_by=request.user,
)
)
except ValidationError:
@ -381,6 +382,7 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet):
workspace=invitation.workspace,
member=request.user,
role=invitation.role,
created_by=request.user,
)
for invitation in workspace_invitations
],
@ -421,6 +423,43 @@ class WorkSpaceMemberViewSet(BaseViewSet):
.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):
serializer_class = TeamSerializer
@ -783,4 +822,3 @@ class WorkspaceThemeViewSet(BaseViewSet):
{"error": "Something went wrong please try again later"},
status=status.HTTP_400_BAD_REQUEST,
)

View 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

View File

@ -27,7 +27,7 @@ from plane.db.models import (
User,
)
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
@ -58,7 +58,7 @@ def service_importer(service, importer_id):
)
[
send_welcome_email.delay(
send_welcome_slack.delay(
str(user.id),
True,
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
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
],
batch_size=100,
@ -91,6 +95,7 @@ def service_importer(service, importer_id):
project_id=importer.project_id,
workspace_id=importer.workspace_id,
member=user,
created_by=importer.created_by,
)
for user in workspace_users
],

View File

@ -136,7 +136,6 @@ def track_priority(
comment=f"{actor.email} updated the priority to {requested_data.get('priority')}",
)
)
print(issue_activities)
# Track chnages in state of the issue

View File

@ -1,8 +1,5 @@
# Django imports
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
from celery import shared_task
@ -15,31 +12,11 @@ from plane.db.models import User
@shared_task
def send_welcome_email(user_id, created, message):
def send_welcome_slack(user_id, created, message):
try:
instance = User.objects.get(pk=user_id)
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
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)

View 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',),
},
),
]

View 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),
),
]

View File

@ -67,3 +67,5 @@ from .importer import Importer
from .page import Page, PageBlock, PageFavorite, PageLabel
from .estimate import Estimate, EstimatePoint
from .analytic import AnalyticView

View 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}>"

View File

@ -4,6 +4,7 @@ from uuid import uuid4
# Django import
from django.db import models
from django.core.exceptions import ValidationError
from django.conf import settings
# Module import
from . import BaseModel
@ -16,9 +17,11 @@ def get_upload_path(instance, filename):
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.")
# File limit check is only for cloud hosted
if not settings.DOCKERIZED:
limit = 5 * 1024 * 1024
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.")
class FileAsset(BaseModel):

View File

@ -85,8 +85,13 @@ class Issue(ProjectBaseModel):
).first()
# if there is no default state assign any random state
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:
if default_state.group == "started":
self.start_date = timezone.now().date()
self.state = default_state
except ImportError:
pass
@ -94,18 +99,15 @@ class Issue(ProjectBaseModel):
try:
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
if self.state.id in completed_states:
if self.state.group == "completed":
self.completed_at = timezone.now()
# check if there are any page blocks
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=timezone.now()
)
elif self.state.group == "started":
self.start_date = timezone.now().date()
else:
PageBlock.objects.filter(issue_id=self.id).filter().update(
completed_at=None
@ -116,7 +118,6 @@ class Issue(ProjectBaseModel):
pass
if self._state.adding:
# Get the maximum display_id value from the database
last_id = IssueSequence.objects.filter(project=self.project).aggregate(
largest=models.Max("sequence")
)["largest"]
@ -131,6 +132,9 @@ class Issue(ProjectBaseModel):
if largest_sort_order is not None:
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
self.description_stripped = (
None
@ -206,9 +210,11 @@ def get_upload_path(instance, filename):
def file_size(value):
limit = 5 * 1024 * 1024
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.")
# File limit check is only for cloud hosted
if not settings.DOCKERIZED:
limit = 5 * 1024 * 1024
if value.size > limit:
raise ValidationError("File too large. Size should not exceed 5 MB.")
class IssueAttachment(ProjectBaseModel):

View File

@ -63,7 +63,8 @@ class Project(BaseModel):
null=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)
cycle_view = models.BooleanField(default=True)
issue_views_view = models.BooleanField(default=True)

View File

@ -104,29 +104,9 @@ class User(AbstractBaseUser, PermissionsMixin):
@receiver(post_save, sender=User)
def send_welcome_email(sender, instance, created, **kwargs):
def send_welcome_slack(sender, instance, created, **kwargs):
try:
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
if settings.SLACK_BOT_TOKEN:
client = WebClient(token=settings.SLACK_BOT_TOKEN)

View File

@ -25,7 +25,9 @@ DATABASES = {
}
}
DOCKERIZED = os.environ.get("DOCKERIZED", False)
DOCKERIZED = int(os.environ.get(
"DOCKERIZED", 0
)) == 1
if DOCKERIZED:
DATABASES["default"] = dj_database_url.config()

View File

@ -29,9 +29,10 @@ DATABASES = {
DATABASES["default"] = dj_database_url.config()
SITE_ID = 1
DOCKERIZED = os.environ.get(
"DOCKERIZED", False
) # Set the variable true if running in docker-compose environment
# Set the variable true if running in docker environment
DOCKERIZED = int(os.environ.get(
"DOCKERIZED", 0
)) == 1
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -69,7 +70,7 @@ CORS_ALLOW_CREDENTIALS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
if os.environ.get("SENTRY_DSN", False):
if bool(os.environ.get("SENTRY_DSN", False)):
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[DjangoIntegration(), RedisIntegration()],
@ -80,12 +81,21 @@ if os.environ.get("SENTRY_DSN", False):
environment="production",
)
if (
os.environ.get("AWS_REGION", False)
and os.environ.get("AWS_ACCESS_KEY_ID", False)
and os.environ.get("AWS_SECRET_ACCESS_KEY", False)
and os.environ.get("AWS_S3_BUCKET_NAME", False)
):
if DOCKERIZED:
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
# The AWS access key to use.
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key")
# 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.
AWS_REGION = os.environ.get("AWS_REGION", "")
@ -99,7 +109,7 @@ if (
# AWS_SESSION_TOKEN = ""
# 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").
AWS_S3_ADDRESSING_STYLE = "auto"
@ -166,14 +176,8 @@ if (
# extra characters appended.
AWS_S3_FILE_OVERWRITE = False
# AWS Settings End
DEFAULT_FILE_STORAGE = "django_s3_storage.storage.S3Storage"
else:
MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
# AWS Settings End
# Enable Connection Pooling (if desired)
# DATABASES['default']['ENGINE'] = 'django_postgrespool'
@ -218,12 +222,6 @@ else:
}
}
RQ_QUEUES = {
"default": {
"USE_REDIS_CACHE": "default",
}
}
WEB_URL = os.environ.get("WEB_URL")

View File

@ -49,6 +49,10 @@ CORS_ALLOW_ALL_ORIGINS = True
# Simplified static file serving.
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# Make true if running in a docker environment
DOCKERIZED = int(os.environ.get(
"DOCKERIZED", 0
)) == 1
sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN"),

View File

@ -7,7 +7,7 @@ from django.urls import path
from django.views.generic import TemplateView
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
@ -17,9 +17,8 @@ urlpatterns = [
path("api/", include("plane.api.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:
import debug_toolbar

View 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

View File

@ -198,6 +198,39 @@ def filter_issue_state_type(params, filter, method):
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):
filter = dict()
@ -216,6 +249,9 @@ def issue_filters(query_params, method):
"target_date": filter_target_date,
"completed_at": filter_completed_at,
"type": filter_issue_state_type,
"project": filter_project,
"cycle": filter_cycle,
"module": filter_module,
}
for key, value in ISSUE_FILTER.items():

View File

@ -1,6 +1,6 @@
# base requirements
Django==3.2.18
Django==3.2.19
django-braces==1.15.0
django-taggit==3.1.0
psycopg2==2.9.5

View File

@ -4,7 +4,7 @@ dj-database-url==1.2.0
gunicorn==20.1.0
whitenoise==6.3.0
django-storages==1.13.2
boto==2.49.0
boto3==1.26.136
django-anymail==9.0
twilio==7.16.2
django-debug-toolbar==3.8.1

View File

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

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

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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: {},
}}
/>
);
};

View 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";

View File

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

View 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>
);
};

View 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>
);
};

View File

@ -0,0 +1,4 @@
export * from "./custom-analytics";
export * from "./scope-and-demand";
export * from "./select";
export * from "./project-modal";

View 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>
);
};

View 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>
);

View File

@ -0,0 +1,5 @@
export * from "./demand";
export * from "./leaderboard";
export * from "./scope-and-demand";
export * from "./scope";
export * from "./year-wise-issues";

View File

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

View File

@ -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>
)}
</>
);
};

View 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>
);

View File

@ -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>
);
};

View File

@ -0,0 +1,4 @@
export * from "./project";
export * from "./segment";
export * from "./x-axis";
export * from "./y-axis";

View 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
/>
);
};

View 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>
);
};

View 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>
);
};

View 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>
);

View File

@ -27,7 +27,7 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
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">
<Image
src={type === "project" ? ProjectNotAuthorizedImg : WorkspaceNotAuthorizedImg}
@ -36,7 +36,9 @@ export const NotAuthorizedView: React.FC<Props> = ({ actionButton, type }) => {
alt="ProjectSettingImg"
/>
</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">
{user ? (

View File

@ -41,11 +41,11 @@ export const JoinProject: React.FC = () => {
};
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">
<Image src={JoinProjectImg} height="176" width="288" alt="JoinProject" />
</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">
<p className="mx-auto w-full text-sm md:w-3/4">

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useRouter } from "next/router";
@ -60,51 +60,46 @@ export const SingleBoard: React.FC<Props> = ({
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
useEffect(() => {
if (currentState?.group === "completed" || currentState?.group === "cancelled")
setIsCollapsed(false);
}, [currentState]);
return (
<div className={`h-full flex-shrink-0 ${!isCollapsed ? "" : "w-96"}`}>
<div className={`${!isCollapsed ? "" : "flex h-full flex-col space-y-3"}`}>
<BoardHeader
addIssueToState={addIssueToState}
currentState={currentState}
groupTitle={groupTitle}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
isCompleted={isCompleted}
/>
{isCollapsed && (
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative h-full overflow-y-auto p-1 ${
snapshot.isDraggingOver ? "bg-brand-base/20" : ""
} ${!isCollapsed ? "hidden" : "block"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-brand-surface-1 opacity-50`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-brand-base p-2 text-xs`}
>
This board is ordered by{" "}
{replaceUnderscoreIfSnakeCase(
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
)}
</div>
</>
)}
<div className={`flex-shrink-0 ${!isCollapsed ? "" : "flex h-full flex-col w-96"}`}>
<BoardHeader
addIssueToState={addIssueToState}
currentState={currentState}
groupTitle={groupTitle}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
isCompleted={isCompleted}
/>
{isCollapsed && (
<StrictModeDroppable key={groupTitle} droppableId={groupTitle}>
{(provided, snapshot) => (
<div
className={`relative h-full ${
orderBy !== "sort_order" && snapshot.isDraggingOver ? "bg-brand-base/20" : ""
} ${!isCollapsed ? "hidden" : "flex flex-col"}`}
ref={provided.innerRef}
{...provided.droppableProps}
>
{orderBy !== "sort_order" && (
<>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-0 left-0 z-[99] h-full w-full bg-brand-surface-1 opacity-50`}
/>
<div
className={`absolute ${
snapshot.isDraggingOver ? "block" : "hidden"
} pointer-events-none top-1/2 left-1/2 z-[99] -translate-y-1/2 -translate-x-1/2 whitespace-nowrap rounded bg-brand-base p-2 text-xs`}
>
This board is ordered by{" "}
{replaceUnderscoreIfSnakeCase(
orderBy ? (orderBy[0] === "-" ? orderBy.slice(1) : orderBy) : "created_at"
)}
</div>
</>
)}
<div className="pt-3 overflow-hidden overflow-y-scroll">
{groupedByIssues?.[groupTitle].map((issue, index) => (
<Draggable
key={issue.id}
@ -146,10 +141,12 @@ export const SingleBoard: React.FC<Props> = ({
>
{provided.placeholder}
</span>
</div>
<div>
{type === "issue" ? (
<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}
>
<PlusIcon className="h-4 w-4" />
@ -167,7 +164,7 @@ export const SingleBoard: React.FC<Props> = ({
Add Issue
</button>
}
optionsPosition="left"
position="left"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>
@ -182,10 +179,10 @@ export const SingleBoard: React.FC<Props> = ({
)
)}
</div>
)}
</StrictModeDroppable>
)}
</div>
</div>
)}
</StrictModeDroppable>
)}
</div>
);
};

View File

@ -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 { useRouter } from "next/router";
@ -17,6 +17,7 @@ import issuesService from "services/issues.service";
// hooks
import useIssuesView from "hooks/use-issues-view";
import useToast from "hooks/use-toast";
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// components
import {
ViewAssigneeSelect,
@ -36,7 +37,9 @@ import {
XMarkIcon,
ArrowTopRightOnSquareIcon,
PaperClipIcon,
EllipsisHorizontalIcon,
} from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helpers
import { handleIssuesMutation } from "constants/issue";
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
@ -89,6 +92,9 @@ export const SingleBoardIssue: React.FC<Props> = ({
// context menu
const [contextMenu, setContextMenu] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
const [isMenuActive, setIsMenuActive] = useState(false);
const actionSectionRef = useRef<HTMLDivElement | null>(null);
const { orderBy, params } = useIssuesView();
@ -98,7 +104,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
const { setToastAlert } = useToast();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
(formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
@ -164,7 +170,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
}
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then(() => {
if (cycleId) {
mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId as string, params));
@ -178,18 +184,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
console.log(error);
});
},
[
workspaceSlug,
projectId,
cycleId,
moduleId,
issue,
groupTitle,
index,
selectedGroup,
orderBy,
params,
]
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
);
const getStyle = (
@ -217,6 +212,7 @@ export const SingleBoardIssue: React.FC<Props> = ({
title: "Link Copied!",
message: "Issue link copied to clipboard.",
});
setIsMenuActive(false);
});
};
@ -224,6 +220,8 @@ export const SingleBoardIssue: React.FC<Props> = ({
if (snapshot.isDragging) handleTrashBox(snapshot.isDragging);
}, [snapshot, handleTrashBox]);
useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false));
const isNotAllowed = userAuth.isGuest || userAuth.isViewer || isCompleted;
return (
@ -276,9 +274,23 @@ export const SingleBoardIssue: React.FC<Props> = ({
>
<div className="group/card relative select-none p-3.5">
{!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 && (
<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}>
<div className="flex items-center justify-start gap-2">
<PencilIcon className="h-4 w-4" />
@ -348,11 +360,6 @@ export const SingleBoardIssue: React.FC<Props> = ({
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 && (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
@ -388,6 +395,16 @@ export const SingleBoardIssue: React.FC<Props> = ({
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 && (
<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}`}>

View File

@ -121,7 +121,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
leaveFrom="opacity-100 scale-100"
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>
<Combobox
onChange={(val: string) => {
@ -149,7 +149,7 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
<Combobox.Options
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 ? (
<li className="p-2">
@ -158,15 +158,15 @@ export const BulkDeleteIssuesModal: React.FC<Props> = ({ isOpen, setIsOpen }) =>
Select issues to delete
</h2>
)}
<ul className="text-sm text-gray-700">
<ul className="text-sm text-brand-secondary">
{filteredIssues.map((issue) => (
<Combobox.Option
key={issue.id}
as="div"
value={issue.id}
className={({ active }) =>
className={({ active, selected }) =>
`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,
}}
/>
<span className="flex-shrink-0 text-xs text-brand-secondary">
<span className="flex-shrink-0 text-xs">
{issue.project_detail.identifier}-{issue.sequence_id}
</span>
<span>{issue.name}</span>

View 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;

View File

@ -1,9 +1,20 @@
import React, { useState } from "react";
import useSWR, { mutate } from "swr";
import Link from "next/link";
import React, { useEffect, useState } from "react";
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 {
startOfWeek,
@ -11,156 +22,61 @@ import {
eachDayOfInterval,
weekDayInterval,
formatDate,
getCurrentWeekStartDate,
getCurrentWeekEndDate,
subtractMonths,
addMonths,
updateDateWithYear,
updateDateWithMonth,
isSameMonth,
isSameYear,
subtract7DaysToDate,
addSevenDaysToDate,
} from "helpers/calendar.helper";
// ui
import { Popover, Transition } from "@headlessui/react";
import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
import { CustomMenu, Spinner, ToggleSwitch } from "components/ui";
// icon
// types
import { ICalendarRange, IIssue, UserAuth } from "types";
// fetch-keys
import {
CheckIcon,
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
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,
CYCLE_ISSUES_WITH_PARAMS,
MODULE_ISSUES_WITH_PARAMS,
PROJECT_ISSUES_LIST_WITH_PARAMS,
VIEW_ISSUES,
} 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 = {
handleEditIssue: (issue: IIssue) => void;
handleDeleteIssue: (issue: IIssue) => void;
addIssueToDate: (date: string) => void;
isCompleted: boolean;
userAuth: UserAuth;
};
interface ICalendarRange {
startDate: Date;
endDate: Date;
}
export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
export const CalendarView: React.FC<Props> = ({
handleEditIssue,
handleDeleteIssue,
addIssueToDate,
isCompleted = false,
userAuth,
}) => {
const [showWeekEnds, setShowWeekEnds] = useState(false);
const [currentDate, setCurrentDate] = useState(new Date());
const [isMonthlyView, setIsMonthlyView] = useState(true);
const [showAllIssues, setShowAllIssues] = useState(false);
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { params } = useIssuesView();
const [calendarDateRange, setCalendarDateRange] = useState<ICalendarRange>({
const [calendarDates, setCalendarDates] = useState<ICalendarRange>({
startDate: startOfWeek(currentDate),
endDate: lastDayOfWeek(currentDate),
});
const targetDateFilter = {
target_date: `${renderDateFormat(calendarDateRange.startDate)};after,${renderDateFormat(
calendarDateRange.endDate
)};before`,
};
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query;
const { data: projectCalendarIssues } = useSWR(
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 { calendarIssues, params, setCalendarDateRange } = useCalendarIssuesView();
const totalDate = eachDayOfInterval({
start: calendarDateRange.startDate,
end: calendarDateRange.endDate,
start: calendarDates.startDate,
end: calendarDates.endDate,
});
const onlyWeekDays = weekDayInterval({
start: calendarDateRange.startDate,
end: calendarDateRange.endDate,
start: calendarDates.startDate,
end: calendarDates.endDate,
});
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 filterIssue =
calendarIssues && calendarIssues.length > 0
calendarIssues.length > 0
? calendarIssues.filter(
(issue) =>
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;
if (!destination || !workspaceSlug || !projectId) return;
if (source.droppableId === destination.droppableId) return;
const fetchKey = cycleId
? CYCLE_CALENDAR_ISSUES(projectId as string, cycleId as string)
? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params)
: moduleId
? MODULE_CALENDAR_ISSUES(projectId as string, moduleId as string)
: PROJECT_CALENDAR_ISSUES(projectId as string);
? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params)
: viewId
? VIEW_ISSUES(viewId.toString(), params)
: PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params);
mutate<IIssue[]>(
fetchKey,
@ -208,314 +127,105 @@ export const CalendarView: React.FC<Props> = ({ addIssueToDate }) => {
...p,
target_date: destination.droppableId,
};
return p;
}),
false
);
issuesService.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
target_date: destination?.droppableId,
});
issuesService
.patchIssue(workspaceSlug as string, projectId as string, draggableId, {
target_date: destination?.droppableId,
})
.then(() => mutate(fetchKey));
};
const updateDate = (date: Date) => {
setCurrentDate(date);
setCalendarDateRange({
startDate: startOfWeek(date),
endDate: lastDayOfWeek(date),
const changeDateRange = (startDate: Date, endDate: Date) => {
setCalendarDates({
startDate,
endDate,
});
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 ? (
<DragDropContext onDragEnd={onDragEnd}>
<div className="-m-2 h-full overflow-y-auto rounded-lg p-8 text-brand-secondary">
<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 className={`group flex h-full items-start gap-1 text-brand-base`}>
<div className="flex items-center justify-center gap-2 text-2xl font-semibold">
<span>{formatDate(currentDate, "Month")}</span>{" "}
<span>{formatDate(currentDate, "yyyy")}</span>
</div>
</Popover.Button>
<div className="h-full">
<DragDropContext onDragEnd={onDragEnd}>
<div className="h-full rounded-lg p-8 text-brand-secondary">
<CalendarHeader
isMonthlyView={isMonthlyView}
setIsMonthlyView={setIsMonthlyView}
showWeekEnds={showWeekEnds}
setShowWeekEnds={setShowWeekEnds}
currentDate={currentDate}
setCurrentDate={setCurrentDate}
changeDateRange={changeDateRange}
/>
<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
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start gap-2 border-brand-base bg-brand-surface-1 p-1.5 text-base font-medium text-brand-secondary ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 0
? ""
: "border-r"
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div>
<div
className={`grid h-full auto-rows-[minmax(150px,1fr)] ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => {
const totalIssues = date.issues.length;
return (
<StrictModeDroppable droppableId={date.date}>
{(provided) => (
<div
key={index}
ref={provided.innerRef}
{...provided.droppableProps}
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
<div
className={`grid auto-rows-[minmax(36px,1fr)] rounded-lg ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
}`}
>
{weeks.map((date, index) => (
<div
key={index}
className={`flex items-center justify-start gap-2 border-brand-base bg-brand-surface-1 p-1.5 text-base font-medium text-brand-secondary ${
!isMonthlyView
? showWeekEnds
? (index + 1) % 7 === 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>
);
})}
: (index + 1) % 5 === 0
? ""
: "border-r"
: ""
}`}
>
<span>
{isMonthlyView
? formatDate(date, "eee").substring(0, 3)
: formatDate(date, "eee")}
</span>
{!isMonthlyView && <span>{formatDate(date, "d")}</span>}
</div>
))}
</div>
<div
className={`grid h-full ${isMonthlyView ? "auto-rows-min" : ""} ${
showWeekEnds ? "grid-cols-7" : "grid-cols-5"
} `}
>
{currentViewDaysData.map((date, index) => (
<SingleCalendarDate
index={index}
date={date}
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
addIssueToDate={addIssueToDate}
isMonthlyView={isMonthlyView}
showWeekEnds={showWeekEnds}
isNotAllowed={isNotAllowed}
/>
))}
</div>
</div>
</div>
</DragDropContext>
</DragDropContext>
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<Spinner />

View File

@ -1 +1,4 @@
export * from "./calendar"
export * from "./calendar-header";
export * from "./calendar";
export * from "./single-date";
export * from "./single-issue";

View 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>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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>
);
};

View File

@ -79,7 +79,7 @@ const activityDetails: {
},
estimate_point: {
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: {
message: "set the due date to",

View 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 />
)}
</>
);
};

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useState, forwardRef, useRef } from "react";
import { useRouter } from "next/router";
import dynamic from "next/dynamic";
@ -35,6 +35,14 @@ const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor
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> = ({
isOpen,
handleClose,
@ -52,6 +60,8 @@ export const GptAssistantModal: React.FC<Props> = ({
const router = useRouter();
const { workspaceSlug } = router.query;
const editorRef = useRef<any>(null);
const { setToastAlert } = useToast();
const {
@ -119,21 +129,26 @@ export const GptAssistantModal: React.FC<Props> = ({
if (isOpen) setFocus("task");
}, [isOpen, setFocus]);
useEffect(() => {
editorRef.current?.setEditorValue(htmlContent ?? `<p>${content}</p>`);
}, [htmlContent, editorRef, content]);
return (
<div
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"
}`}
>
{((content && content !== "") || htmlContent !== "<p></p>") && (
{((content && content !== "") || (htmlContent && htmlContent !== "<p></p>")) && (
<div className="remirror-section text-sm">
Content:
<RemirrorRichTextEditor
<WrappedRemirrorRichTextEditor
value={htmlContent ?? <p>{content}</p>}
customClassName="-m-3"
noBorder
borderOnFocus={false}
editable={false}
ref={editorRef}
/>
</div>
)}

View File

@ -110,7 +110,7 @@ export const ImageUploadModal: React.FC<Props> = ({
leaveFrom="opacity-100"
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>
<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"
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">
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-brand-base">
Upload Image
@ -133,9 +133,9 @@ export const ImageUploadModal: React.FC<Props> = ({
<div className="flex items-center gap-3">
<div
{...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
? "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
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
</button>
@ -152,17 +152,18 @@ export const ImageUploadModal: React.FC<Props> = ({
objectFit="cover"
src={image ? URL.createObjectURL(image) : value ? value : ""}
alt="image"
className="rounded-lg"
/>
</>
) : (
<>
<UserCircleIcon className="mx-auto h-16 w-16 text-gray-400" />
<span className="mt-2 block text-sm font-medium text-brand-base">
<div>
<UserCircleIcon className="mx-auto h-16 w-16 text-brand-secondary" />
<span className="mt-2 block text-sm font-medium text-brand-secondary">
{isDragActive
? "Drop image here to upload"
: "Drag & drop image here"}
</span>
</>
</div>
)}
<input {...getInputProps()} type="text" />

View File

@ -1,14 +1,18 @@
export * from "./board-view";
export * from "./calendar-view";
export * from "./gantt-chart-view";
export * from "./list-view";
export * from "./sidebar";
export * from "./bulk-delete-issues-modal";
export * from "./existing-issues-list-modal";
export * from "./filters-list";
export * from "./gpt-assistant-modal";
export * from "./image-upload-modal";
export * from "./issues-view-filter";
export * from "./issues-view";
export * from "./link-modal";
export * from "./image-picker-popover";
export * from "./filter-list";
export * from "./feeds";
export * from "./theme-switch";
export * from "./custom-theme-selector";
export * from "./color-picker-input";

View File

@ -17,6 +17,7 @@ import {
ListBulletIcon,
Squares2X2Icon,
CalendarDaysIcon,
ChartBarIcon,
} from "@heroicons/react/24/outline";
// helpers
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
@ -82,6 +83,17 @@ export const IssuesFilterView: React.FC = () => {
>
<CalendarDaysIcon 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 ${
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>
<SelectFilters
filters={filters}
@ -233,31 +245,30 @@ export const IssuesFilterView: React.FC = () => {
</>
)}
</div>
{issueView !== "calendar" && (
<div className="space-y-2 py-3">
<h4 className="text-sm text-brand-secondary">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-brand-accent bg-brand-accent text-white"
: "border-brand-base"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
<div className="space-y-2 py-3">
<h4 className="text-sm text-brand-secondary">Display Properties</h4>
<div className="flex flex-wrap items-center gap-2">
{Object.keys(properties).map((key) => {
if (key === "estimate" && !isEstimateActive) return null;
return (
<button
key={key}
type="button"
className={`rounded border px-2 py-1 text-xs capitalize ${
properties[key as keyof Properties]
? "border-brand-accent bg-brand-accent text-white"
: "border-brand-base"
}`}
onClick={() => setProperties(key as keyof Properties)}
>
{key === "key" ? "ID" : replaceUnderscoreIfSnakeCase(key)}
</button>
);
})}
</div>
)}
</div>
</div>
</Popover.Panel>
</Transition>

View File

@ -6,6 +6,7 @@ import useSWR, { mutate } from "swr";
// react-beautiful-dnd
import { DragDropContext, DropResult } from "react-beautiful-dnd";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
// services
import issuesService from "services/issues.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 useIssuesView from "hooks/use-issues-view";
// components
import { AllLists, AllBoards, FilterList } from "components/core";
import { AllLists, AllBoards, FilterList, CalendarView, GanttChartView } from "components/core";
import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues";
import StrictModeDroppable from "components/dnd/StrictModeDroppable";
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
import { EmptySpace, EmptySpaceItem, EmptyState, PrimaryButton, Spinner } from "components/ui";
import { CalendarView } from "./calendar-view";
// icons
import {
ListBulletIcon,
@ -48,7 +48,7 @@ import {
PROJECT_ISSUES_LIST_WITH_PARAMS,
STATES_LIST,
} from "constants/fetch-keys";
// image
import { ModuleIssuesGanttChartView } from "components/modules";
type Props = {
type?: "issue" | "cycle" | "module";
@ -446,27 +446,25 @@ export const IssuesView: React.FC<Props> = ({
<>
<div className="flex items-center justify-between gap-2 px-5 pt-3 pb-0">
<FilterList filters={filters} setFilters={setFilters} />
{areFiltersApplied && (
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view
</PrimaryButton>
)}
<PrimaryButton
onClick={() => {
if (viewId) {
setFilters({}, true);
setToastAlert({
title: "View updated",
message: "Your view has been updated",
type: "success",
});
} else
setCreateViewModal({
query: filters,
});
}}
className="flex items-center gap-2 text-sm"
>
{!viewId && <PlusIcon className="h-4 w-4" />}
{viewId ? "Update" : "Save"} view
</PrimaryButton>
</div>
{<div className="mt-3 border-t border-brand-base" />}
</>
@ -478,14 +476,14 @@ export const IssuesView: React.FC<Props> = ({
<div
className={`${
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 ${
snapshot.isDraggingOver ? "bg-red-500/100 text-white" : ""
} duration-200`}
} 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 blur-2xl opacity-70" : ""
} transition duration-300`}
ref={provided.innerRef}
{...provided.droppableProps}
>
<TrashIcon className="h-4 w-4" />
Drop issue here to delete
Drop here to delete the issue.
</div>
)}
</StrictModeDroppable>
@ -532,8 +530,16 @@ export const IssuesView: React.FC<Props> = ({
isCompleted={isCompleted}
userAuth={memberRole}
/>
) : issueView === "calendar" ? (
<CalendarView
handleEditIssue={handleEditIssue}
handleDeleteIssue={handleDeleteIssue}
addIssueToDate={addIssueToDate}
isCompleted={isCompleted}
userAuth={memberRole}
/>
) : (
<CalendarView addIssueToDate={addIssueToDate} />
issueView === "gantt_chart" && <GanttChartView />
)}
</>
) : type === "issue" ? (

View File

@ -31,6 +31,7 @@ import {
ArrowTopRightOnSquareIcon,
PaperClipIcon,
} from "@heroicons/react/24/outline";
import { LayerDiagonalIcon } from "components/icons";
// helpers
import { copyTextToClipboard, truncateText } from "helpers/string.helper";
import { handleIssuesMutation } from "constants/issue";
@ -84,7 +85,7 @@ export const SingleListIssue: React.FC<Props> = ({
const { groupByProperty: selectedGroup, orderBy, params } = useIssueView();
const partialUpdateIssue = useCallback(
(formData: Partial<IIssue>) => {
(formData: Partial<IIssue>, issueId: string) => {
if (!workspaceSlug || !projectId) return;
if (cycleId)
@ -140,7 +141,7 @@ export const SingleListIssue: React.FC<Props> = ({
);
issuesService
.patchIssue(workspaceSlug as string, projectId as string, issue.id, formData)
.patchIssue(workspaceSlug as string, projectId as string, issueId, formData)
.then(() => {
if (cycleId) {
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));
});
},
[
workspaceSlug,
projectId,
cycleId,
moduleId,
issue,
groupTitle,
index,
selectedGroup,
orderBy,
params,
]
[workspaceSlug, projectId, cycleId, moduleId, groupTitle, index, selectedGroup, orderBy, params]
);
const handleCopyText = () => {
@ -216,7 +206,7 @@ export const SingleListIssue: React.FC<Props> = ({
</a>
</ContextMenu>
<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) => {
e.preventDefault();
setContextMenu(true);
@ -224,7 +214,7 @@ export const SingleListIssue: React.FC<Props> = ({
}}
>
<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">
{properties.key && (
<Tooltip
@ -245,7 +235,7 @@ export const SingleListIssue: React.FC<Props> = ({
</div>
</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 && (
<ViewPrioritySelect
issue={issue}
@ -269,11 +259,6 @@ export const SingleListIssue: React.FC<Props> = ({
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 ? (
<div className="flex flex-wrap gap-1">
{issue.label_details.map((label) => (
@ -310,6 +295,16 @@ export const SingleListIssue: React.FC<Props> = ({
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}`}>

View File

@ -168,7 +168,7 @@ export const SingleList: React.FC<Props> = ({
<PlusIcon className="h-4 w-4" />
</div>
}
optionsPosition="right"
position="right"
noBorder
>
<CustomMenu.MenuItem onClick={addIssueToState}>Create new</CustomMenu.MenuItem>
@ -204,7 +204,8 @@ export const SingleList: React.FC<Props> = ({
makeIssueCopy={() => makeIssueCopy(issue)}
handleDeleteIssue={handleDeleteIssue}
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}
userAuth={userAuth}

View File

@ -12,9 +12,11 @@ type Props = {
issues: IIssue[];
start: 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 endDate = new Date(end);
const getChartData = () => {
@ -51,8 +53,8 @@ const ProgressChart: React.FC<Props> = ({ issues, start, end }) => {
return (
<div className="absolute -left-4 flex h-full w-full items-center justify-center text-xs">
<AreaChart
width={360}
height={160}
width={width}
height={height}
data={ChartData}
margin={{
top: 12,

View File

@ -29,6 +29,8 @@ type Props = {
issues: IIssue[];
module?: IModule;
userAuth?: UserAuth;
roundedTab?: boolean;
noBackground?: boolean;
};
const stateGroupColours: {
@ -46,6 +48,8 @@ export const SidebarProgressStats: React.FC<Props> = ({
issues,
module,
userAuth,
roundedTab,
noBackground,
}) => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
@ -100,12 +104,16 @@ export const SidebarProgressStats: React.FC<Props> = ({
>
<Tab.List
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"} `}
>
<Tab
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"
}`
}
@ -114,7 +122,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
<Tab
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"
}`
}
@ -123,7 +133,9 @@ export const SidebarProgressStats: React.FC<Props> = ({
</Tab>
<Tab
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"
}`
}
@ -131,10 +143,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
States
</Tab>
</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">
{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");
if (totalArray.length > 0) {
@ -150,19 +162,19 @@ export const SidebarProgressStats: React.FC<Props> = ({
completed={completeArray.length}
total={totalArray.length}
onClick={() => {
if (filters.assignees?.includes(member.member.id))
if (filters?.assignees?.includes(member.member.id))
setFilters({
assignees: filters.assignees?.filter((a) => a !== member.member.id),
assignees: filters?.assignees?.filter((a) => a !== member.member.id),
});
else
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
title={
<>
@ -180,10 +192,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
}
completed={
issues?.filter(
(i) => i.state_detail.group === "completed" && i.assignees?.length === 0
(i) => i?.state_detail.group === "completed" && i.assignees?.length === 0
).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 as="div" className="w-full space-y-1">
{issueLabels?.map((label, index) => {
const totalArray = issues?.filter((i) => i.labels?.includes(label.id));
const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed");
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 (
@ -207,7 +219,7 @@ export const SidebarProgressStats: React.FC<Props> = ({
label.color && label.color !== "" ? label.color : "#000000",
}}
/>
<span className="text-xs capitalize">{label.name}</span>
<span className="text-xs capitalize">{label?.name}</span>
</div>
}
completed={completeArray.length}
@ -215,11 +227,11 @@ export const SidebarProgressStats: React.FC<Props> = ({
onClick={() => {
if (filters.labels?.includes(label.id))
setFilters({
labels: filters.labels?.filter((l) => l !== label.id),
labels: filters?.labels?.filter((l) => l !== label.id),
});
else setFilters({ labels: [...(filters?.labels ?? []), label.id] });
}}
selected={filters.labels?.includes(label.id)}
selected={filters?.labels?.includes(label.id)}
/>
);
}

View File

@ -18,7 +18,7 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
selected = false,
}) => (
<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" : ""
} ${selected ? "bg-brand-surface-1" : ""}`}
onClick={onClick}
@ -29,7 +29,12 @@ export const SingleProgressStats: React.FC<TSingleProgressStatsProps> = ({
<span className="h-4 w-4 ">
<ProgressBar value={completed} maxValue={total} />
</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>
<span>of</span>
<span>{total}</span>

View File

@ -1,12 +1,30 @@
import { useState, useEffect, ChangeEvent } from "react";
import { useTheme } from "next-themes";
import { THEMES_OBJ } from "constants/themes";
import { CustomSelect } from "components/ui";
import { CustomThemeModal } from "./custom-theme-modal";
import { useState, useEffect, Dispatch, SetStateAction } from "react";
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 [customThemeModal, setCustomThemeModal] = useState(false);
const { theme, setTheme } = useTheme();
// useEffect only runs on the client, so now we can safely show the UI
@ -18,15 +36,49 @@ export const ThemeSwitch = () => {
return null;
}
const currentThemeObj = THEMES_OBJ.find((t) => t.value === theme);
return (
<>
<CustomSelect
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 }) => {
if (value === "custom") {
if (!customThemeModal) setCustomThemeModal(true);
if (user?.theme.palette) {
setPreLoadedData(user.theme);
}
if (!customThemeSelectorOptions) setCustomThemeSelectorOptions(true);
} else {
if (customThemeSelectorOptions) setCustomThemeSelectorOptions(false);
const cssVars = [
"--color-bg-base",
"--color-bg-surface-1",
@ -41,20 +93,41 @@ export const ThemeSwitch = () => {
];
cssVars.forEach((cssVar) => document.documentElement.style.removeProperty(cssVar));
}
document.documentElement.style.setProperty("color-scheme", type);
setTheme(value);
document.documentElement.style.setProperty("color-scheme", type);
}}
input
width="w-full"
position="right"
>
{THEMES_OBJ.map(({ value, label, type }) => (
{THEMES_OBJ.map(({ value, label, type, icon }) => (
<CustomSelect.Option key={value} value={{ value, type }}>
{label}
<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}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
{/* <CustomThemeModal isOpen={customThemeModal} handleClose={() => setCustomThemeModal(false)} /> */}
</>
);
};

View 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>
);
};

View 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>
);
};

View File

@ -17,7 +17,7 @@ type TCycleStatsViewProps = {
type: "current" | "upcoming" | "draft";
};
export const CyclesList: React.FC<TCycleStatsViewProps> = ({
export const AllCyclesBoard: React.FC<TCycleStatsViewProps> = ({
cycles,
setCreateUpdateCycleModal,
setSelectedCycle,
@ -61,7 +61,7 @@ export const CyclesList: React.FC<TCycleStatsViewProps> = ({
</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 current cycle is present.</h3>
<h3 className="text-base font-medium text-brand-base ">No cycle is present.</h3>
</div>
) : (
<EmptyState

View 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>
)}
</>
);
};

View File

@ -7,9 +7,9 @@ import useSWR from "swr";
// services
import cyclesService from "services/cycles.service";
// components
import { DeleteCycleModal, SingleCycleCard } from "components/cycles";
import { DeleteCycleModal, SingleCycleCard, SingleCycleList } from "components/cycles";
// icons
import { CompletedCycleIcon, ExclamationIcon } from "components/icons";
import { ExclamationIcon } from "components/icons";
// types
import { ICycle, SelectCycleType } from "types";
// fetch-keys
@ -19,11 +19,13 @@ import { EmptyState, Loader } from "components/ui";
import emptyCycle from "public/empty-state/empty-cycle.svg";
export interface CompletedCyclesListProps {
cycleView: string;
setCreateUpdateCycleModal: React.Dispatch<React.SetStateAction<boolean>>;
setSelectedCycle: React.Dispatch<React.SetStateAction<SelectCycleType>>;
}
export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
export const CompletedCycles: React.FC<CompletedCyclesListProps> = ({
cycleView,
setCreateUpdateCycleModal,
setSelectedCycle,
}) => {
@ -72,17 +74,36 @@ export const CompletedCyclesList: React.FC<CompletedCyclesListProps> = ({
/>
<span>Completed cycles are not editable.</span>
</div>
<div className="grid grid-cols-1 gap-9 md:grid-cols-2 lg:grid-cols-3">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
isCompleted
/>
))}
</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">
{completedCycles.completed_cycles.map((cycle) => (
<SingleCycleCard
key={cycle.id}
cycle={cycle}
handleDeleteCycle={() => handleDeleteCycle(cycle)}
handleEditCycle={() => handleEditCycle(cycle)}
isCompleted
/>
))}
</div>
)}
</div>
) : (
<EmptyState

View 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>
);
};

View 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>
</>
);
};

View File

@ -29,6 +29,7 @@ type TConfirmCycleDeletionProps = {
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
CYCLE_LIST,
} from "constants/fetch-keys";
@ -114,6 +115,14 @@ export const DeleteCycleModal: React.FC<TConfirmCycleDeletionProps> = ({
false
);
}
mutate(
CYCLE_DETAILS(projectId as string),
(prevData: any) => {
if (!prevData) return;
return prevData.filter((cycle: any) => cycle.id !== data?.id);
},
false
);
handleClose();
setToastAlert({

View 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>
);
};

View File

@ -1,11 +1,18 @@
export * from "./completed-cycles-list";
export * from "./cycles-list";
export * from "./active-cycle-details";
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 "./form";
export * from "./gantt-chart";
export * from "./modal";
export * from "./select";
export * from "./sidebar";
export * from "./single-cycle-list";
export * from "./single-cycle-card";
export * from "./empty-cycle";
export * from "./transfer-issues-modal";
export * from "./transfer-issues";
export * from "./active-cycle-stats";

View File

@ -20,6 +20,7 @@ import type { ICycle } from "types";
import {
CYCLE_COMPLETE_LIST,
CYCLE_CURRENT_AND_UPCOMING_LIST,
CYCLE_DETAILS,
CYCLE_DRAFT_LIST,
CYCLE_INCOMPLETE_LIST,
} from "constants/fetch-keys";
@ -58,6 +59,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
mutate(CYCLE_INCOMPLETE_LIST(projectId as string));
mutate(CYCLE_DETAILS(projectId as string));
handleClose();
setToastAlert({
@ -92,6 +94,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
default:
mutate(CYCLE_DRAFT_LIST(projectId as string));
}
mutate(CYCLE_DETAILS(projectId as string));
if (
getDateRangeStatus(data?.start_date, data?.end_date) !=
getDateRangeStatus(res.start_date, res.end_date)
@ -157,14 +160,10 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
title: "Error!",
message: "Unable to create cycle in past date. Please enter a valid date.",
});
handleClose();
return;
}
const isDateValid = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
});
if (data?.start_date && data?.end_date) {
const isDateValidForExistingCycle = await dateChecker({
start_date: payload.start_date,
@ -182,10 +181,16 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
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",
});
handleClose();
return;
}
}
const isDateValid = await dateChecker({
start_date: payload.start_date,
end_date: payload.end_date,
});
if (isDateValid) {
if (data) {
await updateCycle(data.id, payload);
@ -199,6 +204,7 @@ export const CreateUpdateCycleModal: React.FC<CycleModalProps> = ({
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",
});
handleClose();
}
} else {
if (data) {

View File

@ -8,7 +8,6 @@ import useSWR, { mutate } from "swr";
// react-hook-form
import { useForm } from "react-hook-form";
import { Disclosure, Popover, Transition } from "@headlessui/react";
import DatePicker from "react-datepicker";
// icons
import {
CalendarDaysIcon,
@ -21,7 +20,7 @@ import {
LinkIcon,
} from "@heroicons/react/24/outline";
// ui
import { CustomMenu, Loader, ProgressBar } from "components/ui";
import { CustomMenu, CustomRangeDatePicker, Loader, ProgressBar } from "components/ui";
// hooks
import useToast from "hooks/use-toast";
// services
@ -34,7 +33,11 @@ import { DeleteCycleModal } from "components/cycles";
import { ExclamationIcon } from "components/icons";
// helpers
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
import { ICycle, IIssue } from "types";
// fetch-keys
@ -77,7 +80,7 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
: null
);
const { reset, watch } = useForm({
const { setValue, reset, watch } = useForm({
defaultValues,
});
@ -122,6 +125,166 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
});
}, [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 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} />
<div
className={`fixed top-0 ${
className={`fixed top-[66px] ${
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 ? (
<>
@ -158,7 +321,12 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
>
<CalendarDaysIcon className="h-3 w-3" />
<span>
{renderShortDate(new Date(`${cycle?.start_date}`), "Start date")}
{renderShortDate(
new Date(
`${watch("start_date") ? watch("start_date") : cycle?.start_date}`
),
"Start date"
)}
</span>
</Popover.Button>
@ -172,36 +340,17 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<DatePicker
selected={
watch("start_date")
? new Date(`${watch("start_date")}`)
: new Date()
}
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.",
});
}
<CustomRangeDatePicker
value={watch("start_date") ? watch("start_date") : cycle?.start_date}
onChange={(val) => {
if (val) {
handleStartDateChange(val);
}
}}
selectsStart
startDate={new Date(`${watch("start_date")}`)}
endDate={new Date(`${watch("end_date")}`)}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
maxDate={new Date(`${watch("end_date")}`)}
shouldCloseOnSelect
inline
selectsStart
/>
</Popover.Panel>
</Transition>
@ -222,7 +371,14 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
>
<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>
<Transition
@ -235,37 +391,17 @@ export const CycleDetailsSidebar: React.FC<Props> = ({
leaveTo="opacity-0 translate-y-1"
>
<Popover.Panel className="absolute top-10 -right-5 z-20 transform overflow-hidden">
<DatePicker
selected={
watch("end_date") ? new Date(`${watch("end_date")}`) : new Date()
}
onChange={(date) => {
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.",
});
}
<CustomRangeDatePicker
value={watch("end_date") ? watch("end_date") : cycle?.end_date}
onChange={(val) => {
if (val) {
handleEndDateChange(val);
}
}}
selectsEnd
startDate={new Date(`${watch("start_date")}`)}
endDate={new Date(`${watch("end_date")}`)}
startDate={watch("start_date") ? `${watch("start_date")}` : null}
endDate={watch("end_date") ? `${watch("end_date")}` : null}
minDate={new Date(`${watch("start_date")}`)}
shouldCloseOnSelect
inline
selectsEnd
/>
</Popover.Panel>
</Transition>

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