mirror of
https://github.com/makeplane/plane
synced 2025-08-07 19:59:33 +00:00
Compare commits
13 Commits
chore/anal
...
chore-main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b23a83be6 | ||
|
|
2f4aa843fc | ||
|
|
cfac8ce350 | ||
|
|
75a11ba31a | ||
|
|
1fc3709731 | ||
|
|
7e21618762 | ||
|
|
2d475491e9 | ||
|
|
2a2feaf88e | ||
|
|
e48b2da623 | ||
|
|
9c9952a823 | ||
|
|
906ce8b500 | ||
|
|
6c483fad2f | ||
|
|
5b776392bd |
@@ -98,11 +98,7 @@ export const InstanceGithubConfigForm: FC<Props> = (props) => {
|
||||
key: "GITHUB_ORGANIZATION_ID",
|
||||
type: "text",
|
||||
label: "Organization ID",
|
||||
description: (
|
||||
<>
|
||||
The organization github ID.
|
||||
</>
|
||||
),
|
||||
description: <>The organization github ID.</>,
|
||||
placeholder: "123456789",
|
||||
error: Boolean(errors.GITHUB_ORGANIZATION_ID),
|
||||
required: false,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { LogOut, UserCog2, Palette } from "lucide-react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
// plane internal packages
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import {AuthService } from "@plane/services";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL, cn } from "@plane/utils";
|
||||
// hooks
|
||||
|
||||
@@ -2,7 +2,7 @@ import set from "lodash/set";
|
||||
import { observable, action, computed, makeObservable, runInAction } from "mobx";
|
||||
// plane internal packages
|
||||
import { EInstanceStatus, TInstanceStatus } from "@plane/constants";
|
||||
import {InstanceService} from "@plane/services";
|
||||
import { InstanceService } from "@plane/services";
|
||||
import {
|
||||
IInstance,
|
||||
IInstanceAdmin,
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
export * from "ce/components/authentication/authentication-modes";
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"build": "next build",
|
||||
"preview": "next build && next start",
|
||||
"start": "next start",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:errors": "eslint . --ext .ts,.tsx --quiet"
|
||||
},
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
{
|
||||
"extends": "@plane/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["core/*"],
|
||||
"@/public/*": ["public/*"],
|
||||
"@/plane-admin/*": ["ce/*"],
|
||||
"@/styles/*": ["styles/*"]
|
||||
}
|
||||
},
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -15,4 +15,4 @@ from .state import StateLiteSerializer, StateSerializer
|
||||
from .cycle import CycleSerializer, CycleIssueSerializer, CycleLiteSerializer
|
||||
from .module import ModuleSerializer, ModuleIssueSerializer, ModuleLiteSerializer
|
||||
from .intake import IntakeIssueSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
from .estimate import EstimatePointSerializer
|
||||
|
||||
@@ -160,12 +160,15 @@ class IssueSerializer(BaseSerializer):
|
||||
else:
|
||||
try:
|
||||
# Then assign it to default assignee, if it is a valid assignee
|
||||
if default_assignee_id is not None and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True
|
||||
).exists():
|
||||
if (
|
||||
default_assignee_id is not None
|
||||
and ProjectMember.objects.filter(
|
||||
member_id=default_assignee_id,
|
||||
project_id=project_id,
|
||||
role__gte=15,
|
||||
is_active=True,
|
||||
).exists()
|
||||
):
|
||||
IssueAssignee.objects.create(
|
||||
assignee_id=default_assignee_id,
|
||||
issue=issue,
|
||||
|
||||
@@ -53,6 +53,7 @@ def get_entity_model_and_serializer(entity_type):
|
||||
}
|
||||
return entity_map.get(entity_type, (None, None))
|
||||
|
||||
|
||||
class UserFavoriteSerializer(serializers.ModelSerializer):
|
||||
entity_data = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -148,7 +148,6 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def create(self, validated_data):
|
||||
# Filtering the WorkspaceUserLink with the given url to check if the link already exists.
|
||||
|
||||
@@ -157,7 +156,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=validated_data.get("workspace_id"),
|
||||
owner_id=validated_data.get("owner_id")
|
||||
owner_id=validated_data.get("owner_id"),
|
||||
)
|
||||
|
||||
if workspace_user_link.exists():
|
||||
@@ -173,10 +172,8 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
url = validated_data.get("url")
|
||||
|
||||
workspace_user_link = WorkspaceUserLink.objects.filter(
|
||||
url=url,
|
||||
workspace_id=instance.workspace_id,
|
||||
owner=instance.owner
|
||||
)
|
||||
url=url, workspace_id=instance.workspace_id, owner=instance.owner
|
||||
)
|
||||
|
||||
if workspace_user_link.exclude(pk=instance.id).exists():
|
||||
raise serializers.ValidationError(
|
||||
@@ -185,6 +182,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
|
||||
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class IssueRecentVisitSerializer(serializers.ModelSerializer):
|
||||
project_identifier = serializers.SerializerMethodField()
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from plane.app.views import (
|
||||
AdvanceAnalyticsChartEndpoint,
|
||||
DefaultAnalyticsEndpoint,
|
||||
ProjectStatsEndpoint,
|
||||
AdvanceAnalyticsExportEndpoint,
|
||||
)
|
||||
|
||||
|
||||
@@ -68,9 +67,4 @@ urlpatterns = [
|
||||
AdvanceAnalyticsChartEndpoint.as_view(),
|
||||
name="advance-analytics-chart",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/advance-analytics-export/",
|
||||
AdvanceAnalyticsExportEndpoint.as_view(),
|
||||
name="advance-analytics-export",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -203,7 +203,6 @@ from .analytic.advance import (
|
||||
AdvanceAnalyticsEndpoint,
|
||||
AdvanceAnalyticsStatsEndpoint,
|
||||
AdvanceAnalyticsChartEndpoint,
|
||||
AdvanceAnalyticsExportEndpoint,
|
||||
)
|
||||
|
||||
from .notification.base import (
|
||||
|
||||
@@ -5,7 +5,7 @@ from django.db.models import QuerySet, Q, Count
|
||||
from django.http import HttpRequest
|
||||
from django.db.models.functions import TruncMonth
|
||||
from django.utils import timezone
|
||||
|
||||
from datetime import timedelta
|
||||
from plane.app.views.base import BaseAPIView
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.db.models import (
|
||||
@@ -16,19 +16,18 @@ from plane.db.models import (
|
||||
Module,
|
||||
IssueView,
|
||||
ProjectPage,
|
||||
Workspace
|
||||
Workspace,
|
||||
CycleIssue,
|
||||
ModuleIssue,
|
||||
)
|
||||
|
||||
from django.db import models
|
||||
from django.db.models import F, Case, When, Value
|
||||
from django.db.models.functions import Concat
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
|
||||
from plane.utils.date_utils import (
|
||||
get_analytics_filters,
|
||||
)
|
||||
|
||||
from plane.utils.build_chart import build_analytics_chart
|
||||
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
|
||||
from plane.utils.date_utils import get_analytics_filters
|
||||
|
||||
|
||||
class AdvanceAnalyticsBaseView(BaseAPIView):
|
||||
def initialize_workspace(self, slug: str, type: str) -> None:
|
||||
@@ -43,7 +42,6 @@ class AdvanceAnalyticsBaseView(BaseAPIView):
|
||||
|
||||
|
||||
class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
|
||||
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
|
||||
def get_filtered_count() -> int:
|
||||
if self.filters["analytics_date_range"]:
|
||||
@@ -73,7 +71,7 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
|
||||
return {
|
||||
"count": get_filtered_count(),
|
||||
"filter_count": get_previous_count(),
|
||||
# "filter_count": get_previous_count(),
|
||||
}
|
||||
|
||||
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
|
||||
@@ -120,9 +118,25 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
def get_work_items_stats(
|
||||
self, cycle_id=None, module_id=None
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Returns work item stats for the workspace, or filtered by cycle_id or module_id if provided.
|
||||
"""
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
return {
|
||||
"total_work_items": self.get_filtered_counts(base_queryset),
|
||||
@@ -150,13 +164,14 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
|
||||
self.get_overview_data(),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
elif tab == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
return Response(
|
||||
self.get_work_items_stats(),
|
||||
self.get_work_items_stats(cycle_id=cycle_id, module_id=module_id),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -184,14 +199,100 @@ class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
def get_work_items_stats(
|
||||
self, cycle_id=None, module_id=None, peek_view=False
|
||||
) -> Dict[str, Dict[str, int]]:
|
||||
base_queryset = None
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=cycle_issues)
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
base_queryset = Issue.issue_objects.filter(id__in=module_issues)
|
||||
elif peek_view:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
else:
|
||||
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
return (
|
||||
base_queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count(
|
||||
"id", filter=Q(state__group="cancelled")
|
||||
),
|
||||
completed_work_items=Count(
|
||||
"id", filter=Q(state__group="completed")
|
||||
),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count(
|
||||
"id", filter=Q(state__group="unstarted")
|
||||
),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
return (
|
||||
base_queryset.annotate(display_name=F("assignees__display_name"))
|
||||
.annotate(assignee_id=F("assignees__id"))
|
||||
.annotate(avatar=F("assignees__avatar"))
|
||||
.annotate(
|
||||
avatar_url=Case(
|
||||
# If `avatar_asset` exists, use it to generate the asset URL
|
||||
When(
|
||||
assignees__avatar_asset__isnull=False,
|
||||
then=Concat(
|
||||
Value("/api/assets/v2/static/"),
|
||||
"assignees__avatar_asset", # Assuming avatar_asset has an id or relevant field
|
||||
Value("/"),
|
||||
),
|
||||
),
|
||||
# If `avatar_asset` is None, fall back to using `avatar` field directly
|
||||
When(
|
||||
assignees__avatar_asset__isnull=True, then="assignees__avatar"
|
||||
),
|
||||
default=Value(None),
|
||||
output_field=models.CharField(),
|
||||
)
|
||||
)
|
||||
.values("display_name", "assignee_id", "avatar_url")
|
||||
.annotate(
|
||||
cancelled_work_items=Count(
|
||||
"id", filter=Q(state__group="cancelled"), distinct=True
|
||||
),
|
||||
completed_work_items=Count(
|
||||
"id", filter=Q(state__group="completed"), distinct=True
|
||||
),
|
||||
backlog_work_items=Count(
|
||||
"id", filter=Q(state__group="backlog"), distinct=True
|
||||
),
|
||||
un_started_work_items=Count(
|
||||
"id", filter=Q(state__group="unstarted"), distinct=True
|
||||
),
|
||||
started_work_items=Count(
|
||||
"id", filter=Q(state__group="started"), distinct=True
|
||||
),
|
||||
)
|
||||
.order_by("display_name")
|
||||
)
|
||||
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def get(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
type = request.GET.get("type", "work-items")
|
||||
|
||||
if type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
peek_view = request.GET.get("peek_view", False)
|
||||
return Response(
|
||||
self.get_project_issues_stats(),
|
||||
self.get_work_items_stats(
|
||||
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -251,7 +352,9 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
for key, value in data.items()
|
||||
]
|
||||
|
||||
def work_item_completion_chart(self) -> Dict[str, Any]:
|
||||
def work_item_completion_chart(
|
||||
self, cycle_id=None, module_id=None, peek_view=False
|
||||
) -> Dict[str, Any]:
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
@@ -261,61 +364,143 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
)
|
||||
)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
# Annotate by month and count
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count("id", filter=Q(completed_at__isnull=False)),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
workspace = Workspace.objects.get(slug=self._workspace_slug)
|
||||
start_date = workspace.created_at.date().replace(day=1)
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats[
|
||||
"created_count"
|
||||
], # <- Total created issues in that month
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(year=current_month.year + 1, month=1)
|
||||
if cycle_id is not None and peek_view:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
cycle = Cycle.objects.filter(id=cycle_id).first()
|
||||
if cycle and cycle.start_date:
|
||||
start_date = cycle.start_date.date()
|
||||
end_date = cycle.end_date.date()
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = cycle_issues
|
||||
elif module_id is not None and peek_view:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
module = Module.objects.filter(id=module_id).first()
|
||||
if module and module.start_date:
|
||||
start_date = module.start_date
|
||||
end_date = module.target_date
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
queryset = module_issues
|
||||
elif peek_view:
|
||||
project_ids_str = self.request.GET.get("project_ids")
|
||||
if project_ids_str:
|
||||
project_id_list = [
|
||||
pid.strip() for pid in project_ids_str.split(",") if pid.strip()
|
||||
]
|
||||
else:
|
||||
project_id_list = []
|
||||
return {"data": [], "schema": {}}
|
||||
project_id = project_id_list[0]
|
||||
project = Project.objects.filter(id=project_id).first()
|
||||
if project.created_at:
|
||||
start_date = project.created_at.date().replace(day=1)
|
||||
else:
|
||||
return {"data": [], "schema": {}}
|
||||
else:
|
||||
workspace = Workspace.objects.get(slug=self._workspace_slug)
|
||||
start_date = workspace.created_at.date().replace(day=1)
|
||||
|
||||
if cycle_id or module_id:
|
||||
# Get daily stats with optimized query
|
||||
daily_stats = (
|
||||
queryset.values("created_at__date")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count(
|
||||
"id", filter=Q(issue__state__group="completed")
|
||||
),
|
||||
)
|
||||
.order_by("created_at__date")
|
||||
)
|
||||
|
||||
# Create a dictionary of existing stats with summed counts
|
||||
stats_dict = {
|
||||
stat["created_at__date"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in daily_stats
|
||||
}
|
||||
|
||||
# Generate data for all days in the range
|
||||
data = []
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(
|
||||
date_str, {"created_count": 0, "completed_count": 0}
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"] + stats["completed_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
current_date += timedelta(days=1)
|
||||
else:
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
# Annotate by month and count
|
||||
monthly_stats = (
|
||||
queryset.annotate(month=TruncMonth("created_at"))
|
||||
.values("month")
|
||||
.annotate(
|
||||
created_count=Count("id"),
|
||||
completed_count=Count("id", filter=Q(state__group="completed")),
|
||||
)
|
||||
.order_by("month")
|
||||
)
|
||||
|
||||
# Create dictionary of month -> counts
|
||||
stats_dict = {
|
||||
stat["month"].strftime("%Y-%m-%d"): {
|
||||
"created_count": stat["created_count"],
|
||||
"completed_count": stat["completed_count"],
|
||||
}
|
||||
for stat in monthly_stats
|
||||
}
|
||||
|
||||
# Generate monthly data (ensure months with 0 count are included)
|
||||
data = []
|
||||
# include the current date at the end
|
||||
end_date = timezone.now().date()
|
||||
last_month = end_date.replace(day=1)
|
||||
current_month = start_date
|
||||
|
||||
while current_month <= last_month:
|
||||
date_str = current_month.strftime("%Y-%m-%d")
|
||||
stats = stats_dict.get(
|
||||
date_str, {"created_count": 0, "completed_count": 0}
|
||||
)
|
||||
data.append(
|
||||
{
|
||||
"key": date_str,
|
||||
"name": date_str,
|
||||
"count": stats["created_count"],
|
||||
"completed_issues": stats["completed_count"],
|
||||
"created_issues": stats["created_count"],
|
||||
}
|
||||
)
|
||||
# Move to next month
|
||||
if current_month.month == 12:
|
||||
current_month = current_month.replace(
|
||||
year=current_month.year + 1, month=1
|
||||
)
|
||||
else:
|
||||
current_month = current_month.replace(month=current_month.month + 1)
|
||||
|
||||
schema = {
|
||||
"completed_issues": "completed_issues",
|
||||
@@ -330,12 +515,13 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
type = request.GET.get("type", "projects")
|
||||
group_by = request.GET.get("group_by", None)
|
||||
x_axis = request.GET.get("x_axis", "PRIORITY")
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
|
||||
if type == "projects":
|
||||
return Response(self.project_chart(), status=status.HTTP_200_OK)
|
||||
|
||||
elif type == "custom-work-items":
|
||||
# Get the base queryset
|
||||
queryset = (
|
||||
Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
.select_related("workspace", "state", "parent")
|
||||
@@ -344,6 +530,19 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
)
|
||||
)
|
||||
|
||||
# Apply cycle/module filters if present
|
||||
if cycle_id is not None:
|
||||
cycle_issues = CycleIssue.objects.filter(
|
||||
**self.filters["base_filters"], cycle_id=cycle_id
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=cycle_issues)
|
||||
|
||||
elif module_id is not None:
|
||||
module_issues = ModuleIssue.objects.filter(
|
||||
**self.filters["base_filters"], module_id=module_id
|
||||
).values_list("issue_id", flat=True)
|
||||
queryset = queryset.filter(id__in=module_issues)
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
@@ -357,66 +556,15 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
|
||||
)
|
||||
|
||||
elif type == "work-items":
|
||||
# Optionally accept cycle_id or module_id as query params
|
||||
cycle_id = request.GET.get("cycle_id", None)
|
||||
module_id = request.GET.get("module_id", None)
|
||||
peek_view = request.GET.get("peek_view", False)
|
||||
return Response(
|
||||
self.work_item_completion_chart(),
|
||||
self.work_item_completion_chart(
|
||||
cycle_id=cycle_id, module_id=module_id, peek_view=peek_view
|
||||
),
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class AdvanceAnalyticsExportEndpoint(AdvanceAnalyticsBaseView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
|
||||
def post(self, request: HttpRequest, slug: str) -> Response:
|
||||
self.initialize_workspace(slug, type="chart")
|
||||
queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
|
||||
|
||||
# Apply date range filter if available
|
||||
if self.filters["chart_period_range"]:
|
||||
start_date, end_date = self.filters["chart_period_range"]
|
||||
queryset = queryset.filter(
|
||||
created_at__date__gte=start_date, created_at__date__lte=end_date
|
||||
)
|
||||
|
||||
queryset = (
|
||||
queryset.values("project_id", "project__name")
|
||||
.annotate(
|
||||
cancelled_work_items=Count("id", filter=Q(state__group="cancelled")),
|
||||
completed_work_items=Count("id", filter=Q(state__group="completed")),
|
||||
backlog_work_items=Count("id", filter=Q(state__group="backlog")),
|
||||
un_started_work_items=Count("id", filter=Q(state__group="unstarted")),
|
||||
started_work_items=Count("id", filter=Q(state__group="started")),
|
||||
)
|
||||
.order_by("project_id")
|
||||
)
|
||||
|
||||
# Convert QuerySet to list of dictionaries for serialization
|
||||
serialized_data = list(queryset)
|
||||
|
||||
headers = [
|
||||
"Projects",
|
||||
"Completed Issues",
|
||||
"Backlog Issues",
|
||||
"Unstarted Issues",
|
||||
"Started Issues",
|
||||
]
|
||||
|
||||
keys = [
|
||||
"project__name",
|
||||
"completed_work_items",
|
||||
"backlog_work_items",
|
||||
"un_started_work_items",
|
||||
"started_work_items",
|
||||
]
|
||||
|
||||
email = request.user.email
|
||||
|
||||
# Send serialized data to background task
|
||||
export_analytics_to_csv_email.delay(serialized_data, headers, keys, email, slug)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"message": f"Once the export is ready it will be emailed to you at {str(email)}"
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@@ -1119,14 +1119,13 @@ class CycleUserPropertiesEndpoint(BaseAPIView):
|
||||
class CycleProgressEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def get(self, request, slug, project_id, cycle_id):
|
||||
|
||||
cycle = Cycle.objects.filter(
|
||||
workspace__slug=slug, project_id=project_id, id=cycle_id
|
||||
).first()
|
||||
if not cycle:
|
||||
return Response(
|
||||
{"error": "Cycle not found"}, status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
)
|
||||
aggregate_estimates = (
|
||||
Issue.issue_objects.filter(
|
||||
estimate_point__estimate__type="points",
|
||||
@@ -1177,7 +1176,7 @@ class CycleProgressEndpoint(BaseAPIView):
|
||||
),
|
||||
)
|
||||
)
|
||||
if cycle.progress_snapshot:
|
||||
if cycle.progress_snapshot:
|
||||
backlog_issues = cycle.progress_snapshot.get("backlog_issues", 0)
|
||||
unstarted_issues = cycle.progress_snapshot.get("unstarted_issues", 0)
|
||||
started_issues = cycle.progress_snapshot.get("started_issues", 0)
|
||||
|
||||
@@ -29,6 +29,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class CycleIssueViewSet(BaseViewSet):
|
||||
serializer_class = CycleIssueSerializer
|
||||
model = CycleIssue
|
||||
|
||||
69
apiserver/plane/app/views/external/base.py
vendored
69
apiserver/plane/app/views/external/base.py
vendored
@@ -11,8 +11,7 @@ from rest_framework.response import Response
|
||||
|
||||
# Module import
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import (ProjectLiteSerializer,
|
||||
WorkspaceLiteSerializer)
|
||||
from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
|
||||
from plane.db.models import Project, Workspace
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.utils.exception_logger import log_exception
|
||||
@@ -22,6 +21,7 @@ from ..base import BaseAPIView
|
||||
|
||||
class LLMProvider:
|
||||
"""Base class for LLM provider configurations"""
|
||||
|
||||
name: str = ""
|
||||
models: List[str] = []
|
||||
default_model: str = ""
|
||||
@@ -34,11 +34,13 @@ class LLMProvider:
|
||||
"default_model": cls.default_model,
|
||||
}
|
||||
|
||||
|
||||
class OpenAIProvider(LLMProvider):
|
||||
name = "OpenAI"
|
||||
models = ["gpt-3.5-turbo", "gpt-4o-mini", "gpt-4o", "o1-mini", "o1-preview"]
|
||||
default_model = "gpt-4o-mini"
|
||||
|
||||
|
||||
class AnthropicProvider(LLMProvider):
|
||||
name = "Anthropic"
|
||||
models = [
|
||||
@@ -49,40 +51,45 @@ class AnthropicProvider(LLMProvider):
|
||||
"claude-2.1",
|
||||
"claude-2",
|
||||
"claude-instant-1.2",
|
||||
"claude-instant-1"
|
||||
"claude-instant-1",
|
||||
]
|
||||
default_model = "claude-3-sonnet-20240229"
|
||||
|
||||
|
||||
class GeminiProvider(LLMProvider):
|
||||
name = "Gemini"
|
||||
models = ["gemini-pro", "gemini-1.5-pro-latest", "gemini-pro-vision"]
|
||||
default_model = "gemini-pro"
|
||||
|
||||
|
||||
SUPPORTED_PROVIDERS = {
|
||||
"openai": OpenAIProvider,
|
||||
"anthropic": AnthropicProvider,
|
||||
"gemini": GeminiProvider,
|
||||
}
|
||||
|
||||
|
||||
def get_llm_config() -> Tuple[str | None, str | None, str | None]:
|
||||
"""
|
||||
Helper to get LLM configuration values, returns:
|
||||
- api_key, model, provider
|
||||
"""
|
||||
api_key, provider_key, model = get_configuration_value([
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"default": os.environ.get("LLM_MODEL", None),
|
||||
},
|
||||
])
|
||||
api_key, provider_key, model = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "LLM_API_KEY",
|
||||
"default": os.environ.get("LLM_API_KEY", None),
|
||||
},
|
||||
{
|
||||
"key": "LLM_PROVIDER",
|
||||
"default": os.environ.get("LLM_PROVIDER", "openai"),
|
||||
},
|
||||
{
|
||||
"key": "LLM_MODEL",
|
||||
"default": os.environ.get("LLM_MODEL", None),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
provider = SUPPORTED_PROVIDERS.get(provider_key.lower())
|
||||
if not provider:
|
||||
@@ -99,16 +106,20 @@ def get_llm_config() -> Tuple[str | None, str | None, str | None]:
|
||||
|
||||
# Validate model is supported by provider
|
||||
if model not in provider.models:
|
||||
log_exception(ValueError(
|
||||
f"Model {model} not supported by {provider.name}. "
|
||||
f"Supported models: {', '.join(provider.models)}"
|
||||
))
|
||||
log_exception(
|
||||
ValueError(
|
||||
f"Model {model} not supported by {provider.name}. "
|
||||
f"Supported models: {', '.join(provider.models)}"
|
||||
)
|
||||
)
|
||||
return None, None, None
|
||||
|
||||
return api_key, model, provider_key
|
||||
|
||||
|
||||
def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> Tuple[str | None, str | None]:
|
||||
def get_llm_response(
|
||||
task, prompt, api_key: str, model: str, provider: str
|
||||
) -> Tuple[str | None, str | None]:
|
||||
"""Helper to get LLM completion response"""
|
||||
final_text = task + "\n" + prompt
|
||||
try:
|
||||
@@ -118,10 +129,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
|
||||
|
||||
client = OpenAI(api_key=api_key)
|
||||
chat_completion = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=[
|
||||
{"role": "user", "content": final_text}
|
||||
]
|
||||
model=model, messages=[{"role": "user", "content": final_text}]
|
||||
)
|
||||
text = chat_completion.choices[0].message.content
|
||||
return text, None
|
||||
@@ -135,6 +143,7 @@ def get_llm_response(task, prompt, api_key: str, model: str, provider: str) -> T
|
||||
else:
|
||||
return None, f"Error occurred while generating response from {provider}"
|
||||
|
||||
|
||||
class GPTIntegrationEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
|
||||
def post(self, request, slug, project_id):
|
||||
@@ -152,7 +161,9 @@ class GPTIntegrationEndpoint(BaseAPIView):
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
text, error = get_llm_response(
|
||||
task, request.data.get("prompt", False), api_key, model, provider
|
||||
)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
@@ -190,7 +201,9 @@ class WorkspaceGPTIntegrationEndpoint(BaseAPIView):
|
||||
{"error": "Task is required"}, status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
text, error = get_llm_response(task, request.data.get("prompt", False), api_key, model, provider)
|
||||
text, error = get_llm_response(
|
||||
task, request.data.get("prompt", False), api_key, model, provider
|
||||
)
|
||||
if not text and error:
|
||||
return Response(
|
||||
{"error": "An internal error has occurred."},
|
||||
|
||||
@@ -38,6 +38,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
from plane.app.permissions import allow_permission, ROLE
|
||||
from plane.utils.error_codes import ERROR_CODES
|
||||
from plane.utils.host import base_host
|
||||
|
||||
# Module imports
|
||||
from .. import BaseViewSet, BaseAPIView
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from plane.settings.storage import S3Storage
|
||||
from plane.bgtasks.storage_metadata_task import get_asset_object_metadata
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueAttachmentEndpoint(BaseAPIView):
|
||||
serializer_class = IssueAttachmentSerializer
|
||||
model = FileAsset
|
||||
|
||||
@@ -19,6 +19,7 @@ from plane.db.models import IssueComment, ProjectMember, CommentReaction, Projec
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueCommentViewSet(BaseViewSet):
|
||||
serializer_class = IssueCommentSerializer
|
||||
model = IssueComment
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.db.models import IssueLink
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueLinkViewSet(BaseViewSet):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from plane.db.models import IssueReaction
|
||||
from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueReactionViewSet(BaseViewSet):
|
||||
serializer_class = IssueReactionSerializer
|
||||
model = IssueReaction
|
||||
|
||||
@@ -29,6 +29,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_relation_mapper import get_actual_relation
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class IssueRelationViewSet(BaseViewSet):
|
||||
serializer_class = IssueRelationSerializer
|
||||
model = IssueRelation
|
||||
|
||||
@@ -25,6 +25,7 @@ from collections import defaultdict
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.order_queryset import order_issue_queryset
|
||||
|
||||
|
||||
class SubIssuesEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectEntityPermission]
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ from .. import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleViewSet(BaseViewSet):
|
||||
model = Module
|
||||
webhook_event = "module"
|
||||
|
||||
@@ -36,6 +36,7 @@ from plane.utils.paginator import GroupedOffsetPaginator, SubGroupedOffsetPagina
|
||||
from .. import BaseViewSet
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ModuleIssueViewSet(BaseViewSet):
|
||||
serializer_class = ModuleIssueSerializer
|
||||
model = ModuleIssue
|
||||
@@ -280,7 +281,11 @@ class ModuleIssueViewSet(BaseViewSet):
|
||||
issue_id=str(issue_id),
|
||||
project_id=str(project_id),
|
||||
current_instance=json.dumps(
|
||||
{"module_name": module_issue.first().module.name if (module_issue.first() and module_issue.first().module) else None}
|
||||
{
|
||||
"module_name": module_issue.first().module.name
|
||||
if (module_issue.first() and module_issue.first().module)
|
||||
else None
|
||||
}
|
||||
),
|
||||
epoch=int(timezone.now().timestamp()),
|
||||
notification=True,
|
||||
|
||||
@@ -29,6 +29,7 @@ from plane.db.models import (
|
||||
from plane.db.models.project import ProjectNetwork
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class ProjectInvitationsViewset(BaseViewSet):
|
||||
serializer_class = ProjectMemberInviteSerializer
|
||||
model = ProjectMemberInvite
|
||||
|
||||
@@ -24,125 +24,152 @@ class TimezoneEndpoint(APIView):
|
||||
@method_decorator(cache_page(60 * 60 * 2))
|
||||
def get(self, request):
|
||||
timezone_locations = [
|
||||
('Midway Island', 'Pacific/Midway'), # UTC-11:00
|
||||
('American Samoa', 'Pacific/Pago_Pago'), # UTC-11:00
|
||||
('Hawaii', 'Pacific/Honolulu'), # UTC-10:00
|
||||
('Aleutian Islands', 'America/Adak'), # UTC-10:00 (DST: UTC-09:00)
|
||||
('Marquesas Islands', 'Pacific/Marquesas'), # UTC-09:30
|
||||
('Alaska', 'America/Anchorage'), # UTC-09:00 (DST: UTC-08:00)
|
||||
('Gambier Islands', 'Pacific/Gambier'), # UTC-09:00
|
||||
('Pacific Time (US and Canada)', 'America/Los_Angeles'), # UTC-08:00 (DST: UTC-07:00)
|
||||
('Baja California', 'America/Tijuana'), # UTC-08:00 (DST: UTC-07:00)
|
||||
('Mountain Time (US and Canada)', 'America/Denver'), # UTC-07:00 (DST: UTC-06:00)
|
||||
('Arizona', 'America/Phoenix'), # UTC-07:00
|
||||
('Chihuahua, Mazatlan', 'America/Chihuahua'), # UTC-07:00 (DST: UTC-06:00)
|
||||
('Central Time (US and Canada)', 'America/Chicago'), # UTC-06:00 (DST: UTC-05:00)
|
||||
('Saskatchewan', 'America/Regina'), # UTC-06:00
|
||||
('Guadalajara, Mexico City, Monterrey', 'America/Mexico_City'), # UTC-06:00 (DST: UTC-05:00)
|
||||
('Tegucigalpa, Honduras', 'America/Tegucigalpa'), # UTC-06:00
|
||||
('Costa Rica', 'America/Costa_Rica'), # UTC-06:00
|
||||
('Eastern Time (US and Canada)', 'America/New_York'), # UTC-05:00 (DST: UTC-04:00)
|
||||
('Lima', 'America/Lima'), # UTC-05:00
|
||||
('Bogota', 'America/Bogota'), # UTC-05:00
|
||||
('Quito', 'America/Guayaquil'), # UTC-05:00
|
||||
('Chetumal', 'America/Cancun'), # UTC-05:00 (DST: UTC-04:00)
|
||||
('Caracas (Old Venezuela Time)', 'America/Caracas'), # UTC-04:30
|
||||
('Atlantic Time (Canada)', 'America/Halifax'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('Caracas', 'America/Caracas'), # UTC-04:00
|
||||
('Santiago', 'America/Santiago'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('La Paz', 'America/La_Paz'), # UTC-04:00
|
||||
('Manaus', 'America/Manaus'), # UTC-04:00
|
||||
('Georgetown', 'America/Guyana'), # UTC-04:00
|
||||
('Bermuda', 'Atlantic/Bermuda'), # UTC-04:00 (DST: UTC-03:00)
|
||||
('Newfoundland Time (Canada)', 'America/St_Johns'), # UTC-03:30 (DST: UTC-02:30)
|
||||
('Buenos Aires', 'America/Argentina/Buenos_Aires'), # UTC-03:00
|
||||
('Brasilia', 'America/Sao_Paulo'), # UTC-03:00
|
||||
('Greenland', 'America/Godthab'), # UTC-03:00 (DST: UTC-02:00)
|
||||
('Montevideo', 'America/Montevideo'), # UTC-03:00
|
||||
('Falkland Islands', 'Atlantic/Stanley'), # UTC-03:00
|
||||
('South Georgia and the South Sandwich Islands', 'Atlantic/South_Georgia'), # UTC-02:00
|
||||
('Azores', 'Atlantic/Azores'), # UTC-01:00 (DST: UTC+00:00)
|
||||
('Cape Verde Islands', 'Atlantic/Cape_Verde'), # UTC-01:00
|
||||
('Dublin', 'Europe/Dublin'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Reykjavik', 'Atlantic/Reykjavik'), # UTC+00:00
|
||||
('Lisbon', 'Europe/Lisbon'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Monrovia', 'Africa/Monrovia'), # UTC+00:00
|
||||
('Casablanca', 'Africa/Casablanca'), # UTC+00:00 (DST: UTC+01:00)
|
||||
('Central European Time (Berlin, Rome, Paris)', 'Europe/Paris'), # UTC+01:00 (DST: UTC+02:00)
|
||||
('West Central Africa', 'Africa/Lagos'), # UTC+01:00
|
||||
('Algiers', 'Africa/Algiers'), # UTC+01:00
|
||||
('Lagos', 'Africa/Lagos'), # UTC+01:00
|
||||
('Tunis', 'Africa/Tunis'), # UTC+01:00
|
||||
('Eastern European Time (Cairo, Helsinki, Kyiv)', 'Europe/Kiev'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Athens', 'Europe/Athens'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Jerusalem', 'Asia/Jerusalem'), # UTC+02:00 (DST: UTC+03:00)
|
||||
('Johannesburg', 'Africa/Johannesburg'), # UTC+02:00
|
||||
('Harare, Pretoria', 'Africa/Harare'), # UTC+02:00
|
||||
('Moscow Time', 'Europe/Moscow'), # UTC+03:00
|
||||
('Baghdad', 'Asia/Baghdad'), # UTC+03:00
|
||||
('Nairobi', 'Africa/Nairobi'), # UTC+03:00
|
||||
('Kuwait, Riyadh', 'Asia/Riyadh'), # UTC+03:00
|
||||
('Tehran', 'Asia/Tehran'), # UTC+03:30 (DST: UTC+04:30)
|
||||
('Abu Dhabi', 'Asia/Dubai'), # UTC+04:00
|
||||
('Baku', 'Asia/Baku'), # UTC+04:00 (DST: UTC+05:00)
|
||||
('Yerevan', 'Asia/Yerevan'), # UTC+04:00 (DST: UTC+05:00)
|
||||
('Astrakhan', 'Europe/Astrakhan'), # UTC+04:00
|
||||
('Tbilisi', 'Asia/Tbilisi'), # UTC+04:00
|
||||
('Mauritius', 'Indian/Mauritius'), # UTC+04:00
|
||||
('Islamabad', 'Asia/Karachi'), # UTC+05:00
|
||||
('Karachi', 'Asia/Karachi'), # UTC+05:00
|
||||
('Tashkent', 'Asia/Tashkent'), # UTC+05:00
|
||||
('Yekaterinburg', 'Asia/Yekaterinburg'), # UTC+05:00
|
||||
('Maldives', 'Indian/Maldives'), # UTC+05:00
|
||||
('Chagos', 'Indian/Chagos'), # UTC+05:00
|
||||
('Chennai', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Kolkata', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Mumbai', 'Asia/Kolkata'), # UTC+05:30
|
||||
('New Delhi', 'Asia/Kolkata'), # UTC+05:30
|
||||
('Sri Jayawardenepura', 'Asia/Colombo'), # UTC+05:30
|
||||
('Kathmandu', 'Asia/Kathmandu'), # UTC+05:45
|
||||
('Dhaka', 'Asia/Dhaka'), # UTC+06:00
|
||||
('Almaty', 'Asia/Almaty'), # UTC+06:00
|
||||
('Bishkek', 'Asia/Bishkek'), # UTC+06:00
|
||||
('Thimphu', 'Asia/Thimphu'), # UTC+06:00
|
||||
('Yangon (Rangoon)', 'Asia/Yangon'), # UTC+06:30
|
||||
('Cocos Islands', 'Indian/Cocos'), # UTC+06:30
|
||||
('Bangkok', 'Asia/Bangkok'), # UTC+07:00
|
||||
('Hanoi', 'Asia/Ho_Chi_Minh'), # UTC+07:00
|
||||
('Jakarta', 'Asia/Jakarta'), # UTC+07:00
|
||||
('Novosibirsk', 'Asia/Novosibirsk'), # UTC+07:00
|
||||
('Krasnoyarsk', 'Asia/Krasnoyarsk'), # UTC+07:00
|
||||
('Beijing', 'Asia/Shanghai'), # UTC+08:00
|
||||
('Singapore', 'Asia/Singapore'), # UTC+08:00
|
||||
('Perth', 'Australia/Perth'), # UTC+08:00
|
||||
('Hong Kong', 'Asia/Hong_Kong'), # UTC+08:00
|
||||
('Ulaanbaatar', 'Asia/Ulaanbaatar'), # UTC+08:00
|
||||
('Palau', 'Pacific/Palau'), # UTC+08:00
|
||||
('Eucla', 'Australia/Eucla'), # UTC+08:45
|
||||
('Tokyo', 'Asia/Tokyo'), # UTC+09:00
|
||||
('Seoul', 'Asia/Seoul'), # UTC+09:00
|
||||
('Yakutsk', 'Asia/Yakutsk'), # UTC+09:00
|
||||
('Adelaide', 'Australia/Adelaide'), # UTC+09:30 (DST: UTC+10:30)
|
||||
('Darwin', 'Australia/Darwin'), # UTC+09:30
|
||||
('Sydney', 'Australia/Sydney'), # UTC+10:00 (DST: UTC+11:00)
|
||||
('Brisbane', 'Australia/Brisbane'), # UTC+10:00
|
||||
('Guam', 'Pacific/Guam'), # UTC+10:00
|
||||
('Vladivostok', 'Asia/Vladivostok'), # UTC+10:00
|
||||
('Tahiti', 'Pacific/Tahiti'), # UTC+10:00
|
||||
('Lord Howe Island', 'Australia/Lord_Howe'), # UTC+10:30 (DST: UTC+11:00)
|
||||
('Solomon Islands', 'Pacific/Guadalcanal'), # UTC+11:00
|
||||
('Magadan', 'Asia/Magadan'), # UTC+11:00
|
||||
('Norfolk Island', 'Pacific/Norfolk'), # UTC+11:00
|
||||
('Bougainville Island', 'Pacific/Bougainville'), # UTC+11:00
|
||||
('Chokurdakh', 'Asia/Srednekolymsk'), # UTC+11:00
|
||||
('Auckland', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Wellington', 'Pacific/Auckland'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Fiji Islands', 'Pacific/Fiji'), # UTC+12:00 (DST: UTC+13:00)
|
||||
('Anadyr', 'Asia/Anadyr'), # UTC+12:00
|
||||
('Chatham Islands', 'Pacific/Chatham'), # UTC+12:45 (DST: UTC+13:45)
|
||||
("Nuku'alofa", 'Pacific/Tongatapu'), # UTC+13:00
|
||||
('Samoa', 'Pacific/Apia'), # UTC+13:00 (DST: UTC+14:00)
|
||||
('Kiritimati Island', 'Pacific/Kiritimati') # UTC+14:00
|
||||
("Midway Island", "Pacific/Midway"), # UTC-11:00
|
||||
("American Samoa", "Pacific/Pago_Pago"), # UTC-11:00
|
||||
("Hawaii", "Pacific/Honolulu"), # UTC-10:00
|
||||
("Aleutian Islands", "America/Adak"), # UTC-10:00 (DST: UTC-09:00)
|
||||
("Marquesas Islands", "Pacific/Marquesas"), # UTC-09:30
|
||||
("Alaska", "America/Anchorage"), # UTC-09:00 (DST: UTC-08:00)
|
||||
("Gambier Islands", "Pacific/Gambier"), # UTC-09:00
|
||||
(
|
||||
"Pacific Time (US and Canada)",
|
||||
"America/Los_Angeles",
|
||||
), # UTC-08:00 (DST: UTC-07:00)
|
||||
("Baja California", "America/Tijuana"), # UTC-08:00 (DST: UTC-07:00)
|
||||
(
|
||||
"Mountain Time (US and Canada)",
|
||||
"America/Denver",
|
||||
), # UTC-07:00 (DST: UTC-06:00)
|
||||
("Arizona", "America/Phoenix"), # UTC-07:00
|
||||
("Chihuahua, Mazatlan", "America/Chihuahua"), # UTC-07:00 (DST: UTC-06:00)
|
||||
(
|
||||
"Central Time (US and Canada)",
|
||||
"America/Chicago",
|
||||
), # UTC-06:00 (DST: UTC-05:00)
|
||||
("Saskatchewan", "America/Regina"), # UTC-06:00
|
||||
(
|
||||
"Guadalajara, Mexico City, Monterrey",
|
||||
"America/Mexico_City",
|
||||
), # UTC-06:00 (DST: UTC-05:00)
|
||||
("Tegucigalpa, Honduras", "America/Tegucigalpa"), # UTC-06:00
|
||||
("Costa Rica", "America/Costa_Rica"), # UTC-06:00
|
||||
(
|
||||
"Eastern Time (US and Canada)",
|
||||
"America/New_York",
|
||||
), # UTC-05:00 (DST: UTC-04:00)
|
||||
("Lima", "America/Lima"), # UTC-05:00
|
||||
("Bogota", "America/Bogota"), # UTC-05:00
|
||||
("Quito", "America/Guayaquil"), # UTC-05:00
|
||||
("Chetumal", "America/Cancun"), # UTC-05:00 (DST: UTC-04:00)
|
||||
("Caracas (Old Venezuela Time)", "America/Caracas"), # UTC-04:30
|
||||
("Atlantic Time (Canada)", "America/Halifax"), # UTC-04:00 (DST: UTC-03:00)
|
||||
("Caracas", "America/Caracas"), # UTC-04:00
|
||||
("Santiago", "America/Santiago"), # UTC-04:00 (DST: UTC-03:00)
|
||||
("La Paz", "America/La_Paz"), # UTC-04:00
|
||||
("Manaus", "America/Manaus"), # UTC-04:00
|
||||
("Georgetown", "America/Guyana"), # UTC-04:00
|
||||
("Bermuda", "Atlantic/Bermuda"), # UTC-04:00 (DST: UTC-03:00)
|
||||
(
|
||||
"Newfoundland Time (Canada)",
|
||||
"America/St_Johns",
|
||||
), # UTC-03:30 (DST: UTC-02:30)
|
||||
("Buenos Aires", "America/Argentina/Buenos_Aires"), # UTC-03:00
|
||||
("Brasilia", "America/Sao_Paulo"), # UTC-03:00
|
||||
("Greenland", "America/Godthab"), # UTC-03:00 (DST: UTC-02:00)
|
||||
("Montevideo", "America/Montevideo"), # UTC-03:00
|
||||
("Falkland Islands", "Atlantic/Stanley"), # UTC-03:00
|
||||
(
|
||||
"South Georgia and the South Sandwich Islands",
|
||||
"Atlantic/South_Georgia",
|
||||
), # UTC-02:00
|
||||
("Azores", "Atlantic/Azores"), # UTC-01:00 (DST: UTC+00:00)
|
||||
("Cape Verde Islands", "Atlantic/Cape_Verde"), # UTC-01:00
|
||||
("Dublin", "Europe/Dublin"), # UTC+00:00 (DST: UTC+01:00)
|
||||
("Reykjavik", "Atlantic/Reykjavik"), # UTC+00:00
|
||||
("Lisbon", "Europe/Lisbon"), # UTC+00:00 (DST: UTC+01:00)
|
||||
("Monrovia", "Africa/Monrovia"), # UTC+00:00
|
||||
("Casablanca", "Africa/Casablanca"), # UTC+00:00 (DST: UTC+01:00)
|
||||
(
|
||||
"Central European Time (Berlin, Rome, Paris)",
|
||||
"Europe/Paris",
|
||||
), # UTC+01:00 (DST: UTC+02:00)
|
||||
("West Central Africa", "Africa/Lagos"), # UTC+01:00
|
||||
("Algiers", "Africa/Algiers"), # UTC+01:00
|
||||
("Lagos", "Africa/Lagos"), # UTC+01:00
|
||||
("Tunis", "Africa/Tunis"), # UTC+01:00
|
||||
(
|
||||
"Eastern European Time (Cairo, Helsinki, Kyiv)",
|
||||
"Europe/Kiev",
|
||||
), # UTC+02:00 (DST: UTC+03:00)
|
||||
("Athens", "Europe/Athens"), # UTC+02:00 (DST: UTC+03:00)
|
||||
("Jerusalem", "Asia/Jerusalem"), # UTC+02:00 (DST: UTC+03:00)
|
||||
("Johannesburg", "Africa/Johannesburg"), # UTC+02:00
|
||||
("Harare, Pretoria", "Africa/Harare"), # UTC+02:00
|
||||
("Moscow Time", "Europe/Moscow"), # UTC+03:00
|
||||
("Baghdad", "Asia/Baghdad"), # UTC+03:00
|
||||
("Nairobi", "Africa/Nairobi"), # UTC+03:00
|
||||
("Kuwait, Riyadh", "Asia/Riyadh"), # UTC+03:00
|
||||
("Tehran", "Asia/Tehran"), # UTC+03:30 (DST: UTC+04:30)
|
||||
("Abu Dhabi", "Asia/Dubai"), # UTC+04:00
|
||||
("Baku", "Asia/Baku"), # UTC+04:00 (DST: UTC+05:00)
|
||||
("Yerevan", "Asia/Yerevan"), # UTC+04:00 (DST: UTC+05:00)
|
||||
("Astrakhan", "Europe/Astrakhan"), # UTC+04:00
|
||||
("Tbilisi", "Asia/Tbilisi"), # UTC+04:00
|
||||
("Mauritius", "Indian/Mauritius"), # UTC+04:00
|
||||
("Islamabad", "Asia/Karachi"), # UTC+05:00
|
||||
("Karachi", "Asia/Karachi"), # UTC+05:00
|
||||
("Tashkent", "Asia/Tashkent"), # UTC+05:00
|
||||
("Yekaterinburg", "Asia/Yekaterinburg"), # UTC+05:00
|
||||
("Maldives", "Indian/Maldives"), # UTC+05:00
|
||||
("Chagos", "Indian/Chagos"), # UTC+05:00
|
||||
("Chennai", "Asia/Kolkata"), # UTC+05:30
|
||||
("Kolkata", "Asia/Kolkata"), # UTC+05:30
|
||||
("Mumbai", "Asia/Kolkata"), # UTC+05:30
|
||||
("New Delhi", "Asia/Kolkata"), # UTC+05:30
|
||||
("Sri Jayawardenepura", "Asia/Colombo"), # UTC+05:30
|
||||
("Kathmandu", "Asia/Kathmandu"), # UTC+05:45
|
||||
("Dhaka", "Asia/Dhaka"), # UTC+06:00
|
||||
("Almaty", "Asia/Almaty"), # UTC+06:00
|
||||
("Bishkek", "Asia/Bishkek"), # UTC+06:00
|
||||
("Thimphu", "Asia/Thimphu"), # UTC+06:00
|
||||
("Yangon (Rangoon)", "Asia/Yangon"), # UTC+06:30
|
||||
("Cocos Islands", "Indian/Cocos"), # UTC+06:30
|
||||
("Bangkok", "Asia/Bangkok"), # UTC+07:00
|
||||
("Hanoi", "Asia/Ho_Chi_Minh"), # UTC+07:00
|
||||
("Jakarta", "Asia/Jakarta"), # UTC+07:00
|
||||
("Novosibirsk", "Asia/Novosibirsk"), # UTC+07:00
|
||||
("Krasnoyarsk", "Asia/Krasnoyarsk"), # UTC+07:00
|
||||
("Beijing", "Asia/Shanghai"), # UTC+08:00
|
||||
("Singapore", "Asia/Singapore"), # UTC+08:00
|
||||
("Perth", "Australia/Perth"), # UTC+08:00
|
||||
("Hong Kong", "Asia/Hong_Kong"), # UTC+08:00
|
||||
("Ulaanbaatar", "Asia/Ulaanbaatar"), # UTC+08:00
|
||||
("Palau", "Pacific/Palau"), # UTC+08:00
|
||||
("Eucla", "Australia/Eucla"), # UTC+08:45
|
||||
("Tokyo", "Asia/Tokyo"), # UTC+09:00
|
||||
("Seoul", "Asia/Seoul"), # UTC+09:00
|
||||
("Yakutsk", "Asia/Yakutsk"), # UTC+09:00
|
||||
("Adelaide", "Australia/Adelaide"), # UTC+09:30 (DST: UTC+10:30)
|
||||
("Darwin", "Australia/Darwin"), # UTC+09:30
|
||||
("Sydney", "Australia/Sydney"), # UTC+10:00 (DST: UTC+11:00)
|
||||
("Brisbane", "Australia/Brisbane"), # UTC+10:00
|
||||
("Guam", "Pacific/Guam"), # UTC+10:00
|
||||
("Vladivostok", "Asia/Vladivostok"), # UTC+10:00
|
||||
("Tahiti", "Pacific/Tahiti"), # UTC+10:00
|
||||
("Lord Howe Island", "Australia/Lord_Howe"), # UTC+10:30 (DST: UTC+11:00)
|
||||
("Solomon Islands", "Pacific/Guadalcanal"), # UTC+11:00
|
||||
("Magadan", "Asia/Magadan"), # UTC+11:00
|
||||
("Norfolk Island", "Pacific/Norfolk"), # UTC+11:00
|
||||
("Bougainville Island", "Pacific/Bougainville"), # UTC+11:00
|
||||
("Chokurdakh", "Asia/Srednekolymsk"), # UTC+11:00
|
||||
("Auckland", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00)
|
||||
("Wellington", "Pacific/Auckland"), # UTC+12:00 (DST: UTC+13:00)
|
||||
("Fiji Islands", "Pacific/Fiji"), # UTC+12:00 (DST: UTC+13:00)
|
||||
("Anadyr", "Asia/Anadyr"), # UTC+12:00
|
||||
("Chatham Islands", "Pacific/Chatham"), # UTC+12:45 (DST: UTC+13:45)
|
||||
("Nuku'alofa", "Pacific/Tongatapu"), # UTC+13:00
|
||||
("Samoa", "Pacific/Apia"), # UTC+13:00 (DST: UTC+14:00)
|
||||
("Kiritimati Island", "Pacific/Kiritimati"), # UTC+14:00
|
||||
]
|
||||
|
||||
timezone_list = []
|
||||
@@ -150,7 +177,6 @@ class TimezoneEndpoint(APIView):
|
||||
|
||||
# Process timezone mapping
|
||||
for friendly_name, tz_identifier in timezone_locations:
|
||||
|
||||
try:
|
||||
tz = pytz.timezone(tz_identifier)
|
||||
current_offset = now.astimezone(tz).strftime("%z")
|
||||
|
||||
@@ -12,6 +12,7 @@ from plane.app.permissions import WorkspaceViewerPermission
|
||||
from plane.app.serializers.cycle import CycleSerializer
|
||||
from plane.utils.timezone_converter import user_timezone_converter
|
||||
|
||||
|
||||
class WorkspaceCyclesEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkspaceViewerPermission]
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ from plane.bgtasks.issue_activities_task import issue_activity
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
class WorkspaceDraftIssueViewSet(BaseViewSet):
|
||||
model = DraftIssue
|
||||
|
||||
|
||||
@@ -27,10 +27,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
|
||||
create_preference_keys = []
|
||||
|
||||
keys = [
|
||||
key
|
||||
for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices
|
||||
]
|
||||
keys = [key for key, _ in WorkspaceUserPreference.UserPreferenceKeys.choices]
|
||||
|
||||
for preference in keys:
|
||||
if preference not in get_preference.values_list("key", flat=True):
|
||||
@@ -39,7 +36,10 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
preference = WorkspaceUserPreference.objects.bulk_create(
|
||||
[
|
||||
WorkspaceUserPreference(
|
||||
key=key, user=request.user, workspace=workspace, sort_order=(65535 + (i*10000))
|
||||
key=key,
|
||||
user=request.user,
|
||||
workspace=workspace,
|
||||
sort_order=(65535 + (i * 10000)),
|
||||
)
|
||||
for i, key in enumerate(create_preference_keys)
|
||||
],
|
||||
@@ -47,10 +47,13 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
preferences = WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
).order_by("sort_order").values("key", "is_pinned", "sort_order")
|
||||
|
||||
preferences = (
|
||||
WorkspaceUserPreference.objects.filter(
|
||||
user=request.user, workspace_id=workspace.id
|
||||
)
|
||||
.order_by("sort_order")
|
||||
.values("key", "is_pinned", "sort_order")
|
||||
)
|
||||
|
||||
user_preferences = {}
|
||||
|
||||
@@ -58,7 +61,7 @@ class WorkspaceUserPreferenceViewSet(BaseAPIView):
|
||||
user_preferences[(str(preference["key"]))] = {
|
||||
"is_pinned": preference["is_pinned"],
|
||||
"sort_order": preference["sort_order"],
|
||||
}
|
||||
}
|
||||
return Response(
|
||||
user_preferences,
|
||||
status=status.HTTP_200_OK,
|
||||
|
||||
@@ -18,6 +18,7 @@ from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
|
||||
class Adapter:
|
||||
"""Common interface for all auth providers"""
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"GOOGLE_OAUTH_PROVIDER_ERROR": 5115,
|
||||
"GITHUB_OAUTH_PROVIDER_ERROR": 5120,
|
||||
"GITLAB_OAUTH_PROVIDER_ERROR": 5121,
|
||||
|
||||
# Reset Password
|
||||
"INVALID_PASSWORD_TOKEN": 5125,
|
||||
"EXPIRED_PASSWORD_TOKEN": 5130,
|
||||
|
||||
@@ -25,23 +25,24 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
organization_scope = "read:org"
|
||||
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = (
|
||||
get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
||||
@@ -128,7 +129,10 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
def is_user_in_organization(self, github_username):
|
||||
headers = {"Authorization": f"Bearer {self.token_data.get('access_token')}"}
|
||||
response = requests.get(f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}", headers=headers)
|
||||
response = requests.get(
|
||||
f"{self.org_membership_url}/{self.organization_id}/memberships/{github_username}",
|
||||
headers=headers,
|
||||
)
|
||||
return response.status_code == 200 # 200 means the user is a member
|
||||
|
||||
def set_user_data(self):
|
||||
@@ -145,7 +149,6 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
error_message="GITHUB_USER_NOT_IN_ORG",
|
||||
)
|
||||
|
||||
|
||||
email = self.__get_email(headers=headers)
|
||||
super().set_user_data(
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
|
||||
def user_login(request, user, is_app=False, is_admin=False, is_space=False):
|
||||
login(request=request, user=user)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class SignInAuthEndpoint(View):
|
||||
def post(self, request):
|
||||
next_path = request.POST.get("next_path")
|
||||
|
||||
@@ -18,6 +18,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitHubOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
# Get host and next path
|
||||
|
||||
@@ -18,6 +18,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GitLabOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
# Get host and next path
|
||||
|
||||
@@ -20,6 +20,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class GoogleOauthInitiateEndpoint(View):
|
||||
def get(self, request):
|
||||
request.session["host"] = base_host(request=request, is_app=True)
|
||||
@@ -95,7 +96,9 @@ class GoogleCallbackEndpoint(View):
|
||||
# Get the redirection path
|
||||
path = get_redirection_path(user=user)
|
||||
# redirect to referer path
|
||||
url = urljoin(base_host, str(validate_next_path(next_path)) if next_path else path)
|
||||
url = urljoin(
|
||||
base_host, str(validate_next_path(next_path)) if next_path else path
|
||||
)
|
||||
return HttpResponseRedirect(url)
|
||||
except AuthenticationException as e:
|
||||
params = e.get_error_dict()
|
||||
|
||||
@@ -53,12 +53,14 @@ class ChangePasswordEndpoint(APIView):
|
||||
error_message="MISSING_PASSWORD",
|
||||
payload={"error": "Old password is missing"},
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response(
|
||||
exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Get the new password
|
||||
new_password = request.data.get("new_password", False)
|
||||
|
||||
if not new_password:
|
||||
if not new_password:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["MISSING_PASSWORD"],
|
||||
error_message="MISSING_PASSWORD",
|
||||
@@ -66,7 +68,6 @@ class ChangePasswordEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
# If the user password is not autoset then we need to check the old passwords
|
||||
if not user.is_password_autoset and not user.check_password(old_password):
|
||||
exc = AuthenticationException(
|
||||
|
||||
@@ -25,6 +25,7 @@ from plane.authentication.adapter.error import (
|
||||
)
|
||||
from plane.utils.path_validator import validate_next_path
|
||||
|
||||
|
||||
class MagicGenerateSpaceEndpoint(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
@@ -38,7 +39,6 @@ class MagicGenerateSpaceEndpoint(APIView):
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
email = request.data.get("email", "").strip().lower()
|
||||
try:
|
||||
validate_email(email)
|
||||
|
||||
@@ -8,6 +8,7 @@ import boto3
|
||||
from botocore.client import Config
|
||||
from uuid import UUID
|
||||
from datetime import datetime, date
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
@@ -90,8 +91,11 @@ def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
|
||||
zip_buffer.seek(0)
|
||||
return zip_buffer
|
||||
|
||||
|
||||
# TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table
|
||||
def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str) -> None:
|
||||
def upload_to_s3(
|
||||
zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str
|
||||
) -> None:
|
||||
"""
|
||||
Upload a ZIP file to S3 and generate a presigned URL.
|
||||
"""
|
||||
@@ -308,7 +312,12 @@ def update_table_row(rows: List[List[str]], row: List[str]) -> None:
|
||||
rows.append(row)
|
||||
|
||||
|
||||
def generate_csv(header: List[str], project_id: str, issues: List[dict], files: List[tuple[str, str | bytes]]) -> None:
|
||||
def generate_csv(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate CSV export for all the passed issues.
|
||||
"""
|
||||
@@ -320,7 +329,12 @@ def generate_csv(header: List[str], project_id: str, issues: List[dict], files:
|
||||
files.append((f"{project_id}.csv", csv_file))
|
||||
|
||||
|
||||
def generate_json(header: List[str], project_id: str, issues: List[dict], files: List[tuple[str, str | bytes]]) -> None:
|
||||
def generate_json(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate JSON export for all the passed issues.
|
||||
"""
|
||||
@@ -332,7 +346,12 @@ def generate_json(header: List[str], project_id: str, issues: List[dict], files:
|
||||
files.append((f"{project_id}.json", json_file))
|
||||
|
||||
|
||||
def generate_xlsx(header: List[str], project_id: str, issues: List[dict], files: List[tuple[str, str | bytes]]) -> None:
|
||||
def generate_xlsx(
|
||||
header: List[str],
|
||||
project_id: str,
|
||||
issues: List[dict],
|
||||
files: List[tuple[str, str | bytes]],
|
||||
) -> None:
|
||||
"""
|
||||
Generate XLSX export for all the passed issues.
|
||||
"""
|
||||
@@ -355,7 +374,14 @@ def get_created_by(obj: Issue | IssueComment) -> str:
|
||||
|
||||
|
||||
@shared_task
|
||||
def issue_export_task(provider: str, workspace_id: UUID, project_ids: List[str], token_id: str, multiple: bool, slug: str):
|
||||
def issue_export_task(
|
||||
provider: str,
|
||||
workspace_id: UUID,
|
||||
project_ids: List[str],
|
||||
token_id: str,
|
||||
multiple: bool,
|
||||
slug: str,
|
||||
):
|
||||
"""
|
||||
Export issues from the workspace.
|
||||
provider (str): The provider to export the issues to csv | json | xlsx.
|
||||
@@ -408,7 +434,7 @@ def issue_export_task(provider: str, workspace_id: UUID, project_ids: List[str],
|
||||
# Get the attachments for the issues
|
||||
file_assets = FileAsset.objects.filter(
|
||||
issue_id__in=workspace_issues.values_list("id", flat=True),
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT
|
||||
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
|
||||
).annotate(work_item_id=F("issue_id"), asset_id=F("id"))
|
||||
|
||||
# Create a dictionary to store the attachments for the issues
|
||||
@@ -452,8 +478,8 @@ def issue_export_task(provider: str, workspace_id: UUID, project_ids: List[str],
|
||||
}
|
||||
for comment in issue.issue_comments.all()
|
||||
],
|
||||
"estimate": issue.estimate_point.estimate.name
|
||||
if issue.estimate_point and issue.estimate_point.estimate
|
||||
"estimate": issue.estimate_point.value
|
||||
if issue.estimate_point and issue.estimate_point.value
|
||||
else "",
|
||||
"link": [link.url for link in issue.issue_link.all()],
|
||||
"assignees": [
|
||||
|
||||
@@ -5,7 +5,9 @@ from plane.db.models import Workspace
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
|
||||
help = (
|
||||
"Updates the slug of a soft-deleted workspace by appending the epoch timestamp"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -75,4 +77,4 @@ class Command(BaseCommand):
|
||||
self.style.ERROR(
|
||||
f"Error updating workspace '{workspace.name}': {str(e)}"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -82,4 +82,4 @@ from .label import Label
|
||||
|
||||
from .device import Device, DeviceSession
|
||||
|
||||
from .sticky import Sticky
|
||||
from .sticky import Sticky
|
||||
|
||||
@@ -153,12 +153,8 @@ class Workspace(BaseModel):
|
||||
return None
|
||||
|
||||
def delete(
|
||||
self,
|
||||
using: Optional[str] = None,
|
||||
soft: bool = True,
|
||||
*args: Any,
|
||||
**kwargs: Any
|
||||
):
|
||||
self, using: Optional[str] = None, soft: bool = True, *args: Any, **kwargs: Any
|
||||
):
|
||||
"""
|
||||
Override the delete method to append epoch timestamp to the slug when soft deleting.
|
||||
|
||||
@@ -172,7 +168,7 @@ class Workspace(BaseModel):
|
||||
result = super().delete(using=using, soft=soft, *args, **kwargs)
|
||||
|
||||
# If it's a soft delete and the model still exists (not hard deleted)
|
||||
if soft and hasattr(self, 'deleted_at') and self.deleted_at:
|
||||
if soft and hasattr(self, "deleted_at") and self.deleted_at:
|
||||
# Use the deleted_at timestamp to update the slug
|
||||
deletion_timestamp: int = int(self.deleted_at.timestamp())
|
||||
self.slug = f"{self.slug}__{deletion_timestamp}"
|
||||
|
||||
@@ -157,7 +157,7 @@ class Command(BaseCommand):
|
||||
},
|
||||
# Deprecated, use LLM_MODEL
|
||||
{
|
||||
"key": "GPT_ENGINE",
|
||||
"key": "GPT_ENGINE",
|
||||
"value": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"),
|
||||
"category": "SMTP",
|
||||
"is_encrypted": False,
|
||||
|
||||
@@ -32,7 +32,6 @@ class S3Storage(S3Boto3Storage):
|
||||
) or os.environ.get("MINIO_ENDPOINT_URL")
|
||||
|
||||
if os.environ.get("USE_MINIO") == "1":
|
||||
|
||||
# Determine protocol based on environment variable
|
||||
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
|
||||
endpoint_protocol = "https"
|
||||
|
||||
@@ -135,7 +135,7 @@ def issue_on_results(
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(votes__isnull=False,votes__deleted_at__isnull=True),
|
||||
filter=Q(votes__isnull=False, votes__deleted_at__isnull=True),
|
||||
distinct=True,
|
||||
),
|
||||
reaction_items=ArrayAgg(
|
||||
@@ -169,7 +169,9 @@ def issue_on_results(
|
||||
default=None,
|
||||
output_field=JSONField(),
|
||||
),
|
||||
filter=Q(issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True),
|
||||
filter=Q(
|
||||
issue_reactions__isnull=False, issue_reactions__deleted_at__isnull=True
|
||||
),
|
||||
distinct=True,
|
||||
),
|
||||
).values(*required_fields, "vote_items", "reaction_items")
|
||||
|
||||
@@ -14,9 +14,7 @@ class ProjectMetaDataEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
try:
|
||||
deploy_board = DeployBoard.objects.get(
|
||||
anchor=anchor, entity_name="project"
|
||||
)
|
||||
deploy_board = DeployBoard.objects.get(anchor=anchor, entity_name="project")
|
||||
except DeployBoard.DoesNotExist:
|
||||
return Response(
|
||||
{"error": "Project is not published"}, status=status.HTTP_404_NOT_FOUND
|
||||
|
||||
@@ -182,9 +182,7 @@ def burndown_plot(queryset, slug, project_id, plot_type, cycle_id=None, module_i
|
||||
# Get all dates between the two dates
|
||||
date_range = [
|
||||
(queryset.start_date + timedelta(days=x))
|
||||
for x in range(
|
||||
(queryset.target_date - queryset.start_date).days + 1
|
||||
)
|
||||
for x in range((queryset.target_date - queryset.start_date).days + 1)
|
||||
]
|
||||
|
||||
chart_data = {str(date): 0 for date in date_range}
|
||||
|
||||
@@ -160,7 +160,6 @@ def build_analytics_chart(
|
||||
group_by: Optional[str] = None,
|
||||
date_filter: Optional[str] = None,
|
||||
) -> Dict[str, Union[List[Dict[str, Any]], Dict[str, str]]]:
|
||||
|
||||
# Validate x_axis
|
||||
if x_axis not in x_axis_mapper:
|
||||
raise ValidationError(f"Invalid x_axis field: {x_axis}")
|
||||
|
||||
@@ -174,8 +174,8 @@ def get_analytics_filters(
|
||||
"workspace__slug": slug,
|
||||
"project_projectmember__member": user,
|
||||
"project_projectmember__is_active": True,
|
||||
"project__deleted_at__isnull": True,
|
||||
"project__archived_at__isnull": True,
|
||||
"deleted_at__isnull": True,
|
||||
"archived_at__isnull": True,
|
||||
}
|
||||
|
||||
# Add project IDs to filters if provided
|
||||
|
||||
@@ -35,9 +35,7 @@ def user_timezone_converter(queryset, datetime_fields, user_timezone):
|
||||
return queryset_values
|
||||
|
||||
|
||||
def convert_to_utc(
|
||||
date, project_id, is_start_date=False
|
||||
):
|
||||
def convert_to_utc(date, project_id, is_start_date=False):
|
||||
"""
|
||||
Converts a start date string to the project's local timezone at 12:00 AM
|
||||
and then converts it to UTC for storage.
|
||||
|
||||
@@ -42,7 +42,7 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
# Respect magic trailing commas.
|
||||
skip-magic-trailing-comma = true
|
||||
# skip-magic-trailing-comma = true
|
||||
|
||||
# Automatically detect the appropriate line ending.
|
||||
line-ending = "auto"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"devDependencies": {
|
||||
"prettier": "latest",
|
||||
"prettier-plugin-tailwindcss": "^0.5.4",
|
||||
"turbo": "^2.5.2"
|
||||
"turbo": "^2.5.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"nanoid": "3.3.8",
|
||||
|
||||
13
packages/editor/src/ce/types/storage.ts
Normal file
13
packages/editor/src/ce/types/storage.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { HeadingExtensionStorage } from "@/extensions";
|
||||
import { CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { CustomLinkStorage } from "@/extensions/custom-link";
|
||||
import { MentionExtensionStorage } from "@/extensions/mentions";
|
||||
import { ImageExtensionStorage } from "@/plugins/image";
|
||||
|
||||
export type ExtensionStorageMap = {
|
||||
imageComponent: CustomImageExtensionStorage;
|
||||
image: ImageExtensionStorage;
|
||||
link: CustomLinkStorage;
|
||||
headingList: HeadingExtensionStorage;
|
||||
mention: MentionExtensionStorage;
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
|
||||
import { CustomImageNode } from "@/extensions/custom-image";
|
||||
// helpers
|
||||
import { isFileValid } from "@/helpers/file";
|
||||
import { getExtensionStorage } from "@/helpers/get-extension-storage";
|
||||
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
|
||||
// plugins
|
||||
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
|
||||
@@ -32,10 +33,9 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
export const getImageComponentImageFileMap = (editor: Editor) =>
|
||||
(editor.storage.imageComponent as UploadImageExtensionStorage | undefined)?.fileMap;
|
||||
export const getImageComponentImageFileMap = (editor: Editor) => getExtensionStorage(editor, "imageComponent")?.fileMap;
|
||||
|
||||
export interface UploadImageExtensionStorage {
|
||||
export interface CustomImageExtensionStorage {
|
||||
assetsUploadStatus: TFileHandler["assetsUploadStatus"];
|
||||
fileMap: Map<string, UploadEntity>;
|
||||
deletedImageSet: Map<string, boolean>;
|
||||
@@ -55,7 +55,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
|
||||
validation: { maxFileSize },
|
||||
} = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: true,
|
||||
group: "block",
|
||||
|
||||
@@ -2,14 +2,14 @@ import { mergeAttributes } from "@tiptap/core";
|
||||
import { Image } from "@tiptap/extension-image";
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
// components
|
||||
import { CustomImageNode, UploadImageExtensionStorage } from "@/extensions/custom-image";
|
||||
import { CustomImageNode, CustomImageExtensionStorage } from "@/extensions/custom-image";
|
||||
// types
|
||||
import { TReadOnlyFileHandler } from "@/types";
|
||||
|
||||
export const CustomReadOnlyImageExtension = (props: TReadOnlyFileHandler) => {
|
||||
const { getAssetSrc, restore: restoreImageFn } = props;
|
||||
|
||||
return Image.extend<Record<string, unknown>, UploadImageExtensionStorage>({
|
||||
return Image.extend<Record<string, unknown>, CustomImageExtensionStorage>({
|
||||
name: "imageComponent",
|
||||
selectable: false,
|
||||
group: "block",
|
||||
|
||||
@@ -38,11 +38,10 @@ export const findTableAncestor = (node: Node | null): HTMLTableElement | null =>
|
||||
return node as HTMLTableElement;
|
||||
};
|
||||
|
||||
export const getTrimmedHTML = (html: string) => {
|
||||
html = html.replace(/^(<p><\/p>)+/, "");
|
||||
html = html.replace(/(<p><\/p>)+$/, "");
|
||||
return html;
|
||||
};
|
||||
export const getTrimmedHTML = (html: string) =>
|
||||
html
|
||||
.replace(/^(?:<p><\/p>)+/g, "") // Remove from beginning
|
||||
.replace(/(?:<p><\/p>)+$/g, ""); // Remove from end
|
||||
|
||||
export const isValidHttpUrl = (string: string): { isValid: boolean; url: string } => {
|
||||
// List of potentially dangerous protocols to block
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import {
|
||||
CustomLinkStorage,
|
||||
HeadingExtensionStorage,
|
||||
MentionExtensionStorage,
|
||||
UploadImageExtensionStorage,
|
||||
} from "@/extensions";
|
||||
import { ImageExtensionStorage } from "@/plugins/image";
|
||||
// plane editor types
|
||||
import { ExtensionStorageMap } from "@/plane-editor/types/storage";
|
||||
|
||||
type ExtensionNames = "imageComponent" | "image" | "link" | "headingList" | "mention";
|
||||
|
||||
interface ExtensionStorageMap {
|
||||
imageComponent: UploadImageExtensionStorage;
|
||||
image: ImageExtensionStorage;
|
||||
link: CustomLinkStorage;
|
||||
headingList: HeadingExtensionStorage;
|
||||
mention: MentionExtensionStorage;
|
||||
}
|
||||
|
||||
export const getExtensionStorage = <K extends ExtensionNames>(
|
||||
export const getExtensionStorage = <K extends keyof ExtensionStorageMap>(
|
||||
editor: Editor,
|
||||
extensionName: K
|
||||
): ExtensionStorageMap[K] => editor.storage[extensionName];
|
||||
|
||||
51
packages/types/src/analytics-v2.d.ts
vendored
51
packages/types/src/analytics-v2.d.ts
vendored
@@ -1,52 +1,55 @@
|
||||
import { ChartXAxisProperty, ChartYAxisMetric } from "@plane/constants";
|
||||
import { TChartData } from "./charts";
|
||||
|
||||
export type TAnalyticsTabsV2Base = "overview" | "work-items"
|
||||
export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items"
|
||||
|
||||
export type TAnalyticsTabsV2Base = "overview" | "work-items";
|
||||
export type TAnalyticsGraphsV2Base = "projects" | "work-items" | "custom-work-items";
|
||||
|
||||
// service types
|
||||
|
||||
export interface IAnalyticsResponseV2 {
|
||||
[key: string]: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface IAnalyticsResponseFieldsV2 {
|
||||
count: number;
|
||||
filter_count: number;
|
||||
count: number;
|
||||
filter_count: number;
|
||||
}
|
||||
|
||||
export interface IAnalyticsRadarEntityV2 {
|
||||
key: string,
|
||||
name: string,
|
||||
count: number
|
||||
key: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// chart types
|
||||
|
||||
export interface IChartResponseV2 {
|
||||
schema: Record<string, string>;
|
||||
data: TChartData<string, string>[];
|
||||
schema: Record<string, string>;
|
||||
data: TChartData<string, string>[];
|
||||
}
|
||||
|
||||
// table types
|
||||
|
||||
export interface WorkItemInsightColumns {
|
||||
project_id: string;
|
||||
project__name: string;
|
||||
cancelled_work_items: number;
|
||||
completed_work_items: number;
|
||||
backlog_work_items: number;
|
||||
un_started_work_items: number;
|
||||
started_work_items: number;
|
||||
project_id?: string;
|
||||
project__name?: string;
|
||||
cancelled_work_items: number;
|
||||
completed_work_items: number;
|
||||
backlog_work_items: number;
|
||||
un_started_work_items: number;
|
||||
started_work_items: number;
|
||||
// because of the peek view, we will display the name of the project instead of project__name
|
||||
display_name?: string;
|
||||
avatar_url?: string;
|
||||
assignee_id?: string;
|
||||
}
|
||||
|
||||
export type AnalyticsTableDataMap = {
|
||||
"work-items": WorkItemInsightColumns,
|
||||
}
|
||||
"work-items": WorkItemInsightColumns;
|
||||
};
|
||||
|
||||
export interface IAnalyticsV2Params {
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
x_axis: ChartXAxisProperty;
|
||||
y_axis: ChartYAxisMetric;
|
||||
group_by?: ChartXAxisProperty;
|
||||
}
|
||||
|
||||
2
packages/types/src/issues/issue.d.ts
vendored
2
packages/types/src/issues/issue.d.ts
vendored
@@ -119,7 +119,7 @@ export type TBulkOperationsPayload = {
|
||||
properties: Partial<TBulkIssueProperties>;
|
||||
};
|
||||
|
||||
export type TIssueDetailWidget = "sub-issues" | "relations" | "links" | "attachments";
|
||||
export type TWorkItemWidgets = "sub-work-items" | "relations" | "links" | "attachments";
|
||||
|
||||
export type TIssueServiceType = EIssueServiceType.ISSUES | EIssueServiceType.EPICS | EIssueServiceType.WORK_ITEMS;
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ const IssueDetailsPage = observer(() => {
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
issueId={issueId.toString()}
|
||||
is_archived={!!issue?.archived_at}
|
||||
/>
|
||||
</ProjectAuthWrapper>
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
// ui
|
||||
import { Breadcrumbs, Button, ContrastIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { CycleQuickActions } from "@/components/cycles";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
@@ -161,7 +161,8 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
projectDetails={currentProjectDetails}
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
|
||||
@@ -19,7 +19,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, IssueLayoutIcon } from "@/components/issues";
|
||||
// helpers
|
||||
import { isIssueFilterActive } from "@/helpers/filter.helper";
|
||||
@@ -123,7 +123,8 @@ export const CycleIssuesMobileHeader = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
projectDetails={currentProjectDetails}
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
cycleDetails={cycleDetails ?? undefined}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
// ui
|
||||
import { Breadcrumbs, Button, DiceIcon, Tooltip, Header, CustomSearchSelect } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import { BreadcrumbLink, SwitcherLabel } from "@/components/common";
|
||||
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues";
|
||||
// helpers
|
||||
@@ -155,10 +155,11 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
projectDetails={currentProjectDetails}
|
||||
/>
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// components
|
||||
import { ProjectAnalyticsModal } from "@/components/analytics";
|
||||
import { WorkItemsModal } from "@/components/analytics-v2/work-items/modal";
|
||||
import {
|
||||
DisplayFiltersSelection,
|
||||
FilterSelection,
|
||||
@@ -106,10 +107,11 @@ export const ModuleIssuesMobileHeader = observer(() => {
|
||||
|
||||
return (
|
||||
<div className="block md:hidden">
|
||||
<ProjectAnalyticsModal
|
||||
<WorkItemsModal
|
||||
isOpen={analyticsModal}
|
||||
onClose={() => setAnalyticsModal(false)}
|
||||
moduleDetails={moduleDetails ?? undefined}
|
||||
projectDetails={currentProjectDetails}
|
||||
/>
|
||||
<div className="flex justify-evenly border-b border-custom-border-200 bg-custom-background-100 py-2">
|
||||
<CustomMenu
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
export const MaintenanceMessage = () => (
|
||||
<h1 className="text-xl font-medium text-custom-text-100 text-center md:text-left">
|
||||
Plane didn't start up. This could be because one or more Plane services failed to start. <br /> Choose View
|
||||
Logs from setup.sh and Docker logs to be sure.
|
||||
We're working on this. If you need immediate assistance,{" "}
|
||||
<a
|
||||
href="https://plane.so"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
>
|
||||
reach out to us
|
||||
</a>
|
||||
. Otherwise, try refreshing the page occasionally or visit our{" "}
|
||||
<a
|
||||
href="https://status.plane.so/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-custom-primary-100 hover:underline"
|
||||
>
|
||||
Status page
|
||||
</a>
|
||||
.
|
||||
</h1>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FC } from "react";
|
||||
// plane types
|
||||
import { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
|
||||
export type TWorkItemAdditionalWidgetActionButtonsProps = {
|
||||
disabled: boolean;
|
||||
hideWidgets: TWorkItemWidgets[];
|
||||
issueServiceType: TIssueServiceType;
|
||||
projectId: string;
|
||||
workItemId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WorkItemAdditionalWidgetActionButtons: FC<TWorkItemAdditionalWidgetActionButtonsProps> = () => null;
|
||||
@@ -1,10 +0,0 @@
|
||||
import { FC } from "react";
|
||||
|
||||
export type TWorkItemAdditionalWidgets = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
workItemId: string;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export const WorkItemAdditionalWidgets: FC<TWorkItemAdditionalWidgets> = (props) => <></>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { FC } from "react";
|
||||
// plane types
|
||||
import { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
|
||||
export type TWorkItemAdditionalWidgetCollapsiblesProps = {
|
||||
disabled: boolean;
|
||||
hideWidgets: TWorkItemWidgets[];
|
||||
issueServiceType: TIssueServiceType;
|
||||
projectId: string;
|
||||
workItemId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WorkItemAdditionalWidgetCollapsibles: FC<TWorkItemAdditionalWidgetCollapsiblesProps> = () => null;
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./additional-widgets";
|
||||
13
web/ce/components/issues/issue-detail-widgets/modals.tsx
Normal file
13
web/ce/components/issues/issue-detail-widgets/modals.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { FC } from "react";
|
||||
// plane types
|
||||
import { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
|
||||
export type TWorkItemAdditionalWidgetModalsProps = {
|
||||
hideWidgets: TWorkItemWidgets[];
|
||||
issueServiceType: TIssueServiceType;
|
||||
projectId: string;
|
||||
workItemId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const WorkItemAdditionalWidgetModals: FC<TWorkItemAdditionalWidgetModalsProps> = () => null;
|
||||
@@ -21,7 +21,7 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">
|
||||
const { data, isLoading, columns, columnsLabels } = props;
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
if (isLoading) {
|
||||
return <TableLoader columns={columns} rows={5} />;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const InsightTable = <T extends Exclude<TAnalyticsTabsV2Base, "overview">
|
||||
|
||||
const exportCSV = (rows: Row<AnalyticsTableDataMap[T]>[]) => {
|
||||
const rowData: any = rows.map((row) => {
|
||||
const { project_id, ...exportableData } = row.original;
|
||||
const { project_id, avatar_url, assignee_id, ...exportableData } = row.original;
|
||||
return Object.fromEntries(
|
||||
Object.entries(exportableData).map(([key, value]) => {
|
||||
if (columnsLabels?.[key]) {
|
||||
|
||||
@@ -26,16 +26,20 @@ const analyticsV2Service = new AnalyticsV2Service();
|
||||
const ProjectInsights = observer(() => {
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalyticsV2();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-radar" });
|
||||
|
||||
const { data: projectInsightsData, isLoading: isLoadingProjectInsight } = useSWR(
|
||||
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
`radar-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChartData<string, string>[]>(workspaceSlug, "projects", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -18,16 +18,20 @@ const analyticsV2Service = new AnalyticsV2Service();
|
||||
const TotalInsights: React.FC<{ analyticsType: TAnalyticsTabsV2Base; peekView?: boolean }> = observer(
|
||||
({ analyticsType, peekView }) => {
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
const { selectedDuration, selectedProjects, selectedDurationLabel } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedProjects, selectedDurationLabel, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalyticsV2();
|
||||
|
||||
const { data: totalInsightsData, isLoading } = useSWR(
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}`,
|
||||
`total-insights-${analyticsType}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalytics<IAnalyticsResponseV2>(workspaceSlug, analyticsType, {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -19,17 +19,21 @@ import { ChartLoader } from "../loaders";
|
||||
|
||||
const analyticsV2Service = new AnalyticsV2Service();
|
||||
const CreatedVsResolved = observer(() => {
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedDurationLabel, selectedProjects, selectedCycle, selectedModule, isPeekView } =
|
||||
useAnalyticsV2();
|
||||
const params = useParams();
|
||||
const { t } = useTranslation();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-area" });
|
||||
const { data: createdVsResolvedData, isLoading: isCreatedVsResolvedLoading } = useSWR(
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
`created-vs-resolved-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<IChartResponseV2>(workspaceSlug, "work-items", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
const parsedData: TChartData<string, string>[] = useMemo(() => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
import { ICycle, IModule, IProject } from "@plane/types";
|
||||
import { Spinner } from "@plane/ui";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store";
|
||||
@@ -15,20 +15,52 @@ import WorkItemsInsightTable from "../workitems-insight-table";
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
projectDetails: IProject | undefined;
|
||||
cycleDetails: ICycle | undefined;
|
||||
moduleDetails: IModule | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModalMainContent: React.FC<Props> = observer((props) => {
|
||||
const { projectDetails, fullScreen } = props;
|
||||
const { updateSelectedProjects } = useAnalyticsV2();
|
||||
const [isProjectConfigured, setIsProjectConfigured] = useState(false);
|
||||
const { projectDetails, cycleDetails, moduleDetails, fullScreen } = props;
|
||||
const { updateSelectedProjects, updateSelectedCycle, updateSelectedModule, updateIsPeekView } = useAnalyticsV2();
|
||||
const [isModalConfigured, setIsModalConfigured] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectDetails?.id) return;
|
||||
updateSelectedProjects([projectDetails?.id ?? ""]);
|
||||
setIsProjectConfigured(true);
|
||||
}, [projectDetails?.id, updateSelectedProjects]);
|
||||
updateIsPeekView(true);
|
||||
|
||||
if (!isProjectConfigured)
|
||||
// Handle project selection
|
||||
if (projectDetails?.id) {
|
||||
updateSelectedProjects([projectDetails.id]);
|
||||
}
|
||||
|
||||
// Handle cycle selection
|
||||
if (cycleDetails?.id) {
|
||||
updateSelectedCycle(cycleDetails.id);
|
||||
}
|
||||
|
||||
// Handle module selection
|
||||
if (moduleDetails?.id) {
|
||||
updateSelectedModule(moduleDetails.id);
|
||||
}
|
||||
setIsModalConfigured(true);
|
||||
|
||||
// Cleanup fields
|
||||
return () => {
|
||||
updateSelectedProjects([]);
|
||||
updateSelectedCycle("");
|
||||
updateSelectedModule("");
|
||||
updateIsPeekView(false);
|
||||
};
|
||||
}, [
|
||||
projectDetails?.id,
|
||||
cycleDetails?.id,
|
||||
moduleDetails?.id,
|
||||
updateSelectedProjects,
|
||||
updateSelectedCycle,
|
||||
updateSelectedModule,
|
||||
updateIsPeekView,
|
||||
]);
|
||||
|
||||
if (!isModalConfigured)
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { observer } from "mobx-react";
|
||||
|
||||
// icons
|
||||
// plane package imports
|
||||
import { Expand, Shrink, X } from "lucide-react";
|
||||
import { ICycle, IModule } from "@plane/types";
|
||||
// icons
|
||||
|
||||
type Props = {
|
||||
fullScreen: boolean;
|
||||
handleClose: () => void;
|
||||
setFullScreen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
title: string;
|
||||
cycle?: ICycle;
|
||||
module?: IModule;
|
||||
};
|
||||
|
||||
export const WorkItemsModalHeader: React.FC<Props> = observer((props) => {
|
||||
const { fullScreen, handleClose, setFullScreen, title } = props;
|
||||
const { fullScreen, handleClose, setFullScreen, title, cycle, module } = props;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 bg-custom-background-100 px-5 py-4 text-sm">
|
||||
<h3 className="break-words">Analytics for {title}</h3>
|
||||
<h3 className="break-words">
|
||||
Analytics for {title} {cycle && `in ${cycle.name}`} {module && `in ${module.name}`}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { IProject } from "@plane/types";
|
||||
import { ICycle, IModule, IProject } from "@plane/types";
|
||||
// plane web components
|
||||
import { WorkItemsModalMainContent } from "./content";
|
||||
import { WorkItemsModalHeader } from "./header";
|
||||
@@ -11,10 +11,12 @@ type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
projectDetails?: IProject | undefined;
|
||||
cycleDetails?: ICycle | undefined;
|
||||
moduleDetails?: IModule | undefined;
|
||||
};
|
||||
|
||||
export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose, projectDetails } = props;
|
||||
const { isOpen, onClose, projectDetails, moduleDetails, cycleDetails } = props;
|
||||
|
||||
const [fullScreen, setFullScreen] = useState(false);
|
||||
|
||||
@@ -51,8 +53,15 @@ export const WorkItemsModal: React.FC<Props> = observer((props) => {
|
||||
handleClose={handleClose}
|
||||
setFullScreen={setFullScreen}
|
||||
title={projectDetails?.name ?? ""}
|
||||
cycle={cycleDetails}
|
||||
module={moduleDetails}
|
||||
/>
|
||||
<WorkItemsModalMainContent
|
||||
fullScreen={fullScreen}
|
||||
projectDetails={projectDetails}
|
||||
cycleDetails={cycleDetails}
|
||||
moduleDetails={moduleDetails}
|
||||
/>
|
||||
<WorkItemsModalMainContent fullScreen={fullScreen} projectDetails={projectDetails} />
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -46,19 +46,23 @@ const PriorityChart = observer((props: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-chart-bar" });
|
||||
// store hooks
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
|
||||
const { workspaceStates } = useProjectState();
|
||||
const { resolvedTheme } = useTheme();
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
|
||||
const { data: priorityChartData, isLoading: priorityChartLoading } = useSWR(
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${props.x_axis}-${props.y_axis}-${props.group_by}`,
|
||||
`customized-insights-chart-${workspaceSlug}-${selectedDuration}-
|
||||
${selectedProjects}-${selectedCycle}-${selectedModule}-${props.x_axis}-${props.y_axis}-${props.group_by}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsCharts<TChart>(workspaceSlug, "custom-work-items", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 && { project_ids: selectedProjects?.join(",") }),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
...props,
|
||||
})
|
||||
);
|
||||
@@ -158,10 +162,23 @@ const PriorityChart = observer((props: Props) => {
|
||||
});
|
||||
|
||||
const exportCSV = (rows: Row<TChartDatum>[]) => {
|
||||
const rowData = rows.map((row) => ({
|
||||
name: row.original.name,
|
||||
count: row.original.count,
|
||||
}));
|
||||
const rowData = rows.map((row) => {
|
||||
const hiddenFields = ["key", "avatar_url", "assignee_id", "project_id"];
|
||||
const otherFields = Object.keys(row.original).filter(
|
||||
(key) => key !== "name" && key !== "count" && !hiddenFields.includes(key) && !key.includes("id")
|
||||
);
|
||||
return {
|
||||
name: row.original.name,
|
||||
count: row.original.count,
|
||||
...otherFields.reduce(
|
||||
(acc, key) => {
|
||||
acc[parsedData?.schema[key] ?? key] = row.original[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string | number>
|
||||
),
|
||||
};
|
||||
});
|
||||
const csv = generateCsv(csvConfig)(rowData);
|
||||
download(csvConfig)(csv);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useMemo } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { ColumnDef, Row } from "@tanstack/react-table";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import { Briefcase } from "lucide-react";
|
||||
import { Briefcase, UserRound } from "lucide-react";
|
||||
// plane package imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { WorkItemInsightColumns, AnalyticsTableDataMap } from "@plane/types";
|
||||
// plane web components
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { getFileURL } from "@plane/utils";
|
||||
import { Logo } from "@/components/common/logo";
|
||||
// hooks
|
||||
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
|
||||
@@ -21,44 +23,85 @@ const analyticsV2Service = new AnalyticsV2Service();
|
||||
const WorkItemsInsightTable = observer(() => {
|
||||
// router
|
||||
const params = useParams();
|
||||
const workspaceSlug = params.workspaceSlug as string;
|
||||
const workspaceSlug = params.workspaceSlug.toString();
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { getProjectById } = useProject();
|
||||
const { selectedDuration, selectedProjects } = useAnalyticsV2();
|
||||
const { selectedDuration, selectedProjects, selectedCycle, selectedModule, isPeekView } = useAnalyticsV2();
|
||||
const { data: workItemsData, isLoading } = useSWR(
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}`,
|
||||
`insights-table-work-items-${workspaceSlug}-${selectedDuration}-${selectedProjects}-${selectedCycle}-${selectedModule}-${isPeekView}`,
|
||||
() =>
|
||||
analyticsV2Service.getAdvanceAnalyticsStats<WorkItemInsightColumns[]>(workspaceSlug, "work-items", {
|
||||
// date_filter: selectedDuration,
|
||||
...(selectedProjects?.length > 0 ? { project_ids: selectedProjects.join(",") } : {}),
|
||||
...(selectedCycle ? { cycle_id: selectedCycle } : {}),
|
||||
...(selectedModule ? { module_id: selectedModule } : {}),
|
||||
...(isPeekView ? { peek_view: true } : {}),
|
||||
})
|
||||
);
|
||||
// derived values
|
||||
const columnsLabels: Record<string, string> = {
|
||||
backlog_work_items: t("workspace_projects.state.backlog"),
|
||||
started_work_items: t("workspace_projects.state.started"),
|
||||
un_started_work_items: t("workspace_projects.state.unstarted"),
|
||||
completed_work_items: t("workspace_projects.state.completed"),
|
||||
cancelled_work_items: t("workspace_projects.state.cancelled"),
|
||||
project__name: t("common.project"),
|
||||
};
|
||||
const columnsLabels = useMemo(
|
||||
() => ({
|
||||
backlog_work_items: t("workspace_projects.state.backlog"),
|
||||
started_work_items: t("workspace_projects.state.started"),
|
||||
un_started_work_items: t("workspace_projects.state.unstarted"),
|
||||
completed_work_items: t("workspace_projects.state.completed"),
|
||||
cancelled_work_items: t("workspace_projects.state.cancelled"),
|
||||
project__name: t("common.project"),
|
||||
display_name: t("common.assignee"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessorKey: "project__name",
|
||||
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
|
||||
cell: ({ row }) => {
|
||||
const project = getProjectById(row.original.project_id);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{project?.logo_props ? <Logo logo={project.logo_props} size={18} /> : <Briefcase className="h-4 w-4" />}
|
||||
{project?.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
!isPeekView
|
||||
? {
|
||||
accessorKey: "project__name",
|
||||
header: () => <div className="text-left">{columnsLabels["project__name"]}</div>,
|
||||
cell: ({ row }) => {
|
||||
const project = getProjectById(row.original.project_id);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{project?.logo_props ? (
|
||||
<Logo logo={project.logo_props} size={18} />
|
||||
) : (
|
||||
<Briefcase className="h-4 w-4" />
|
||||
)}
|
||||
{project?.name}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
: {
|
||||
accessorKey: "display_name",
|
||||
header: () => <div className="text-left">{columnsLabels["display_name"]}</div>,
|
||||
cell: ({ row }: { row: Row<WorkItemInsightColumns> }) => (
|
||||
<div className="text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
{row.original.avatar_url && row.original.avatar_url !== "" ? (
|
||||
<Avatar
|
||||
name={row.original.display_name}
|
||||
src={getFileURL(row.original.avatar_url)}
|
||||
size={24}
|
||||
shape="circle"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full bg-custom-background-80 capitalize overflow-hidden">
|
||||
{row.original.display_name ? (
|
||||
row.original.display_name?.[0]
|
||||
) : (
|
||||
<UserRound className="text-custom-text-200 " size={12} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="break-words text-custom-text-200">
|
||||
{row.original.display_name ?? t(`Unassigned`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "backlog_work_items",
|
||||
header: () => <div className="text-right">{columnsLabels["backlog_work_items"]}</div>,
|
||||
@@ -85,7 +128,7 @@ const WorkItemsInsightTable = observer(() => {
|
||||
cell: ({ row }) => <div className="text-right">{row.original.cancelled_work_items}</div>,
|
||||
},
|
||||
] as ColumnDef<AnalyticsTableDataMap["work-items"]>[],
|
||||
[getProjectById]
|
||||
[columnsLabels, getProjectById, isPeekView, t]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
107
web/core/components/analytics/old-page.tsx
Normal file
107
web/core/components/analytics/old-page.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import React, { Fragment } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Tab } from "@headlessui/react";
|
||||
// plane package imports
|
||||
import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Header, EHeaderVariant } from "@plane/ui";
|
||||
// components
|
||||
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
|
||||
import { PageHead } from "@/components/core";
|
||||
import { ComicBoxButton, DetailedEmptyState } from "@/components/empty-state";
|
||||
// hooks
|
||||
import { useCommandPalette, useEventTracker, useProject, useUserPermissions, useWorkspace } from "@/hooks/store";
|
||||
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
|
||||
|
||||
const OldAnalyticsPage = observer(() => {
|
||||
const searchParams = useSearchParams();
|
||||
const analytics_tab = searchParams.get("analytics_tab");
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { toggleCreateProjectModal } = useCommandPalette();
|
||||
const { setTrackElement } = useEventTracker();
|
||||
const { workspaceProjectIds, loader } = useProject();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
// helper hooks
|
||||
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/onboarding/analytics" });
|
||||
// derived values
|
||||
const pageTitle = currentWorkspace?.name
|
||||
? t(`workspace_analytics.page_label`, { workspace: currentWorkspace?.name })
|
||||
: undefined;
|
||||
|
||||
// permissions
|
||||
const canPerformEmptyStateActions = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
// TODO: refactor loader implementation
|
||||
return (
|
||||
<>
|
||||
<PageHead title={pageTitle} />
|
||||
{workspaceProjectIds && (
|
||||
<>
|
||||
{workspaceProjectIds.length > 0 || loader === "init-loader" ? (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
|
||||
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
|
||||
<Header variant={EHeaderVariant.SECONDARY}>
|
||||
<Tab.List as="div" className="flex space-x-2 h-full">
|
||||
{ANALYTICS_TABS.map((tab) => (
|
||||
<Tab key={tab.key} as={Fragment}>
|
||||
{({ selected }) => (
|
||||
<button
|
||||
className={`text-sm group relative flex items-center gap-1 h-full px-3 cursor-pointer transition-all font-medium outline-none ${
|
||||
selected ? "text-custom-primary-100 " : "hover:text-custom-text-200"
|
||||
}`}
|
||||
>
|
||||
{t(tab.i18n_title)}
|
||||
<div
|
||||
className={`border absolute bottom-0 right-0 left-0 rounded-t-md ${selected ? "border-custom-primary-100" : "border-transparent group-hover:border-custom-border-200"}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
</Header>
|
||||
<Tab.Panels as={Fragment}>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<ScopeAndDemand fullScreen />
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as={Fragment}>
|
||||
<CustomAnalytics fullScreen />
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
) : (
|
||||
<DetailedEmptyState
|
||||
title={t("workspace_analytics.empty_state.general.title")}
|
||||
description={t("workspace_analytics.empty_state.general.description")}
|
||||
assetPath={resolvedPath}
|
||||
customPrimaryButton={
|
||||
<ComicBoxButton
|
||||
label={t("workspace_analytics.empty_state.general.primary_button.text")}
|
||||
title={t("workspace_analytics.empty_state.general.primary_button.comic.title")}
|
||||
description={t("workspace_analytics.empty_state.general.primary_button.comic.description")}
|
||||
onClick={() => {
|
||||
setTrackElement("Analytics empty state");
|
||||
toggleCreateProjectModal(true);
|
||||
}}
|
||||
disabled={!canPerformEmptyStateActions}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default OldAnalyticsPage;
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { IIssueActivity } from "@plane/types";
|
||||
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon, Intake } from "@plane/ui";
|
||||
import { Tooltip, BlockedIcon, BlockerIcon, RelatedIcon, LayersIcon, DiceIcon, Intake, EpicIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { generateWorkItemLink } from "@/helpers/issue.helper";
|
||||
@@ -196,15 +196,7 @@ const activityDetails: {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
uploaded a new{" "}
|
||||
<a
|
||||
href={`${activity.new_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
attachment
|
||||
</a>
|
||||
uploaded a new attachment
|
||||
{showIssue && (
|
||||
<>
|
||||
{" "}
|
||||
@@ -279,6 +271,12 @@ const activityDetails: {
|
||||
created <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
else if (activity.verb === "converted")
|
||||
return (
|
||||
<>
|
||||
converted <IssueLink activity={activity} /> to an epic
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
@@ -288,6 +286,29 @@ const activityDetails: {
|
||||
},
|
||||
icon: <LayersIcon width={12} height={12} className="text-custom-text-200" aria-hidden="true" />,
|
||||
},
|
||||
epic: {
|
||||
message: (activity) => {
|
||||
if (activity.verb === "created")
|
||||
return (
|
||||
<>
|
||||
created <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
else if (activity.verb === "converted")
|
||||
return (
|
||||
<>
|
||||
converted <IssueLink activity={activity} /> to a work item
|
||||
</>
|
||||
);
|
||||
else
|
||||
return (
|
||||
<>
|
||||
deleted <IssueLink activity={activity} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
icon: <EpicIcon width={12} height={12} className="text-custom-text-200" aria-hidden="true" />,
|
||||
},
|
||||
labels: {
|
||||
message: (activity, showIssue, workspaceSlug) => {
|
||||
if (activity.old_value === "")
|
||||
@@ -735,10 +756,11 @@ type ActivityMessageProps = {
|
||||
export const ActivityMessage = ({ activity, showIssue = false }: ActivityMessageProps) => {
|
||||
// router params
|
||||
const { workspaceSlug } = useParams();
|
||||
const activityField = activity.field ?? "issue";
|
||||
|
||||
return (
|
||||
<>
|
||||
{activityDetails[activity.field as keyof typeof activityDetails]?.message(
|
||||
{activityDetails[activityField as keyof typeof activityDetails]?.message(
|
||||
activity,
|
||||
showIssue,
|
||||
workspaceSlug ? workspaceSlug.toString() : (activity.workspace_detail?.slug ?? "")
|
||||
|
||||
@@ -13,6 +13,7 @@ import { CycleForm } from "@/components/cycles";
|
||||
// constants
|
||||
// hooks
|
||||
import { useEventTracker, useCycle, useProject } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
// services
|
||||
@@ -180,8 +181,12 @@ export const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
|
||||
setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null);
|
||||
}, [activeProject, data, projectId, workspaceProjectIds, isOpen]);
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen) handleClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<CycleForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { FC, useState } from "react";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// components
|
||||
import { InboxIssueCreateRoot } from "@/components/inbox/modals/create-modal";
|
||||
// hooks
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
|
||||
type TInboxIssueCreateModalRoot = {
|
||||
workspaceSlug: string;
|
||||
@@ -20,13 +22,16 @@ export const InboxIssueCreateModalRoot: FC<TInboxIssueCreateModalRoot> = (props)
|
||||
// handlers
|
||||
const handleDuplicateIssueModal = (value: boolean) => setIsDuplicateModalOpen(value);
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (modalState) {
|
||||
handleModalClose();
|
||||
setIsDuplicateModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={modalState}
|
||||
handleClose={() => {
|
||||
handleModalClose();
|
||||
setIsDuplicateModalOpen(false);
|
||||
}}
|
||||
position={EModalPosition.TOP}
|
||||
width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL}
|
||||
className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear"
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { FC } from "react";
|
||||
import { Layers, Link, Paperclip, Waypoints } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
import { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
IssueAttachmentActionButton,
|
||||
@@ -12,8 +12,9 @@ import {
|
||||
RelationActionButton,
|
||||
SubIssuesActionButton,
|
||||
IssueDetailWidgetButton,
|
||||
TWorkItemWidgets,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
// plane web imports
|
||||
import { WorkItemAdditionalWidgetActionButtons } from "@/plane-web/components/issues/issue-detail-widgets/action-buttons";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -88,6 +89,14 @@ export const IssueDetailWidgetActionButtons: FC<Props> = (props) => {
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
<WorkItemAdditionalWidgetActionButtons
|
||||
disabled={disabled}
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
import { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
AttachmentsCollapsible,
|
||||
LinksCollapsible,
|
||||
RelationsCollapsible,
|
||||
SubIssuesCollapsible,
|
||||
TWorkItemWidgets,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
// Plane-web
|
||||
import { WorkItemAdditionalWidgets } from "@/plane-web/components/issues/issue-detail-widgets";
|
||||
import { WorkItemAdditionalWidgetCollapsibles } from "@/plane-web/components/issues/issue-detail-widgets/collapsibles";
|
||||
import { useTimeLineRelationOptions } from "@/plane-web/components/relations";
|
||||
|
||||
type Props = {
|
||||
@@ -87,11 +86,13 @@ export const IssueDetailWidgetCollapsibles: FC<Props> = observer((props) => {
|
||||
issueServiceType={issueServiceType}
|
||||
/>
|
||||
)}
|
||||
<WorkItemAdditionalWidgets
|
||||
workspaceSlug={workspaceSlug}
|
||||
<WorkItemAdditionalWidgetCollapsibles
|
||||
disabled={disabled}
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
disabled={disabled}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import React, { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { ISearchIssueResponse, TIssue, TIssueServiceType } from "@plane/types";
|
||||
import { ISearchIssueResponse, TIssue, TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
import { setToast, TOAST_TYPE } from "@plane/ui";
|
||||
// components
|
||||
import { ExistingIssuesListModal } from "@/components/core";
|
||||
import { CreateUpdateIssueModal } from "@/components/issues/issue-modal";
|
||||
// hooks
|
||||
import { useIssueDetail } from "@/hooks/store";
|
||||
|
||||
// plane web imports
|
||||
import { WorkItemAdditionalWidgetModals } from "@/plane-web/components/issues/issue-detail-widgets/modals";
|
||||
// local imports
|
||||
import { IssueLinkCreateUpdateModal } from "../issue-detail/links/create-update-link-modal";
|
||||
// helpers
|
||||
import { useLinkOperations } from "./links/helper";
|
||||
import { useSubIssueOperations } from "./sub-issues/helper";
|
||||
import { TWorkItemWidgets } from ".";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
@@ -65,7 +66,7 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
|
||||
const handleExistingIssuesModalClose = () => {
|
||||
handleIssueCrudState("existing", null, null);
|
||||
setLastWidgetAction("sub-issues");
|
||||
setLastWidgetAction("sub-work-items");
|
||||
toggleSubIssuesModal(null);
|
||||
};
|
||||
|
||||
@@ -80,7 +81,7 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
const handleCreateUpdateModalClose = () => {
|
||||
handleIssueCrudState("create", null, null);
|
||||
toggleCreateIssueModal(false);
|
||||
setLastWidgetAction("sub-issues");
|
||||
setLastWidgetAction("sub-work-items");
|
||||
};
|
||||
|
||||
const handleCreateUpdateModalOnSubmit = async (_issue: TIssue) => {
|
||||
@@ -190,6 +191,14 @@ export const IssueDetailWidgetModals: FC<Props> = observer((props) => {
|
||||
workspaceLevelToggle
|
||||
/>
|
||||
)}
|
||||
|
||||
<WorkItemAdditionalWidgetModals
|
||||
hideWidgets={hideWidgets ?? []}
|
||||
issueServiceType={issueServiceType}
|
||||
projectId={projectId}
|
||||
workItemId={issueId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { FC } from "react";
|
||||
// plane imports
|
||||
import { TIssueServiceType } from "@plane/types";
|
||||
import { TIssueServiceType, TWorkItemWidgets } from "@plane/types";
|
||||
// components
|
||||
import {
|
||||
IssueDetailWidgetActionButtons,
|
||||
@@ -10,8 +10,6 @@ import {
|
||||
IssueDetailWidgetModals,
|
||||
} from "@/components/issues/issue-detail-widgets";
|
||||
|
||||
export type TWorkItemWidgets = "sub-work-items" | "relations" | "links" | "attachments";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
|
||||
@@ -22,12 +22,12 @@ export const SubIssuesCollapsible: FC<Props> = observer((props) => {
|
||||
// store hooks
|
||||
const { openWidgets, toggleOpenWidget } = useIssueDetail(issueServiceType);
|
||||
// derived values
|
||||
const isCollapsibleOpen = openWidgets.includes("sub-issues");
|
||||
const isCollapsibleOpen = openWidgets.includes("sub-work-items");
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
isOpen={isCollapsibleOpen}
|
||||
onToggle={() => toggleOpenWidget("sub-issues")}
|
||||
onToggle={() => toggleOpenWidget("sub-work-items")}
|
||||
title={
|
||||
<SubIssuesCollapsibleTitle
|
||||
isOpen={isCollapsibleOpen}
|
||||
|
||||
@@ -25,17 +25,7 @@ export const IssueAttachmentActivity: FC<TIssueAttachmentActivity> = observer((p
|
||||
ends={ends}
|
||||
>
|
||||
<>
|
||||
{activity.verb === "created" ? `uploaded a new ` : `removed an attachment`}
|
||||
{activity.verb === "created" && (
|
||||
<a
|
||||
href={`${activity.new_value}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 font-medium text-custom-text-100 hover:underline"
|
||||
>
|
||||
attachment
|
||||
</a>
|
||||
)}
|
||||
{activity.verb === "created" ? `uploaded a new attachment` : `removed an attachment`}
|
||||
{showIssue && (activity.verb === "created" ? ` to ` : ` from `)}
|
||||
{showIssue && <IssueLink activityId={activityId} />}.
|
||||
</>
|
||||
|
||||
@@ -375,7 +375,6 @@ export const CreateUpdateIssueModalBase: React.FC<IssuesModalProps> = observer((
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={() => handleClose(true)}
|
||||
position={EModalPosition.TOP}
|
||||
width={isDuplicateModalOpen ? EModalWidth.VIXL : EModalWidth.XXXXL}
|
||||
className="!bg-transparent rounded-lg shadow-none transition-[width] ease-linear"
|
||||
|
||||
@@ -13,6 +13,7 @@ import { ModuleForm } from "@/components/modules";
|
||||
// constants
|
||||
// hooks
|
||||
import { useEventTracker, useModule, useProject } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import { usePlatformOS } from "@/hooks/use-platform-os";
|
||||
|
||||
type Props = {
|
||||
@@ -142,8 +143,12 @@ export const CreateUpdateModuleModal: React.FC<Props> = observer((props) => {
|
||||
setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null);
|
||||
}, [activeProject, data, projectId, workspaceProjectIds, isOpen]);
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen) handleClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ModuleForm
|
||||
handleFormSubmit={handleFormSubmit}
|
||||
handleClose={handleClose}
|
||||
|
||||
@@ -113,16 +113,7 @@ export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const message =
|
||||
activityItem.verb === "created" &&
|
||||
!["cycles", "modules", "attachment", "link", "estimate"].includes(activityItem.field) &&
|
||||
!activityItem.field ? (
|
||||
<span>
|
||||
created <IssueLink activity={activityItem} />
|
||||
</span>
|
||||
) : (
|
||||
<ActivityMessage activity={activityItem} showIssue />
|
||||
);
|
||||
const message = <ActivityMessage activity={activityItem} showIssue />;
|
||||
|
||||
if ("field" in activityItem && activityItem.field !== "updated_by")
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
// helpers
|
||||
import { getAssetIdFromUrl } from "@/helpers/file.helper";
|
||||
import { checkURLValidity } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// plane web components
|
||||
import { CreateProjectForm } from "@/plane-web/components/projects/create/root";
|
||||
// plane web types
|
||||
@@ -54,8 +56,12 @@ export const CreateProjectModal: FC<Props> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen) onClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
{currentStep === EProjectCreationSteps.CREATE_PROJECT && (
|
||||
<CreateProjectForm
|
||||
setToFavorite={setToFavorite}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@p
|
||||
import { ProjectViewForm } from "@/components/views";
|
||||
// hooks
|
||||
import { useProjectView } from "@/hooks/store";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
|
||||
type Props = {
|
||||
data?: IProjectView | null;
|
||||
@@ -65,8 +66,12 @@ export const CreateUpdateProjectViewModal: FC<Props> = observer((props) => {
|
||||
else await handleUpdateView(formData);
|
||||
};
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen) handleClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<ProjectViewForm
|
||||
data={data}
|
||||
handleClose={handleClose}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { IWebhook, IWorkspace, TWebhookEventTypes } from "@plane/types";
|
||||
import { EModalPosition, EModalWidth, ModalCore, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
// helpers
|
||||
import { csvDownload } from "@/helpers/download.helper";
|
||||
// hooks
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
// components
|
||||
import { WebhookForm } from "./form";
|
||||
import { GeneratedHookDetails } from "./generated-hook-details";
|
||||
@@ -93,16 +95,12 @@ export const CreateWebhookModal: React.FC<ICreateWebhookModal> = (props) => {
|
||||
}, 350);
|
||||
};
|
||||
|
||||
useKeypress("Escape", () => {
|
||||
if (isOpen && !generatedWebhook) handleClose();
|
||||
});
|
||||
|
||||
return (
|
||||
<ModalCore
|
||||
isOpen={isOpen}
|
||||
handleClose={() => {
|
||||
if (!generatedWebhook) handleClose();
|
||||
}}
|
||||
position={EModalPosition.TOP}
|
||||
width={EModalWidth.XXL}
|
||||
className="p-4 pb-0"
|
||||
>
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL} className="p-4 pb-0">
|
||||
{!generatedWebhook ? (
|
||||
<WebhookForm onSubmit={handleCreateWebhook} handleClose={handleClose} />
|
||||
) : (
|
||||
|
||||
@@ -10,6 +10,9 @@ export interface IAnalyticsStoreV2 {
|
||||
currentTab: TAnalyticsTabsV2Base;
|
||||
selectedProjects: string[];
|
||||
selectedDuration: DurationType;
|
||||
selectedCycle: string;
|
||||
selectedModule: string;
|
||||
isPeekView?: boolean;
|
||||
|
||||
//computed
|
||||
selectedDurationLabel: DurationType | null;
|
||||
@@ -17,25 +20,36 @@ export interface IAnalyticsStoreV2 {
|
||||
//actions
|
||||
updateSelectedProjects: (projects: string[]) => void;
|
||||
updateSelectedDuration: (duration: DurationType) => void;
|
||||
updateSelectedCycle: (cycle: string) => void;
|
||||
updateSelectedModule: (module: string) => void;
|
||||
updateIsPeekView: (isPeekView: boolean) => void;
|
||||
}
|
||||
|
||||
export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
||||
//observables
|
||||
currentTab: TAnalyticsTabsV2Base = "overview";
|
||||
selectedProjects: DurationType[] = [];
|
||||
selectedProjects: string[] = [];
|
||||
selectedDuration: DurationType = "last_30_days";
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
selectedCycle: string = "";
|
||||
selectedModule: string = "";
|
||||
isPeekView: boolean = false;
|
||||
constructor() {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
currentTab: observable.ref,
|
||||
selectedDuration: observable.ref,
|
||||
selectedProjects: observable.ref,
|
||||
selectedCycle: observable.ref,
|
||||
selectedModule: observable.ref,
|
||||
isPeekView: observable.ref,
|
||||
// computed
|
||||
selectedDurationLabel: computed,
|
||||
// actions
|
||||
updateSelectedProjects: action,
|
||||
updateSelectedDuration: action,
|
||||
updateSelectedCycle: action,
|
||||
updateSelectedModule: action,
|
||||
updateIsPeekView: action,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +58,6 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
||||
}
|
||||
|
||||
updateSelectedProjects = (projects: string[]) => {
|
||||
const initialState = this.selectedProjects;
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.selectedProjects = projects;
|
||||
@@ -65,4 +78,22 @@ export class AnalyticsStoreV2 implements IAnalyticsStoreV2 {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
updateSelectedCycle = (cycle: string) => {
|
||||
runInAction(() => {
|
||||
this.selectedCycle = cycle;
|
||||
});
|
||||
};
|
||||
|
||||
updateSelectedModule = (module: string) => {
|
||||
runInAction(() => {
|
||||
this.selectedModule = module;
|
||||
});
|
||||
};
|
||||
|
||||
updateIsPeekView = (isPeekView: boolean) => {
|
||||
runInAction(() => {
|
||||
this.isPeekView = isPeekView;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
TIssueCommentReaction,
|
||||
TIssueLink,
|
||||
TIssueReaction,
|
||||
TIssueDetailWidget,
|
||||
TIssueServiceType,
|
||||
TWorkItemWidgets,
|
||||
} from "@plane/types";
|
||||
// plane web store
|
||||
import {
|
||||
@@ -70,8 +70,8 @@ export interface IIssueDetail
|
||||
relationKey: TIssueRelationTypes | null;
|
||||
issueLinkData: TIssueLink | null;
|
||||
issueCrudOperationState: TIssueCrudOperationState;
|
||||
openWidgets: TIssueDetailWidget[];
|
||||
lastWidgetAction: TIssueDetailWidget | null;
|
||||
openWidgets: TWorkItemWidgets[];
|
||||
lastWidgetAction: TWorkItemWidgets | null;
|
||||
isCreateIssueModalOpen: boolean;
|
||||
isIssueLinkModalOpen: boolean;
|
||||
isParentIssueModalOpen: string | null;
|
||||
@@ -95,9 +95,9 @@ export interface IIssueDetail
|
||||
toggleRelationModal: (issueId: string | null, relationType: TIssueRelationTypes | null) => void;
|
||||
toggleSubIssuesModal: (value: string | null) => void;
|
||||
toggleDeleteAttachmentModal: (attachmentId: string | null) => void;
|
||||
setOpenWidgets: (state: TIssueDetailWidget[]) => void;
|
||||
setLastWidgetAction: (action: TIssueDetailWidget) => void;
|
||||
toggleOpenWidget: (state: TIssueDetailWidget) => void;
|
||||
setOpenWidgets: (state: TWorkItemWidgets[]) => void;
|
||||
setLastWidgetAction: (action: TWorkItemWidgets) => void;
|
||||
toggleOpenWidget: (state: TWorkItemWidgets) => void;
|
||||
setRelationKey: (relationKey: TIssueRelationTypes | null) => void;
|
||||
setIssueCrudOperationState: (state: TIssueCrudOperationState) => void;
|
||||
// store
|
||||
@@ -131,8 +131,8 @@ export class IssueDetail implements IIssueDetail {
|
||||
issue: undefined,
|
||||
},
|
||||
};
|
||||
openWidgets: TIssueDetailWidget[] = ["sub-issues", "links", "attachments"];
|
||||
lastWidgetAction: TIssueDetailWidget | null = null;
|
||||
openWidgets: TWorkItemWidgets[] = ["sub-work-items", "links", "attachments"];
|
||||
lastWidgetAction: TWorkItemWidgets | null = null;
|
||||
isCreateIssueModalOpen: boolean = false;
|
||||
isIssueLinkModalOpen: boolean = false;
|
||||
isParentIssueModalOpen: string | null = null;
|
||||
@@ -238,14 +238,14 @@ export class IssueDetail implements IIssueDetail {
|
||||
(this.isRelationModalOpen = { issueId, relationType });
|
||||
toggleSubIssuesModal = (issueId: string | null) => (this.isSubIssuesModalOpen = issueId);
|
||||
toggleDeleteAttachmentModal = (attachmentId: string | null) => (this.attachmentDeleteModalId = attachmentId);
|
||||
setOpenWidgets = (state: TIssueDetailWidget[]) => {
|
||||
setOpenWidgets = (state: TWorkItemWidgets[]) => {
|
||||
this.openWidgets = state;
|
||||
if (this.lastWidgetAction) this.lastWidgetAction = null;
|
||||
};
|
||||
setLastWidgetAction = (action: TIssueDetailWidget) => {
|
||||
setLastWidgetAction = (action: TWorkItemWidgets) => {
|
||||
this.openWidgets = [action];
|
||||
};
|
||||
toggleOpenWidget = (state: TIssueDetailWidget) => {
|
||||
toggleOpenWidget = (state: TWorkItemWidgets) => {
|
||||
if (this.openWidgets && this.openWidgets.includes(state))
|
||||
this.openWidgets = this.openWidgets.filter((s) => s !== state);
|
||||
else this.openWidgets = [state, ...this.openWidgets];
|
||||
|
||||
@@ -293,7 +293,7 @@ export class ProjectStore implements IProjectStore {
|
||||
update(this.projectMap, [project.id], (p) => ({ ...p, ...project }));
|
||||
});
|
||||
this.loader = "loaded";
|
||||
this.fetchStatus = "partial";
|
||||
if (!this.fetchStatus) this.fetchStatus = "partial";
|
||||
});
|
||||
return projectsResponse;
|
||||
} catch (error) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user