Compare commits

..
226 changed files with 1152 additions and 7016 deletions
@@ -1,4 +1,5 @@
@import "@plane/propel/styles/fonts";
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@200;300;400;500;600;700;800&display=swap");
@import url("https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@48,400,0,0&display=swap");
@tailwind base;
@tailwind components;
@@ -59,31 +60,23 @@
--color-border-300: 212, 212, 212; /* strong border- 1 */
--color-border-400: 185, 185, 185; /* strong border- 2 */
--color-shadow-2xs:
0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
--color-shadow-2xs: 0px 0px 1px 0px rgba(23, 23, 23, 0.06), 0px 1px 2px 0px rgba(23, 23, 23, 0.06),
0px 1px 2px 0px rgba(23, 23, 23, 0.14);
--color-shadow-xs:
0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
--color-shadow-xs: 0px 1px 2px 0px rgba(0, 0, 0, 0.16), 0px 2px 4px 0px rgba(16, 24, 40, 0.12),
0px 1px 8px -1px rgba(16, 24, 40, 0.1);
--color-shadow-sm:
0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02), 0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg:
0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
--color-shadow-sm: 0px 1px 4px 0px rgba(0, 0, 0, 0.01), 0px 4px 8px 0px rgba(0, 0, 0, 0.02),
0px 1px 12px 0px rgba(0, 0, 0, 0.12);
--color-shadow-rg: 0px 3px 6px 0px rgba(0, 0, 0, 0.1), 0px 4px 4px 0px rgba(16, 24, 40, 0.08),
0px 1px 12px 0px rgba(16, 24, 40, 0.04);
--color-shadow-md:
0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
--color-shadow-md: 0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12),
0px 1px 16px 0px rgba(16, 24, 40, 0.12);
--color-shadow-lg:
0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
--color-shadow-lg: 0px 6px 12px 0px rgba(0, 0, 0, 0.12), 0px 8px 16px 0px rgba(0, 0, 0, 0.12),
0px 1px 24px 0px rgba(16, 24, 40, 0.12);
--color-shadow-xl:
0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
--color-shadow-xl: 0px 0px 18px 0px rgba(0, 0, 0, 0.16), 0px 0px 24px 0px rgba(16, 24, 40, 0.16),
0px 0px 52px 0px rgba(16, 24, 40, 0.16);
--color-shadow-2xl:
0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
--color-shadow-2xl: 0px 8px 16px 0px rgba(0, 0, 0, 0.12), 0px 12px 24px 0px rgba(16, 24, 40, 0.12),
0px 1px 32px 0px rgba(16, 24, 40, 0.12);
--color-shadow-3xl:
0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
--color-shadow-3xl: 0px 12px 24px 0px rgba(0, 0, 0, 0.12), 0px 16px 32px 0px rgba(0, 0, 0, 0.12),
0px 1px 48px 0px rgba(16, 24, 40, 0.12);
--color-shadow-4xl: 0px 8px 40px 0px rgba(0, 0, 61, 0.05), 0px 12px 32px -16px rgba(0, 0, 0, 0.05);
+4 -2
View File
@@ -3,16 +3,18 @@
import { ReactNode } from "react";
import { ThemeProvider, useTheme } from "next-themes";
import { SWRConfig } from "swr";
// plane imports
// ui
import { ADMIN_BASE_PATH, DEFAULT_SWR_CONFIG } from "@plane/constants";
import { Toast } from "@plane/ui";
import { resolveGeneralTheme } from "@plane/utils";
// constants
// helpers
// lib
import { InstanceProvider } from "@/lib/instance-provider";
import { StoreProvider } from "@/lib/store-provider";
import { UserProvider } from "@/lib/user-provider";
// styles
import "@/styles/globals.css";
import "./globals.css";
const ToastWithTheme = () => {
const { resolvedTheme } = useTheme();
-1
View File
@@ -17,7 +17,6 @@
"@headlessui/react": "^1.7.19",
"@plane/constants": "*",
"@plane/hooks": "*",
"@plane/propel": "*",
"@plane/services": "*",
"@plane/types": "*",
"@plane/ui": "*",
+8 -2
View File
@@ -1,2 +1,8 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");
module.exports = {
plugins: {
"postcss-import": {},
"tailwindcss/nesting": {},
tailwindcss: {},
autoprefixer: {},
},
};
+1 -2
View File
@@ -6,8 +6,7 @@
"paths": {
"@/*": ["core/*"],
"@/public/*": ["public/*"],
"@/plane-admin/*": ["ce/*"],
"@/styles/*": ["styles/*"]
"@/plane-admin/*": ["ce/*"]
}
},
"include": ["next-env.d.ts", "next.config.js", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
-24
View File
@@ -6,12 +6,8 @@ from plane.app.views import (
AnalyticViewViewset,
SavedAnalyticEndpoint,
ExportAnalyticsEndpoint,
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
AdvanceAnalyticsExportEndpoint,
)
@@ -53,24 +49,4 @@ 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",
),
]
-7
View File
@@ -199,13 +199,6 @@ from .analytic.base import (
ProjectStatsEndpoint,
)
from .analytic.advance import (
AdvanceAnalyticsEndpoint,
AdvanceAnalyticsStatsEndpoint,
AdvanceAnalyticsChartEndpoint,
AdvanceAnalyticsExportEndpoint,
)
from .notification.base import (
NotificationViewSet,
UnreadNotificationEndpoint,
@@ -1,411 +0,0 @@
from rest_framework.response import Response
from rest_framework import status
from typing import Dict, List, Any
from datetime import timedelta
from django.db.models import QuerySet, Q, Count
from django.http import HttpRequest
from 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,
)
from django.db.models import (
Q,
Count,
)
from plane.utils.build_chart import build_analytics_chart
from datetime import timedelta
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
from plane.utils.date_utils import (
get_analytics_filters,
)
from plane.utils.build_chart import build_analytics_chart
from plane.bgtasks.analytic_plot_export import export_analytics_to_csv_email
from plane.utils.date_utils import get_analytics_filters
class AdvanceAnalyticsBaseView(BaseAPIView):
def initialize_workspace(self, slug: str, type: str) -> None:
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.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
)
# Get daily stats with optimized query
daily_stats = (
queryset.values("created_at__date")
.annotate(
created_count=Count("id"),
completed_count=Count("id", filter=Q(completed_at__isnull=False)),
)
.order_by("created_at__date")
)
# Create a dictionary of existing stats with summed counts
stats_dict = {
stat["created_at__date"].strftime("%Y-%m-%d"): {
"created_count": stat["created_count"],
"completed_count": stat["completed_count"],
}
for stat in daily_stats
}
# Generate data for all days in the range
data = []
current_date = start_date
while current_date <= end_date:
date_str = current_date.strftime("%Y-%m-%d")
stats = stats_dict.get(date_str, {"created_count": 0, "completed_count": 0})
data.append(
{
"key": date_str,
"name": date_str,
"count": stats["created_count"] + stats["completed_count"],
"completed_issues": stats["completed_count"],
"created_issues": stats["created_count"],
}
)
current_date += timedelta(days=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,
)
+4
View File
@@ -234,6 +234,7 @@ class CycleViewSet(BaseViewSet):
"status",
"version",
"created_by",
"created_at",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
@@ -267,6 +268,7 @@ class CycleViewSet(BaseViewSet):
"status",
"version",
"created_by",
"created_at",
)
datetime_fields = ["start_date", "end_date"]
data = user_timezone_converter(data, datetime_fields, project_timezone)
@@ -314,6 +316,7 @@ class CycleViewSet(BaseViewSet):
"assignee_ids",
"status",
"created_by",
"created_at",
)
.first()
)
@@ -476,6 +479,7 @@ class CycleViewSet(BaseViewSet):
"assignee_ids",
"status",
"created_by",
"created_at",
)
.first()
)
+35 -99
View File
@@ -1,5 +1,5 @@
# Django imports
from django.db.models import Q, QuerySet
from django.db.models import Q
# Third party imports
from rest_framework import status
@@ -12,95 +12,6 @@ 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")
@@ -110,6 +21,7 @@ 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(
@@ -120,28 +32,52 @@ class IssueSearchEndpoint(BaseAPIView):
)
if workspace_search == "false":
issues = self.filter_issues_by_project(project_id, issues)
issues = issues.filter(project_id=project_id)
if query:
issues = self.search_issues_by_query(query, issues)
issues = search_issues(query, issues)
if parent == "true" and issue_id:
issues = self.search_issues_and_excluding_parent(issues, 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)
)
if issue_relation == "true" and issue_id:
issues = self.filter_issues_excluding_related_issues(issue_id, 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),
)
if sub_issue == "true" and issue_id:
issues = self.filter_root_issues_only(issue_id, issues)
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))
if cycle == "true":
issues = self.exclude_issues_in_cycles(issues)
issues = issues.exclude(
Q(issue_cycle__isnull=False) & Q(issue_cycle__deleted_at__isnull=True)
)
if module:
issues = self.exclude_issues_in_module(issues, module)
issues = issues.exclude(
Q(issue_module__module=module)
& Q(issue_module__deleted_at__isnull=True)
)
if target_date == "none":
issues = self.filter_issues_without_target_date(issues)
issues = issues.filter(target_date__isnull=True)
if ProjectMember.objects.filter(
project_id=project_id, member=self.request.user, is_active=True, role=5
+2 -6
View File
@@ -21,9 +21,7 @@ def base_host(
# Admin redirection
if is_admin:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str):
admin_base_path = "/god-mode/"
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", "/god-mode/")
if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):
@@ -36,9 +34,7 @@ def base_host(
# Space redirection
if is_space:
space_base_path = getattr(settings, "SPACE_BASE_PATH", None)
if not isinstance(space_base_path, str):
space_base_path = "/spaces/"
space_base_path = getattr(settings, "SPACE_BASE_PATH", "/spaces/")
if not space_base_path.startswith("/"):
space_base_path = "/" + space_base_path
if not space_base_path.endswith("/"):
@@ -459,37 +459,8 @@ def analytic_export_task(email, data, slug):
csv_buffer = generate_csv_from_rows(rows)
send_export_email(email, slug, csv_buffer, rows)
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent succesfully.")
return
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
@@ -309,7 +309,7 @@ def send_email_notification(
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email Sent Successfully")
logging.getLogger("plane").info("Email Sent Successfully")
# Update the logs
EmailNotificationLog.objects.filter(
@@ -325,7 +325,7 @@ def send_email_notification(
release_lock(lock_id=lock_id)
return
else:
logging.getLogger("plane.worker").info("Duplicate email received skipping")
logging.getLogger("plane").info("Duplicate email received skipping")
return
except (Issue.DoesNotExist, User.DoesNotExist):
release_lock(lock_id=lock_id)
+109 -250
View File
@@ -3,48 +3,34 @@ 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, FileAsset, Label, User, IssueComment
from plane.db.models import ExporterHistory, Issue
from plane.utils.exception_logger import log_exception
def dateTimeConverter(time: datetime) -> str | None:
"""
Convert a datetime object to a formatted string.
"""
def dateTimeConverter(time):
if time:
return time.strftime("%a, %d %b %Y %I:%M:%S %Z%z")
def dateConverter(time: date) -> str | None:
"""
Convert a date object to a formatted string.
"""
def dateConverter(time):
if time:
return time.strftime("%a, %d %b %Y")
def create_csv_file(data: List[List[str]]) -> str:
"""
Create a CSV file from the provided data.
"""
def create_csv_file(data):
csv_buffer = io.StringIO()
csv_writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
@@ -55,17 +41,11 @@ def create_csv_file(data: List[List[str]]) -> str:
return csv_buffer.getvalue()
def create_json_file(data: List[dict]) -> str:
"""
Create a JSON file from the provided data.
"""
def create_json_file(data):
return json.dumps(data)
def create_xlsx_file(data: List[List[str]]) -> bytes:
"""
Create an XLSX file from the provided data.
"""
def create_xlsx_file(data):
workbook = Workbook()
sheet = workbook.active
@@ -78,10 +58,7 @@ def create_xlsx_file(data: List[List[str]]) -> bytes:
return xlsx_buffer.getvalue()
def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
"""
Create a ZIP file from the provided files.
"""
def create_zip_file(files):
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zipf:
for filename, file_content in files:
@@ -90,11 +67,8 @@ def create_zip_file(files: List[tuple[str, str | bytes]]) -> io.BytesIO:
zip_buffer.seek(0)
return zip_buffer
# TODO: Change the upload_to_s3 function to use the new storage method with entry in file asset table
def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug: str) -> None:
"""
Upload a ZIP file to S3 and generate a presigned URL.
"""
def upload_to_s3(zip_file, workspace_id, token_id, slug):
file_name = (
f"{workspace_id}/export-{slug}-{token_id[:6]}-{str(timezone.now().date())}.zip"
)
@@ -176,85 +150,75 @@ def upload_to_s3(zip_file: io.BytesIO, workspace_id: UUID, token_id: str, slug:
exporter_instance.save(update_fields=["status", "url", "key"])
def generate_table_row(issue: dict) -> List[str]:
"""
Generate a table row from an issue dictionary.
"""
def generate_table_row(issue):
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"],
issue["state_name"],
issue["description_stripped"],
issue["state__name"],
dateConverter(issue["start_date"]),
dateConverter(issue["target_date"]),
issue["priority"],
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 "",
(
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"]),
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: dict) -> dict:
"""
Generate a JSON row from an issue dictionary.
"""
def generate_json_row(issue):
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"],
"State": issue["state_name"],
"Description": issue["description_stripped"],
"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']}" 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 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 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: List[dict], row: dict) -> None:
"""
Update the json row with the new assignee and label.
"""
def update_json_row(rows, row):
matched_index = next(
(
index
@@ -283,10 +247,7 @@ def update_json_row(rows: List[dict], row: dict) -> None:
rows.append(row)
def update_table_row(rows: List[List[str]], row: List[str]) -> None:
"""
Update the table row with the new assignee and label.
"""
def update_table_row(rows, row):
matched_index = next(
(index for index, existing_row in enumerate(rows) if existing_row[0] == row[0]),
None,
@@ -308,7 +269,7 @@ def update_table_row(rows: List[List[str]], row: List[str]) -> None:
rows.append(row)
def generate_csv(header: List[str], project_id: str, issues: List[dict], files: List[tuple[str, str | bytes]]) -> None:
def generate_csv(header, project_id, issues, files):
"""
Generate CSV export for all the passed issues.
"""
@@ -320,10 +281,7 @@ def generate_csv(header: List[str], project_id: str, issues: List[dict], files:
files.append((f"{project_id}.csv", csv_file))
def generate_json(header: List[str], project_id: str, issues: List[dict], files: List[tuple[str, str | bytes]]) -> None:
"""
Generate JSON export for all the passed issues.
"""
def generate_json(header, project_id, issues, files):
rows = []
for issue in issues:
row = generate_json_row(issue)
@@ -332,157 +290,68 @@ def generate_json(header: List[str], project_id: str, issues: List[dict], files:
files.append((f"{project_id}.json", json_file))
def generate_xlsx(header: List[str], project_id: str, issues: List[dict], files: List[tuple[str, str | bytes]]) -> None:
"""
Generate XLSX export for all the passed issues.
"""
def generate_xlsx(header, project_id, issues, files):
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: 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.
"""
def issue_export_task(provider, workspace_id, project_ids, token_id, multiple, slug):
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",
"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",
(
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",
)
)
.order_by("project__identifier", "sequence_id")
.distinct()
)
# 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
# CSV header
header = [
"ID",
"Project",
@@ -493,25 +362,20 @@ def issue_export_task(provider: str, workspace_id: UUID, project_ids: List[str],
"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,
@@ -520,13 +384,8 @@ def issue_export_task(provider: str, workspace_id: UUID, project_ids: List[str],
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 = project_dict.get(str(project_id), [])
issues = workspace_issues.filter(project__id=project_id)
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, project_id, issues, files)
@@ -534,7 +393,7 @@ def issue_export_task(provider: str, workspace_id: UUID, project_ids: List[str],
else:
exporter = EXPORTER_MAPPER.get(provider)
if exporter is not None:
exporter(header, workspace_id, issues_data, files)
exporter(header, workspace_id, workspace_issues, files)
zip_buffer = create_zip_file(files)
upload_to_s3(zip_buffer, workspace_id, token_id, slug)
@@ -63,7 +63,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully")
logging.getLogger("plane").info("Email sent successfully")
return
except Exception as e:
log_exception(e)
@@ -53,7 +53,7 @@ def magic_link(email, key, token):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
@@ -80,7 +80,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id):
# Send the email
msg.send()
# Log the success
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
@@ -76,7 +76,7 @@ def project_invitation(email, project_id, token, current_site, invitor):
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except (Project.DoesNotExist, ProjectMemberInvite.DoesNotExist):
return
@@ -58,7 +58,7 @@ def user_activation_email(current_site, user_id):
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
@@ -60,7 +60,7 @@ def user_deactivation_email(current_site, user_id):
# Attach HTML content
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully.")
logging.getLogger("plane").info("Email sent successfully.")
return
except Exception as e:
log_exception(e)
@@ -78,7 +78,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
)
msg.attach_alternative(html_content, "text/html")
msg.send()
logging.getLogger("plane.worker").info("Email sent successfully")
logging.getLogger("plane").info("Email sent successfully")
return
except (Workspace.DoesNotExist, WorkspaceMemberInvite.DoesNotExist):
return
@@ -1,23 +0,0 @@
# Generated by Django 4.2.14 on 2025-05-09 11:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('db', '0094_auto_20250425_0902'),
]
operations = [
migrations.AddField(
model_name='page',
name='external_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='page',
name='external_source',
field=models.CharField(blank=True, max_length=255, null=True),
),
]
+15 -21
View File
@@ -18,28 +18,22 @@ class BaseModel(AuditModel):
class Meta:
abstract = True
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()
def save(self, *args, **kwargs):
user = get_current_user()
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)
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)
def __str__(self):
return str(self.id)
-3
View File
@@ -58,9 +58,6 @@ class Page(BaseModel):
moved_to_page = models.UUIDField(null=True, blank=True)
moved_to_project = models.UUIDField(null=True, blank=True)
external_id = models.CharField(max_length=255, null=True, blank=True)
external_source = models.CharField(max_length=255, null=True, blank=True)
class Meta:
verbose_name = "Page"
verbose_name_plural = "Pages"
+2
View File
@@ -2,10 +2,12 @@
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")),
-205
View File
@@ -1,205 +0,0 @@
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}
-197
View File
@@ -1,197 +0,0 @@
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, period_ranges["last_7_days"])
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 filters
project_filters = {
"workspace__slug": slug,
"project_projectmember__member": user,
"project_projectmember__is_active": 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,
}
+2 -6
View File
@@ -25,9 +25,7 @@ def base_host(
# Admin redirection
if is_admin:
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", None)
if not isinstance(admin_base_path, str):
admin_base_path = "/god-mode/"
admin_base_path = getattr(settings, "ADMIN_BASE_PATH", "/god-mode/")
if not admin_base_path.startswith("/"):
admin_base_path = "/" + admin_base_path
if not admin_base_path.endswith("/"):
@@ -40,9 +38,7 @@ def base_host(
# Space redirection
if is_space:
space_base_path = getattr(settings, "SPACE_BASE_PATH", None)
if not isinstance(space_base_path, str):
space_base_path = "/spaces/"
space_base_path = getattr(settings, "SPACE_BASE_PATH", "/spaces/")
if not space_base_path.startswith("/"):
space_base_path = "/" + space_base_path
if not space_base_path.endswith("/"):
+2 -2
View File
@@ -1,4 +1,4 @@
from django.urls import path
from plane.web.views import robots_txt, health_check
from django.views.generic import TemplateView
urlpatterns = [path("robots.txt", robots_txt), path("", health_check)]
urlpatterns = [path("about/", TemplateView.as_view(template_name="about.html"))]
+1 -9
View File
@@ -1,9 +1 @@
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")
# Create your views here.
+1 -1
View File
@@ -1,7 +1,7 @@
# base requirements
# django
Django==4.2.21
Django==4.2.20
# rest framework
djangorestframework==3.15.2
# postgres
+9
View File
@@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<h1>Hello from plane!</h1>
<p>Made with Django</p>
{% endblock content %}
+5
View File
@@ -0,0 +1,5 @@
{% extends 'base.html' %} {% load static %} {% block content %}
<div class="container mt-5">
<h1>Hello from plane!</h1>
</div>
{% endblock content %}
+23 -49
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 exec $api_container_id python3 -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" > /dev/null 2>&1;
while ! docker logs $api_container_id 2>&1 | grep -m 1 -i "Application startup complete" | grep -q ".";
do
local message=">> Waiting for API Service to Start"
local dots=$(printf '%*s' $idx2 | tr ' ' '.')
@@ -508,69 +508,43 @@ function viewLogs(){
echo "INVALID SERVICE NAME SUPPLIED"
fi
}
function backup_container_dir() {
local BACKUP_FOLDER=$1
local CONTAINER_NAME=$2
local CONTAINER_DATA_DIR=$3
local SERVICE_FOLDER=$4
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=""
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
local svcName="${selectedVolume//$tobereplaced/$replacewith}"
# 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"
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}'
}
function backupData() {
local datetime=$(date +"%Y%m%d-%H%M")
local BACKUP_FOLDER=$PLANE_INSTALL_DIR/backup/$datetime
mkdir -p "$BACKUP_FOLDER"
# Check if docker-compose.yml exists
if [ ! -f "$DOCKER_FILE_PATH" ]; then
echo "Error: docker-compose.yml not found at $DOCKER_FILE_PATH"
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'"
exit 1
fi
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
for vol in $volumes; do
echo "Backing Up $vol"
backupSingleVolume "$BACKUP_FOLDER" "$vol"
done
echo ""
echo "Backup completed successfully. Backup files are stored in $BACKUP_FOLDER"
echo ""
}
function askForAction() {
local DEFAULT_ACTION=$1
+2 -4
View File
@@ -66,10 +66,8 @@ 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 "$volume_suffix")
volumes=$(docker volume ls -f "name=plane-app" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
# Check if there are any matching volumes
if [ -z "$volumes" ]; then
echo ".....No volumes found starting with 'plane-app'"
@@ -89,7 +87,7 @@ function restoreData() {
echo "Found $BACKUP_FILE"
local docVol
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "$volume_suffix")
docVol=$(docker volume ls -f "name=$restoreVolName" --format "{{.Name}}" | grep -E "_pgdata|_redisdata|_uploads")
if [ -z "$docVol" ]; then
echo "Skipping: No volume found with name $restoreVolName"
@@ -1,105 +0,0 @@
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"];
@@ -1 +0,0 @@
export * from "./common"
-155
View File
@@ -1,157 +1,2 @@
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",
],
},
];
-1
View File
@@ -33,4 +33,3 @@ export * from "./page";
export * from "./emoji";
export * from "./subscription";
export * from "./icon";
export * from "./analytics-v2";
+1 -21
View File
@@ -1,10 +1,4 @@
import {
TIssueGroupByOptions,
TIssueOrderByOptions,
IIssueDisplayProperties,
IIssueFilterOptions,
TIssue,
} from "@plane/types";
import { TIssueGroupByOptions, TIssueOrderByOptions, IIssueDisplayProperties } from "@plane/types";
export const ALL_ISSUES = "All Issues";
@@ -367,17 +361,3 @@ export const SPREADSHEET_PROPERTY_DETAILS: {
icon: "LayersIcon",
},
};
// Map filter keys to their corresponding issue property keys
export const FILTER_TO_ISSUE_MAP: Partial<Record<keyof IIssueFilterOptions, keyof TIssue>> = {
assignees: "assignee_ids",
created_by: "created_by",
labels: "label_ids",
priority: "priority",
cycle: "cycle_id",
module: "module_ids",
project: "project_id",
state: "state_id",
issue_type: "type_id",
state_group: "state__group",
} as const;
+19 -7
View File
@@ -1,4 +1,7 @@
import { ILayoutDisplayFiltersOptions, TIssueActivityComment } from "@plane/types";
import {
ILayoutDisplayFiltersOptions,
TIssueActivityComment,
} from "@plane/types";
import {
TIssueFilterPriorityObject,
ISSUE_DISPLAY_PROPERTIES_KEYS,
@@ -355,10 +358,9 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
sub_work_items: {
list: {
display_properties: SUB_ISSUES_DISPLAY_PROPERTIES_KEYS,
filters: ["priority", "state", "project", "issue_type", "assignees", "start_date", "target_date"],
filters: [],
display_filters: {
order_by: ["-created_at", "-updated_at", "start_date", "-priority"],
group_by: ["state", "priority", "assignees", null],
},
extra_options: {
access: true,
@@ -368,7 +370,9 @@ export const ISSUE_DISPLAY_FILTERS_BY_PAGE: TIssueFiltersToDisplayByPageType = {
},
};
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<Record<EIssuesStoreType, TFiltersByLayout>> = {
export const ISSUE_STORE_TO_FILTERS_MAP: Partial<
Record<EIssuesStoreType, TFiltersByLayout>
> = {
[EIssuesStoreType.PROJECT]: ISSUE_DISPLAY_FILTERS_BY_PAGE.issues,
};
@@ -379,7 +383,10 @@ export enum EActivityFilterType {
export type TActivityFilters = EActivityFilterType;
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<TActivityFilters, { labelTranslationKey: string }> = {
export const ACTIVITY_FILTER_TYPE_OPTIONS: Record<
TActivityFilters,
{ labelTranslationKey: string }
> = {
[EActivityFilterType.ACTIVITY]: {
labelTranslationKey: "common.updates",
},
@@ -395,12 +402,17 @@ export type TActivityFilterOption = {
onClick: () => void;
};
export const defaultActivityFilters: TActivityFilters[] = [EActivityFilterType.ACTIVITY, EActivityFilterType.COMMENT];
export const defaultActivityFilters: TActivityFilters[] = [
EActivityFilterType.ACTIVITY,
EActivityFilterType.COMMENT,
];
export const filterActivityOnSelectedFilters = (
activity: TIssueActivityComment[],
filters: TActivityFilters[]
): TIssueActivityComment[] =>
activity.filter((activity) => filters.includes(activity.activity_type as TActivityFilters));
activity.filter((activity) =>
filters.includes(activity.activity_type as TActivityFilters)
);
export const ENABLE_ISSUE_DEPENDENCIES = false;
-50
View File
@@ -71,53 +71,3 @@ 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",
},
];
-2
View File
@@ -1,4 +1,3 @@
"use client"
export type TStateGroups = "backlog" | "unstarted" | "started" | "completed" | "cancelled";
export type TDraggableData = {
@@ -78,5 +77,4 @@ export const PROGRESS_STATE_GROUPS_DETAILS = [
},
];
export const DISPLAY_WORKFLOW_PRO_CTA = false;
@@ -1,10 +1,9 @@
import { Extensions } from "@tiptap/core";
// types
import { TExtensions, TFileHandler } from "@/types";
import { TExtensions } from "@/types";
type Props = {
disabledExtensions: TExtensions[];
fileHandler: TFileHandler;
};
export const CoreEditorAdditionalExtensions = (props: Props): Extensions => {
+2 -52
View File
@@ -8,55 +8,5 @@ export const DEFAULT_DISPLAY_CONFIG: TDisplayConfig = {
wideLayout: false,
};
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",
];
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]}`);
@@ -3,11 +3,11 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef } from "react";
// plane utils
import { cn } from "@plane/utils";
// constants
import { ACCEPTED_IMAGE_MIME_TYPES } from "@/constants/config";
import { ACCEPTED_FILE_EXTENSIONS } from "@/constants/config";
// extensions
import { CustoBaseImageNodeViewProps, getImageComponentImageFileMap } from "@/extensions/custom-image";
// hooks
import { useUploader, useDropZone, uploadFirstFileAndInsertRemaining } from "@/hooks/use-file-upload";
import { useUploader, useDropZone, uploadFirstImageAndInsertRemaining } from "@/hooks/use-file-upload";
type CustomImageUploaderProps = CustoBaseImageNodeViewProps & {
maxFileSize: number;
@@ -41,9 +41,7 @@ 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();
@@ -53,7 +51,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 === node.type.name && currentNode.attrs.src === url) {
if (currentNode && currentNode.type.name === "imageComponent" && currentNode.attrs.src === url) {
// control cursor position after upload
const nextNode = editor.state.doc.nodeAt(pos + 1);
@@ -70,23 +68,17 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
[imageComponentImageFileMap, imageEntityId, updateAttributes, getPos]
);
// hooks
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,
const { uploading: isImageBeingUploaded, uploadFile } = useUploader({
blockId: imageEntityId ?? "",
editor,
loadImageFromFileSystem,
maxFileSize,
onUpload,
});
const { draggedInside, onDrop, onDragEnter, onDragLeave } = useDropZone({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
editor,
maxFileSize,
pos: getPos(),
type: "image",
uploader: uploadFile,
});
@@ -118,13 +110,11 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
if (!filesList) {
return;
}
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
await uploadFirstImageAndInsertRemaining({
editor,
filesList,
maxFileSize,
pos: getPos(),
type: "image",
uploader: uploadFile,
});
},
@@ -180,7 +170,7 @@ export const CustomImageUploader = (props: CustomImageUploaderProps) => {
ref={fileInputRef}
hidden
type="file"
accept={ACCEPTED_IMAGE_MIME_TYPES.join(",")}
accept={ACCEPTED_FILE_EXTENSIONS.join(",")}
onChange={onFileChange}
multiple
/>
@@ -2,15 +2,12 @@ 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 } from "@/plugins/image";
import { TrackImageDeletionPlugin, TrackImageRestorationPlugin, isFileValid } from "@/plugins/image";
// types
import { TFileHandler } from "@/types";
@@ -149,7 +146,6 @@ export const CustomImageExtension = (props: TFileHandler) => {
if (
props?.file &&
!isFileValid({
acceptedMimeTypes: ACCEPTED_IMAGE_MIME_TYPES,
file: props.file,
maxFileSize,
})
-127
View File
@@ -1,127 +0,0 @@
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;
}
};
@@ -0,0 +1,94 @@
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;
}
};
@@ -172,7 +172,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
CustomColorExtension,
...CoreEditorAdditionalExtensions({
disabledExtensions,
fileHandler,
}),
];
@@ -1,6 +1,7 @@
import { Editor, Range } from "@tiptap/core";
// extensions
// types
import { InsertImageComponentProps } from "@/extensions";
// extensions
import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-with-text";
// helpers
import { findTableAncestor } from "@/helpers/common";
@@ -205,7 +206,6 @@ 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();
@@ -1,87 +1,87 @@
import { Editor } from "@tiptap/core";
import { DragEvent, useCallback, useEffect, useState } from "react";
import { Editor } from "@tiptap/core";
// extensions
import { insertFilesSafely } from "@/extensions/drop";
import { insertImagesSafely } from "@/extensions/drop";
// plugins
import { isFileValid } from "@/helpers/file";
// types
import { TEditorCommands } from "@/types";
import { isFileValid } from "@/plugins/image";
type TUploaderArgs = {
acceptedMimeTypes: string[];
editorCommand: (file: File) => Promise<string>;
handleProgressStatus?: (isUploading: boolean) => void;
loadFileFromFileSystem?: (file: string) => void;
blockId: string;
editor: Editor;
loadImageFromFileSystem: (file: string) => void;
maxFileSize: number;
onUpload: (url: string, file: File) => void;
onUpload: (url: string) => void;
};
export const useUploader = (args: TUploaderArgs) => {
const { acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload } =
args;
const { blockId, editor, loadImageFromFileSystem, maxFileSize, onUpload } = args;
// states
const [isUploading, setIsUploading] = useState(false);
const [uploading, setUploading] = useState(false);
const uploadFile = useCallback(
async (file: File) => {
handleProgressStatus?.(true);
setIsUploading(true);
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 });
const isValid = isFileValid({
acceptedMimeTypes,
file,
file: fileWithTrimmedName,
maxFileSize,
});
if (!isValid) {
handleProgressStatus?.(false);
setIsUploading(false);
setImageUploadInProgress(false);
return;
}
try {
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);
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 (!url) {
throw new Error("Something went wrong while uploading the file.");
throw new Error("Something went wrong while uploading the image");
}
onUpload(url, file);
} catch (errPayload) {
onUpload(url);
} catch (errPayload: any) {
console.log(errPayload);
const error = errPayload?.response?.data?.error || "Something went wrong";
console.error(error);
} finally {
handleProgressStatus?.(false);
setIsUploading(false);
setImageUploadInProgress(false);
setUploading(false);
}
},
[acceptedMimeTypes, editorCommand, handleProgressStatus, loadFileFromFileSystem, maxFileSize, onUpload]
[onUpload]
);
return { isUploading, uploadFile };
return { uploading, 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 { acceptedMimeTypes, editor, maxFileSize, pos, type, uploader } = args;
const { editor, maxFileSize, pos, uploader } = args;
// states
const [isDragging, setIsDragging] = useState<boolean>(false);
const [draggedInside, setDraggedInside] = useState<boolean>(false);
@@ -112,79 +112,83 @@ export const useDropZone = (args: TDropzoneArgs) => {
return;
}
const filesList = e.dataTransfer.files;
await uploadFirstFileAndInsertRemaining({
acceptedMimeTypes,
await uploadFirstImageAndInsertRemaining({
editor,
filesList,
maxFileSize,
pos,
type,
uploader,
});
},
[acceptedMimeTypes, editor, maxFileSize, pos, type, uploader]
[uploader, editor, pos]
);
const onDragEnter = useCallback(() => setDraggedInside(true), []);
const onDragLeave = useCallback(() => setDraggedInside(false), []);
return {
isDragging,
draggedInside,
onDragEnter,
onDragLeave,
onDrop,
const onDragEnter = () => {
setDraggedInside(true);
};
const onDragLeave = () => {
setDraggedInside(false);
};
return { isDragging, draggedInside, onDragEnter, onDragLeave, onDrop };
};
type TMultipleFileArgs = {
acceptedMimeTypes: string[];
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 = {
editor: Editor;
filesList: FileList;
maxFileSize: number;
pos: number;
type: Extract<TEditorCommands, "attachment" | "image">;
uploader: (file: File) => Promise<void>;
};
// 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;
// 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;
const filteredFiles: File[] = [];
for (let i = 0; i < filesList.length; i += 1) {
const file = filesList.item(i);
const item = filesList.item(i);
if (
file &&
item &&
item.type.indexOf("image") !== -1 &&
isFileValid({
acceptedMimeTypes,
file,
file: item,
maxFileSize,
})
) {
filteredFiles.push(file);
filteredFiles.push(item);
}
}
if (filteredFiles.length !== filesList.length) {
console.warn("Some files were invalid and have been ignored.");
console.warn("Some files were not images and have been ignored.");
}
if (filteredFiles.length === 0) {
console.error("No files found to upload.");
console.error("No image files found to upload");
return;
}
// Upload the first file
// Upload the first image
const firstFile = filteredFiles[0];
uploader(firstFile);
// Insert the remaining files
// Insert the remaining images
const remainingFiles = filteredFiles.slice(1);
if (remainingFiles.length > 0) {
const docSize = editor.state.doc.content.size;
const posOfNextFileToBeInserted = Math.min(pos + 1, docSize);
insertFilesSafely({
editor,
files: remainingFiles,
initialPos: posOfNextFileToBeInserted,
event: "drop",
type,
});
const posOfNextImageToBeInserted = Math.min(pos + 1, docSize);
insertImagesSafely({ editor, files: remainingFiles, initialPos: posOfNextImageToBeInserted, event: "drop" });
}
};
}
@@ -10,10 +10,10 @@ const verticalEllipsisIcon =
const generalSelectors = [
"li",
"p.editor-paragraph-block:not(:first-child)",
"p:not(:first-child)",
".code-block",
"blockquote",
"h1.editor-heading-block, h2.editor-heading-block, h3.editor-heading-block, h4.editor-heading-block, h5.editor-heading-block, h6.editor-heading-block",
"h1, h2, h3, h4, h5, h6",
"[data-type=horizontalRule]",
".table-wrapper",
".issue-embed",
@@ -0,0 +1,7 @@
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";
@@ -37,16 +37,20 @@ export const TrackImageDeletionPlugin = (editor: Editor, deleteImage: DeleteImag
removedImages.forEach(async (node) => {
const src = node.attrs.src;
editor.storage[nodeType].deletedImageSet?.set(src, true);
if (!src) return;
try {
await deleteImage(src);
} catch (error) {
console.error("Error deleting image:", error);
}
editor.storage[nodeType].deletedImageSet.set(src, true);
await onNodeDeleted(src, deleteImage);
});
});
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);
}
}
@@ -1,3 +1,5 @@
export * from "./types";
export * from "./utils";
export * from "./constants";
export * from "./delete-image";
export * from "./restore-image";
@@ -0,0 +1 @@
export * from "./validate-file";
@@ -1,18 +1,20 @@
// constants
import { ACCEPTED_FILE_MIME_TYPES } from "@/constants/config";
type TArgs = {
acceptedMimeTypes: string[];
file: File;
maxFileSize: number;
};
export const isFileValid = (args: TArgs): boolean => {
const { acceptedMimeTypes, file, maxFileSize } = args;
const { file, maxFileSize } = args;
if (!file) {
alert("No file selected. Please select a file to upload.");
return false;
}
if (!acceptedMimeTypes.includes(file.type)) {
if (!ACCEPTED_FILE_MIME_TYPES.includes(file.type)) {
alert("Invalid file type. Please select a JPEG, JPG, PNG, WEBP or GIF file.");
return false;
}
+1 -32
View File
@@ -1311,38 +1311,7 @@
}
}
}
},
"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ů}}",
+1 -32
View File
@@ -1311,38 +1311,7 @@
}
}
}
},
"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}}",
+1 -33
View File
@@ -699,8 +699,7 @@
"view": "View",
"deactivated_user": "Deactivated user",
"apply": "Apply",
"applying": "Applying",
"overview": "Overview"
"applying": "Applying"
},
"chart": {
"x_axis": "X-axis",
@@ -1147,37 +1146,6 @@
}
}
}
},
"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": {
+1 -32
View File
@@ -1314,38 +1314,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1312,38 +1312,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1311,38 +1311,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1310,38 +1310,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1312,38 +1312,7 @@
}
}
}
},
"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 {プロジェクト}}",
+1 -32
View File
@@ -1313,38 +1313,7 @@
}
}
}
},
"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 {프로젝트}}",
+1 -32
View File
@@ -1313,38 +1313,7 @@
}
}
}
},
"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}}",
@@ -1313,38 +1313,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1311,38 +1311,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1313,38 +1313,7 @@
}
}
}
},
"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 {Проекты}}",
+1 -32
View File
@@ -1313,38 +1313,7 @@
}
}
}
},
"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}}",
@@ -1314,38 +1314,7 @@
}
}
}
},
"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}}",
+1 -32
View File
@@ -1313,38 +1313,7 @@
}
}
}
},
"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 {Проєктів}}",
@@ -1312,38 +1312,7 @@
}
}
}
},
"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}}",
@@ -1312,38 +1312,7 @@
}
}
}
},
"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 {项目}}",
@@ -1313,38 +1313,7 @@
}
}
}
},
"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 {專案}}",
+2 -5
View File
@@ -9,13 +9,10 @@
},
"exports": {
"./ui/*": "./src/ui/*.tsx",
"./charts/*": "./src/charts/*/index.ts",
"./table": "./src/table/index.ts",
"./styles/fonts": "./src/styles/fonts/index.css"
"./charts/*": "./src/charts/*/index.ts"
},
"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",
@@ -31,4 +28,4 @@
"@types/react-dom": "18.3.0",
"typescript": "^5.3.3"
}
}
}
+6 -2
View File
@@ -1,2 +1,6 @@
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = require("@plane/tailwind-config/postcss.config.js");
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+10 -17
View File
@@ -29,21 +29,13 @@ 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, 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 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 renderAreas = useMemo(
() =>
@@ -85,7 +77,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
@@ -99,6 +91,7 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
};
});
}, [data, xAxis.key]);
return (
<div className={className}>
<ResponsiveContainer width="100%" height="100%">
@@ -135,8 +128,8 @@ export const AreaChart = React.memo(<K extends string, T extends string>(props:
value: yAxis.label,
angle: -90,
position: "bottom",
offset: yAxis.offset ?? -24,
dx: yAxis.dx ?? -16,
offset: -24,
dx: -16,
className: AXIS_LABEL_CLASSNAME,
}
}
+7 -16
View File
@@ -40,22 +40,13 @@ 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, 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 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 renderBars = useMemo(
() =>
@@ -111,7 +102,7 @@ export const BarChart = React.memo(<K extends string, T extends string>(props: T
axisLine={false}
label={{
value: xAxis.label,
dy: xAxis.dy ?? 28,
dy: 28,
className: AXIS_LABEL_CLASSNAME,
}}
tickCount={tickCount.x}
@@ -15,17 +15,16 @@ 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",
}),
...args.wrapperStyles,
left: 0,
bottom: 0,
width: "100%",
justifyContent: "center",
}),
},
content: <CustomLegend {...args} />,
};
@@ -34,8 +33,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;
+2 -26
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, getLabel }: any) => (
export const CustomXAxisTick = React.memo<any>(({ x, y, payload }: any) => (
<g transform={`translate(${x},${y})`}>
<text y={0} dy={16} textAnchor="middle" className={AXIS_TICK_CLASSNAME}>
{getLabel ? getLabel(payload.value) : payload.value}
{payload.value}
</text>
</g>
));
@@ -20,28 +20,4 @@ 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";
+6 -14
View File
@@ -38,21 +38,13 @@ 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, 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 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 renderLines = useMemo(
() =>
@@ -1 +0,0 @@
export * from "./root";
@@ -1,95 +0,0 @@
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 };
@@ -1 +0,0 @@
export * from "./root";
@@ -1,155 +0,0 @@
/* 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";
+12
View File
@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-border;
}
body {
@apply font-sans antialiased bg-background text-foreground;
}
}
-120
View File
@@ -1,120 +0,0 @@
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,
}
-1
View File
@@ -1 +0,0 @@
export * from "./core";
-52
View File
@@ -1,52 +0,0 @@
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;
}
@@ -1,14 +1,7 @@
// ============================================================
// 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 = {
@@ -29,7 +22,6 @@ type TChartProps<K extends string, T extends string> = {
key: keyof TChartData<K, T>;
label?: string;
strokeColor?: string;
dy?: number;
};
yAxis: {
allowDecimals?: boolean;
@@ -37,8 +29,6 @@ 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;
@@ -50,10 +40,6 @@ type TChartProps<K extends string, T extends string> = {
showTooltip?: boolean;
};
// ============================================================
// Bar Chart
// ============================================================
export type TBarItem<T extends string> = {
key: T;
label: string;
@@ -70,10 +56,6 @@ 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;
@@ -89,25 +71,6 @@ 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;
@@ -129,10 +92,6 @@ export type TAreaChartProps<K extends string, T extends string> = TChartProps<K,
};
};
// ============================================================
// Pie Chart
// ============================================================
export type TCellItem<T extends string> = {
key: T;
fill: string;
@@ -160,10 +119,6 @@ export type TPieChartProps<K extends string, T extends string> = Pick<
customLegend?: (props: any) => React.ReactNode;
};
// ============================================================
// Tree Map
// ============================================================
export type TreeMapItem = {
name: string;
value: number;
@@ -171,13 +126,13 @@ export type TreeMapItem = {
textClassName?: string;
icon?: React.ReactElement;
} & (
| {
| {
fillColor: string;
}
| {
| {
fillClassName: string;
}
);
);
export type TreeMapChartProps = {
data: TreeMapItem[];
@@ -203,32 +158,3 @@ 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;
};
}
-16
View File
@@ -1,16 +0,0 @@
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>;
};
+1
View File
@@ -104,6 +104,7 @@ export interface ICycle extends TProgressSnapshot {
project_detail: IProjectDetails;
progress: any[];
version: number;
created_at?: string;
}
export interface CycleIssueResponse {
-6
View File
@@ -67,9 +67,3 @@ 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",
}
-1
View File
@@ -43,4 +43,3 @@ export * from "./home";
export * from "./stickies";
export * from "./utils";
export * from "./payment";
export * from "./analytics-v2";
-1
View File
@@ -64,7 +64,6 @@ export type TIssue = TBaseIssue & {
tempId?: string;
// sourceIssueId is used to store the original issue id when creating a copy of an issue. Used in cloning property values. It is not a part of the API response.
sourceIssueId?: string;
state__group?: string | null;
};
export type TIssueMap = {
+8 -3
View File
@@ -1,4 +1,3 @@
import { EStartOfTheWeek } from "@plane/constants";
import { IIssueActivity, TIssuePriorities, TStateGroups } from ".";
import { TUserPermissions } from "./enums";
@@ -65,7 +64,6 @@ export type TUserProfile = {
language: string;
created_at: Date | string;
updated_at: Date | string;
start_of_the_week: EStartOfTheWeek;
};
export interface IInstanceAdminStatus {
@@ -157,7 +155,14 @@ 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;
};
-1
View File
@@ -17,7 +17,6 @@ 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",

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