Compare commits

...

31 Commits

Author SHA1 Message Date
JayashTripathy
f0a065a7bc chore: added cycles and modules in analytics peek view 2025-05-16 21:40:22 +05:30
JayashTripathy
e00b2fa836 Merge branch 'preview' of https://github.com/makeplane/plane into chore/analytics-filter 2025-05-16 20:48:50 +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
78edcd5d27 fix: removed unnecessary props from data-table 2025-05-16 18:57:00 +05:30
JayashTripathy
8616104d7e merge branch 'chore/analytics-filter' of https://github.com/makeplane/plane into chore/analytics-filter 2025-05-16 18:47:53 +05:30
JayashTripathy
6095004ed5 fix: limited items in table 2025-05-16 18:47:25 +05:30
NarayanBavisetti
4cffcebf67 chore: changed issue manager 2025-05-16 18:42:27 +05:30
JayashTripathy
8c6e62b6c2 Merge branch 'chore/analytics-filter' of https://github.com/makeplane/plane into chore/analytics-filter 2025-05-16 18:21:12 +05:30
JayashTripathy
973fea9dff bottom text of insight trend card 2025-05-16 18:20:45 +05:30
NarayanBavisetti
1b34b00e03 Merge branch 'chore/analytics-filter' of github.com:makeplane/plane into chore/analytics-filter 2025-05-16 18:17:53 +05:30
NarayanBavisetti
a555ea6650 chore: removed the date filter 2025-05-16 18:17:43 +05:30
JayashTripathy
6a0fe9d90f removed subtitle from title and date_filter from service call 2025-05-16 18:15:13 +05:30
Aaryan Khandelwal
f492d6df61 chore: remove analytics duration filter 2025-05-16 17:22:09 +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
JayashTripathy
75d81f9e95 [WEB-3781] Analytics page enhancements (#7005)
* chore: analytics endpoint

* added anlytics v2

* updated status icons

* added area chart in workitems and en translations

* active projects

* chore: created analytics chart

* chore: validation errors

* improved radar-chart , added empty states , added projects summary

* chore: added a new graph in advance analytics

* integrated priority chart

* chore: added csv exporter

* added priority dropdown

* integrated created vs resolved chart

* custom x and y axis label in bar and area chart

* added wrapper styles to legends

* added filter components

* fixed temp data imports

* integrated filters in priority charts

* added label to priority chart and updated duration filter

* refactor

* reverted to void onchange

* fixed some contant exports

* fixed type issues

* fixed some type and build issues

* chore: updated the filtering logic for analytics

* updated default value to last_30_days

* percentage value whole number and added some rules for axis options

* fixed some translations

* added - custom tick for radar, calc of insight cards, filter labels

* chore: opitmised the analytics endpoint

* replace old analytics path with new , updated labels of insight card, done some store fixes

* chore: updated the export request

* Enhanced ProjectSelect to support multi-select, improved state management, and optimized data fetching and component structure.

* fix: round completion percentage calculation in ActiveProjectItem

* added empty states in project insights

* Added loader and empty state in created/resolved chart

* added loaders

* added icons in filters

* added custom colors in customised charts

* cleaned up some code

* added some responsiveness

* updated translations

* updated serrchbar for the table

* added work item modal in project analytics

* fixed some of the layput issues in the peek view

* chore: updated the base function for viewsets

* synced tab to url

* code cleanup

* chore: updated the export logic

* fixed project_ids filter

* added icon in projectdropdown

* updated export button position

* export csv and emptystates icons

* refactor

* code refactor

* updated loaders, moved color pallete to contants, added nullish collasece operator in neccessary places

* removed uneccessary cn

* fixed formatting issues

* fixed empty project_ids in payload

* improved null checks

* optimized charts

* modified relevant variables to observable.ref

* fixed the duration type

* optimized some code

* updated query key in project-insight

* updated query key in project-insight

* updated formatting

* chore: replaced analytics route with new one and done some optimizations

* removed the old analytics

---------

Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
2025-05-12 20:50:33 +05:30
Aaryan Khandelwal
0d5c7c6653 [WEB-4051] regression: update font size of comment editor #7048 2025-05-12 19:47:44 +05:30
Anmol Singh Bhatia
079c3a3a99 [WEB-3978] chore: cmd k search result redirection improvements (#7012)
* fix: work item tab highlight

* chore: projectListOpen state and toggle method added to command palette store

* chore: openProjectAndScrollToSidebar helper function and highlight keyframes added

* chore: SidebarProjectsListItem updated

* chore: openProjectAndScrollToSidebar implementation

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-05-12 19:15:39 +05:30
Sangeetha
5f8d5ea388 [WEB-4054] chore: search-issues endpoint code refactoring (#7029)
* chore: moved some code to seperate function

* fix: function name typo
2025-05-12 19:14:10 +05:30
Anmol Singh Bhatia
8613a80b16 [WEB-3523] feat: start of week preference (#7033)
* chore: startOfWeek constant and types updated

* chore: startOfWeek updated in profile store

* chore: StartOfWeekPreference added to profile appearance settings

* chore: calendar layout startOfWeek implementation

* chore: date picker startOfWeek implementation

* chore: gantt layout startOfWeek implementation

* chore: code refactor

* chore: code refactor

* chore: code refactor
2025-05-12 19:13:39 +05:30
Aaryan Khandelwal
dc16f2862e [WIKI-181] refactor: make file handling generic in editor (#7046)
* refactor: make file handling generic

* fix: useeffect dependency array

* chore: remove mime type to extension conversion
2025-05-12 18:37:36 +05:30
Vamsi Krishna
e68d344410 [WEB-4074]fix: removed sub-work item filters at nested levels #7047 2025-05-12 18:21:05 +05:30
Aaron Heckmann
26c8cba322 [WEB-4008] fix: handle when settings are None #7016
https://app.plane.so/plane/browse/WEB-4008/
2025-05-12 13:16:30 +05:30
Bavisetti Narayan
b435ceedfc [WEB-3782] chore: analytics endpoints (#6973)
* chore: analytics endpoint

* chore: created analytics chart

* chore: validation errors

* chore: added a new graph in advance analytics

* chore: added csv exporter

* chore: updated the filtering logic for analytics

* chore: opitmised the analytics endpoint

* chore: updated the base function for viewsets

* chore: updated the export logic

* chore: added type hints

* chore: added type hints
2025-05-12 13:15:17 +05:30
Sangeetha
13c46e0fdf [WEB-3987] chore: project export funtionality enhancement (#7002)
* chore: comment details of work item

* chore: attachment count and attachment name

* chore: issue link and subscriber count

* chore: list of assignees

* chore: asset_url as attachment_links

* chore: code refactor

* fix: cannot export Excel

* chore: remove print statements

* fix: filtering in list

* chore: optimize attachment_count and attachment_link query

* chore: optimize fetching issue details for multiple select

* chore: use Prefetch to avoid duplicates
2025-05-09 21:09:13 +05:30
sriram veeraghanta
02bccb44d6 chore: adding robots txt file for not indexing the server 2025-05-09 21:07:24 +05:30
Surya Prashanth
b5634f5fa1 chore: add disable_auto_set_user flag on base model save method (#7041)
- when disable_auto_set_user flag is set, user fields like created_by
are derived from payload instead of crum
2025-05-09 21:05:05 +05:30
169 changed files with 5981 additions and 662 deletions

View File

@@ -6,8 +6,12 @@ from plane.app.views import (
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
AdvanceAnalyticsExportEndpoint,
)
@@ -49,4 +53,24 @@ urlpatterns = [
ProjectStatsEndpoint.as_view(),
name="project-analytics",
),
path(
"workspaces/<str:slug>/advance-analytics/",
AdvanceAnalyticsEndpoint.as_view(),
name="advance-analytics",
),
path(
"workspaces/<str:slug>/advance-analytics-stats/",
AdvanceAnalyticsStatsEndpoint.as_view(),
name="advance-analytics-stats",
),
path(
"workspaces/<str:slug>/advance-analytics-charts/",
AdvanceAnalyticsChartEndpoint.as_view(),
name="advance-analytics-chart",
),
path(
"workspaces/<str:slug>/advance-analytics-export/",
AdvanceAnalyticsExportEndpoint.as_view(),
name="advance-analytics-export",
),
]

View File

@@ -199,6 +199,13 @@ from .analytic.base import (
ProjectStatsEndpoint,
)
from .analytic.advance import (
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
AdvanceAnalyticsExportEndpoint,
)
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,

View File

@@ -0,0 +1,422 @@
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, List, Any
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 plane.app.views.base import BaseAPIView
from plane.app.permissions import ROLE, allow_permission
from plane.db.models import (
WorkspaceMember,
Project,
Issue,
Cycle,
Module,
IssueView,
ProjectPage,
Workspace
)
from plane.utils.build_chart import build_analytics_chart
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
from plane.utils.date_utils import (
get_analytics_filters,
)
from plane.utils.build_chart import build_analytics_chart
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
from plane.utils.date_utils import get_analytics_filters
class AdvanceAnalyticsBaseView(BaseAPIView):
def initialize_workspace(self, slug: str, type: str) -> None:
self._workspace_slug = slug
self.filters = get_analytics_filters(
slug=slug,
type=type,
user=self.request.user,
date_filter=self.request.GET.get("date_filter", None),
project_ids=self.request.GET.get("project_ids", None),
)
class AdvanceAnalyticsEndpoint(AdvanceAnalyticsBaseView):
def get_filtered_counts(self, queryset: QuerySet) -> Dict[str, int]:
def get_filtered_count() -> int:
if self.filters["analytics_date_range"]:
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["current"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["current"][
"lte"
],
).count()
return queryset.count()
def get_previous_count() -> int:
if self.filters["analytics_date_range"] and self.filters[
"analytics_date_range"
].get("previous"):
return queryset.filter(
created_at__gte=self.filters["analytics_date_range"]["previous"][
"gte"
],
created_at__lte=self.filters["analytics_date_range"]["previous"][
"lte"
],
).count()
return 0
return {
"count": get_filtered_count(),
"filter_count": get_previous_count(),
}
def get_overview_data(self) -> Dict[str, Dict[str, int]]:
return {
"total_users": self.get_filtered_counts(
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True
)
),
"total_admins": self.get_filtered_counts(
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug,
role=ROLE.ADMIN.value,
is_active=True,
)
),
"total_members": self.get_filtered_counts(
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug,
role=ROLE.MEMBER.value,
is_active=True,
)
),
"total_guests": self.get_filtered_counts(
WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug,
role=ROLE.GUEST.value,
is_active=True,
)
),
"total_projects": self.get_filtered_counts(
Project.objects.filter(**self.filters["project_filters"])
),
"total_work_items": self.get_filtered_counts(
Issue.issue_objects.filter(**self.filters["base_filters"])
),
"total_cycles": self.get_filtered_counts(
Cycle.objects.filter(**self.filters["base_filters"])
),
"total_intake": self.get_filtered_counts(
Issue.objects.filter(**self.filters["base_filters"]).filter(
issue_intake__isnull=False
)
),
}
def get_work_items_stats(self) -> Dict[str, Dict[str, int]]:
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
return {
"total_work_items": self.get_filtered_counts(base_queryset),
"started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="started")
),
"backlog_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="backlog")
),
"un_started_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="unstarted")
),
"completed_work_items": self.get_filtered_counts(
base_queryset.filter(state__group="completed")
),
}
@allow_permission([ROLE.ADMIN, ROLE.MEMBER], level="WORKSPACE")
def get(self, request: HttpRequest, slug: str) -> Response:
self.initialize_workspace(slug, type="analytics")
tab = request.GET.get("tab", "overview")
if tab == "overview":
return Response(
self.get_overview_data(),
status=status.HTTP_200_OK,
)
elif tab == "work-items":
return Response(
self.get_work_items_stats(),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid tab"}, status=status.HTTP_400_BAD_REQUEST)
class AdvanceAnalyticsStatsEndpoint(AdvanceAnalyticsBaseView):
def get_project_issues_stats(self) -> QuerySet:
# Get the base queryset with workspace and project filters
base_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"]
base_queryset = base_queryset.filter(
created_at__date__gte=start_date, created_at__date__lte=end_date
)
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")
)
@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":
return Response(
self.get_project_issues_stats(),
status=status.HTTP_200_OK,
)
return Response({"message": "Invalid type"}, status=status.HTTP_400_BAD_REQUEST)
class AdvanceAnalyticsChartEndpoint(AdvanceAnalyticsBaseView):
def project_chart(self) -> List[Dict[str, Any]]:
# Get the base queryset with workspace and project filters
base_queryset = Issue.issue_objects.filter(**self.filters["base_filters"])
date_filter = {}
# Apply date range filter if available
if self.filters["chart_period_range"]:
start_date, end_date = self.filters["chart_period_range"]
date_filter = {
"created_at__date__gte": start_date,
"created_at__date__lte": end_date,
}
total_work_items = base_queryset.filter(**date_filter).count()
total_cycles = Cycle.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_modules = Module.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_intake = Issue.objects.filter(
issue_intake__isnull=False, **self.filters["base_filters"], **date_filter
).count()
total_members = WorkspaceMember.objects.filter(
workspace__slug=self._workspace_slug, is_active=True, **date_filter
).count()
total_pages = ProjectPage.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
total_views = IssueView.objects.filter(
**self.filters["base_filters"], **date_filter
).count()
data = {
"work_items": total_work_items,
"cycles": total_cycles,
"modules": total_modules,
"intake": total_intake,
"members": total_members,
"pages": total_pages,
"views": total_views,
}
return [
{
"key": key,
"name": key.replace("_", " ").title(),
"count": value or 0,
}
for key, value in data.items()
]
def work_item_completion_chart(self) -> Dict[str, Any]:
# Get the base queryset
queryset = (
Issue.issue_objects.filter(**self.filters["base_filters"])
.select_related("workspace", "state", "parent")
.prefetch_related(
"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
)
# Annotate by month and count
monthly_stats = (
queryset.annotate(month=TruncMonth("created_at"))
.values("month")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(completed_at__isnull=False)),
)
.order_by("month")
)
# Create dictionary of month -> counts
stats_dict = {
stat["month"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in monthly_stats
}
# Generate monthly data (ensure months with 0 count are included)
data = []
workspace = Workspace.objects.get(slug=self._workspace_slug)
start_date = workspace.created_at.date().replace(day=1)
# include the current date at the end
end_date = timezone.now().date()
last_month = end_date.replace(day=1)
current_month = start_date
while current_month <= last_month:
date_str = current_month.strftime("%Y-%m-%d")
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
data.append(
{
"key": date_str,
"name": date_str,
"count": stats[
"created_count"
], # <- Total created issues in that month
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
# Move to next month
if current_month.month == 12:
current_month = current_month.replace(year=current_month.year + 1, month=1)
else:
current_month = current_month.replace(month=current_month.month + 1)
schema = {
"completed_issues": "completed_issues",
"created_issues": "created_issues",
}
return {"data": data, "schema": schema}
@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", "projects")
group_by = request.GET.get("group_by", None)
x_axis = request.GET.get("x_axis", "PRIORITY")
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")
.prefetch_related(
"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
)
return Response(
build_analytics_chart(queryset, x_axis, group_by),
status=status.HTTP_200_OK,
)
elif type == "work-items":
return Response(
self.work_item_completion_chart(),
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,
)

View File

@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Q
from django.db.models import Q, QuerySet
# Third party imports
from rest_framework import status
@@ -12,6 +12,95 @@ from plane.utils.issue_search import search_issues
class IssueSearchEndpoint(BaseAPIView):
def filter_issues_by_project(self, project_id: int, issues: QuerySet) -> QuerySet:
"""
Filter issues by project
"""
issues = issues.filter(project_id=project_id)
return issues
def search_issues_by_query(self, query: str, issues: QuerySet) -> QuerySet:
"""
Search issues by query
"""
issues = search_issues(query, issues)
return issues
def search_issues_and_excluding_parent(
self, issues: QuerySet, issue_id: str
) -> QuerySet:
"""
Search issues and epics by query excluding the parent
"""
issue = Issue.issue_objects.filter(pk=issue_id).first()
if issue:
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
return issues
def filter_issues_excluding_related_issues(
self, issue_id: str, issues: QuerySet
) -> QuerySet:
"""
Filter issues excluding related issues
"""
issue = Issue.issue_objects.filter(pk=issue_id).first()
related_issue_ids = (
IssueRelation.objects.filter(Q(related_issue=issue) | Q(issue=issue))
.values_list("issue_id", "related_issue_id")
.distinct()
)
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
if issue:
issues = issues.filter(~Q(pk=issue_id), ~Q(pk__in=related_issue_ids))
return issues
def filter_root_issues_only(self, issue_id: str, issues: QuerySet) -> QuerySet:
"""
Filter root issues only
"""
issue = Issue.issue_objects.filter(pk=issue_id).first()
if issue:
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
return issues
def exclude_issues_in_cycles(self, issues: QuerySet) -> QuerySet:
"""
Exclude issues in cycles
"""
issues = issues.exclude(
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
)
return issues
def exclude_issues_in_module(self, issues: QuerySet, module: str) -> QuerySet:
"""
Exclude issues in a module
"""
issues = issues.exclude(
Q(issue_module__module=module) & Q(issue_module__deleted_at__isnull=True)
)
return issues
def filter_issues_without_target_date(self, issues: QuerySet) -> QuerySet:
"""
Filter issues without a target date
"""
issues = issues.filter(target_date__isnull=True)
return issues
def get(self, request, slug, project_id):
query = request.query_params.get("search", False)
workspace_search = request.query_params.get("workspace_search", "false")
@@ -21,7 +110,6 @@ class IssueSearchEndpoint(BaseAPIView):
module = request.query_params.get("module", False)
sub_issue = request.query_params.get("sub_issue", "false")
target_date = request.query_params.get("target_date", True)
issue_id = request.query_params.get("issue_id", False)
issues = Issue.issue_objects.filter(
@@ -32,52 +120,28 @@ class IssueSearchEndpoint(BaseAPIView):
)
if workspace_search == "false":
issues = issues.filter(project_id=project_id)
issues = self.filter_issues_by_project(project_id, issues)
if query:
issues = search_issues(query, issues)
issues = self.search_issues_by_query(query, issues)
if parent == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first()
if issue:
issues = issues.filter(
~Q(pk=issue_id), ~Q(pk=issue.parent_id), ~Q(parent_id=issue_id)
)
issues = self.search_issues_and_excluding_parent(issues, issue_id)
if issue_relation == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first()
related_issue_ids = IssueRelation.objects.filter(
Q(related_issue=issue) | Q(issue=issue)
).values_list(
"issue_id", "related_issue_id"
).distinct()
issues = self.filter_issues_excluding_related_issues(issue_id, issues)
related_issue_ids = [item for sublist in related_issue_ids for item in sublist]
if issue:
issues = issues.filter(
~Q(pk=issue_id),
~Q(pk__in=related_issue_ids),
)
if sub_issue == "true" and issue_id:
issue = Issue.issue_objects.filter(pk=issue_id).first()
if issue:
issues = issues.filter(~Q(pk=issue_id), parent__isnull=True)
if issue.parent:
issues = issues.filter(~Q(pk=issue.parent_id))
issues = self.filter_root_issues_only(issue_id, issues)
if cycle == "true":
issues = issues.exclude(
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
)
issues = self.exclude_issues_in_cycles(issues)
if module:
issues = issues.exclude(
Q(issue_module__module=module)
& Q(issue_module__deleted_at__isnull=True)
)
issues = self.exclude_issues_in_module(issues, module)
if target_date == "none":
issues = issues.filter(target_date__isnull=True)
issues = self.filter_issues_without_target_date(issues)
if ProjectMember.objects.filter(
project_id=project_id, member=self.request.user, is_active=True, role=5

View File

@@ -21,7 +21,9 @@ def base_host(
# Admin redirection
if is_admin:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", "/god-mode/")
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str):
admin_base_path = "/god-mode/"
if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):
@@ -34,7 +36,9 @@ def base_host(
# Space redirection
if is_space:
space_base_path = getattr(settings, "SPACE_BASE_PATH", "/spaces/")
space_base_path = getattr(settings, "SPACE_BASE_PATH", None)
if not isinstance(space_base_path, str):
space_base_path = "/spaces/"
if not space_base_path.startswith("/"):
space_base_path = "/" + space_base_path
if not space_base_path.endswith("/"):

View File

@@ -464,3 +464,32 @@ def analytic_export_task(email, data, slug):
except Exception as e:
log_exception(e)
return
@shared_task
def export_analytics_to_csv_email(data, headers, keys, email, slug):
try:
"""
Prepares a CSV from data and sends it as an email attachment.
Parameters:
- data: List of dictionaries (e.g. from .values())
- headers: List of CSV column headers
- keys: Keys to extract from each data item (dict)
- email: Email address to send to
- slug: Used for the filename
"""
# Prepare rows: header + data rows
rows = [headers]
for item in data:
row = [item.get(key, "") for key in keys]
rows.append(row)
# Generate CSV buffer
csv_buffer = generate_csv_from_rows(rows)
# Send email with CSV attachment
send_export_email(email=email, slug=slug, csv_buffer=csv_buffer, rows=rows)
except Exception as e:
log_exception(e)
return

View File

@@ -3,34 +3,48 @@ 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
# Django imports
from django.conf import settings
from django.utils import timezone
from openpyxl import Workbook
from django.db.models import F, Prefetch
from collections import defaultdict
# Module imports
from plane.db.models import ExporterHistory, Issue
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)
@@ -41,11 +55,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
@@ -58,7 +78,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:
@@ -67,8 +90,11 @@ def create_zip_file(files):
zip_buffer.seek(0)
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"
)
@@ -150,75 +176,85 @@ 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"],
f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
issue["project_name"],
issue["name"],
issue["description_stripped"],
issue["state__name"],
issue["description"],
issue["state_name"],
dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]),
issue["priority"],
(
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
(
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else ""
),
issue["labels__name"] if issue["labels__name"] else "",
issue["issue_cycle__cycle__name"],
dateConverter(issue["issue_cycle__cycle__start_date"]),
dateConverter(issue["issue_cycle__cycle__end_date"]),
issue["issue_module__module__name"],
dateConverter(issue["issue_module__module__start_date"]),
dateConverter(issue["issue_module__module__target_date"]),
issue["created_by"],
", ".join(issue["labels"]) if issue["labels"] else "",
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 ""
),
issue["estimate"] if issue["estimate"] else "",
", ".join(issue["link"]) if issue["link"] else "",
", ".join(issue["assignees"]) if issue["assignees"] else "",
issue["subscribers_count"] if issue["subscribers_count"] else "",
issue["attachment_count"] if issue["attachment_count"] else "",
", ".join(issue["attachment_links"]) if issue["attachment_links"] else "",
]
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"],
"ID": f"""{issue["project_identifier"]}-{issue["sequence_id"]}""",
"Project": issue["project_name"],
"Name": issue["name"],
"Description": issue["description_stripped"],
"State": issue["state__name"],
"Description": issue["description"],
"State": issue["state_name"],
"Start Date": dateConverter(issue["start_date"]),
"Target Date": dateConverter(issue["target_date"]),
"Priority": issue["priority"],
"Created By": (
f"{issue['created_by__first_name']} {issue['created_by__last_name']}"
if issue["created_by__first_name"] and issue["created_by__last_name"]
else ""
),
"Assignee": (
f"{issue['assignees__first_name']} {issue['assignees__last_name']}"
if issue["assignees__first_name"] and issue["assignees__last_name"]
else ""
),
"Labels": issue["labels__name"] if issue["labels__name"] else "",
"Cycle Name": issue["issue_cycle__cycle__name"],
"Cycle Start Date": dateConverter(issue["issue_cycle__cycle__start_date"]),
"Cycle End Date": dateConverter(issue["issue_cycle__cycle__end_date"]),
"Module Name": issue["issue_module__module__name"],
"Module Start Date": dateConverter(issue["issue_module__module__start_date"]),
"Module Target Date": dateConverter(issue["issue_module__module__target_date"]),
"Created By": (f"{issue['created_by']}" if issue["created_by"] else ""),
"Assignee": issue["assignees"],
"Labels": issue["labels"],
"Cycle Name": issue["cycle_name"],
"Cycle Start Date": issue["cycle_start_date"],
"Cycle End Date": issue["cycle_end_date"],
"Module Name": issue["module_name"],
"Created At": dateTimeConverter(issue["created_at"]),
"Updated At": dateTimeConverter(issue["updated_at"]),
"Completed At": dateTimeConverter(issue["completed_at"]),
"Archived At": dateTimeConverter(issue["archived_at"]),
"Comments": issue["comments"],
"Estimate": issue["estimate"],
"Link": issue["link"],
"Subscribers Count": issue["subscribers_count"],
"Attachment Count": issue["attachment_count"],
"Attachment Links": issue["attachment_links"],
}
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
@@ -247,7 +283,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,
@@ -269,7 +308,7 @@ 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.
"""
@@ -281,7 +320,10 @@ def generate_csv(header, project_id, issues, files):
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)
@@ -290,68 +332,157 @@ 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)
update_table_row(rows, row)
xlsx_file = create_xlsx_file(rows)
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,
project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related("project", "workspace", "state", "parent", "created_by")
.prefetch_related(
"assignees", "labels", "issue_cycle__cycle", "issue_module__module"
)
.values(
"id",
"project__identifier",
"project__name",
"project__id",
"sequence_id",
"name",
"description_stripped",
"priority",
"start_date",
"target_date",
"state__name",
"created_at",
"updated_at",
"completed_at",
"archived_at",
"issue_cycle__cycle__name",
"issue_cycle__cycle__start_date",
"issue_cycle__cycle__end_date",
"issue_module__module__name",
"issue_module__module__start_date",
"issue_module__module__target_date",
"created_by__first_name",
"created_by__last_name",
"assignees__first_name",
"assignees__last_name",
"labels__name",
)
Issue.objects.filter(
workspace__id=workspace_id,
project_id__in=project_ids,
project__project_projectmember__member=exporter_instance.initiated_by_id,
project__project_projectmember__is_active=True,
project__archived_at__isnull=True,
)
.select_related(
"project",
"workspace",
"state",
"parent",
"created_by",
"estimate_point",
)
.prefetch_related(
"labels",
"issue_cycle__cycle",
"issue_module__module",
"issue_comments",
"assignees",
Prefetch(
"assignees",
queryset=User.objects.only("first_name", "last_name").distinct(),
to_attr="assignee_details",
),
Prefetch(
"labels",
queryset=Label.objects.only("name").distinct(),
to_attr="label_details",
),
"issue_subscribers",
"issue_link",
)
.order_by("project__identifier", "sequence_id")
.distinct()
)
# CSV header
# Get the attachments for the issues
file_assets = FileAsset.objects.filter(
issue_id__in=workspace_issues.values_list("id", flat=True),
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT
).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, [])
issue_data = {
"id": issue.id,
"project_identifier": issue.project.identifier,
"project_name": issue.project.name,
"project_id": issue.project.id,
"sequence_id": issue.sequence_id,
"name": issue.name,
"description": issue.description_stripped,
"priority": issue.priority,
"start_date": issue.start_date,
"target_date": issue.target_date,
"state_name": issue.state.name if issue.state else None,
"created_at": issue.created_at,
"updated_at": issue.updated_at,
"completed_at": issue.completed_at,
"archived_at": issue.archived_at,
"module_name": [
module.module.name for module in issue.issue_module.all()
],
"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": get_created_by(comment),
}
for comment in issue.issue_comments.all()
],
"estimate": issue.estimate_point.estimate.name
if issue.estimate_point and issue.estimate_point.estimate
else "",
"link": [link.url for link in issue.issue_link.all()],
"assignees": [
f"{assignee.first_name} {assignee.last_name}"
for assignee in issue.assignee_details
],
"subscribers_count": issue.issue_subscribers.count(),
"attachment_count": len(attachments),
"attachment_links": [
f"/api/assets/v2/workspaces/{issue.workspace.slug}/projects/{issue.project_id}/issues/{issue.id}/attachments/{asset}/"
for asset in attachments
],
}
# 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)
# CSV header
header = [
"ID",
"Project",
@@ -362,20 +493,25 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
"Target Date",
"Priority",
"Created By",
"Assignee",
"Labels",
"Cycle Name",
"Cycle Start Date",
"Cycle End Date",
"Module Name",
"Module Start Date",
"Module Target Date",
"Created At",
"Updated At",
"Completed At",
"Archived At",
"Comments",
"Estimate",
"Link",
"Assignees",
"Subscribers Count",
"Attachment Count",
"Attachment Links",
]
# Map the provider to the function
EXPORTER_MAPPER = {
"csv": generate_csv,
"json": generate_json,
@@ -384,8 +520,13 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
files = []
if multiple:
project_dict = defaultdict(list)
for issue in issues_data:
project_dict[str(issue["project_id"])].append(issue)
for project_id in project_ids:
issues = workspace_issues.filter(project__id=project_id)
issues = project_dict.get(str(project_id), [])
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, project_id, issues, files)
@@ -393,7 +534,7 @@ def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, s
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, workspace_id, workspace_issues, files)
exporter(header, workspace_id, issues_data, files)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)

View File

@@ -18,22 +18,28 @@ class BaseModel(AuditModel):
class Meta:
abstract = True
def save(self, *args, **kwargs):
user = get_current_user()
def save(self, *args, created_by_id=None, disable_auto_set_user=False, **kwargs):
if not disable_auto_set_user:
# Check if created_by_id is provided
if created_by_id:
self.created_by_id = created_by_id
else:
user = get_current_user()
if user is None or user.is_anonymous:
self.created_by = None
self.updated_by = None
super(BaseModel, self).save(*args, **kwargs)
else:
# Check if the model is being created or updated
if self._state.adding:
# If created only set created_by value: set updated_by to None
self.created_by = user
self.updated_by = None
# If updated only set updated_by value don't touch created_by
self.updated_by = user
super(BaseModel, self).save(*args, **kwargs)
if user is None or user.is_anonymous:
self.created_by = None
self.updated_by = None
else:
# Check if the model is being created or updated
if self._state.adding:
# If creating, set created_by and leave updated_by as None
self.created_by = user
self.updated_by = None
else:
# If updating, set updated_by only
self.updated_by = user
super(BaseModel, self).save(*args, **kwargs)
def __str__(self):
return str(self.id)

View File

@@ -2,12 +2,10 @@
from django.conf import settings
from django.urls import include, path, re_path
from django.views.generic import TemplateView
handler404 = "plane.app.views.error_404.custom_404_view"
urlpatterns = [
path("", TemplateView.as_view(template_name="index.html")),
path("api/", include("plane.app.urls")),
path("api/public/", include("plane.space.urls")),
path("api/instances/", include("plane.license.urls")),

View File

@@ -0,0 +1,205 @@
from typing import Dict, Any, Tuple, Optional, List, Union
# Django imports
from django.db.models import (
Count,
F,
QuerySet,
Aggregate,
)
from plane.db.models import Issue
from rest_framework.exceptions import ValidationError
x_axis_mapper = {
"STATES": "STATES",
"STATE_GROUPS": "STATE_GROUPS",
"LABELS": "LABELS",
"ASSIGNEES": "ASSIGNEES",
"ESTIMATE_POINTS": "ESTIMATE_POINTS",
"CYCLES": "CYCLES",
"MODULES": "MODULES",
"PRIORITY": "PRIORITY",
"START_DATE": "START_DATE",
"TARGET_DATE": "TARGET_DATE",
"CREATED_AT": "CREATED_AT",
"COMPLETED_AT": "COMPLETED_AT",
"CREATED_BY": "CREATED_BY",
}
def get_y_axis_filter(y_axis: str) -> Dict[str, Any]:
filter_mapping = {
"WORK_ITEM_COUNT": {"id": F("id")},
}
return filter_mapping.get(y_axis, {})
def get_x_axis_field() -> Dict[str, Tuple[str, str, Optional[Dict[str, Any]]]]:
return {
"STATES": ("state__id", "state__name", None),
"STATE_GROUPS": ("state__group", "state__group", None),
"LABELS": (
"labels__id",
"labels__name",
{"label_issue__deleted_at__isnull": True},
),
"ASSIGNEES": (
"assignees__id",
"assignees__display_name",
{"issue_assignee__deleted_at__isnull": True},
),
"ESTIMATE_POINTS": ("estimate_point__value", "estimate_point__key", None),
"CYCLES": (
"issue_cycle__cycle_id",
"issue_cycle__cycle__name",
{"issue_cycle__deleted_at__isnull": True},
),
"MODULES": (
"issue_module__module_id",
"issue_module__module__name",
{"issue_module__deleted_at__isnull": True},
),
"PRIORITY": ("priority", "priority", None),
"START_DATE": ("start_date", "start_date", None),
"TARGET_DATE": ("target_date", "target_date", None),
"CREATED_AT": ("created_at__date", "created_at__date", None),
"COMPLETED_AT": ("completed_at__date", "completed_at__date", None),
"CREATED_BY": ("created_by_id", "created_by__display_name", None),
}
def process_grouped_data(
data: List[Dict[str, Any]],
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
response = {}
schema = {}
for item in data:
key = item["key"]
if key not in response:
response[key] = {
"key": key if key else "none",
"name": (
item.get("display_name", key)
if item.get("display_name", key)
else "None"
),
"count": 0,
}
group_key = str(item["group_key"]) if item["group_key"] else "none"
schema[group_key] = item.get("group_name", item["group_key"])
schema[group_key] = schema[group_key] if schema[group_key] else "None"
response[key][group_key] = response[key].get(group_key, 0) + item["count"]
response[key]["count"] += item["count"]
return list(response.values()), schema
def build_number_chart_response(
queryset: QuerySet[Issue],
y_axis_filter: Dict[str, Any],
y_axis: str,
aggregate_func: Aggregate,
) -> List[Dict[str, Any]]:
count = (
queryset.filter(**y_axis_filter).aggregate(total=aggregate_func).get("total", 0)
)
return [{"key": y_axis, "name": y_axis, "count": count}]
def build_grouped_chart_response(
queryset: QuerySet[Issue],
id_field: str,
name_field: str,
group_field: str,
group_name_field: str,
aggregate_func: Aggregate,
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
data = (
queryset.annotate(
key=F(id_field),
group_key=F(group_field),
group_name=F(group_name_field),
display_name=F(name_field) if name_field else F(id_field),
)
.values("key", "group_key", "group_name", "display_name")
.annotate(count=aggregate_func)
.order_by("-count")
)
return process_grouped_data(data)
def build_simple_chart_response(
queryset: QuerySet, id_field: str, name_field: str, aggregate_func: Aggregate
) -> List[Dict[str, Any]]:
data = (
queryset.annotate(
key=F(id_field), display_name=F(name_field) if name_field else F(id_field)
)
.values("key", "display_name")
.annotate(count=aggregate_func)
.order_by("key")
)
return [
{
"key": item["key"] if item["key"] else "None",
"name": item["display_name"] if item["display_name"] else "None",
"count": item["count"],
}
for item in data
]
def build_analytics_chart(
queryset: QuerySet[Issue],
x_axis: str,
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}")
# Validate group_by
if group_by and group_by not in x_axis_mapper:
raise ValidationError(f"Invalid group_by field: {group_by}")
field_mapping = get_x_axis_field()
id_field, name_field, additional_filter = field_mapping.get(
x_axis, (None, None, {})
)
group_field, group_name_field, group_additional_filter = field_mapping.get(
group_by, (None, None, {})
)
# Apply additional filters if they exist
if additional_filter or {}:
queryset = queryset.filter(**additional_filter)
if group_additional_filter or {}:
queryset = queryset.filter(**group_additional_filter)
aggregate_func = Count("id", distinct=True)
if group_field:
response, schema = build_grouped_chart_response(
queryset,
id_field,
name_field,
group_field,
group_name_field,
aggregate_func,
)
else:
response = build_simple_chart_response(
queryset, id_field, name_field, aggregate_func
)
schema = {}
return {"data": response, "schema": schema}

View File

@@ -0,0 +1,201 @@
from datetime import datetime, timedelta, date
from django.utils import timezone
from typing import Dict, Optional, List, Union, Tuple, Any
from plane.db.models import User
def get_analytics_date_range(
date_filter: Optional[str] = None,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
) -> Optional[Dict[str, Dict[str, datetime]]]:
"""
Get date range for analytics with current and previous periods for comparison.
Returns a dictionary with current and previous date ranges.
Args:
date_filter (str): The type of date filter to apply
start_date (str): Start date for custom range (format: YYYY-MM-DD)
end_date (str): End date for custom range (format: YYYY-MM-DD)
Returns:
dict: Dictionary containing current and previous date ranges
"""
if not date_filter:
return None
today = timezone.now().date()
if date_filter == "yesterday":
yesterday = today - timedelta(days=1)
return {
"current": {
"gte": datetime.combine(yesterday, datetime.min.time()),
"lte": datetime.combine(yesterday, datetime.max.time()),
}
}
elif date_filter == "last_7_days":
return {
"current": {
"gte": datetime.combine(today - timedelta(days=7), datetime.min.time()),
"lte": datetime.combine(today, datetime.max.time()),
},
"previous": {
"gte": datetime.combine(
today - timedelta(days=14), datetime.min.time()
),
"lte": datetime.combine(today - timedelta(days=8), datetime.max.time()),
},
}
elif date_filter == "last_30_days":
return {
"current": {
"gte": datetime.combine(
today - timedelta(days=30), datetime.min.time()
),
"lte": datetime.combine(today, datetime.max.time()),
},
"previous": {
"gte": datetime.combine(
today - timedelta(days=60), datetime.min.time()
),
"lte": datetime.combine(
today - timedelta(days=31), datetime.max.time()
),
},
}
elif date_filter == "last_3_months":
return {
"current": {
"gte": datetime.combine(
today - timedelta(days=90), datetime.min.time()
),
"lte": datetime.combine(today, datetime.max.time()),
},
"previous": {
"gte": datetime.combine(
today - timedelta(days=180), datetime.min.time()
),
"lte": datetime.combine(
today - timedelta(days=91), datetime.max.time()
),
},
}
elif date_filter == "custom" and start_date and end_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d").date()
end = datetime.strptime(end_date, "%Y-%m-%d").date()
return {
"current": {
"gte": datetime.combine(start, datetime.min.time()),
"lte": datetime.combine(end, datetime.max.time()),
}
}
except (ValueError, TypeError):
return None
return None
def get_chart_period_range(
date_filter: Optional[str] = None,
) -> Optional[Tuple[date, date]]:
"""
Get date range for chart visualization.
Returns a tuple of (start_date, end_date) for the specified period.
Args:
date_filter (str): The type of date filter to apply. Options are:
- "yesterday": Yesterday's date
- "last_7_days": Last 7 days
- "last_30_days": Last 30 days
- "last_3_months": Last 90 days
Defaults to "last_7_days" if not specified or invalid.
Returns:
tuple: A tuple containing (start_date, end_date) as date objects
"""
if not date_filter:
return None
today = timezone.now().date()
period_ranges = {
"yesterday": (
today - timedelta(days=1),
today - timedelta(days=1),
),
"last_7_days": (today - timedelta(days=7), today),
"last_30_days": (today - timedelta(days=30), today),
"last_3_months": (today - timedelta(days=90), today),
}
return period_ranges.get(date_filter, None)
def get_analytics_filters(
slug: str,
user: User,
type: str,
date_filter: Optional[str] = None,
project_ids: Optional[Union[str, List[str]]] = None,
) -> Dict[str, Any]:
"""
Get combined project and date filters for analytics endpoints
Args:
slug: The workspace slug
user: The current user
type: The type of filter ("analytics" or "chart")
date_filter: Optional date filter string
project_ids: Optional list of project IDs or comma-separated string of project IDs
Returns:
dict: A dictionary containing:
- base_filters: Base filters for the workspace and user
- project_filters: Project-specific filters
- analytics_date_range: Date range filters for analytics comparison
- chart_period_range: Date range for chart visualization
"""
# Get project IDs from request
if project_ids and isinstance(project_ids, str):
project_ids = [str(project_id) for project_id in project_ids.split(",")]
# Base filters for workspace and user
base_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
project_filters = {
"workspace__slug": slug,
"project_projectmember__member": user,
"project_projectmember__is_active": True,
"project__deleted_at__isnull": True,
"project__archived_at__isnull": True,
}
# Add project IDs to filters if provided
if project_ids:
base_filters["project_id__in"] = project_ids
project_filters["id__in"] = project_ids
# Initialize date range variables
analytics_date_range = None
chart_period_range = None
# Get date range filters based on type
if type == "analytics":
analytics_date_range = get_analytics_date_range(date_filter)
elif type == "chart":
chart_period_range = get_chart_period_range(date_filter)
return {
"base_filters": base_filters,
"project_filters": project_filters,
"analytics_date_range": analytics_date_range,
"chart_period_range": chart_period_range,
}

View File

@@ -25,7 +25,9 @@ def base_host(
# Admin redirection
if is_admin:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", "/god-mode/")
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str):
admin_base_path = "/god-mode/"
if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):
@@ -38,7 +40,9 @@ def base_host(
# Space redirection
if is_space:
space_base_path = getattr(settings, "SPACE_BASE_PATH", "/spaces/")
space_base_path = getattr(settings, "SPACE_BASE_PATH", None)
if not isinstance(space_base_path, str):
space_base_path = "/spaces/"
if not space_base_path.startswith("/"):
space_base_path = "/" + space_base_path
if not space_base_path.endswith("/"):

View File

@@ -1,4 +1,4 @@
from django.urls import path
from django.views.generic import TemplateView
from plane.web.views import robots_txt, health_check
urlpatterns = [path("about/", TemplateView.as_view(template_name="about.html"))]
urlpatterns = [path("robots.txt", robots_txt), path("", health_check)]

View File

@@ -1 +1,9 @@
# Create your views here.
from django.http import HttpResponse, JsonResponse
def health_check(request):
return JsonResponse({"status": "OK"})
def robots_txt(request):
return HttpResponse("User-agent: *\nDisallow: /", content_type="text/plain")

View File

@@ -1,9 +0,0 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<h1>Hello from plane!</h1>
<p>Made with Django</p>
{% endblock content %}

View File

@@ -1,5 +0,0 @@
{% extends 'base.html' %} {% load static %} {% block content %}
<div class="container mt-5">
<h1>Hello from plane!</h1>
</div>
{% endblock content %}

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

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"

View File

@@ -0,0 +1,105 @@
import { TAnalyticsTabsV2Base } from "@plane/types";
import { ChartXAxisProperty, ChartYAxisMetric } from "../chart";
export const insightsFields: Record<TAnalyticsTabsV2Base, string[]> = {
overview: [
"total_users",
"total_admins",
"total_members",
"total_guests",
"total_projects",
"total_work_items",
"total_cycles",
"total_intake",
],
"work-items": [
"total_work_items",
"started_work_items",
"backlog_work_items",
"un_started_work_items",
"completed_work_items",
],
};
export const ANALYTICS_V2_DURATION_FILTER_OPTIONS = [
{
name: "Yesterday",
value: "yesterday",
},
{
name: "Last 7 days",
value: "last_7_days",
},
{
name: "Last 30 days",
value: "last_30_days",
},
{
name: "Last 3 months",
value: "last_3_months",
},
];
export const ANALYTICS_V2_X_AXIS_VALUES: { value: ChartXAxisProperty; label: string }[] = [
{
value: ChartXAxisProperty.STATES,
label: "State name",
},
{
value: ChartXAxisProperty.STATE_GROUPS,
label: "State group",
},
{
value: ChartXAxisProperty.PRIORITY,
label: "Priority",
},
{
value: ChartXAxisProperty.LABELS,
label: "Label",
},
{
value: ChartXAxisProperty.ASSIGNEES,
label: "Assignee",
},
{
value: ChartXAxisProperty.ESTIMATE_POINTS,
label: "Estimate point",
},
{
value: ChartXAxisProperty.CYCLES,
label: "Cycle",
},
{
value: ChartXAxisProperty.MODULES,
label: "Module",
},
{
value: ChartXAxisProperty.COMPLETED_AT,
label: "Completed date",
},
{
value: ChartXAxisProperty.TARGET_DATE,
label: "Due date",
},
{
value: ChartXAxisProperty.START_DATE,
label: "Start date",
},
{
value: ChartXAxisProperty.CREATED_AT,
label: "Created date",
},
];
export const ANALYTICS_V2_Y_AXIS_VALUES: { value: ChartYAxisMetric; label: string }[] = [
{
value: ChartYAxisMetric.WORK_ITEM_COUNT,
label: "Work item",
},
{
value: ChartYAxisMetric.ESTIMATE_POINT_COUNT,
label: "Estimate",
},
];
export const ANALYTICS_V2_DATE_KEYS = ["completed_at", "target_date", "start_date", "created_at"];

View File

@@ -0,0 +1 @@
export * from "./common"

View File

@@ -1,2 +1,157 @@
import { TChartColorScheme } from "@plane/types";
export const LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
export const AXIS_LABEL_CLASSNAME = "uppercase text-custom-text-300/60 text-sm tracking-wide";
export enum ChartXAxisProperty {
STATES = "STATES",
STATE_GROUPS = "STATE_GROUPS",
LABELS = "LABELS",
ASSIGNEES = "ASSIGNEES",
ESTIMATE_POINTS = "ESTIMATE_POINTS",
CYCLES = "CYCLES",
MODULES = "MODULES",
PRIORITY = "PRIORITY",
START_DATE = "START_DATE",
TARGET_DATE = "TARGET_DATE",
CREATED_AT = "CREATED_AT",
COMPLETED_AT = "COMPLETED_AT",
CREATED_BY = "CREATED_BY",
WORK_ITEM_TYPES = "WORK_ITEM_TYPES",
PROJECTS = "PROJECTS",
EPICS = "EPICS",
}
export enum ChartYAxisMetric {
WORK_ITEM_COUNT = "WORK_ITEM_COUNT",
ESTIMATE_POINT_COUNT = "ESTIMATE_POINT_COUNT",
PENDING_WORK_ITEM_COUNT = "PENDING_WORK_ITEM_COUNT",
COMPLETED_WORK_ITEM_COUNT = "COMPLETED_WORK_ITEM_COUNT",
IN_PROGRESS_WORK_ITEM_COUNT = "IN_PROGRESS_WORK_ITEM_COUNT",
WORK_ITEM_DUE_THIS_WEEK_COUNT = "WORK_ITEM_DUE_THIS_WEEK_COUNT",
WORK_ITEM_DUE_TODAY_COUNT = "WORK_ITEM_DUE_TODAY_COUNT",
BLOCKED_WORK_ITEM_COUNT = "BLOCKED_WORK_ITEM_COUNT",
}
export enum ChartXAxisDateGrouping {
DAY = "DAY",
WEEK = "WEEK",
MONTH = "MONTH",
YEAR = "YEAR",
}
export const TO_CAPITALIZE_PROPERTIES: ChartXAxisProperty[] = [
ChartXAxisProperty.PRIORITY,
ChartXAxisProperty.STATE_GROUPS,
];
export const CHART_X_AXIS_DATE_PROPERTIES: ChartXAxisProperty[] = [
ChartXAxisProperty.START_DATE,
ChartXAxisProperty.TARGET_DATE,
ChartXAxisProperty.CREATED_AT,
ChartXAxisProperty.COMPLETED_AT,
];
export enum EChartModels {
BASIC = "BASIC",
STACKED = "STACKED",
GROUPED = "GROUPED",
MULTI_LINE = "MULTI_LINE",
COMPARISON = "COMPARISON",
PROGRESS = "PROGRESS",
}
export const CHART_COLOR_PALETTES: {
key: TChartColorScheme;
i18n_label: string;
light: string[];
dark: string[];
}[] = [
{
key: "modern",
i18n_label: "dashboards.widget.color_palettes.modern",
light: [
"#6172E8",
"#8B6EDB",
"#E05F99",
"#29A383",
"#CB8A37",
"#3AA7C1",
"#F1B24A",
"#E84855",
"#50C799",
"#B35F9E",
],
dark: [
"#6B7CDE",
"#8E9DE6",
"#D45D9E",
"#2EAF85",
"#D4A246",
"#29A7C1",
"#B89F6A",
"#D15D64",
"#4ED079",
"#A169A4",
],
},
{
key: "horizon",
i18n_label: "dashboards.widget.color_palettes.horizon",
light: [
"#E76E50",
"#289D90",
"#F3A362",
"#E9C368",
"#264753",
"#8A6FA0",
"#5B9EE5",
"#7CC474",
"#BA7DB5",
"#CF8640",
],
dark: [
"#E05A3A",
"#1D8A7E",
"#D98B4D",
"#D1AC50",
"#3A6B7C",
"#7D6297",
"#4D8ACD",
"#569C64",
"#C16A8C",
"#B77436",
],
},
{
key: "earthen",
i18n_label: "dashboards.widget.color_palettes.earthen",
light: [
"#386641",
"#6A994E",
"#A7C957",
"#E97F4E",
"#BC4749",
"#9E2A2B",
"#80CED1",
"#5C3E79",
"#526EAB",
"#6B5B95",
],
dark: [
"#497752",
"#7BAA5F",
"#B8DA68",
"#FA905F",
"#CD585A",
"#AF3B3C",
"#91DFE2",
"#6D4F8A",
"#637FBC",
"#7C6CA6",
],
},
];

View File

@@ -33,3 +33,4 @@ export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./icon";
export * from "./analytics-v2";

View File

@@ -71,3 +71,53 @@ export const PROFILE_ADMINS_TAB = [
selected: "/activity/",
},
];
/**
* @description The start of the week for the user
* @enum {number}
*/
export enum EStartOfTheWeek {
SUNDAY = 0,
MONDAY = 1,
TUESDAY = 2,
WEDNESDAY = 3,
THURSDAY = 4,
FRIDAY = 5,
SATURDAY = 6,
}
/**
* @description The options for the start of the week
* @type {Array<{value: EStartOfTheWeek, label: string}>}
* @constant
*/
export const START_OF_THE_WEEK_OPTIONS = [
{
value: EStartOfTheWeek.SUNDAY,
label: "Sunday",
},
{
value: EStartOfTheWeek.MONDAY,
label: "Monday",
},
{
value: EStartOfTheWeek.TUESDAY,
label: "Tuesday",
},
{
value: EStartOfTheWeek.WEDNESDAY,
label: "Wednesday",
},
{
value: EStartOfTheWeek.THURSDAY,
label: "Thursday",
},
{
value: EStartOfTheWeek.FRIDAY,
label: "Friday",
},
{
value: EStartOfTheWeek.SATURDAY,
label: "Saturday",
},
];

View File

@@ -1,3 +1,4 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
@@ -77,4 +78,5 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [
},
];
export const DISPLAY_WORKFLOW_PRO_CTA = false;

View File

@@ -1,9 +1,10 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions } from "@/types";
import { TExtensions, TFileHandler } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {

View File

@@ -8,5 +8,55 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
wideLayout: false,
};
export const ACCEPTED_FILE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
export const ACCEPTED_FILE_EXTENSIONS = ACCEPTED_FILE_MIME_TYPES.map((type) => `.${type.split("/")[1]}`);
export const ACCEPTED_IMAGE_MIME_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif"];
export const ACCEPTED_ATTACHMENT_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/svg+xml",
"image/webp",
"image/tiff",
"image/bmp",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"application/rtf",
"audio/mpeg",
"audio/wav",
"audio/ogg",
"audio/midi",
"audio/x-midi",
"audio/aac",
"audio/flac",
"audio/x-m4a",
"video/mp4",
"video/mpeg",
"video/ogg",
"video/webm",
"video/quicktime",
"video/x-msvideo",
"video/x-ms-wmv",
"application/zip",
"application/x-rar-compressed",
"application/x-tar",
"application/gzip",
"model/gltf-binary",
"model/gltf+json",
"application/octet-stream",
"font/ttf",
"font/otf",
"font/woff",
"font/woff2",
"text/css",
"text/javascript",
"application/json",
"text/xml",
"text/csv",
"application/xml",
];

View File

@@ -3,11 +3,11 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config";
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// extensions
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// hooks
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
maxFileSize: number;
@@ -41,7 +41,9 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
if (!imageEntityId) return;
setIsUploaded(true);
// Update the node view's src attribute post upload
updateAttributes({ src: url });
updateAttributes({
src: url,
});
imageComponentImageFileMap?.delete(imageEntityId);
const pos = getPos();
@@ -51,7 +53,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
// only if the cursor is at the current image component, manipulate
// the cursor position
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
if (currentNode && currentNode.type.name === node.type.name && currentNode.attrs.src === url) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
@@ -68,17 +70,23 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
blockId: imageEntityId ?? "",
editor,
loadImageFromFileSystem,
const { isUploading: isImageBeingUploaded, uploadFile } = useUploader({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
// @ts-expect-error - TODO: fix typings, and don't remove await from here for now
editorCommand: async (file) => await editor?.commands.uploadImage(imageEntityId, file),
handleProgressStatus: (isUploading) => {
editor.storage.imageComponent.uploadInProgress = isUploading;
},
loadFileFromFileSystem: loadImageFromFileSystem,
maxFileSize,
onUpload,
});
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
maxFileSize,
pos: getPos(),
type: "image",
uploader: uploadFile,
});
@@ -110,11 +118,13 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
if (!filesList) {
return;
}
await uploadFirstImageAndInsertRemaining({
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
filesList,
maxFileSize,
pos: getPos(),
type: "image",
uploader: uploadFile,
});
},
@@ -170,7 +180,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
ref={fileInputRef}
hidden
type="file"
accept={ACCEPTED_FILE_EXTENSIONS.join(",")}
accept={ACCEPTED_IMAGE_MIME_TYPES.join(",")}
onChange={onFileChange}
multiple
/>

View File

@@ -2,12 +2,15 @@ import { Editor, mergeAttributes } from "@tiptap/core";
import { Image } from "@tiptap/extension-image";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { v4 as uuidv4 } from "uuid";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// extensions
import { CustomImageNode } from "@/extensions/custom-image";
// helpers
import { isFileValid } from "@/helpers/file";
import { insertEmptyParagraphAtNodeBoundaries } from "@/helpers/insert-empty-paragraph-at-node-boundary";
// plugins
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
@@ -146,6 +149,7 @@ export const CustomImageExtension = (props: TFileHandler) => {
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
})

View File

@@ -0,0 +1,127 @@
import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
// constants
import { ACCEPTED_ATTACHMENT_MIME_TYPES, ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
// types
import { TEditorCommands } from "@/types";
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view, event) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const acceptedFiles = files.filter(
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
);
if (acceptedFiles.length) {
const pos = view.state.selection.from;
insertFilesSafely({
editor,
files: acceptedFiles,
initialPos: pos,
event: "drop",
});
}
return true;
}
return false;
},
handleDrop: (view, event, _slice, moved) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const acceptedFiles = files.filter(
(f) => ACCEPTED_IMAGE_MIME_TYPES.includes(f.type) || ACCEPTED_ATTACHMENT_MIME_TYPES.includes(f.type)
);
if (acceptedFiles.length) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertFilesSafely({
editor,
files: acceptedFiles,
initialPos: pos,
event: "drop",
});
}
return true;
}
}
return false;
},
},
}),
];
},
});
type InsertFilesSafelyArgs = {
editor: Editor;
event: "insert" | "drop";
files: File[];
initialPos: number;
type?: Extract<TEditorCommands, "attachment" | "image">;
};
export const insertFilesSafely = async (args: InsertFilesSafelyArgs) => {
const { editor, event, files, initialPos, type } = args;
let pos = initialPos;
for (const file of files) {
// safe insertion
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
let fileType: "image" | "attachment" | null = null;
try {
if (type) {
if (["image", "attachment"].includes(type)) fileType = type;
else throw new Error("Wrong file type passed");
} else {
if (ACCEPTED_IMAGE_MIME_TYPES.includes(file.type)) fileType = "image";
else if (ACCEPTED_ATTACHMENT_MIME_TYPES.includes(file.type)) fileType = "attachment";
}
// insert file depending on the type at the current position
if (fileType === "image") {
editor.commands.insertImageComponent({
file,
pos,
event,
});
} else if (fileType === "attachment") {
}
} catch (error) {
console.error(`Error while ${event}ing file:`, error);
}
// Move to the next position
pos += 1;
}
};

View File

@@ -1,94 +0,0 @@
import { Extension, Editor } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { EditorView } from "@tiptap/pm/view";
export const DropHandlerExtension = Extension.create({
name: "dropHandler",
priority: 1000,
addProseMirrorPlugins() {
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("drop-handler-plugin"),
props: {
handlePaste: (view: EditorView, event: ClipboardEvent) => {
if (
editor.isEditable &&
event.clipboardData &&
event.clipboardData.files &&
event.clipboardData.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.clipboardData.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const pos = view.state.selection.from;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
return false;
},
handleDrop: (view: EditorView, event: DragEvent, _slice: any, moved: boolean) => {
if (
editor.isEditable &&
!moved &&
event.dataTransfer &&
event.dataTransfer.files &&
event.dataTransfer.files.length > 0
) {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter((file) => file.type.startsWith("image"));
if (imageFiles.length > 0) {
const coordinates = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (coordinates) {
const pos = coordinates.pos;
insertImagesSafely({ editor, files: imageFiles, initialPos: pos, event: "drop" });
}
return true;
}
}
return false;
},
},
}),
];
},
});
export const insertImagesSafely = async ({
editor,
files,
initialPos,
event,
}: {
editor: Editor;
files: File[];
initialPos: number;
event: "insert" | "drop";
}) => {
let pos = initialPos;
for (const file of files) {
// safe insertion
const docSize = editor.state.doc.content.size;
pos = Math.min(pos, docSize);
try {
// Insert the image at the current position
editor.commands.insertImageComponent({ file, pos, event });
} catch (error) {
console.error(`Error while ${event}ing image:`, error);
}
// Move to the next position
pos += 1;
}
};

View File

@@ -172,6 +172,7 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];

View File

@@ -1,7 +1,6 @@
import { Editor, Range } from "@tiptap/core";
// types
import { InsertImageComponentProps } from "@/extensions";
// extensions
import { InsertImageComponentProps } from "@/extensions";
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
// helpers
import { findTableAncestor } from "@/helpers/common";
@@ -206,6 +205,7 @@ export const insertHorizontalRule = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).setHorizontalRule().run();
else editor.chain().focus().setHorizontalRule().run();
};
export const insertCallout = (editor: Editor, range?: Range) => {
if (range) editor.chain().focus().deleteRange(range).insertCallout().run();
else editor.chain().focus().insertCallout().run();

View File

@@ -1,20 +1,18 @@
// constants
import { ACCEPTED_FILE_MIME_TYPES } from "@/constants/config";
type TArgs = {
acceptedMimeTypes: string[];
file: File;
maxFileSize: number;
};
export const isFileValid = (args: TArgs): boolean => {
const { file, maxFileSize } = args;
const { acceptedMimeTypes, file, maxFileSize } = args;
if (!file) {
alert("No file selected. Please select a file to upload.");
return false;
}
if (!ACCEPTED_FILE_MIME_TYPES.includes(file.type)) {
if (!acceptedMimeTypes.includes(file.type)) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file.");
return false;
}

View File

@@ -1,87 +1,87 @@
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
import { DragEvent, useCallback, useEffect, useState } from "react";
// extensions
import { insertImagesSafely } from "@/extensions/drop";
import { insertFilesSafely } from "@/extensions/drop";
// plugins
import { isFileValid } from "@/plugins/image";
import { isFileValid } from "@/helpers/file";
// types
import { TEditorCommands } from "@/types";
type TUploaderArgs = {
blockId: string;
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
acceptedMimeTypes: string[];
editorCommand: (file: File) => Promise<string>;
handleProgressStatus?: (isUploading: boolean) => void;
loadFileFromFileSystem?: (file: string) => void;
maxFileSize: number;
onUpload: (url: string) => void;
onUpload: (url: string, file: File) => void;
};
export const useUploader = (args: TUploaderArgs) => {
const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
const { acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload } =
args;
// states
const [uploading, setUploading] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
const setImageUploadInProgress = (isUploading: boolean) => {
if (editor.storage.imageComponent) {
editor.storage.imageComponent.uploadInProgress = isUploading;
}
};
setImageUploadInProgress(true);
setUploading(true);
const fileNameTrimmed = trimFileName(file.name);
const fileWithTrimmedName = new File([file], fileNameTrimmed, { type: file.type });
handleProgressStatus?.(true);
setIsUploading(true);
const isValid = isFileValid({
file: fileWithTrimmedName,
acceptedMimeTypes,
file,
maxFileSize,
});
if (!isValid) {
setImageUploadInProgress(false);
handleProgressStatus?.(false);
setIsUploading(false);
return;
}
try {
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
loadImageFromFileSystem(reader.result as string);
} else {
console.error("Failed to read the file: reader.result is null");
}
};
reader.onerror = () => {
console.error("Error reading file");
};
reader.readAsDataURL(fileWithTrimmedName);
// @ts-expect-error - TODO: fix typings, and don't remove await from
// here for now
const url: string = await editor?.commands.uploadImage(blockId, fileWithTrimmedName);
if (loadFileFromFileSystem) {
const reader = new FileReader();
reader.onload = () => {
if (reader.result) {
loadFileFromFileSystem(reader.result as string);
} else {
console.error("Failed to read the file: reader.result is null");
}
};
reader.onerror = () => {
console.error("Error reading file");
};
reader.readAsDataURL(file);
}
const url: string = await editorCommand(file);
if (!url) {
throw new Error("Something went wrong while uploading the image");
throw new Error("Something went wrong while uploading the file.");
}
onUpload(url);
} catch (errPayload: any) {
console.log(errPayload);
onUpload(url, file);
} catch (errPayload) {
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
} finally {
setImageUploadInProgress(false);
setUploading(false);
handleProgressStatus?.(false);
setIsUploading(false);
}
},
[onUpload]
[acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload]
);
return { uploading, uploadFile };
return { isUploading, uploadFile };
};
type TDropzoneArgs = {
acceptedMimeTypes: string[];
editor: Editor;
maxFileSize: number;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
};
export const useDropZone = (args: TDropzoneArgs) => {
const { editor, maxFileSize, pos, uploader } = args;
const { acceptedMimeTypes, editor, maxFileSize, pos, type, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -112,83 +112,79 @@ export const useDropZone = (args: TDropzoneArgs) => {
return;
}
const filesList = e.dataTransfer.files;
await uploadFirstImageAndInsertRemaining({
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes,
editor,
filesList,
maxFileSize,
pos,
type,
uploader,
});
},
[uploader, editor, pos]
[acceptedMimeTypes, editor, maxFileSize, pos, type, uploader]
);
const onDragEnter = useCallback(() => setDraggedInside(true), []);
const onDragLeave = useCallback(() => setDraggedInside(false), []);
const onDragEnter = () => {
setDraggedInside(true);
return {
isDragging,
draggedInside,
onDragEnter,
onDragLeave,
onDrop,
};
const onDragLeave = () => {
setDraggedInside(false);
};
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
};
function trimFileName(fileName: string, maxLength = 100) {
if (fileName.length > maxLength) {
const extension = fileName.split(".").pop();
const nameWithoutExtension = fileName.slice(0, -(extension?.length ?? 0 + 1));
const allowedNameLength = maxLength - (extension?.length ?? 0) - 1; // -1 for the dot
return `${nameWithoutExtension.slice(0, allowedNameLength)}.${extension}`;
}
return fileName;
}
type TMultipleImagesArgs = {
type TMultipleFileArgs = {
acceptedMimeTypes: string[];
editor: Editor;
filesList: FileList;
maxFileSize: number;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
};
// Upload the first image and insert the remaining images for uploading multiple image
// post insertion of image-component
export async function uploadFirstImageAndInsertRemaining(args: TMultipleImagesArgs) {
const { editor, filesList, maxFileSize, pos, uploader } = args;
// Upload the first file and insert the remaining ones for uploading multiple files
export const uploadFirstFileAndInsertRemaining = async (args: TMultipleFileArgs) => {
const { acceptedMimeTypes, editor, filesList, maxFileSize, pos, type, uploader } = args;
const filteredFiles: File[] = [];
for (let i = 0; i < filesList.length; i += 1) {
const item = filesList.item(i);
const file = filesList.item(i);
if (
item &&
item.type.indexOf("image") !== -1 &&
file &&
isFileValid({
file: item,
acceptedMimeTypes,
file,
maxFileSize,
})
) {
filteredFiles.push(item);
filteredFiles.push(file);
}
}
if (filteredFiles.length !== filesList.length) {
console.warn("Some files were not images and have been ignored.");
console.warn("Some files were invalid and have been ignored.");
}
if (filteredFiles.length === 0) {
console.error("No image files found to upload");
console.error("No files found to upload.");
return;
}
// Upload the first image
// Upload the first file
const firstFile = filteredFiles[0];
uploader(firstFile);
// Insert the remaining images
// Insert the remaining files
const remainingFiles = filteredFiles.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
insertFilesSafely({
editor,
files: remainingFiles,
initialPos: posOfNextFileToBeInserted,
event: "drop",
type,
});
}
}
};

View File

@@ -10,10 +10,10 @@ const verticalEllipsisIcon =
const generalSelectors = [
"li",
"p:not(:first-child)",
"p.editor-paragraph-block:not(:first-child)",
".code-block",
"blockquote",
"h1, h2, h3, h4, h5, h6",
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",

View File

@@ -1,7 +0,0 @@
import { PluginKey } from "@tiptap/pm/state";
export const uploadKey = new PluginKey("upload-image");
export const deleteKey = new PluginKey("delete-image");
export const restoreKey = new PluginKey("restore-image");
export const IMAGE_NODE_TYPE = "image";

View File

@@ -37,20 +37,16 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
removedImages.forEach(async (node) => {
const src = node.attrs.src;
editor.storage[nodeType].deletedImageSet.set(src, true);
await onNodeDeleted(src, deleteImage);
editor.storage[nodeType].deletedImageSet?.set(src, true);
if (!src) return;
try {
await deleteImage(src);
} catch (error) {
console.error("Error deleting image:", error);
}
});
});
return null;
},
});
async function onNodeDeleted(src: string, deleteImage: DeleteImage): Promise<void> {
if (!src) return;
try {
await deleteImage(src);
} catch (error) {
console.error("Error deleting image: ", error);
}
}

View File

@@ -1,5 +1,3 @@
export * from "./types";
export * from "./utils";
export * from "./constants";
export * from "./delete-image";
export * from "./restore-image";

View File

@@ -1 +0,0 @@
export * from "./validate-file";

View File

@@ -1311,7 +1311,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí.",
"title": "Zatím žádná data"
},
"created_vs_resolved": {
"description": "Pracovní položky vytvořené a vyřešené v průběhu času se zde zobrazí.",
"title": "Zatím žádná data"
},
"project_insights": {
"title": "Zatím žádná data",
"description": "Pracovní položky přiřazené vám, rozdělené podle stavu, se zde zobrazí."
}
},
"created_vs_resolved": "Vytvořeno vs Vyřešeno",
"customized_insights": "Přizpůsobené přehledy",
"backlog_work_items": "Pracovní položky v backlogu",
"active_projects": "Aktivní projekty",
"trend_on_charts": "Trend na grafech",
"all_projects": "Všechny projekty",
"summary_of_projects": "Souhrn projektů",
"project_insights": "Přehled projektu",
"started_work_items": "Zahájené pracovní položky",
"total_work_items": "Celkový počet pracovních položek",
"total_projects": "Celkový počet projektů",
"total_admins": "Celkový počet administrátorů",
"total_users": "Celkový počet uživatelů",
"total_intake": "Celkový příjem",
"un_started_work_items": "Nezahájené pracovní položky",
"total_guests": "Celkový počet hostů",
"completed_work_items": "Dokončené pracovní položky"
},
"workspace_projects": {
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektů}}",

View File

@@ -1311,7 +1311,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt.",
"title": "Noch keine Daten"
},
"created_vs_resolved": {
"description": "Im Laufe der Zeit erstellte und gelöste Arbeitselemente werden hier angezeigt.",
"title": "Noch keine Daten"
},
"project_insights": {
"title": "Noch keine Daten",
"description": "Ihnen zugewiesene Arbeitselemente, aufgeschlüsselt nach Status, werden hier angezeigt."
}
},
"created_vs_resolved": "Erstellt vs Gelöst",
"customized_insights": "Individuelle Einblicke",
"backlog_work_items": "Backlog-Arbeitselemente",
"active_projects": "Aktive Projekte",
"trend_on_charts": "Trend in Diagrammen",
"all_projects": "Alle Projekte",
"summary_of_projects": "Projektübersicht",
"project_insights": "Projekteinblicke",
"started_work_items": "Begonnene Arbeitselemente",
"total_work_items": "Gesamte Arbeitselemente",
"total_projects": "Gesamtprojekte",
"total_admins": "Gesamtanzahl der Admins",
"total_users": "Gesamtanzahl der Benutzer",
"total_intake": "Gesamteinnahmen",
"un_started_work_items": "Nicht begonnene Arbeitselemente",
"total_guests": "Gesamtanzahl der Gäste",
"completed_work_items": "Abgeschlossene Arbeitselemente"
},
"workspace_projects": {
"label": "{count, plural, one {Projekt} few {Projekte} other {Projekte}}",

View File

@@ -699,7 +699,8 @@
"view": "View",
"deactivated_user": "Deactivated user",
"apply": "Apply",
"applying": "Applying"
"applying": "Applying",
"overview": "Overview"
},
"chart": {
"x_axis": "X-axis",
@@ -1146,6 +1147,37 @@
}
}
}
},
"total_work_items": "Total work items",
"started_work_items": "Started work items",
"backlog_work_items": "Backlog work items",
"un_started_work_items": "Unstarted work items",
"completed_work_items": "Completed work items",
"total_guests": "Total Guests",
"total_intake": "Total Intake",
"total_users": "Total Users",
"total_admins": "Total Admins",
"total_projects": "Total Projects",
"project_insights": "Project Insights",
"summary_of_projects": "Summary of Projects",
"all_projects": "All Projects",
"trend_on_charts": "Trend on charts",
"active_projects": "Active Projects",
"customized_insights": "Customized Insights",
"created_vs_resolved": "Created vs Resolved",
"empty_state_v2": {
"project_insights": {
"title": "No data yet",
"description": "Work items assigned to you, broken down by state, will show up here."
},
"created_vs_resolved": {
"title": "No data yet",
"description": "Work items created and resolved over time will show up here."
},
"customized_insights": {
"title": "No data yet",
"description": "Work items assigned to you, broken down by state, will show up here."
}
}
},
"workspace_projects": {

View File

@@ -1314,7 +1314,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí.",
"title": "Aún no hay datos"
},
"created_vs_resolved": {
"description": "Los elementos de trabajo creados y resueltos con el tiempo aparecerán aquí.",
"title": "Aún no hay datos"
},
"project_insights": {
"title": "Aún no hay datos",
"description": "Los elementos de trabajo asignados a ti, desglosados por estado, aparecerán aquí."
}
},
"created_vs_resolved": "Creado vs Resuelto",
"customized_insights": "Información personalizada",
"backlog_work_items": "Elementos de trabajo en backlog",
"active_projects": "Proyectos activos",
"trend_on_charts": "Tendencia en gráficos",
"all_projects": "Todos los proyectos",
"summary_of_projects": "Resumen de proyectos",
"project_insights": "Información del proyecto",
"started_work_items": "Elementos de trabajo iniciados",
"total_work_items": "Total de elementos de trabajo",
"total_projects": "Total de proyectos",
"total_admins": "Total de administradores",
"total_users": "Total de usuarios",
"total_intake": "Ingreso total",
"un_started_work_items": "Elementos de trabajo no iniciados",
"total_guests": "Total de invitados",
"completed_work_items": "Elementos de trabajo completados"
},
"workspace_projects": {
"label": "{count, plural, one {Proyecto} other {Proyectos}}",

View File

@@ -1312,7 +1312,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici.",
"title": "Pas encore de données"
},
"created_vs_resolved": {
"description": "Les éléments de travail créés et résolus au fil du temps s'afficheront ici.",
"title": "Pas encore de données"
},
"project_insights": {
"title": "Pas encore de données",
"description": "Les éléments de travail qui vous sont assignés, répartis par état, s'afficheront ici."
}
},
"created_vs_resolved": "Créé vs Résolu",
"customized_insights": "Informations personnalisées",
"backlog_work_items": "Éléments de travail en backlog",
"active_projects": "Projets actifs",
"trend_on_charts": "Tendance sur les graphiques",
"all_projects": "Tous les projets",
"summary_of_projects": "Résumé des projets",
"project_insights": "Aperçus du projet",
"started_work_items": "Éléments de travail commencés",
"total_work_items": "Total des éléments de travail",
"total_projects": "Total des projets",
"total_admins": "Total des administrateurs",
"total_users": "Nombre total d'utilisateurs",
"total_intake": "Revenu total",
"un_started_work_items": "Éléments de travail non commencés",
"total_guests": "Nombre total d'invités",
"completed_work_items": "Éléments de travail terminés"
},
"workspace_projects": {
"label": "{count, plural, one {Projet} other {Projets}}",

View File

@@ -1311,7 +1311,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini.",
"title": "Belum ada data"
},
"created_vs_resolved": {
"description": "Item pekerjaan yang dibuat dan diselesaikan dari waktu ke waktu akan muncul di sini.",
"title": "Belum ada data"
},
"project_insights": {
"title": "Belum ada data",
"description": "Item pekerjaan yang ditugaskan kepada Anda, dipecah berdasarkan status, akan muncul di sini."
}
},
"created_vs_resolved": "Dibuat vs Diselesaikan",
"customized_insights": "Wawasan yang Disesuaikan",
"backlog_work_items": "Item pekerjaan backlog",
"active_projects": "Proyek Aktif",
"trend_on_charts": "Tren pada grafik",
"all_projects": "Semua Proyek",
"summary_of_projects": "Ringkasan Proyek",
"project_insights": "Wawasan Proyek",
"started_work_items": "Item pekerjaan yang telah dimulai",
"total_work_items": "Total item pekerjaan",
"total_projects": "Total Proyek",
"total_admins": "Total Admin",
"total_users": "Total Pengguna",
"total_intake": "Total Pemasukan",
"un_started_work_items": "Item pekerjaan yang belum dimulai",
"total_guests": "Total Tamu",
"completed_work_items": "Item pekerjaan yang telah selesai"
},
"workspace_projects": {
"label": "{count, plural, one {Proyek} other {Proyek}}",

View File

@@ -1310,7 +1310,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui.",
"title": "Nessun dato disponibile"
},
"created_vs_resolved": {
"description": "Gli elementi di lavoro creati e risolti nel tempo verranno visualizzati qui.",
"title": "Nessun dato disponibile"
},
"project_insights": {
"title": "Nessun dato disponibile",
"description": "Gli elementi di lavoro assegnati a te, suddivisi per stato, verranno visualizzati qui."
}
},
"created_vs_resolved": "Creato vs Risolto",
"customized_insights": "Approfondimenti personalizzati",
"backlog_work_items": "Elementi di lavoro nel backlog",
"active_projects": "Progetti attivi",
"trend_on_charts": "Tendenza nei grafici",
"all_projects": "Tutti i progetti",
"summary_of_projects": "Riepilogo dei progetti",
"project_insights": "Approfondimenti sul progetto",
"started_work_items": "Elementi di lavoro iniziati",
"total_work_items": "Totale elementi di lavoro",
"total_projects": "Progetti totali",
"total_admins": "Totale amministratori",
"total_users": "Totale utenti",
"total_intake": "Entrate totali",
"un_started_work_items": "Elementi di lavoro non avviati",
"total_guests": "Totale ospiti",
"completed_work_items": "Elementi di lavoro completati"
},
"workspace_projects": {
"label": "{count, plural, one {Progetto} other {Progetti}}",

View File

@@ -1312,7 +1312,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "あなたに割り当てられた作業項目は、ステータスごとに分類されてここに表示されます。",
"title": "まだデータがありません"
},
"created_vs_resolved": {
"description": "時間の経過とともに作成および解決された作業項目がここに表示されます。",
"title": "まだデータがありません"
},
"project_insights": {
"title": "まだデータがありません",
"description": "あなたに割り当てられた作業項目は、ステータスごとに分類されてここに表示されます。"
}
},
"created_vs_resolved": "作成 vs 解決",
"customized_insights": "カスタマイズされたインサイト",
"backlog_work_items": "バックログの作業項目",
"active_projects": "アクティブなプロジェクト",
"trend_on_charts": "グラフの傾向",
"all_projects": "すべてのプロジェクト",
"summary_of_projects": "プロジェクトの概要",
"project_insights": "プロジェクトのインサイト",
"started_work_items": "開始された作業項目",
"total_work_items": "作業項目の合計",
"total_projects": "プロジェクト合計",
"total_admins": "管理者の合計",
"total_users": "ユーザー総数",
"total_intake": "総収入",
"un_started_work_items": "未開始の作業項目",
"total_guests": "ゲストの合計",
"completed_work_items": "完了した作業項目"
},
"workspace_projects": {
"label": "{count, plural, one {プロジェクト} other {プロジェクト}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "귀하에게 할당된 작업 항목이 상태별로 나누어 여기에 표시됩니다.",
"title": "아직 데이터가 없습니다"
},
"created_vs_resolved": {
"description": "시간이 지나면서 생성되고 해결된 작업 항목이 여기에 표시됩니다.",
"title": "아직 데이터가 없습니다"
},
"project_insights": {
"title": "아직 데이터가 없습니다",
"description": "귀하에게 할당된 작업 항목이 상태별로 나누어 여기에 표시됩니다."
}
},
"created_vs_resolved": "생성됨 vs 해결됨",
"customized_insights": "맞춤형 인사이트",
"backlog_work_items": "백로그 작업 항목",
"active_projects": "활성 프로젝트",
"trend_on_charts": "차트의 추세",
"all_projects": "모든 프로젝트",
"summary_of_projects": "프로젝트 요약",
"project_insights": "프로젝트 인사이트",
"started_work_items": "시작된 작업 항목",
"total_work_items": "총 작업 항목",
"total_projects": "총 프로젝트 수",
"total_admins": "총 관리자 수",
"total_users": "총 사용자 수",
"total_intake": "총 수입",
"un_started_work_items": "시작되지 않은 작업 항목",
"total_guests": "총 게스트 수",
"completed_work_items": "완료된 작업 항목"
},
"workspace_projects": {
"label": "{count, plural, one {프로젝트} other {프로젝트}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Przypisane do Ciebie elementy pracy, podzielone według stanu, pojawią się tutaj.",
"title": "Brak danych"
},
"created_vs_resolved": {
"description": "Elementy pracy utworzone i rozwiązane w czasie pojawią się tutaj.",
"title": "Brak danych"
},
"project_insights": {
"title": "Brak danych",
"description": "Przypisane do Ciebie elementy pracy, podzielone według stanu, pojawią się tutaj."
}
},
"created_vs_resolved": "Utworzone vs Rozwiązane",
"customized_insights": "Dostosowane informacje",
"backlog_work_items": "Elementy pracy w backlogu",
"active_projects": "Aktywne projekty",
"trend_on_charts": "Trend na wykresach",
"all_projects": "Wszystkie projekty",
"summary_of_projects": "Podsumowanie projektów",
"project_insights": "Wgląd w projekt",
"started_work_items": "Rozpoczęte elementy pracy",
"total_work_items": "Łączna liczba elementów pracy",
"total_projects": "Łączna liczba projektów",
"total_admins": "Łączna liczba administratorów",
"total_users": "Łączna liczba użytkowników",
"total_intake": "Całkowity dochód",
"un_started_work_items": "Nierozpoczęte elementy pracy",
"total_guests": "Łączna liczba gości",
"completed_work_items": "Ukończone elementy pracy"
},
"workspace_projects": {
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektów}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Os itens de trabalho atribuídos a você, divididos por estado, aparecerão aqui.",
"title": "Ainda não há dados"
},
"created_vs_resolved": {
"description": "Os itens de trabalho criados e resolvidos ao longo do tempo aparecerão aqui.",
"title": "Ainda não há dados"
},
"project_insights": {
"title": "Ainda não há dados",
"description": "Os itens de trabalho atribuídos a você, divididos por estado, aparecerão aqui."
}
},
"created_vs_resolved": "Criado vs Resolvido",
"customized_insights": "Insights personalizados",
"backlog_work_items": "Itens de trabalho no backlog",
"active_projects": "Projetos ativos",
"trend_on_charts": "Tendência nos gráficos",
"all_projects": "Todos os projetos",
"summary_of_projects": "Resumo dos projetos",
"project_insights": "Insights do projeto",
"started_work_items": "Itens de trabalho iniciados",
"total_work_items": "Total de itens de trabalho",
"total_projects": "Total de projetos",
"total_admins": "Total de administradores",
"total_users": "Total de usuários",
"total_intake": "Receita total",
"un_started_work_items": "Itens de trabalho não iniciados",
"total_guests": "Total de convidados",
"completed_work_items": "Itens de trabalho concluídos"
},
"workspace_projects": {
"label": "{count, plural, one {Projeto} other {Projetos}}",

View File

@@ -1311,7 +1311,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Elementele de lucru atribuite ție, împărțite pe stări, vor apărea aici.",
"title": "Nu există date încă"
},
"created_vs_resolved": {
"description": "Elementele de lucru create și rezolvate în timp vor apărea aici.",
"title": "Nu există date încă"
},
"project_insights": {
"title": "Nu există date încă",
"description": "Elementele de lucru atribuite ție, împărțite pe stări, vor apărea aici."
}
},
"created_vs_resolved": "Creat vs Rezolvat",
"customized_insights": "Perspective personalizate",
"backlog_work_items": "Elemente de lucru din backlog",
"active_projects": "Proiecte active",
"trend_on_charts": "Tendință în grafice",
"all_projects": "Toate proiectele",
"summary_of_projects": "Sumarul proiectelor",
"project_insights": "Informații despre proiect",
"started_work_items": "Elemente de lucru începute",
"total_work_items": "Totalul elementelor de lucru",
"total_projects": "Total proiecte",
"total_admins": "Total administratori",
"total_users": "Total utilizatori",
"total_intake": "Venit total",
"un_started_work_items": "Elemente de lucru neîncepute",
"total_guests": "Total invitați",
"completed_work_items": "Elemente de lucru finalizate"
},
"workspace_projects": {
"label": "{count, plural, one {Proiect} other {Proiecte}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Назначенные вам рабочие элементы, разбитые по статусам, появятся здесь.",
"title": "Данных пока нет"
},
"created_vs_resolved": {
"description": "Созданные и решённые со временем рабочие элементы появятся здесь.",
"title": "Данных пока нет"
},
"project_insights": {
"title": "Данных пока нет",
"description": "Назначенные вам рабочие элементы, разбитые по статусам, появятся здесь."
}
},
"created_vs_resolved": "Создано vs Решено",
"customized_insights": "Индивидуальные аналитические данные",
"backlog_work_items": "Элементы работы в бэклоге",
"active_projects": "Активные проекты",
"trend_on_charts": "Тренд на графиках",
"all_projects": "Все проекты",
"summary_of_projects": "Сводка по проектам",
"project_insights": "Аналитика проекта",
"started_work_items": "Начатые рабочие элементы",
"total_work_items": "Общее количество рабочих элементов",
"total_projects": "Всего проектов",
"total_admins": "Всего администраторов",
"total_users": "Всего пользователей",
"total_intake": "Общий доход",
"un_started_work_items": "Не начатые рабочие элементы",
"total_guests": "Всего гостей",
"completed_work_items": "Завершённые рабочие элементы"
},
"workspace_projects": {
"label": "{count, plural, one {Проект} other {Проекты}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Pracovné položky priradené vám, rozdelené podľa stavu, sa zobrazia tu.",
"title": "Zatiaľ žiadne údaje"
},
"created_vs_resolved": {
"description": "Pracovné položky vytvorené a vyriešené v priebehu času sa zobrazia tu.",
"title": "Zatiaľ žiadne údaje"
},
"project_insights": {
"title": "Zatiaľ žiadne údaje",
"description": "Pracovné položky priradené vám, rozdelené podľa stavu, sa zobrazia tu."
}
},
"created_vs_resolved": "Vytvorené vs Vyriešené",
"customized_insights": "Prispôsobené prehľady",
"backlog_work_items": "Pracovné položky v backlogu",
"active_projects": "Aktívne projekty",
"trend_on_charts": "Trend na grafoch",
"all_projects": "Všetky projekty",
"summary_of_projects": "Súhrn projektov",
"project_insights": "Prehľad projektu",
"started_work_items": "Spustené pracovné položky",
"total_work_items": "Celkový počet pracovných položiek",
"total_projects": "Celkový počet projektov",
"total_admins": "Celkový počet administrátorov",
"total_users": "Celkový počet používateľov",
"total_intake": "Celkový príjem",
"un_started_work_items": "Nespustené pracovné položky",
"total_guests": "Celkový počet hostí",
"completed_work_items": "Dokončené pracovné položky"
},
"workspace_projects": {
"label": "{count, plural, one {Projekt} few {Projekty} other {Projektov}}",

View File

@@ -1314,7 +1314,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Size atanan iş öğeleri, duruma göre ayrılarak burada gösterilecektir.",
"title": "Henüz veri yok"
},
"created_vs_resolved": {
"description": "Zaman içinde oluşturulan ve çözümlenen iş öğeleri burada gösterilecektir.",
"title": "Henüz veri yok"
},
"project_insights": {
"title": "Henüz veri yok",
"description": "Size atanan iş öğeleri, duruma göre ayrılarak burada gösterilecektir."
}
},
"created_vs_resolved": "Oluşturulan vs Çözülen",
"customized_insights": "Özelleştirilmiş İçgörüler",
"backlog_work_items": "Backlog iş öğeleri",
"active_projects": "Aktif Projeler",
"trend_on_charts": "Grafiklerdeki eğilim",
"all_projects": "Tüm Projeler",
"summary_of_projects": "Projelerin Özeti",
"project_insights": "Proje İçgörüleri",
"started_work_items": "Başlatılan iş öğeleri",
"total_work_items": "Toplam iş öğesi",
"total_projects": "Toplam Proje",
"total_admins": "Toplam Yönetici",
"total_users": "Toplam Kullanıcı",
"total_intake": "Toplam Gelir",
"un_started_work_items": "Başlanmamış iş öğeleri",
"total_guests": "Toplam Misafir",
"completed_work_items": "Tamamlanmış iş öğeleri"
},
"workspace_projects": {
"label": "{count, plural, one {Proje} other {Projeler}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Призначені вам робочі елементи, розбиті за станом, з’являться тут.",
"title": "Ще немає даних"
},
"created_vs_resolved": {
"description": "Створені та вирішені з часом робочі елементи з’являться тут.",
"title": "Ще немає даних"
},
"project_insights": {
"title": "Ще немає даних",
"description": "Призначені вам робочі елементи, розбиті за станом, з’являться тут."
}
},
"created_vs_resolved": "Створено vs Вирішено",
"customized_insights": "Персоналізовані аналітичні дані",
"backlog_work_items": "Робочі елементи у беклозі",
"active_projects": "Активні проєкти",
"trend_on_charts": "Тенденція на графіках",
"all_projects": "Усі проєкти",
"summary_of_projects": "Зведення проєктів",
"project_insights": "Аналітика проєкту",
"started_work_items": "Розпочаті робочі елементи",
"total_work_items": "Усього робочих елементів",
"total_projects": "Усього проєктів",
"total_admins": "Усього адміністраторів",
"total_users": "Усього користувачів",
"total_intake": "Загальний дохід",
"un_started_work_items": "Нерозпочаті робочі елементи",
"total_guests": "Усього гостей",
"completed_work_items": "Завершені робочі елементи"
},
"workspace_projects": {
"label": "{count, plural, one {Проєкт} few {Проєкти} other {Проєктів}}",

View File

@@ -1312,7 +1312,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "Các hạng mục công việc được giao cho bạn, phân loại theo trạng thái, sẽ hiển thị tại đây.",
"title": "Chưa có dữ liệu"
},
"created_vs_resolved": {
"description": "Các hạng mục công việc được tạo và giải quyết theo thời gian sẽ hiển thị tại đây.",
"title": "Chưa có dữ liệu"
},
"project_insights": {
"title": "Chưa có dữ liệu",
"description": "Các hạng mục công việc được giao cho bạn, phân loại theo trạng thái, sẽ hiển thị tại đây."
}
},
"created_vs_resolved": "Đã tạo vs Đã giải quyết",
"customized_insights": "Thông tin chi tiết tùy chỉnh",
"backlog_work_items": "Các hạng mục công việc tồn đọng",
"active_projects": "Dự án đang hoạt động",
"trend_on_charts": "Xu hướng trên biểu đồ",
"all_projects": "Tất cả dự án",
"summary_of_projects": "Tóm tắt dự án",
"project_insights": "Thông tin chi tiết dự án",
"started_work_items": "Hạng mục công việc đã bắt đầu",
"total_work_items": "Tổng số hạng mục công việc",
"total_projects": "Tổng số dự án",
"total_admins": "Tổng số quản trị viên",
"total_users": "Tổng số người dùng",
"total_intake": "Tổng thu",
"un_started_work_items": "Hạng mục công việc chưa bắt đầu",
"total_guests": "Tổng số khách",
"completed_work_items": "Hạng mục công việc đã hoàn thành"
},
"workspace_projects": {
"label": "{count, plural, one {dự án} other {dự án}}",

View File

@@ -1312,7 +1312,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "分配给您的工作项将按状态分类显示在此处。",
"title": "暂无数据"
},
"created_vs_resolved": {
"description": "随着时间推移创建和解决的工作项将显示在此处。",
"title": "暂无数据"
},
"project_insights": {
"title": "暂无数据",
"description": "分配给您的工作项将按状态分类显示在此处。"
}
},
"created_vs_resolved": "已创建 vs 已解决",
"customized_insights": "自定义洞察",
"backlog_work_items": "待办工作项",
"active_projects": "活跃项目",
"trend_on_charts": "图表趋势",
"all_projects": "所有项目",
"summary_of_projects": "项目概览",
"project_insights": "项目洞察",
"started_work_items": "已开始的工作项",
"total_work_items": "工作项总数",
"total_projects": "项目总数",
"total_admins": "管理员总数",
"total_users": "用户总数",
"total_intake": "总收入",
"un_started_work_items": "未开始的工作项",
"total_guests": "访客总数",
"completed_work_items": "已完成的工作项"
},
"workspace_projects": {
"label": "{count, plural, one {项目} other {项目}}",

View File

@@ -1313,7 +1313,38 @@
}
}
}
}
},
"empty_state_v2": {
"customized_insights": {
"description": "指派給您的工作項目將依狀態分類顯示在此處。",
"title": "尚無資料"
},
"created_vs_resolved": {
"description": "隨著時間推移所建立與解決的工作項目將顯示在此處。",
"title": "尚無資料"
},
"project_insights": {
"title": "尚無資料",
"description": "指派給您的工作項目將依狀態分類顯示在此處。"
}
},
"created_vs_resolved": "已建立 vs 已解決",
"customized_insights": "自訂化洞察",
"backlog_work_items": "待辦工作項目",
"active_projects": "啟用中的專案",
"trend_on_charts": "圖表趨勢",
"all_projects": "所有專案",
"summary_of_projects": "專案摘要",
"project_insights": "專案洞察",
"started_work_items": "已開始的工作項目",
"total_work_items": "工作項目總數",
"total_projects": "專案總數",
"total_admins": "管理員總數",
"total_users": "使用者總數",
"total_intake": "總收入",
"un_started_work_items": "未開始的工作項目",
"total_guests": "訪客總數",
"completed_work_items": "已完成的工作項目"
},
"workspace_projects": {
"label": "{count, plural, one {專案} other {專案}}",

View File

@@ -10,10 +10,12 @@
"exports": {
"./ui/*": "./src/ui/*.tsx",
"./charts/*": "./src/charts/*/index.ts",
"./table": "./src/table/index.ts",
"./styles/fonts": "./src/styles/fonts/index.css"
},
"dependencies": {
"@radix-ui/react-slot": "^1.1.1",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"lucide-react": "^0.469.0",
"react": "^18.3.1",
@@ -29,4 +31,4 @@
"@types/react-dom": "18.3.0",
"typescript": "^5.3.3"
}
}
}

View File

@@ -29,13 +29,21 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
// states
const [activeArea, setActiveArea] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => areas.map((area) => area.key), [areas]);
const itemLabels: Record<string, string> = useMemo(
() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.label }), {}),
[areas]
);
const itemDotColors = useMemo(() => areas.reduce((acc, area) => ({ ...acc, [area.key]: area.fill }), {}), [areas]);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const area of areas) {
keys.push(area.key);
labels[area.key] = area.label;
colors[area.key] = area.fill;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [areas]);
const renderAreas = useMemo(
() =>
@@ -77,7 +85,7 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
// get the last data point
const lastPoint = data[data.length - 1];
// for the y-value in the last point, use its yAxis key value
const lastYValue = lastPoint[yAxis.key] || 0;
const lastYValue = lastPoint[yAxis.key] ?? 0;
// create data for a straight line that has points at each x-axis position
return data.map((item, index) => {
// calculate the y value for this point on the straight line
@@ -91,7 +99,6 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
};
});
}, [data, xAxis.key]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
@@ -128,8 +135,8 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
offset: yAxis.offset ?? -24,
dx: yAxis.dx ?? -16,
className: AXIS_LABEL_CLASSNAME,
}
}

View File

@@ -40,13 +40,22 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
// states
const [activeBar, setActiveBar] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const stackKeys = useMemo(() => bars.map((bar) => bar.key), [bars]);
const stackLabels: Record<string, string> = useMemo(
() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.label }), {}),
[bars]
);
const stackDotColors = useMemo(() => bars.reduce((acc, bar) => ({ ...acc, [bar.key]: bar.fill }), {}), [bars]);
const { stackKeys, stackLabels, stackDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const bar of bars) {
keys.push(bar.key);
labels[bar.key] = bar.label;
// For tooltip, we need a string color. If fill is a function, use a default color
colors[bar.key] = typeof bar.fill === "function" ? "#000000" : bar.fill;
}
return { stackKeys: keys, stackLabels: labels, stackDotColors: colors };
}, [bars]);
const renderBars = useMemo(
() =>
@@ -102,7 +111,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
axisLine={false}
label={{
value: xAxis.label,
dy: 28,
dy: xAxis.dy ?? 28,
className: AXIS_LABEL_CLASSNAME,
}}
tickCount={tickCount.x}

View File

@@ -15,16 +15,17 @@ export const getLegendProps = (args: TChartLegend): LegendProps => {
overflow: "hidden",
...(layout === "vertical"
? {
top: 0,
alignItems: "center",
height: "100%",
}
top: 0,
alignItems: "center",
height: "100%",
}
: {
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
...args.wrapperStyles,
},
content: <CustomLegend {...args} />,
};
@@ -33,8 +34,8 @@ export const getLegendProps = (args: TChartLegend): LegendProps => {
const CustomLegend = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
TChartLegend
Pick<LegendProps, "payload" | "formatter" | "onClick" | "onMouseEnter" | "onMouseLeave"> &
TChartLegend
>((props, ref) => {
const { formatter, layout, onClick, onMouseEnter, onMouseLeave, payload } = props;

View File

@@ -4,10 +4,10 @@ import React from "react";
// Common classnames
const AXIS_TICK_CLASSNAME = "fill-custom-text-300 text-sm";
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
export const CustomXAxisTick = React.memo<any>(({ x, y, payload, getLabel }: any) => (
<g transform={`translate(${x},${y})`}>
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{payload.value}
{getLabel ? getLabel(payload.value) : payload.value}
</text>
</g>
));
@@ -20,4 +20,28 @@ export const CustomYAxisTick = React.memo<any>(({ x, y, payload }: any) => (
</text>
</g>
));
CustomYAxisTick.displayName = "CustomYAxisTick";
export const CustomRadarAxisTick = React.memo<any>(
({ x, y, payload, getLabel, cx, cy, offset = 16 }: any) => {
// Calculate direction vector from center to tick
const dx = x - cx;
const dy = y - cy;
// Normalize and apply offset
const length = Math.sqrt(dx * dx + dy * dy);
const normX = dx / length;
const normY = dy / length;
const labelX = x + normX * offset;
const labelY = y + normY * offset;
return (
<g transform={`translate(${labelX},${labelY})`}>
<text y={0} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{getLabel ? getLabel(payload.value) : payload.value}
</text>
</g>
);
}
);
CustomRadarAxisTick.displayName = "CustomRadarAxisTick";

View File

@@ -38,13 +38,21 @@ export const LineChart = React.memo(<K extends string, T extends string>(props:
// states
const [activeLine, setActiveLine] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
// derived values
const itemKeys = useMemo(() => lines.map((line) => line.key), [lines]);
const itemLabels: Record<string, string> = useMemo(
() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.label }), {}),
[lines]
);
const itemDotColors = useMemo(() => lines.reduce((acc, line) => ({ ...acc, [line.key]: line.stroke }), {}), [lines]);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const line of lines) {
keys.push(line.key);
labels[line.key] = line.label;
colors[line.key] = line.stroke;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [lines]);
const renderLines = useMemo(
() =>

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,95 @@
import { useMemo, useState } from "react";
import {
PolarGrid,
Radar,
RadarChart as CoreRadarChart,
ResponsiveContainer,
PolarAngleAxis,
Tooltip,
Legend,
} from "recharts";
import { TRadarChartProps } from "@plane/types";
import { getLegendProps } from "../components/legend";
import { CustomRadarAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
const RadarChart = <T extends string, K extends string>(props: TRadarChartProps<T, K>) => {
const { data, radars, margin, showTooltip, legend, className, angleAxis } = props;
// states
const [, setActiveIndex] = useState<number | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const radar of radars) {
keys.push(radar.key);
labels[radar.key] = radar.name;
colors[radar.key] = radar.stroke ?? radar.fill ?? "#000000";
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [radars]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreRadarChart cx="50%" cy="50%" outerRadius="80%" data={data} margin={margin}>
<PolarGrid stroke="rgba(var(--color-border-100), 0.9)" />
<PolarAngleAxis dataKey={angleAxis.key} tick={(props) => <CustomRadarAxisTick {...props} />} />
{showTooltip && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activeLegend}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => {
// @ts-expect-error recharts types are not up to date
const key: string | undefined = payload.payload?.key;
if (!key) return;
setActiveLegend(key);
setActiveIndex(null);
}}
onMouseLeave={() => setActiveLegend(null)}
{...getLegendProps(legend)}
/>
)}
{radars.map((radar) => (
<Radar
key={radar.key}
name={radar.name}
dataKey={radar.key}
stroke={radar.stroke}
fill={radar.fill}
fillOpacity={radar.fillOpacity}
dot={radar.dot}
/>
))}
</CoreRadarChart>
</ResponsiveContainer>
</div>
);
};
export { RadarChart };

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,155 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import React, { useMemo, useState } from "react";
import {
CartesianGrid,
ScatterChart as CoreScatterChart,
Legend,
Scatter,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
// plane imports
import { AXIS_LABEL_CLASSNAME } from "@plane/constants";
import { TScatterChartProps } from "@plane/types";
// local components
import { getLegendProps } from "../components/legend";
import { CustomXAxisTick, CustomYAxisTick } from "../components/tick";
import { CustomTooltip } from "../components/tooltip";
export const ScatterChart = React.memo(<K extends string, T extends string>(props: TScatterChartProps<K, T>) => {
const {
data,
scatterPoints,
margin,
xAxis,
yAxis,
className,
tickCount = {
x: undefined,
y: 10,
},
legend,
showTooltip = true,
} = props;
// states
const [activePoint, setActivePoint] = useState<string | null>(null);
const [activeLegend, setActiveLegend] = useState<string | null>(null);
//derived values
const { itemKeys, itemLabels, itemDotColors } = useMemo(() => {
const keys: string[] = [];
const labels: Record<string, string> = {};
const colors: Record<string, string> = {};
for (const point of scatterPoints) {
keys.push(point.key);
labels[point.key] = point.label;
colors[point.key] = point.fill;
}
return { itemKeys: keys, itemLabels: labels, itemDotColors: colors };
}, [scatterPoints]);
const renderPoints = useMemo(
() =>
scatterPoints.map((point) => (
<Scatter
key={point.key}
dataKey={point.key}
fill={point.fill}
stroke={point.stroke}
opacity={!!activeLegend && activeLegend !== point.key ? 0.1 : 1}
onMouseEnter={() => setActivePoint(point.key)}
onMouseLeave={() => setActivePoint(null)}
/>
)),
[activeLegend, scatterPoints]
);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
<CoreScatterChart
data={data}
margin={{
top: margin?.top === undefined ? 5 : margin.top,
right: margin?.right === undefined ? 30 : margin.right,
bottom: margin?.bottom === undefined ? 5 : margin.bottom,
left: margin?.left === undefined ? 20 : margin.left,
}}
>
<CartesianGrid stroke="rgba(var(--color-border-100), 0.8)" vertical={false} />
<XAxis
dataKey={xAxis.key}
tick={(props) => <CustomXAxisTick {...props} />}
tickLine={false}
axisLine={false}
label={
xAxis.label && {
value: xAxis.label,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}
}
tickCount={tickCount.x}
/>
<YAxis
domain={yAxis.domain}
tickLine={false}
axisLine={false}
label={
yAxis.label && {
value: yAxis.label,
angle: -90,
position: "bottom",
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
tick={(props) => <CustomYAxisTick {...props} />}
tickCount={tickCount.y}
allowDecimals={!!yAxis.allowDecimals}
/>
{legend && (
// @ts-expect-error recharts types are not up to date
<Legend
onMouseEnter={(payload) => setActiveLegend(payload.value)}
onMouseLeave={() => setActiveLegend(null)}
formatter={(value) => itemLabels[value]}
{...getLegendProps(legend)}
/>
)}
{showTooltip && (
<Tooltip
cursor={{
stroke: "rgba(var(--color-text-300))",
strokeDasharray: "4 4",
}}
wrapperStyle={{
pointerEvents: "auto",
}}
content={({ active, label, payload }) => (
<CustomTooltip
active={active}
activeKey={activePoint}
label={label}
payload={payload}
itemKeys={itemKeys}
itemLabels={itemLabels}
itemDotColors={itemDotColors}
/>
)}
/>
)}
{renderPoints}
</CoreScatterChart>
</ResponsiveContainer>
</div>
);
});
ScatterChart.displayName = "ScatterChart";

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "@plane/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("bg-custom-background-80 py-4 border-y border-custom-border-200", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"bg-custom-background-300 font-medium",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"transition-colors data-[state=selected]:bg-custom-background-100",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableHeaderCellElement,
React.ThHTMLAttributes<HTMLTableHeaderCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-custom-text-300 [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableDataCellElement,
React.TdHTMLAttributes<HTMLTableDataCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableDataCellElement,
React.HTMLAttributes<HTMLTableDataCellElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-custom-text-300", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1 @@
export * from "./core";

52
packages/types/src/analytics-v2.d.ts vendored Normal file
View File

@@ -0,0 +1,52 @@
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"
// service types
export interface IAnalyticsResponseV2 {
[key: string]: any;
}
export interface IAnalyticsResponseFieldsV2 {
count: number;
filter_count: number;
}
export interface IAnalyticsRadarEntityV2 {
key: string,
name: string,
count: number
}
// chart types
export interface IChartResponseV2 {
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;
}
export type AnalyticsTableDataMap = {
"work-items": WorkItemInsightColumns,
}
export interface IAnalyticsV2Params {
x_axis: ChartXAxisProperty;
y_axis: ChartYAxisMetric;
group_by?: ChartXAxisProperty;
}

16
packages/types/src/charts/common.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
export type TChartColorScheme = "modern" | "horizon" | "earthen";
export type TChartDatum = {
key: string;
name: string;
count: number;
} & Record<string, number>;
export type TChart = {
data: TChartDatum[];
schema: Record<string, string>;
};

View File

@@ -1,7 +1,14 @@
// ============================================================
// Chart Base
// ============================================================
export * from "./common";
export type TChartLegend = {
align: "left" | "center" | "right";
verticalAlign: "top" | "middle" | "bottom";
layout: "horizontal" | "vertical";
wrapperStyles?: React.CSSProperties;
};
export type TChartMargin = {
@@ -22,6 +29,7 @@ type TChartProps<K extends string, T extends string> = {
key: keyof TChartData<K, T>;
label?: string;
strokeColor?: string;
dy?: number;
};
yAxis: {
allowDecimals?: boolean;
@@ -29,6 +37,8 @@ type TChartProps<K extends string, T extends string> = {
key: keyof TChartData<K, T>;
label?: string;
strokeColor?: string;
offset?: number;
dx?: number;
};
className?: string;
legend?: TChartLegend;
@@ -40,6 +50,10 @@ type TChartProps<K extends string, T extends string> = {
showTooltip?: boolean;
};
// ============================================================
// Bar Chart
// ============================================================
export type TBarItem<T extends string> = {
key: T;
label: string;
@@ -56,6 +70,10 @@ export type TBarChartProps<K extends string, T extends string> = TChartProps<K,
barSize?: number;
};
// ============================================================
// Line Chart
// ============================================================
export type TLineItem<T extends string> = {
key: T;
label: string;
@@ -71,6 +89,25 @@ export type TLineChartProps<K extends string, T extends string> = TChartProps<K,
lines: TLineItem<T>[];
};
// ============================================================
// Scatter Chart
// ============================================================
export type TScatterPointItem<T extends string> = {
key: T;
label: string;
fill: string;
stroke: string;
};
export type TScatterChartProps<K extends string, T extends string> = TChartProps<K, T> & {
scatterPoints: TScatterPointItem<T>[];
};
// ============================================================
// Area Chart
// ============================================================
export type TAreaItem<T extends string> = {
key: T;
label: string;
@@ -92,6 +129,10 @@ export type TAreaChartProps<K extends string, T extends string> = TChartProps<K,
};
};
// ============================================================
// Pie Chart
// ============================================================
export type TCellItem<T extends string> = {
key: T;
fill: string;
@@ -119,6 +160,10 @@ export type TPieChartProps<K extends string, T extends string> = Pick<
customLegend?: (props: any) => React.ReactNode;
};
// ============================================================
// Tree Map
// ============================================================
export type TreeMapItem = {
name: string;
value: number;
@@ -126,13 +171,13 @@ export type TreeMapItem = {
textClassName?: string;
icon?: React.ReactElement;
} & (
| {
| {
fillColor: string;
}
| {
| {
fillClassName: string;
}
);
);
export type TreeMapChartProps = {
data: TreeMapItem[];
@@ -158,3 +203,32 @@ export type TContentVisibility = {
top: TTopSectionConfig;
bottom: TBottomSectionConfig;
};
// ============================================================
// Radar Chart
// ============================================================
export type TRadarItem<T extends string> = {
key: T;
name: string;
fill?: string;
stroke?: string;
fillOpacity?: number;
dot?: {
r: number;
fillOpacity: number;
}
}
export type TRadarChartProps<K extends string, T extends string> = Pick<
TChartProps<K, T>,
"className" | "showTooltip" | "margin" | "data" | "legend"
> & {
dataKey: T;
radars: TRadarItem<T>[];
angleAxis: {
key: keyof TChartData<K, T>;
label?: string;
strokeColor?: string;
};
}

View File

@@ -67,3 +67,9 @@ export enum EFileAssetType {
PROJECT_DESCRIPTION = "PROJECT_DESCRIPTION",
TEAM_SPACE_COMMENT_DESCRIPTION = "TEAM_SPACE_COMMENT_DESCRIPTION",
}
export enum EUpdateStatus {
OFF_TRACK = "OFF-TRACK",
ON_TRACK = "ON-TRACK",
AT_RISK = "AT-RISK",
}

View File

@@ -43,3 +43,4 @@ export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";
export * from "./analytics-v2";

View File

@@ -1,3 +1,4 @@
import { EStartOfTheWeek } from "@plane/constants";
import { IIssueActivity, TIssuePriorities, TStateGroups } from ".";
import { TUserPermissions } from "./enums";
@@ -64,6 +65,7 @@ export type TUserProfile = {
language: string;
created_at: Date | string;
updated_at: Date | string;
start_of_the_week: EStartOfTheWeek;
};
export interface IInstanceAdminStatus {
@@ -155,14 +157,7 @@ export interface IUserProfileProjectSegregation {
id: string;
pending_issues: number;
}[];
user_data: Pick<
IUser,
| "avatar_url"
| "cover_image_url"
| "display_name"
| "first_name"
| "last_name"
> & {
user_data: Pick<IUser, "avatar_url" | "cover_image_url" | "display_name" | "first_name" | "last_name"> & {
date_joined: Date;
user_timezone: string;
};

View File

@@ -17,6 +17,7 @@ export const Calendar = ({ className, classNames, showOutsideDays = true, ...pro
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
weekStartsOn={props.weekStartsOn}
// classNames={{
// months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
// month: "space-y-4",

View File

@@ -3,27 +3,18 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const AtRiskIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
fill="#CC7700"
/>
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
stroke="#F3F4F7"
stroke-width="2"
/>
<g clip-path="url(#clip0_21157_25600)">
<svg width={width} height={height} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_365_7561)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0369 15.3346H10.667C11.0352 15.3346 11.3337 15.6331 11.3337 16.0013C11.3337 16.3695 11.0352 16.668 10.667 16.668H10.0369C10.3686 19.6679 12.912 22.0013 16.0003 22.0013C19.0887 22.0013 21.6321 19.6679 21.9637 16.668H21.3337C20.9655 16.668 20.667 16.3695 20.667 16.0013C20.667 15.6331 20.9655 15.3346 21.3337 15.3346H21.9637C21.6321 12.3347 19.0887 10.0013 16.0003 10.0013C12.912 10.0013 10.3686 12.3347 10.0369 15.3346ZM8.66699 16.0013C8.66699 11.9512 11.9502 8.66797 16.0003 8.66797C20.0504 8.66797 23.3337 11.9512 23.3337 16.0013C23.3337 20.0514 20.0504 23.3346 16.0003 23.3346C11.9502 23.3346 8.66699 20.0514 8.66699 16.0013ZM16.0003 12.668C16.3685 12.668 16.667 12.9664 16.667 13.3346V16.0013C16.667 16.3695 16.3685 16.668 16.0003 16.668C15.6321 16.668 15.3337 16.3695 15.3337 16.0013V13.3346C15.3337 12.9664 15.6321 12.668 16.0003 12.668ZM15.3337 18.668C15.3337 18.2998 15.6321 18.0013 16.0003 18.0013H16.007C16.3752 18.0013 16.6737 18.2998 16.6737 18.668C16.6737 19.0362 16.3752 19.3346 16.007 19.3346H16.0003C15.6321 19.3346 15.3337 19.0362 15.3337 18.668Z"
fill="white"
d="M2.03658 7.33335H2.66663C3.03482 7.33335 3.33329 7.63183 3.33329 8.00002C3.33329 8.36821 3.03482 8.66669 2.66663 8.66669H2.03658C2.36821 11.6667 4.91159 14 7.99996 14C11.0883 14 13.6317 11.6667 13.9633 8.66669H13.3333C12.9651 8.66669 12.6666 8.36821 12.6666 8.00002C12.6666 7.63183 12.9651 7.33335 13.3333 7.33335H13.9633C13.6317 4.33339 11.0883 2.00002 7.99996 2.00002C4.91159 2.00002 2.36821 4.33339 2.03658 7.33335ZM0.666626 8.00002C0.666626 3.94993 3.94987 0.666687 7.99996 0.666687C12.05 0.666687 15.3333 3.94993 15.3333 8.00002C15.3333 12.0501 12.05 15.3334 7.99996 15.3334C3.94987 15.3334 0.666626 12.0501 0.666626 8.00002ZM7.99996 4.66669C8.36815 4.66669 8.66663 4.96516 8.66663 5.33335V8.00002C8.66663 8.36821 8.36815 8.66669 7.99996 8.66669C7.63177 8.66669 7.33329 8.36821 7.33329 8.00002V5.33335C7.33329 4.96516 7.63177 4.66669 7.99996 4.66669ZM7.33329 10.6667C7.33329 10.2985 7.63177 10 7.99996 10H8.00663C8.37482 10 8.67329 10.2985 8.67329 10.6667C8.67329 11.0349 8.37482 11.3334 8.00663 11.3334H7.99996C7.63177 11.3334 7.33329 11.0349 7.33329 10.6667Z"
fill="#CC7700"
/>
</g>
<defs>
<clipPath id="clip0_21157_25600">
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
<clipPath id="clip0_365_7561">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>

View File

@@ -3,27 +3,18 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const OffTrackIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
fill="#CC0000"
/>
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
stroke="#F3F4F7"
stroke-width="2"
/>
<g clip-path="url(#clip0_21157_78200)">
<svg width={width} height={height} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_365_7595)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0369 15.3346H10.667C11.0352 15.3346 11.3337 15.6331 11.3337 16.0013C11.3337 16.3695 11.0352 16.668 10.667 16.668H10.0369C10.3686 19.6679 12.912 22.0013 16.0003 22.0013C19.0887 22.0013 21.6321 19.6679 21.9637 16.668H21.3337C20.9655 16.668 20.667 16.3695 20.667 16.0013C20.667 15.6331 20.9655 15.3346 21.3337 15.3346H21.9637C21.6321 12.3347 19.0887 10.0013 16.0003 10.0013C12.912 10.0013 10.3686 12.3347 10.0369 15.3346ZM8.66699 16.0013C8.66699 11.9512 11.9502 8.66797 16.0003 8.66797C20.0504 8.66797 23.3337 11.9512 23.3337 16.0013C23.3337 20.0514 20.0504 23.3346 16.0003 23.3346C11.9502 23.3346 8.66699 20.0514 8.66699 16.0013ZM14.667 12.668C15.0352 12.668 15.3337 12.9664 15.3337 13.3346V16.0013C15.3337 16.3695 15.0352 16.668 14.667 16.668C14.2988 16.668 14.0003 16.3695 14.0003 16.0013V13.3346C14.0003 12.9664 14.2988 12.668 14.667 12.668ZM17.3337 12.668C17.7018 12.668 18.0003 12.9664 18.0003 13.3346V16.0013C18.0003 16.3695 17.7018 16.668 17.3337 16.668C16.9655 16.668 16.667 16.3695 16.667 16.0013V13.3346C16.667 12.9664 16.9655 12.668 17.3337 12.668ZM14.0003 18.668C14.0003 18.2998 14.2988 18.0013 14.667 18.0013H14.6737C15.0418 18.0013 15.3403 18.2998 15.3403 18.668C15.3403 19.0362 15.0418 19.3346 14.6737 19.3346H14.667C14.2988 19.3346 14.0003 19.0362 14.0003 18.668ZM16.667 18.668C16.667 18.2998 16.9655 18.0013 17.3337 18.0013H17.3403C17.7085 18.0013 18.007 18.2998 18.007 18.668C18.007 19.0362 17.7085 19.3346 17.3403 19.3346H17.3337C16.9655 19.3346 16.667 19.0362 16.667 18.668Z"
fill="white"
d="M2.03658 7.33335H2.66663C3.03482 7.33335 3.33329 7.63183 3.33329 8.00002C3.33329 8.36821 3.03482 8.66669 2.66663 8.66669H2.03658C2.36821 11.6667 4.91159 14 7.99996 14C11.0883 14 13.6317 11.6667 13.9633 8.66669H13.3333C12.9651 8.66669 12.6666 8.36821 12.6666 8.00002C12.6666 7.63183 12.9651 7.33335 13.3333 7.33335H13.9633C13.6317 4.33339 11.0883 2.00002 7.99996 2.00002C4.91159 2.00002 2.36821 4.33339 2.03658 7.33335ZM0.666626 8.00002C0.666626 3.94993 3.94987 0.666687 7.99996 0.666687C12.05 0.666687 15.3333 3.94993 15.3333 8.00002C15.3333 12.0501 12.05 15.3334 7.99996 15.3334C3.94987 15.3334 0.666626 12.0501 0.666626 8.00002ZM6.66663 4.66669C7.03482 4.66669 7.33329 4.96516 7.33329 5.33335V8.00002C7.33329 8.36821 7.03482 8.66669 6.66663 8.66669C6.29844 8.66669 5.99996 8.36821 5.99996 8.00002V5.33335C5.99996 4.96516 6.29844 4.66669 6.66663 4.66669ZM9.33329 4.66669C9.70148 4.66669 9.99996 4.96516 9.99996 5.33335V8.00002C9.99996 8.36821 9.70148 8.66669 9.33329 8.66669C8.9651 8.66669 8.66663 8.36821 8.66663 8.00002V5.33335C8.66663 4.96516 8.9651 4.66669 9.33329 4.66669ZM5.99996 10.6667C5.99996 10.2985 6.29844 10 6.66663 10H6.67329C7.04148 10 7.33996 10.2985 7.33996 10.6667C7.33996 11.0349 7.04148 11.3334 6.67329 11.3334H6.66663C6.29844 11.3334 5.99996 11.0349 5.99996 10.6667ZM8.66663 10.6667C8.66663 10.2985 8.9651 10 9.33329 10H9.33996C9.70815 10 10.0066 10.2985 10.0066 10.6667C10.0066 11.0349 9.70815 11.3334 9.33996 11.3334H9.33329C8.9651 11.3334 8.66663 11.0349 8.66663 10.6667Z"
fill="#CC0000"
/>
</g>
<defs>
<clipPath id="clip0_21157_78200">
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
<clipPath id="clip0_365_7595">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>

View File

@@ -3,45 +3,39 @@ import * as React from "react";
import { ISvgIcons } from "./type";
export const OnTrackIcon: React.FC<ISvgIcons> = ({ width = "16", height = "16" }) => (
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
fill="#1FAD40"
/>
<path
d="M16 1C7.71573 1 1 7.71573 1 16C1 24.2843 7.71573 31 16 31C24.2843 31 31 24.2843 31 16C31 7.71573 24.2843 1 16 1Z"
stroke="#F3F4F7"
stroke-width="2"
/>
<g clip-path="url(#clip0_21157_107468)">
<svg width={width} height={height} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_365_7535)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.0005 10.8004C17.8118 10.1146 16.4238 9.85693 15.0681 10.0705C13.7124 10.2841 12.4709 10.956 11.5507 11.9741C10.6304 12.9923 10.087 14.2952 10.0111 15.6655C9.93516 17.0358 10.3313 18.3907 11.1334 19.5043C11.9356 20.6179 13.0953 21.4228 14.4191 21.7849C15.7429 22.1469 17.1508 22.0442 18.408 21.4938C19.6652 20.9435 20.6958 19.9787 21.3278 18.7605C21.9598 17.5423 22.1551 16.1442 21.8811 14.7994C21.8076 14.4387 22.0404 14.0866 22.4012 14.0131C22.762 13.9396 23.1141 14.1725 23.1876 14.5332C23.5225 16.1768 23.2838 17.8856 22.5113 19.3745C21.7389 20.8635 20.4793 22.0426 18.9427 22.7153C17.4061 23.3879 15.6853 23.5135 14.0673 23.071C12.4493 22.6285 11.032 21.6447 10.0516 20.2836C9.07117 18.9226 8.58699 17.2665 8.67979 15.5917C8.77259 13.9169 9.43675 12.3245 10.5615 11.0801C11.6863 9.83568 13.2037 9.01448 14.8606 8.75343C16.5176 8.49238 18.2139 8.80726 19.6668 9.64556C19.9857 9.82957 20.0951 10.2373 19.9111 10.5562C19.7271 10.8751 19.3194 10.9845 19.0005 10.8004ZM23.1384 10.1949C23.3987 10.4553 23.3987 10.8774 23.1384 11.1377L16.4717 17.8044C16.2114 18.0648 15.7893 18.0648 15.5289 17.8044L13.5289 15.8044C13.2686 15.5441 13.2686 15.1219 13.5289 14.8616C13.7893 14.6012 14.2114 14.6012 14.4717 14.8616L16.0003 16.3902L22.1956 10.1949C22.4559 9.93458 22.878 9.93458 23.1384 10.1949Z"
fill="white"
d="M11.0001 2.80075C9.81139 2.11486 8.42345 1.85723 7.06776 2.07082C5.71206 2.28441 4.47057 2.9563 3.55031 3.97445C2.63004 4.9926 2.08664 6.29547 2.01071 7.66578C1.93479 9.03609 2.33093 10.391 3.13308 11.5046C3.93523 12.6182 5.0949 13.4231 6.4187 13.7852C7.74249 14.1472 9.1504 14.0445 10.4076 13.4941C11.6649 12.9438 12.6954 11.979 13.3274 10.7608C13.9594 9.5426 14.1547 8.14453 13.8807 6.79975C13.8072 6.43897 14.0401 6.08691 14.4009 6.0134C14.7616 5.93988 15.1137 6.17276 15.1872 6.53353C15.5221 8.17715 15.2834 9.88591 14.511 11.3748C13.7385 12.8638 12.4789 14.0429 10.9423 14.7156C9.4057 15.3882 7.68493 15.5138 6.06696 15.0713C4.44898 14.6288 3.03161 13.645 2.05121 12.2839C1.0708 10.9229 0.586626 9.26684 0.679423 7.59202C0.772221 5.91719 1.43638 4.3248 2.56115 3.08039C3.68591 1.83598 5.2033 1.01478 6.86025 0.753732C8.51721 0.492682 10.2136 0.807564 11.6665 1.64587C11.9854 1.82987 12.0947 2.23757 11.9107 2.55648C11.7267 2.8754 11.319 2.98476 11.0001 2.80075ZM15.138 2.19524C15.3984 2.45559 15.3984 2.8777 15.138 3.13805L8.47136 9.80471C8.21101 10.0651 7.7889 10.0651 7.52855 9.80471L5.52856 7.80471C5.26821 7.54436 5.26821 7.12225 5.52856 6.8619C5.7889 6.60155 6.21101 6.60155 6.47136 6.8619L7.99996 8.3905L14.1952 2.19524C14.4556 1.93489 14.8777 1.93489 15.138 2.19524Z"
fill="#1FAD40"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.0003 23.333C15.6321 23.333 15.3337 23.0345 15.3337 22.6663V21.333C15.3337 20.9648 15.6321 20.6663 16.0003 20.6663C16.3685 20.6663 16.667 20.9648 16.667 21.333V22.6663C16.667 23.0345 16.3685 23.333 16.0003 23.333Z"
fill="white"
d="M7.99996 15.3333C7.63177 15.3333 7.33329 15.0348x33329 14.6666V13.3333C7.33329 12.9651 7.63177 12.6666 7.99996 12.6666C8.36815 12.6666 8.66663 12.9651 8.66663 13.3333V14.6666C8.66663 15.0348 8.36815 15.3333 7.99996 15.3333Z"
fill="#1FAD40"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.0003 11.333C15.6321 11.333 15.3337 11.0345 15.3337 10.6663V9.333C15.3337 8.96481 15.6321 8.66634 16.0003 8.66634C16.3685 8.66634 16.667 8.96481 16.667 9.333V10.6663C16.667 11.0345 16.3685 11.333 16.0003 11.333Z"
fill="white"
d="M7.99996 3.33331C7.63177 3.33331 7.33329 3.03483 7.33329 2.66664V1.33331C7.33329 0.965117 7.63177 0.66664 7.99996 0.66664C8.36815 0.66664 8.66663 0.965117 8.66663 1.33331V2.66664C8.66663 3.03483 8.36815 3.33331 7.99996 3.33331Z"
fill="#1FAD40"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.3337 15.9997C11.3337 16.3679 11.0352 16.6663 10.667 16.6663H9.33366C8.96547 16.6663 8.66699 16.3679 8.66699 15.9997C8.66699 15.6315 8.96547 15.333 9.33366 15.333H10.667C11.0352 15.333 11.3337 15.6315 11.3337 15.9997Z"
fill="white"
d="M3.33329 7.99997C3.33329 8.36816 3.03482 8.66664 2.66663 8.66664H1.33329C0.965103 8.66664 0.666626 8.36816 0.666626 7.99997C0.666626 7.63178 0.965103 7.33331 1.33329 7.33331H2.66663C3.03482 7.33331 3.33329 7.63178 3.33329 7.99997Z"
fill="#1FAD40"
/>
</g>
<defs>
<clipPath id="clip0_21157_107468">
<rect width="16" height="16" fill="white" transform="translate(8 8)" />
<clipPath id="clip0_365_7535">
<path
d="M0 2C0 0.895431 0.895431 0 2 0H14C15.1046 0 16 0.895431 16 2V14C16 15.1046 15.1046 16 14 16H2C0.895431 16 0 15.1046 0 14V2Z"
fill="white"
/>
</clipPath>
</defs>
</svg>

View File

@@ -0,0 +1,32 @@
export const generateFileName = (fileName: string) => {
const date = new Date();
const timestamp = date.getTime();
const _fileName = getFileName(fileName);
const nameWithoutExtension = _fileName.length > 80 ? _fileName.substring(0, 80) : _fileName;
const extension = getFileExtension(fileName);
return `${nameWithoutExtension}-${timestamp}.${extension}`;
};
export const getFileExtension = (filename: string) => filename.slice(((filename.lastIndexOf(".") - 1) >>> 0) + 2);
export const getFileName = (fileName: string) => {
const dotIndex = fileName.lastIndexOf(".");
const nameWithoutExtension = fileName.substring(0, dotIndex);
return nameWithoutExtension;
};
export const convertBytesToSize = (bytes: number) => {
let size;
if (bytes < 1024 * 1024) {
size = Math.round(bytes / 1024) + " KB";
} else {
size = Math.round(bytes / (1024 * 1024)) + " MB";
}
return size;
};

View File

@@ -1,4 +1,5 @@
export * from "./array";
export * from "./attachment";
export * from "./auth";
export * from "./datetime";
export * from "./color";
@@ -16,4 +17,3 @@ export * from "./work-item";
export * from "./get-icon-for-link";
export * from "./subscription";

View File

@@ -144,6 +144,9 @@ export const CommentCard: React.FC<Props> = observer((props) => {
ref={showEditorRef}
id={comment.id}
initialValue={comment.comment_html}
displayConfig={{
fontSize: "small-font",
}}
/>
<CommentReactions anchor={anchor} commentId={comment.id} />
</div>

View File

@@ -1,6 +1,7 @@
import set from "lodash/set";
import { action, makeObservable, observable, runInAction } from "mobx";
// plane imports
import { EStartOfTheWeek } from "@plane/constants";
import { UserService } from "@plane/services";
import { TUserProfile } from "@plane/types";
// store
@@ -54,6 +55,7 @@ export class ProfileStore implements IProfileStore {
created_at: "",
updated_at: "",
language: "",
start_of_the_week: EStartOfTheWeek.SUNDAY,
};
// services

View File

@@ -1,6 +1,7 @@
"use client";
// components
import { AppHeader, ContentWrapper } from "@/components/core";
// plane web components
import { WorkspaceAnalyticsHeader } from "./header";
export default function WorkspaceAnalyticsLayout({ children }: { children: React.ReactNode }) {

View File

@@ -1,24 +1,24 @@
"use client";
import React, { Fragment } from "react";
import { useMemo } from "react";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { Tab } from "@headlessui/react";
import { useRouter, useSearchParams } from "next/navigation";
// plane package imports
import { ANALYTICS_TABS, EUserPermissionsLevel, EUserPermissions } from "@plane/constants";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Header, EHeaderVariant } from "@plane/ui";
import { Tabs } from "@plane/ui";
// components
import { CustomAnalytics, ScopeAndDemand } from "@/components/analytics";
import AnalyticsFilterActions from "@/components/analytics-v2/analytics-filter-actions";
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";
import { ANALYTICS_TABS } from "@/plane-web/components/analytics-v2/tabs";
const AnalyticsPage = observer(() => {
const router = useRouter();
const searchParams = useSearchParams();
const analytics_tab = searchParams.get("analytics_tab");
// plane imports
const { t } = useTranslation();
// store hooks
@@ -40,44 +40,38 @@ const AnalyticsPage = observer(() => {
EUserPermissionsLevel.WORKSPACE
);
// TODO: refactor loader implementation
const tabs = useMemo(
() =>
ANALYTICS_TABS.map((tab) => ({
key: tab.key,
label: t(tab.i18nKey),
content: <tab.content />,
onClick: () => {
router.push(`?tab=${tab.key}`);
},
})),
[router, t]
);
const defaultTab = searchParams.get("tab") || ANALYTICS_TABS[0].key;
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 className="flex h-full overflow-hidden bg-custom-background-100 justify-between items-center ">
<Tabs
tabs={tabs}
storageKey={`analytics-page-${currentWorkspace?.id}`}
defaultTab={defaultTab}
size="md"
tabListContainerClassName="px-6 py-2 border-b border-custom-border-200 flex items-center justify-between"
tabListClassName="my-2 max-w-36"
tabPanelClassName="h-full w-full overflow-hidden overflow-y-auto"
storeInLocalStorage={false}
actions={<AnalyticsFilterActions />}
/>
</div>
) : (
<DetailedEmptyState

View File

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

View File

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

View File

@@ -20,7 +20,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,
@@ -105,7 +105,7 @@ export const ProjectIssuesMobileHeader = observer(() => {
return (
<>
<ProjectAnalyticsModal
<WorkItemsModal
isOpen={analyticsModal}
onClose={() => setAnalyticsModal(false)}
projectDetails={currentProjectDetails ?? undefined}

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
"use client";
import React from "react";
import { observer } from "mobx-react";
import Image from "next/image";

View File

@@ -10,13 +10,13 @@ import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common";
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// constants
import { ThemeSwitch, PageHead, CustomThemeSelector } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper, StartOfWeekPreference } from "@/components/profile";
// helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
const { t } = useTranslation();
const { setTheme } = useTheme();
@@ -75,6 +75,7 @@ const ProfileAppearancePage = observer(() => {
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
<StartOfWeekPreference />
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">

View File

@@ -0,0 +1,11 @@
import { TAnalyticsTabsV2Base } from "@plane/types";
import { Overview } from "@/components/analytics-v2/overview";
import { WorkItems } from "@/components/analytics-v2/work-items";
export const ANALYTICS_TABS: {
key: TAnalyticsTabsV2Base;
i18nKey: string;
content: React.FC;
}[] = [
{ key: "overview", i18nKey: "common.overview", content: Overview },
{ key: "work-items", i18nKey: "sidebar.work_items", content: WorkItems },
];

View File

@@ -0,0 +1,34 @@
// plane web components
import { observer } from "mobx-react-lite";
// hooks
import { useProject } from "@/hooks/store";
import { useAnalyticsV2 } from "@/hooks/store/use-analytics-v2";
// components
import DurationDropdown from "./select/duration";
import { ProjectSelect } from "./select/project";
const AnalyticsFilterActions = observer(() => {
const { selectedProjects, selectedDuration, updateSelectedProjects, updateSelectedDuration } = useAnalyticsV2();
const { workspaceProjectIds } = useProject();
return (
<div className="flex items-center justify-end gap-2">
<ProjectSelect
value={selectedProjects}
onChange={(val) => {
updateSelectedProjects(val ?? []);
}}
projectIds={workspaceProjectIds}
/>
{/* <DurationDropdown
buttonVariant="border-with-text"
value={selectedDuration}
onChange={(val) => {
updateSelectedDuration(val);
}}
dropdownArrow
/> */}
</div>
);
});
export default AnalyticsFilterActions;

View File

@@ -0,0 +1,30 @@
import { cn } from "@plane/utils";
type Props = {
title?: string;
children: React.ReactNode;
className?: string;
subtitle?: string | null;
actions?: React.ReactNode;
headerClassName?: string;
};
const AnalyticsSectionWrapper: React.FC<Props> = (props) => {
const { title, children, className, subtitle, actions, headerClassName } = props;
return (
<div className={className}>
<div className={cn("mb-6 flex items-center gap-2 text-nowrap ", headerClassName)}>
{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>} */}
</div>
)}
{actions}
</div>
{children}
</div>
);
};
export default AnalyticsSectionWrapper;

View File

@@ -0,0 +1,22 @@
import React from "react";
// plane package imports
import { cn } from "@plane/utils";
type Props = {
title: string;
children: React.ReactNode;
className?: string;
};
const AnalyticsWrapper: React.FC<Props> = (props) => {
const { title, children, className } = props;
return (
<div className={cn("px-6 py-4", className)}>
<h1 className={"mb-4 text-2xl font-bold md:mb-6"}>{title}</h1>
{children}
</div>
);
};
export default AnalyticsWrapper;

View File

@@ -0,0 +1,48 @@
import React from "react";
import Image from "next/image";
// plane package imports
import { cn } from "@plane/utils";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
type Props = {
title: string;
description?: string;
assetPath?: string;
className?: string;
};
const AnalyticsV2EmptyState = ({ title, description, assetPath, className }: Props) => {
const backgroundReolvedPath = useResolvedAssetPath({ basePath: "/empty-state/analytics-v2/empty-grid-background" });
return (
<div
className={cn(
"flex h-full w-full items-center justify-center overflow-y-auto rounded-lg border border-custom-border-100 px-5 py-10 md:px-20",
className
)}
>
<div className={cn("flex flex-col items-center")}>
{assetPath && (
<div className="relative flex max-h-[200px] max-w-[200px] items-center justify-center">
<Image src={assetPath} alt={title} width={100} height={100} layout="fixed" className="z-10 h-2/3 w-2/3" />
<div className="absolute inset-0">
<Image
src={backgroundReolvedPath}
alt={title}
width={100}
height={100}
layout="fixed"
className="h-full w-full"
/>
</div>
</div>
)}
<div className="flex flex-shrink flex-col items-center gap-1.5 text-center">
<h3 className={cn("text-xl font-semibold")}>{title}</h3>
{description && <p className="text-sm text-custom-text-300">{description}</p>}
</div>
</div>
</div>
);
};
export default AnalyticsV2EmptyState;

View File

@@ -0,0 +1 @@
export * from "./overview/root";

View File

@@ -0,0 +1,47 @@
// plane package imports
import React, { useMemo } from "react";
import { IAnalyticsResponseFieldsV2 } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import TrendPiece from "./trend-piece";
export type InsightCardProps = {
data?: IAnalyticsResponseFieldsV2;
label: string;
isLoading?: boolean;
versus?: string | null;
};
const InsightCard = (props: InsightCardProps) => {
const { data, label, isLoading, versus } = props;
const { count, filter_count } = data || {};
const percentage = useMemo(() => {
if (count != null && filter_count != null) {
const result = ((count - filter_count) / count) * 100;
const isFiniteAndNotNaNOrZero = Number.isFinite(result) && !Number.isNaN(result) && result !== 0;
return isFiniteAndNotNaNOrZero ? result : null;
}
return null;
}, [count, filter_count]);
return (
<div className="flex flex-col gap-3">
<div className="text-sm text-custom-text-300">{label}</div>
{!isLoading ? (
<div className="flex flex-col gap-1">
<div className="text-2xl font-bold text-custom-text-100">{count}</div>
{/* {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%" />
)}
</div>
);
};
export default InsightCard;

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