Compare commits

..

20 Commits

Author SHA1 Message Date
pablohashescobar c65e0a6668 chore: add workspace creation validation 2025-05-29 14:51:13 +05:30
Sangeetha 2f4aa843fc [WEB-4122] fix: estimate in project export #7091 2025-05-20 12:56:30 +05:30
sriram veeraghanta cfac8ce350 fix: ruff file formatting based on config file pyproject (#7082) 2025-05-19 17:34:46 +05:30
sriram veeraghanta 75a11ba31a fix: polynomial regular expression used on uncontrolled data (#7083)
* fix: polynomial regular expression used on uncontrolled data

* fix: optimize the function to handle both operations
2025-05-19 17:14:26 +05:30
sriram veeraghanta 1fc3709731 chore: Strict Null Check in Admin app (#7081)
* chore: upgrade to latest version of turbo repo

* fix: tsconfig changes

* chore: adding format script to package json

* fix: formatting of files
2025-05-19 16:25:46 +05:30
Akshita Goyal 7e21618762 [WEB-3461] fix: profile activity rendering issue (#7059)
* fix: profile activity

* fix: icon

* fix: handled conversion case

* fix: handled conversion case
2025-05-19 15:20:57 +05:30
Aaryan Khandelwal 2d475491e9 [WEB-4117] refactor: work item widgets code split (#7078)
* refactor: work item widget code split

* fix: types
2025-05-19 15:20:40 +05:30
Aaryan Khandelwal 2a2feaf88e [WIKI-181] chore: editor extension storage utility code split (#7071)
* chore: storage extension code split

* chore: use storage extension utility
2025-05-19 13:12:52 +05:30
Anmol Singh Bhatia e48b2da623 [WEB-4056] fix: archived work item validation #7060 2025-05-18 15:28:47 +05:30
Anmol Singh Bhatia 9c9952a823 [WEB-3866] fix: work item attachment activity #7062 2025-05-18 15:28:00 +05:30
Akshita Goyal 906ce8b500 [WEB-4104] fix: project loading state #7065 2025-05-18 15:19:05 +05:30
Anmol Singh Bhatia 6c483fad2f [WEB-4041] chore: modal outside click behaviour #7072 2025-05-18 15:18:09 +05:30
Bavisetti Narayan 5b776392bd chore: revamped the analytics for cycle and module in peek view. (#7075)
* chore: added cycles and modules in analytics peek view

* chore: added cycles and modules analytics

* chore: added project filter for work items

* chore: added a peekview flag and based on that table columns

* chore: added peek view

* chore: added check for display name

* chore: cleaned up some code

* chore: fixed export csv data

* chore: added distinct work items

* chore: assignee in peek view

* updated csv fields

* chore: updated workitems peek with assignee

* fix: removed type assersions for workspaceslug

* chore: added day wise filter in cycles and modules

* chore: added extra validations

---------

Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
2025-05-17 17:11:26 +05:30
Aaryan Khandelwal ba158d5d6e [WEB-4109] chore: remove analytics duration filter (#7073)
* chore: remove analytics duration filter

* removed subtitle from title and date_filter from service call

* chore: removed the date filter

* bottom text of insight trend card

* chore: changed issue manager

* fix: limited items in table

* fix: removed unnecessary props from data-table

---------

Co-authored-by: JayashTripathy <jayashtripathy371@gmail.com>
Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-05-16 19:16:30 +05:30
JayashTripathy 084cc75726 [WEB-4092] fix:broken detailed empty state layout #7056 2025-05-14 18:01:36 +05:30
Nikhil 534f5c7dd0 [WEB-4088] fix: issue exports when cycles are not present (#7057)
* fix: issue exports when cycles are not present

* fix: type check
2025-05-14 18:00:49 +05:30
Manish Gupta 080cf70e3f refactor: Enhance backup and restore scripts for container data (#7055)
* refactor: enhance backup and restore scripts for container data management

* fix: ensure proper quoting in backup script to handle paths with spaces

* fix: ensure backup directory is only removed if tar command succeeds

* CodeRabbit fixes
2025-05-14 12:33:53 +05:30
Manish Gupta 4c3f7f27a5 fix: update API service startup check to use HTTP request instead of logs (#7054) 2025-05-14 10:02:21 +05:30
sriram veeraghanta 803f6cc62a chore: yarn lock file updates 2025-05-13 16:20:08 +05:30
Vamsi Krishna 3a6d0c11fb fix: set accordion to expand by default (#7053) 2025-05-13 16:18:13 +05:30
114 changed files with 1462 additions and 876 deletions
+1 -5
View File
@@ -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
+1 -1
View File
@@ -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";
+1
View File
@@ -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"
},
+7 -2
View File
@@ -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"]
+1 -1
View File
@@ -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
+9 -6
View File
@@ -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()
+8 -1
View File
@@ -3,11 +3,18 @@ from rest_framework import serializers
# Module import
from plane.db.models import Account, Profile, User, Workspace, WorkspaceMemberInvite
from plane.utils.url import clean_value
from .base import BaseSerializer
class UserSerializer(BaseSerializer):
def validate_first_name(self, value):
return clean_value(value)
def validate_last_name(self, value):
return clean_value(value)
class Meta:
model = User
# Exclude password field from the serializer
+35 -13
View File
@@ -1,7 +1,11 @@
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from django.template.defaultfilters import slugify
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -25,10 +29,7 @@ from plane.db.models import (
WorkspaceUserPreference,
)
from plane.utils.constants import RESTRICTED_WORKSPACE_SLUGS
# Django imports
from django.core.validators import URLValidator
from django.core.exceptions import ValidationError
from plane.utils.url import clean_value
class WorkSpaceSerializer(DynamicBaseSerializer):
@@ -40,7 +41,30 @@ class WorkSpaceSerializer(DynamicBaseSerializer):
# Check if the slug is restricted
if value in RESTRICTED_WORKSPACE_SLUGS:
raise serializers.ValidationError("Slug is not valid")
return value
# Clean the slug
slug = clean_value(value)
# If the slug is empty, return an error
if not slug:
raise serializers.ValidationError("Slug cannot be empty")
# Slugify the slug
slug = slugify(slug)
# Return the slug
return slug
def validate_name(self, value):
# Clean the name
name = clean_value(value)
# If the name is empty, return an error
if not name:
raise serializers.ValidationError("Name cannot be empty")
# Return the name
return name
class Meta:
model = Workspace
@@ -148,7 +172,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 +180,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 +196,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 +206,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
return super().update(instance, validated_data)
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
-6
View File
@@ -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",
),
]
-1
View File
@@ -203,7 +203,6 @@ from .analytic.advance import (
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
AdvanceAnalyticsExportEndpoint,
)
from .notification.base import (
+279 -120
View File
@@ -1,10 +1,11 @@
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, List, Any
from datetime import timedelta
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 (
@@ -15,23 +16,18 @@ from plane.db.models import (
Module,
IssueView,
ProjectPage,
Workspace,
CycleIssue,
ModuleIssue,
)
from django.db.models import (
Q,
Count,
)
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 datetime import timedelta
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:
@@ -46,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"]:
@@ -76,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]]:
@@ -123,9 +118,25 @@ class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
),
}
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = 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),
@@ -153,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)
@@ -187,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,
)
@@ -254,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"])
@@ -263,48 +363,144 @@ class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
"assignees", "labels", "issue_module__module", "issue_cycle__cycle"
)
)
# 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
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:
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")
)
# Get daily stats with optimized query
daily_stats = (
queryset.values("created_at__date")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(completed_at__isnull=False)),
)
.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"],
# 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")
)
current_date += timedelta(days=1)
# 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",
@@ -319,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")
@@ -333,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"]
@@ -346,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,
)
+2 -3
View File
@@ -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)
+1
View File
@@ -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
+41 -28
View File
@@ -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
+1
View File
@@ -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]
+1
View File
@@ -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"
+6 -1
View File
@@ -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
+146 -120
View File
@@ -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")
+3 -2
View File
@@ -92,8 +92,6 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_403_FORBIDDEN,
)
serializer = WorkSpaceSerializer(data=request.data)
slug = request.data.get("slug", False)
name = request.data.get("name", False)
@@ -109,6 +107,9 @@ class WorkSpaceViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST,
)
# Validate the serializer
serializer = WorkSpaceSerializer(data=request.data)
if serializer.is_valid(raise_exception=True):
serializer.save(owner=request.user)
# Create Workspace member
@@ -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)
+131 -39
View File
@@ -3,9 +3,11 @@ import csv
import io
import json
import zipfile
from typing import List
import boto3
from botocore.client import Config
from uuid import UUID
from datetime import datetime, date
# Third party imports
from celery import shared_task
@@ -20,21 +22,30 @@ from django.db.models import F, Prefetch
from collections import defaultdict
# Module imports
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User
from plane.db.models import ExporterHistory, Issue, FileAsset, Label, User, IssueComment
from plane.utils.exception_logger import log_exception
def dateTimeConverter(time):
def dateTimeConverter(time: datetime) -> str | None:
"""
Convert a datetime object to a formatted string.
"""
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time):
def dateConverter(time: date) -> str | None:
"""
Convert a date object to a formatted string.
"""
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data):
def create_csv_file(data: List[List[str]]) -> str:
"""
Create a CSV file from the provided data.
"""
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
@@ -45,11 +56,17 @@ def create_csv_file(data):
return csv_buffer.getvalue()
def create_json_file(data):
def create_json_file(data: List[dict]) -> str:
"""
Create a JSON file from the provided data.
"""
return json.dumps(data)
def create_xlsx_file(data):
def create_xlsx_file(data: List[List[str]]) -> bytes:
"""
Create an XLSX file from the provided data.
"""
workbook = Workbook()
sheet = workbook.active
@@ -62,7 +79,10 @@ def create_xlsx_file(data):
return xlsx_buffer.getvalue()
def create_zip_file(files):
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
"""
Create a ZIP file from the provided files.
"""
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
@@ -72,7 +92,13 @@ def create_zip_file(files):
return zip_buffer
def upload_to_s3(zip_file, workspace_id, token_id, slug):
# 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:
"""
Upload a ZIP file to S3 and generate a presigned URL.
"""
file_name = (
f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
)
@@ -154,7 +180,10 @@ def upload_to_s3(zip_file, workspace_id, token_id, slug):
exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue):
def generate_table_row(issue: dict) -> List[str]:
"""
Generate a table row from an issue dictionary.
"""
return [
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
issue["project_name"],
@@ -166,22 +195,24 @@ def generate_table_row(issue):
issue["priority"],
issue["created_by"],
", ".join(issue["labels"]) if issue["labels"] else "",
issue.get("cycle_name", ""),
issue.get("cycle_start_date", ""),
issue.get("cycle_end_date", ""),
issue["cycle_name"],
issue["cycle_start_date"],
issue["cycle_end_date"],
", ".join(issue.get("module_name", "")) if issue.get("module_name") else "",
dateTimeConverter(issue["created_at"]),
dateTimeConverter(issue["updated_at"]),
dateTimeConverter(issue["completed_at"]),
dateTimeConverter(issue["archived_at"]),
", ".join(
[
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
for comment in issue["comments"]
]
)
if issue["comments"]
else "",
(
", ".join(
[
f"{comment['comment']} ({comment['created_at']} by {comment['created_by']})"
for comment in issue["comments"]
]
)
if issue["comments"]
else ""
),
issue["estimate"] if issue["estimate"] else "",
", ".join(issue["link"]) if issue["link"] else "",
", ".join(issue["assignees"]) if issue["assignees"] else "",
@@ -191,7 +222,10 @@ def generate_table_row(issue):
]
def generate_json_row(issue):
def generate_json_row(issue: dict) -> dict:
"""
Generate a JSON row from an issue dictionary.
"""
return {
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project_name"],
@@ -221,7 +255,10 @@ def generate_json_row(issue):
}
def update_json_row(rows, row):
def update_json_row(rows: List[dict], row: dict) -> None:
"""
Update the json row with the new assignee and label.
"""
matched_index = next(
(
index
@@ -250,7 +287,10 @@ def update_json_row(rows, row):
rows.append(row)
def update_table_row(rows, row):
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
"""
Update the table row with the new assignee and label.
"""
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
@@ -272,20 +312,32 @@ def update_table_row(rows, row):
rows.append(row)
def generate_csv(header, project_id, issues, files):
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.
"""
rows = [header]
for issue in issues:
row = generate_table_row(issue)
update_table_row(rows, row)
csv_file = create_csv_file(rows)
files.append((f"{project_id}.csv", csv_file))
def generate_json(header, project_id, issues, files):
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.
"""
rows = []
for issue in issues:
row = generate_json_row(issue)
@@ -294,7 +346,15 @@ def generate_json(header, project_id, issues, files):
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header, project_id, issues, files):
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.
"""
rows = [header]
for issue in issues:
row = generate_table_row(issue)
@@ -304,13 +364,36 @@ def generate_xlsx(header, project_id, issues, files):
files.append((f"{project_id}.xlsx", xlsx_file))
def get_created_by(obj: Issue | IssueComment) -> str:
"""
Get the created by user for the given object.
"""
if obj.created_by:
return f"{obj.created_by.first_name} {obj.created_by.last_name}"
return ""
@shared_task
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
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.
token_id (str): The export object token id.
multiple (bool): Whether to export the issues to multiple files per project.
"""
try:
exporter_instance = ExporterHistory.objects.get(token=token_id)
exporter_instance.status = "processing"
exporter_instance.save(update_fields=["status"])
# Base query to get the issues
workspace_issues = (
Issue.objects.filter(
workspace__id=workspace_id,
@@ -348,16 +431,21 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
)
)
# Get the attachments for the issues
file_assets = FileAsset.objects.filter(
issue_id__in=workspace_issues.values_list("id", flat=True)
issue_id__in=workspace_issues.values_list("id", flat=True),
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
attachment_dict = defaultdict(list)
for asset in file_assets:
attachment_dict[asset.work_item_id].append(asset.asset_id)
# Create a list to store the issues data
issues_data = []
# Iterate over the issues
for issue in workspace_issues:
attachments = attachment_dict.get(issue.id, [])
@@ -380,18 +468,18 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
"module_name": [
module.module.name for module in issue.issue_module.all()
],
"created_by": f"{issue.created_by.first_name} {issue.created_by.last_name}",
"created_by": get_created_by(issue),
"labels": [label.name for label in issue.label_details],
"comments": [
{
"comment": comment.comment_stripped,
"created_at": dateConverter(comment.created_at),
"created_by": f"{comment.created_by.first_name} {comment.created_by.last_name}",
"created_by": get_created_by(comment),
}
for comment in issue.issue_comments.all()
],
"estimate": issue.estimate_point.estimate.name
if issue.estimate_point
"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": [
@@ -406,14 +494,17 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
],
}
# Get prefetched cycles and modules
cycles = list(issue.issue_cycle.all())
# Update cycle data
for cycle in cycles:
# Get Cycles data for the issue
cycle = issue.issue_cycle.last()
if cycle:
# Update cycle data
issue_data["cycle_name"] = cycle.cycle.name
issue_data["cycle_start_date"] = dateConverter(cycle.cycle.start_date)
issue_data["cycle_end_date"] = dateConverter(cycle.cycle.end_date)
else:
issue_data["cycle_name"] = ""
issue_data["cycle_start_date"] = ""
issue_data["cycle_end_date"] = ""
issues_data.append(issue_data)
@@ -446,6 +537,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
"Attachment Links",
]
# Map the provider to the function
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
@@ -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)}"
)
)
)
+1 -1
View File
@@ -82,4 +82,4 @@ from .label import Label
from .device import Device, DeviceSession
from .sticky import Sticky
from .sticky import Sticky
+3 -7
View File
@@ -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,
-1
View File
@@ -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"
+4 -2
View File
@@ -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")
+1 -3
View File
@@ -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
+1 -3
View File
@@ -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}
-1
View File
@@ -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}")
+5 -1
View File
@@ -129,7 +129,7 @@ def get_chart_period_range(
"last_3_months": (today - timedelta(days=90), today),
}
return period_ranges.get(date_filter, period_ranges["last_7_days"])
return period_ranges.get(date_filter, None)
def get_analytics_filters(
@@ -165,6 +165,8 @@ def get_analytics_filters(
"workspace__slug": slug,
"project__project_projectmember__member": user,
"project__project_projectmember__is_active": True,
"project__deleted_at__isnull": True,
"project__archived_at__isnull": True,
}
# Project filters
@@ -172,6 +174,8 @@ def get_analytics_filters(
"workspace__slug": slug,
"project_projectmember__member": user,
"project_projectmember__is_active": True,
"deleted_at__isnull": True,
"archived_at__isnull": True,
}
# Add project IDs to filters if provided
+1 -3
View File
@@ -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.
+28
View File
@@ -77,3 +77,31 @@ def normalize_url_path(url: str) -> str:
normalized_path = re.sub(r"/+", "/", parts.path)
# Reconstruct the URL
return urlunparse(parts._replace(path=normalized_path))
def clean_value(value: str) -> str:
"""
Clean the value by removing URLs and domain patterns.
Args:
value (str): The value to clean.
Returns:
str: The cleaned value.
"""
if not value:
return value
url_pattern = r"(https?:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}" # noqa
# Remove URLs and domains
cleaned_value = re.sub(url_pattern, "", value)
# Keep only alphanumeric characters and spaces
cleaned_value = re.sub(r"[^a-zA-Z0-9\s]", "", cleaned_value)
# Remove extra spaces and trim
cleaned_value = " ".join(cleaned_value.split())
return cleaned_value
+1 -1
View File
@@ -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"
+49 -23
View File
@@ -366,7 +366,7 @@ function startServices() {
local api_container_id=$(docker container ls -q -f "name=$SERVICE_FOLDER-api")
local idx2=0
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
while ! docker exec $api_container_id python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" > /dev/null 2>&1;
do
local message=">> Waiting for API Service to Start"
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
@@ -508,43 +508,69 @@ function viewLogs(){
echo "INVALID SERVICE NAME SUPPLIED"
fi
}
function backupSingleVolume() {
backupFolder=$1
selectedVolume=$2
# Backup data from Docker volume to the backup folder
# docker run --rm -v "$selectedVolume":/source -v "$backupFolder":/backup busybox sh -c 'cp -r /source/* /backup/'
local tobereplaced="plane-app_"
local replacewith=""
function backup_container_dir() {
local BACKUP_FOLDER=$1
local CONTAINER_NAME=$2
local CONTAINER_DATA_DIR=$3
local SERVICE_FOLDER=$4
local svcName="${selectedVolume//$tobereplaced/$replacewith}"
echo "Backing up $CONTAINER_NAME data..."
local CONTAINER_ID=$(/bin/bash -c "$COMPOSE_CMD -f $DOCKER_FILE_PATH ps -q $CONTAINER_NAME")
if [ -z "$CONTAINER_ID" ]; then
echo "Error: $CONTAINER_NAME container not found. Make sure the services are running."
return 1
fi
docker run --rm \
-e TAR_NAME="$svcName" \
-v "$selectedVolume":/"$svcName" \
-v "$backupFolder":/backup \
busybox sh -c 'tar -czf "/backup/${TAR_NAME}.tar.gz" /${TAR_NAME}'
# Create a temporary directory for the backup
mkdir -p "$BACKUP_FOLDER/$SERVICE_FOLDER"
# Copy the data directory from the running container
echo "Copying $CONTAINER_NAME data directory..."
docker cp -q "$CONTAINER_ID:$CONTAINER_DATA_DIR/." "$BACKUP_FOLDER/$SERVICE_FOLDER/"
local cp_status=$?
if [ $cp_status -ne 0 ]; then
echo "Error: Failed to copy $SERVICE_FOLDER data"
rm -rf $BACKUP_FOLDER/$SERVICE_FOLDER
return 1
fi
# Create tar.gz of the data
cd "$BACKUP_FOLDER"
tar -czf "${SERVICE_FOLDER}.tar.gz" "$SERVICE_FOLDER/"
local tar_status=$?
if [ $tar_status -eq 0 ]; then
rm -rf "$SERVICE_FOLDER/"
fi
cd - > /dev/null
if [ $tar_status -ne 0 ]; then
echo "Error: Failed to create tar archive"
return 1
fi
echo "Successfully backed up $SERVICE_FOLDER data"
}
function backupData() {
local datetime=$(date +"%Y%m%d-%H%M")
local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime
mkdir -p "$BACKUP_FOLDER"
volumes=$(docker volume ls -f "name=$SERVICE_FOLDER" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo "No volumes found starting with '$SERVICE_FOLDER'"
# Check if docker-compose.yml exists
if [ ! -f "$DOCKER_FILE_PATH" ]; then
echo "Error: docker-compose.yml not found at $DOCKER_FILE_PATH"
exit 1
fi
for vol in $volumes; do
echo "Backing Up $vol"
backupSingleVolume "$BACKUP_FOLDER" "$vol"
done
backup_container_dir "$BACKUP_FOLDER" "plane-db" "/var/lib/postgresql/data" "pgdata" || exit 1
backup_container_dir "$BACKUP_FOLDER" "plane-minio" "/export" "uploads" || exit 1
backup_container_dir "$BACKUP_FOLDER" "plane-mq" "/var/lib/rabbitmq" "rabbitmq_data" || exit 1
backup_container_dir "$BACKUP_FOLDER" "plane-redis" "/data" "redisdata" || exit 1
echo ""
echo "Backup completed successfully. Backup files are stored in $BACKUP_FOLDER"
echo ""
}
function askForAction() {
local DEFAULT_ACTION=$1
+4 -2
View File
@@ -66,8 +66,10 @@ function restoreData() {
exit 1
fi
local volume_suffix
volume_suffix="_pgdata|_redisdata|_uploads|_rabbitmq_data"
local volumes
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "$volume_suffix")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo ".....No volumes found starting with 'plane-app'"
@@ -87,7 +89,7 @@ function restoreData() {
echo "Found $BACKUP_FILE"
local docVol
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "$volume_suffix")
if [ -z "$docVol" ]; then
echo "Skipping: No volume found with name $restoreVolName"
+1 -1
View File
@@ -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
View 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",
+4 -5
View File
@@ -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];
+27 -24
View File
@@ -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;
}
+1 -1
View File
@@ -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
@@ -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";
@@ -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;
@@ -19,14 +19,14 @@ const AnalyticsFilterActions = observer(() => {
}}
projectIds={workspaceProjectIds}
/>
<DurationDropdown
{/* <DurationDropdown
buttonVariant="border-with-text"
value={selectedDuration}
onChange={(val) => {
updateSelectedDuration(val);
}}
dropdownArrow
/>
/> */}
</div>
);
});
@@ -17,7 +17,7 @@ const AnalyticsSectionWrapper: React.FC<Props> = (props) => {
{title && (
<div className="flex items-center gap-2 ">
<h1 className={"text-lg font-medium"}>{title}</h1>
{subtitle && <p className="text-lg text-custom-text-300"> {subtitle}</p>}
{/* {subtitle && <p className="text-lg text-custom-text-300"> • {subtitle}</p>} */}
</div>
)}
{actions}
@@ -30,12 +30,12 @@ const InsightCard = (props: InsightCardProps) => {
{!isLoading ? (
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
{percentage && (
{/* {percentage && (
<div className="flex gap-1 text-xs text-custom-text-300">
<TrendPiece percentage={percentage} size="xs" />
{versus && <div>vs {versus}</div>}
</div>
)}
)} */}
</div>
) : (
<Loader.Item height="50px" width="100%" />
@@ -51,14 +51,10 @@ export function DataTable<TData, TValue>({ columns, data, searchPlaceholder, act
rowSelection,
columnFilters,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
@@ -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,
// 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,
// 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,
// 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,
// 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,
// 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
View 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;
+33 -11
View File
@@ -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 ?? "")
+6 -1
View File
@@ -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}
@@ -85,7 +85,9 @@ export const DetailedEmptyState: React.FC<Props> = observer((props) => {
{description && <p className="text-sm">{description}</p>}
</div>
{assetPath && <Image src={assetPath} alt={title} width={384} height={250} lazyBoundary="100%" />}
{assetPath && (
<Image src={assetPath} alt={title} width={384} height={250} layout="responsive" lazyBoundary="100%" />
)}
{hasButtons && (
<div className="relative flex items-center justify-center gap-2 flex-shrink-0 w-full">
@@ -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;
@@ -45,7 +45,7 @@ export const SubIssuesListGroup: FC<TSubIssuesListGroupProps> = observer((props)
const isAllIssues = group.id === ALL_ISSUES;
// states
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(isAllIssues);
const [isCollapsibleOpen, setIsCollapsibleOpen] = useState(true);
if (!workItemIds.length) return null;
@@ -5,7 +5,7 @@ import { ListFilter } from "lucide-react";
import { EIssueServiceType, EIssuesStoreType } from "@plane/constants";
import { GroupByColumnTypes, TIssue, TIssueServiceType, TSubIssueOperations } from "@plane/types";
// hooks
import { Button } from "@plane/ui";
import { Button, Loader } from "@plane/ui";
import { SectionEmptyState } from "@/components/empty-state";
import { getGroupByColumns, isWorkspaceLevel } from "@/components/issues/issue-layouts/utils";
import { useIssueDetail } from "@/hooks/store";
@@ -44,7 +44,7 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
// store hooks
const {
subIssues: {
subIssuesByIssueId,
subIssuesByIssueId, loader,
filters: { getSubIssueFilters, getGroupedSubWorkItems, getFilteredSubWorkItems, resetFilters },
},
} = useIssueDetail(issueServiceType);
@@ -77,6 +77,16 @@ export const SubIssuesListRoot: React.FC<Props> = observer((props) => {
const isSubWorkItems = issueServiceType === EIssueServiceType.ISSUES;
if (loader === "init-loader") {
return (
<Loader className="space-y-2">
{Array.from({ length: 5 }).map((_, index) => (
<Loader.Item key={index} height="35px" width="100%" />
))}
</Loader>
);
}
return (
<div className="relative">
{isRootLevel && filteredSubWorkItemsCount === 0 ? (

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