mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
[WEB-373] chore: new dashboard updates (#3849)
* chore: replaced marimekko graph with a bar graph * chore: add bar onClick handler * chore: custom date filter for widgets * style: priority graph * chore: workspace profile activity pagination * chore: profile activity pagination * chore: user profile activity pagination * chore: workspace user activity csv download * chore: download activity button added * chore: workspace user pagination * chore: collabrator pagination * chore: field change * chore: recent collaborators pagination * chore: changed the collabrators * chore: collabrators list changed * fix: distinct users * chore: search filter in collaborators * fix: import error * chore: update priority graph x-axis values * chore: admin and member request validation * chore: update csv download request method * chore: search implementation for the collaborators widget * refactor: priority distribution card * chore: add enum for duration filters * chore: update inbox types * chore: add todos for refactoring --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
126d01bdc5
commit
5a32d10f96
@ -22,6 +22,7 @@ from plane.app.views import (
|
|||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceUserPropertiesEndpoint,
|
||||||
WorkspaceStatesEndpoint,
|
WorkspaceStatesEndpoint,
|
||||||
WorkspaceEstimatesEndpoint,
|
WorkspaceEstimatesEndpoint,
|
||||||
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
WorkspaceModulesEndpoint,
|
WorkspaceModulesEndpoint,
|
||||||
WorkspaceCyclesEndpoint,
|
WorkspaceCyclesEndpoint,
|
||||||
)
|
)
|
||||||
@ -191,6 +192,11 @@ urlpatterns = [
|
|||||||
WorkspaceUserActivityEndpoint.as_view(),
|
WorkspaceUserActivityEndpoint.as_view(),
|
||||||
name="workspace-user-activity",
|
name="workspace-user-activity",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"workspaces/<str:slug>/user-activity/<uuid:user_id>/export/",
|
||||||
|
ExportWorkspaceUserActivityEndpoint.as_view(),
|
||||||
|
name="export-workspace-user-activity",
|
||||||
|
),
|
||||||
path(
|
path(
|
||||||
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
||||||
WorkspaceUserProfileEndpoint.as_view(),
|
WorkspaceUserProfileEndpoint.as_view(),
|
||||||
|
@ -49,6 +49,7 @@ from .workspace import (
|
|||||||
WorkspaceUserPropertiesEndpoint,
|
WorkspaceUserPropertiesEndpoint,
|
||||||
WorkspaceStatesEndpoint,
|
WorkspaceStatesEndpoint,
|
||||||
WorkspaceEstimatesEndpoint,
|
WorkspaceEstimatesEndpoint,
|
||||||
|
ExportWorkspaceUserActivityEndpoint,
|
||||||
WorkspaceModulesEndpoint,
|
WorkspaceModulesEndpoint,
|
||||||
WorkspaceCyclesEndpoint,
|
WorkspaceCyclesEndpoint,
|
||||||
)
|
)
|
||||||
|
@ -14,6 +14,7 @@ from django.db.models import (
|
|||||||
JSONField,
|
JSONField,
|
||||||
Func,
|
Func,
|
||||||
Prefetch,
|
Prefetch,
|
||||||
|
IntegerField,
|
||||||
)
|
)
|
||||||
from django.contrib.postgres.aggregates import ArrayAgg
|
from django.contrib.postgres.aggregates import ArrayAgg
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
@ -38,6 +39,8 @@ from plane.db.models import (
|
|||||||
IssueLink,
|
IssueLink,
|
||||||
IssueAttachment,
|
IssueAttachment,
|
||||||
IssueRelation,
|
IssueRelation,
|
||||||
|
IssueAssignee,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from plane.app.serializers import (
|
from plane.app.serializers import (
|
||||||
IssueActivitySerializer,
|
IssueActivitySerializer,
|
||||||
@ -212,11 +215,11 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
if issue_type == "overdue":
|
if issue_type == "overdue":
|
||||||
overdue_issues_count = assigned_issues.filter(
|
overdue_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
overdue_issues = assigned_issues.filter(
|
overdue_issues = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -231,11 +234,11 @@ def dashboard_assigned_issues(self, request, slug):
|
|||||||
if issue_type == "upcoming":
|
if issue_type == "upcoming":
|
||||||
upcoming_issues_count = assigned_issues.filter(
|
upcoming_issues_count = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
upcoming_issues = assigned_issues.filter(
|
upcoming_issues = assigned_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -365,11 +368,11 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
if issue_type == "overdue":
|
if issue_type == "overdue":
|
||||||
overdue_issues_count = created_issues.filter(
|
overdue_issues_count = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
overdue_issues = created_issues.filter(
|
overdue_issues = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__lt=timezone.now()
|
target_date__lt=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -382,11 +385,11 @@ def dashboard_created_issues(self, request, slug):
|
|||||||
if issue_type == "upcoming":
|
if issue_type == "upcoming":
|
||||||
upcoming_issues_count = created_issues.filter(
|
upcoming_issues_count = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
).count()
|
).count()
|
||||||
upcoming_issues = created_issues.filter(
|
upcoming_issues = created_issues.filter(
|
||||||
state__group__in=["backlog", "unstarted", "started"],
|
state__group__in=["backlog", "unstarted", "started"],
|
||||||
target_date__gte=timezone.now()
|
target_date__gte=timezone.now(),
|
||||||
)[:5]
|
)[:5]
|
||||||
return Response(
|
return Response(
|
||||||
{
|
{
|
||||||
@ -503,7 +506,9 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
).exclude(id__in=unique_project_ids)
|
).exclude(id__in=unique_project_ids)
|
||||||
|
|
||||||
# Append additional project IDs to the existing list
|
# Append additional project IDs to the existing list
|
||||||
unique_project_ids.update(additional_projects.values_list("id", flat=True))
|
unique_project_ids.update(
|
||||||
|
additional_projects.values_list("id", flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
list(unique_project_ids)[:4],
|
list(unique_project_ids)[:4],
|
||||||
@ -512,90 +517,97 @@ def dashboard_recent_projects(self, request, slug):
|
|||||||
|
|
||||||
|
|
||||||
def dashboard_recent_collaborators(self, request, slug):
|
def dashboard_recent_collaborators(self, request, slug):
|
||||||
# Fetch all project IDs where the user belongs to
|
# Subquery to count activities for each project member
|
||||||
user_projects = Project.objects.filter(
|
activity_count_subquery = (
|
||||||
project_projectmember__member=request.user,
|
|
||||||
project_projectmember__is_active=True,
|
|
||||||
workspace__slug=slug,
|
|
||||||
).values_list("id", flat=True)
|
|
||||||
|
|
||||||
# Fetch all users who have performed an activity in the projects where the user exists
|
|
||||||
users_with_activities = (
|
|
||||||
IssueActivity.objects.filter(
|
IssueActivity.objects.filter(
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
project_id__in=user_projects,
|
actor=OuterRef("member"),
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.values("actor")
|
.values("actor")
|
||||||
.exclude(actor=request.user)
|
.annotate(num_activities=Count("pk"))
|
||||||
.annotate(num_activities=Count("actor"))
|
.values("num_activities")
|
||||||
.order_by("-num_activities")
|
|
||||||
)[:7]
|
|
||||||
|
|
||||||
# Get the count of active issues for each user in users_with_activities
|
|
||||||
users_with_active_issues = []
|
|
||||||
for user_activity in users_with_activities:
|
|
||||||
user_id = user_activity["actor"]
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[user_id],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
users_with_active_issues.append(
|
|
||||||
{"user_id": user_id, "active_issue_count": active_issue_count}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Insert the logged-in user's ID and their active issue count at the beginning
|
# Get all project members and annotate them with activity counts
|
||||||
active_issue_count = Issue.objects.filter(
|
project_members_with_activities = (
|
||||||
assignees__in=[request.user],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
|
|
||||||
if users_with_activities.count() < 7:
|
|
||||||
# Calculate the additional collaborators needed
|
|
||||||
additional_collaborators_needed = 7 - users_with_activities.count()
|
|
||||||
|
|
||||||
# Fetch additional collaborators from the project_member table
|
|
||||||
additional_collaborators = list(
|
|
||||||
set(
|
|
||||||
ProjectMember.objects.filter(
|
ProjectMember.objects.filter(
|
||||||
~Q(member=request.user),
|
|
||||||
project_id__in=user_projects,
|
|
||||||
workspace__slug=slug,
|
workspace__slug=slug,
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
project__project_projectmember__is_active=True,
|
||||||
)
|
)
|
||||||
.exclude(
|
.annotate(
|
||||||
member__in=[
|
num_activities=Coalesce(
|
||||||
user["actor"] for user in users_with_activities
|
Subquery(activity_count_subquery),
|
||||||
]
|
Value(0),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
|
is_current_user=Case(
|
||||||
|
When(member=request.user, then=Value(0)),
|
||||||
|
default=Value(1),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.values_list("member", flat=True)
|
.values_list("member", flat=True)
|
||||||
|
.order_by("is_current_user", "-num_activities")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
search = request.query_params.get("search", None)
|
||||||
|
if search:
|
||||||
|
project_members_with_activities = (
|
||||||
|
project_members_with_activities.filter(
|
||||||
|
Q(member__display_name__icontains=search)
|
||||||
|
| Q(member__first_name__icontains=search)
|
||||||
|
| Q(member__last_name__icontains=search)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
additional_collaborators = additional_collaborators[
|
return self.paginate(
|
||||||
:additional_collaborators_needed
|
request=request,
|
||||||
]
|
queryset=project_members_with_activities,
|
||||||
|
controller=self.get_results_controller,
|
||||||
# Append additional collaborators to the list
|
|
||||||
for collaborator_id in additional_collaborators:
|
|
||||||
active_issue_count = Issue.objects.filter(
|
|
||||||
assignees__in=[collaborator_id],
|
|
||||||
state__group__in=["unstarted", "started"],
|
|
||||||
).count()
|
|
||||||
users_with_active_issues.append(
|
|
||||||
{
|
|
||||||
"user_id": str(collaborator_id),
|
|
||||||
"active_issue_count": active_issue_count,
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
users_with_active_issues.insert(
|
|
||||||
0,
|
|
||||||
{"user_id": request.user.id, "active_issue_count": active_issue_count},
|
|
||||||
)
|
|
||||||
|
|
||||||
return Response(users_with_active_issues, status=status.HTTP_200_OK)
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardEndpoint(BaseAPIView):
|
class DashboardEndpoint(BaseAPIView):
|
||||||
|
def get_results_controller(self, project_members_with_activities):
|
||||||
|
user_active_issue_counts = (
|
||||||
|
User.objects.filter(id__in=project_members_with_activities)
|
||||||
|
.annotate(
|
||||||
|
active_issue_count=Count(
|
||||||
|
Case(
|
||||||
|
When(
|
||||||
|
issue_assignee__issue__state__group__in=[
|
||||||
|
"unstarted",
|
||||||
|
"started",
|
||||||
|
],
|
||||||
|
then=1,
|
||||||
|
),
|
||||||
|
output_field=IntegerField(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values("active_issue_count", user_id=F("id"))
|
||||||
|
)
|
||||||
|
# Create a dictionary to store the active issue counts by user ID
|
||||||
|
active_issue_counts_dict = {
|
||||||
|
user["user_id"]: user["active_issue_count"]
|
||||||
|
for user in user_active_issue_counts
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve the sequence of project members with activities
|
||||||
|
paginated_results = [
|
||||||
|
{
|
||||||
|
"user_id": member_id,
|
||||||
|
"active_issue_count": active_issue_counts_dict.get(
|
||||||
|
member_id, 0
|
||||||
|
),
|
||||||
|
}
|
||||||
|
for member_id in project_members_with_activities
|
||||||
|
]
|
||||||
|
return paginated_results
|
||||||
|
|
||||||
def create(self, request, slug):
|
def create(self, request, slug):
|
||||||
serializer = DashboardSerializer(data=request.data)
|
serializer = DashboardSerializer(data=request.data)
|
||||||
if serializer.is_valid():
|
if serializer.is_valid():
|
||||||
@ -622,7 +634,9 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
dashboard_type = request.GET.get("dashboard_type", None)
|
dashboard_type = request.GET.get("dashboard_type", None)
|
||||||
if dashboard_type == "home":
|
if dashboard_type == "home":
|
||||||
dashboard, created = Dashboard.objects.get_or_create(
|
dashboard, created = Dashboard.objects.get_or_create(
|
||||||
type_identifier=dashboard_type, owned_by=request.user, is_default=True
|
type_identifier=dashboard_type,
|
||||||
|
owned_by=request.user,
|
||||||
|
is_default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
@ -639,7 +653,9 @@ class DashboardEndpoint(BaseAPIView):
|
|||||||
|
|
||||||
updated_dashboard_widgets = []
|
updated_dashboard_widgets = []
|
||||||
for widget_key in widgets_to_fetch:
|
for widget_key in widgets_to_fetch:
|
||||||
widget = Widget.objects.filter(key=widget_key).values_list("id", flat=True)
|
widget = Widget.objects.filter(
|
||||||
|
key=widget_key
|
||||||
|
).values_list("id", flat=True)
|
||||||
if widget:
|
if widget:
|
||||||
updated_dashboard_widgets.append(
|
updated_dashboard_widgets.append(
|
||||||
DashboardWidget(
|
DashboardWidget(
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
# Python imports
|
# Python imports
|
||||||
import jwt
|
import jwt
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from dateutil.relativedelta import relativedelta
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
# Django imports
|
# Django imports
|
||||||
|
from django.http import HttpResponse
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@ -1238,6 +1241,66 @@ class WorkspaceUserActivityEndpoint(BaseAPIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExportWorkspaceUserActivityEndpoint(BaseAPIView):
|
||||||
|
permission_classes = [
|
||||||
|
WorkspaceEntityPermission,
|
||||||
|
]
|
||||||
|
|
||||||
|
def generate_csv_from_rows(self, rows):
|
||||||
|
"""Generate CSV buffer from rows."""
|
||||||
|
csv_buffer = io.StringIO()
|
||||||
|
writer = csv.writer(csv_buffer, delimiter=",", quoting=csv.QUOTE_ALL)
|
||||||
|
[writer.writerow(row) for row in rows]
|
||||||
|
csv_buffer.seek(0)
|
||||||
|
return csv_buffer
|
||||||
|
|
||||||
|
def post(self, request, slug, user_id):
|
||||||
|
|
||||||
|
if not request.data.get("date"):
|
||||||
|
return Response(
|
||||||
|
{"error": "Date is required"},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST,
|
||||||
|
)
|
||||||
|
|
||||||
|
user_activities = IssueActivity.objects.filter(
|
||||||
|
~Q(field__in=["comment", "vote", "reaction", "draft"]),
|
||||||
|
workspace__slug=slug,
|
||||||
|
created_at__date=request.data.get("date"),
|
||||||
|
project__project_projectmember__member=request.user,
|
||||||
|
actor_id=user_id,
|
||||||
|
).select_related("actor", "workspace", "issue", "project")[:10000]
|
||||||
|
|
||||||
|
header = [
|
||||||
|
"Actor name",
|
||||||
|
"Issue ID",
|
||||||
|
"Project",
|
||||||
|
"Created at",
|
||||||
|
"Updated at",
|
||||||
|
"Action",
|
||||||
|
"Field",
|
||||||
|
"Old value",
|
||||||
|
"New value",
|
||||||
|
]
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
activity.actor.display_name,
|
||||||
|
f"{activity.project.identifier} - {activity.issue.sequence_id if activity.issue else ''}",
|
||||||
|
activity.project.name,
|
||||||
|
activity.created_at,
|
||||||
|
activity.updated_at,
|
||||||
|
activity.verb,
|
||||||
|
activity.field,
|
||||||
|
activity.old_value,
|
||||||
|
activity.new_value,
|
||||||
|
)
|
||||||
|
for activity in user_activities
|
||||||
|
]
|
||||||
|
csv_buffer = self.generate_csv_from_rows([header] + rows)
|
||||||
|
response = HttpResponse(csv_buffer.getvalue(), content_type="text/csv")
|
||||||
|
response["Content-Disposition"] = 'attachment; filename="workspace-user-activity.csv"'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUserProfileEndpoint(BaseAPIView):
|
class WorkspaceUserProfileEndpoint(BaseAPIView):
|
||||||
def get(self, request, slug, user_id):
|
def get(self, request, slug, user_id):
|
||||||
user_data = User.objects.get(pk=user_id)
|
user_data = User.objects.get(pk=user_id)
|
||||||
|
9
packages/types/src/cycles.d.ts
vendored
9
packages/types/src/cycles.d.ts
vendored
@ -1,11 +1,4 @@
|
|||||||
import type {
|
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||||
IUser,
|
|
||||||
TIssue,
|
|
||||||
IProjectLite,
|
|
||||||
IWorkspaceLite,
|
|
||||||
IIssueFilterOptions,
|
|
||||||
IUserLite,
|
|
||||||
} from "@plane/types";
|
|
||||||
|
|
||||||
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
||||||
|
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { IIssueActivity, TIssuePriorities } from "./issues";
|
import { IIssueActivity, TIssuePriorities } from "../issues";
|
||||||
import { TIssue } from "./issues/issue";
|
import { TIssue } from "../issues/issue";
|
||||||
import { TIssueRelationTypes } from "./issues/issue_relation";
|
import { TIssueRelationTypes } from "../issues/issue_relation";
|
||||||
import { TStateGroups } from "./state";
|
import { TStateGroups } from "../state";
|
||||||
|
import { EDurationFilters } from "./enums";
|
||||||
|
|
||||||
export type TWidgetKeys =
|
export type TWidgetKeys =
|
||||||
| "overview_stats"
|
| "overview_stats"
|
||||||
@ -15,30 +16,27 @@ export type TWidgetKeys =
|
|||||||
|
|
||||||
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
|
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
|
||||||
|
|
||||||
export type TDurationFilterOptions =
|
|
||||||
| "none"
|
|
||||||
| "today"
|
|
||||||
| "this_week"
|
|
||||||
| "this_month"
|
|
||||||
| "this_year";
|
|
||||||
|
|
||||||
// widget filters
|
// widget filters
|
||||||
export type TAssignedIssuesWidgetFilters = {
|
export type TAssignedIssuesWidgetFilters = {
|
||||||
duration?: TDurationFilterOptions;
|
custom_dates?: string[];
|
||||||
|
duration?: EDurationFilters;
|
||||||
tab?: TIssuesListTypes;
|
tab?: TIssuesListTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TCreatedIssuesWidgetFilters = {
|
export type TCreatedIssuesWidgetFilters = {
|
||||||
duration?: TDurationFilterOptions;
|
custom_dates?: string[];
|
||||||
|
duration?: EDurationFilters;
|
||||||
tab?: TIssuesListTypes;
|
tab?: TIssuesListTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssuesByStateGroupsWidgetFilters = {
|
export type TIssuesByStateGroupsWidgetFilters = {
|
||||||
duration?: TDurationFilterOptions;
|
duration?: EDurationFilters;
|
||||||
|
custom_dates?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TIssuesByPriorityWidgetFilters = {
|
export type TIssuesByPriorityWidgetFilters = {
|
||||||
duration?: TDurationFilterOptions;
|
custom_dates?: string[];
|
||||||
|
duration?: EDurationFilters;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TWidgetFiltersFormData =
|
export type TWidgetFiltersFormData =
|
||||||
@ -97,6 +95,12 @@ export type TWidgetStatsRequestParams =
|
|||||||
| {
|
| {
|
||||||
target_date: string;
|
target_date: string;
|
||||||
widget_key: "issues_by_priority";
|
widget_key: "issues_by_priority";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
cursor: string;
|
||||||
|
per_page: number;
|
||||||
|
search?: string;
|
||||||
|
widget_key: "recent_collaborators";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TWidgetIssue = TIssue & {
|
export type TWidgetIssue = TIssue & {
|
||||||
@ -141,8 +145,17 @@ export type TRecentActivityWidgetResponse = IIssueActivity;
|
|||||||
export type TRecentProjectsWidgetResponse = string[];
|
export type TRecentProjectsWidgetResponse = string[];
|
||||||
|
|
||||||
export type TRecentCollaboratorsWidgetResponse = {
|
export type TRecentCollaboratorsWidgetResponse = {
|
||||||
|
count: number;
|
||||||
|
extra_stats: Object | null;
|
||||||
|
next_cursor: string;
|
||||||
|
next_page_results: boolean;
|
||||||
|
prev_cursor: string;
|
||||||
|
prev_page_results: boolean;
|
||||||
|
results: {
|
||||||
active_issue_count: number;
|
active_issue_count: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
|
}[];
|
||||||
|
total_pages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TWidgetStatsResponse =
|
export type TWidgetStatsResponse =
|
||||||
@ -153,7 +166,7 @@ export type TWidgetStatsResponse =
|
|||||||
| TCreatedIssuesWidgetResponse
|
| TCreatedIssuesWidgetResponse
|
||||||
| TRecentActivityWidgetResponse[]
|
| TRecentActivityWidgetResponse[]
|
||||||
| TRecentProjectsWidgetResponse
|
| TRecentProjectsWidgetResponse
|
||||||
| TRecentCollaboratorsWidgetResponse[];
|
| TRecentCollaboratorsWidgetResponse;
|
||||||
|
|
||||||
// dashboard
|
// dashboard
|
||||||
export type TDashboard = {
|
export type TDashboard = {
|
8
packages/types/src/dashboard/enums.ts
Normal file
8
packages/types/src/dashboard/enums.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export enum EDurationFilters {
|
||||||
|
NONE = "none",
|
||||||
|
TODAY = "today",
|
||||||
|
THIS_WEEK = "this_week",
|
||||||
|
THIS_MONTH = "this_month",
|
||||||
|
THIS_YEAR = "this_year",
|
||||||
|
CUSTOM = "custom",
|
||||||
|
}
|
2
packages/types/src/dashboard/index.ts
Normal file
2
packages/types/src/dashboard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./dashboard";
|
||||||
|
export * from "./enums";
|
6
packages/types/src/enums.ts
Normal file
6
packages/types/src/enums.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export enum EUserProjectRoles {
|
||||||
|
GUEST = 5,
|
||||||
|
VIEWER = 10,
|
||||||
|
MEMBER = 15,
|
||||||
|
ADMIN = 20,
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { TIssue } from "./issues/base";
|
import { TIssue } from "../issues/base";
|
||||||
import type { IProjectLite } from "./projects";
|
import type { IProjectLite } from "../projects";
|
||||||
|
|
||||||
export type TInboxIssueExtended = {
|
export type TInboxIssueExtended = {
|
||||||
completed_at: string | null;
|
completed_at: string | null;
|
||||||
@ -33,34 +33,6 @@ export interface IInbox {
|
|||||||
workspace: string;
|
workspace: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StatePending {
|
|
||||||
readonly status: -2;
|
|
||||||
}
|
|
||||||
interface StatusReject {
|
|
||||||
status: -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusSnoozed {
|
|
||||||
status: 0;
|
|
||||||
snoozed_till: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusAccepted {
|
|
||||||
status: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusDuplicate {
|
|
||||||
status: 2;
|
|
||||||
duplicate_to: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TInboxStatus =
|
|
||||||
| StatusReject
|
|
||||||
| StatusSnoozed
|
|
||||||
| StatusAccepted
|
|
||||||
| StatusDuplicate
|
|
||||||
| StatePending;
|
|
||||||
|
|
||||||
export interface IInboxFilterOptions {
|
export interface IInboxFilterOptions {
|
||||||
priority?: string[] | null;
|
priority?: string[] | null;
|
||||||
inbox_status?: number[] | null;
|
inbox_status?: number[] | null;
|
3
packages/types/src/inbox/root.d.ts
vendored
3
packages/types/src/inbox/root.d.ts
vendored
@ -1,2 +1,3 @@
|
|||||||
export * from "./inbox";
|
|
||||||
export * from "./inbox-issue";
|
export * from "./inbox-issue";
|
||||||
|
export * from "./inbox-types";
|
||||||
|
export * from "./inbox";
|
||||||
|
4
packages/types/src/index.d.ts
vendored
4
packages/types/src/index.d.ts
vendored
@ -4,7 +4,6 @@ export * from "./cycles";
|
|||||||
export * from "./dashboard";
|
export * from "./dashboard";
|
||||||
export * from "./projects";
|
export * from "./projects";
|
||||||
export * from "./state";
|
export * from "./state";
|
||||||
export * from "./invitation";
|
|
||||||
export * from "./issues";
|
export * from "./issues";
|
||||||
export * from "./modules";
|
export * from "./modules";
|
||||||
export * from "./views";
|
export * from "./views";
|
||||||
@ -15,7 +14,6 @@ export * from "./estimate";
|
|||||||
export * from "./importer";
|
export * from "./importer";
|
||||||
|
|
||||||
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
|
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
|
||||||
export * from "./inbox";
|
|
||||||
export * from "./inbox/root";
|
export * from "./inbox/root";
|
||||||
|
|
||||||
export * from "./analytics";
|
export * from "./analytics";
|
||||||
@ -32,6 +30,8 @@ export * from "./api_token";
|
|||||||
export * from "./instance";
|
export * from "./instance";
|
||||||
export * from "./app";
|
export * from "./app";
|
||||||
|
|
||||||
|
export * from "./enums";
|
||||||
|
|
||||||
export type NestedKeyOf<ObjectType extends object> = {
|
export type NestedKeyOf<ObjectType extends object> = {
|
||||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||||
? ObjectType[Key] extends { pop: any; push: any }
|
? ObjectType[Key] extends { pop: any; push: any }
|
||||||
|
28
packages/types/src/modules.d.ts
vendored
28
packages/types/src/modules.d.ts
vendored
@ -1,16 +1,12 @@
|
|||||||
import type {
|
import type { TIssue, IIssueFilterOptions, ILinkDetails } from "@plane/types";
|
||||||
IUser,
|
|
||||||
IUserLite,
|
|
||||||
TIssue,
|
|
||||||
IProject,
|
|
||||||
IWorkspace,
|
|
||||||
IWorkspaceLite,
|
|
||||||
IProjectLite,
|
|
||||||
IIssueFilterOptions,
|
|
||||||
ILinkDetails,
|
|
||||||
} from "@plane/types";
|
|
||||||
|
|
||||||
export type TModuleStatus = "backlog" | "planned" | "in-progress" | "paused" | "completed" | "cancelled";
|
export type TModuleStatus =
|
||||||
|
| "backlog"
|
||||||
|
| "planned"
|
||||||
|
| "in-progress"
|
||||||
|
| "paused"
|
||||||
|
| "completed"
|
||||||
|
| "cancelled";
|
||||||
|
|
||||||
export interface IModule {
|
export interface IModule {
|
||||||
backlog_issues: number;
|
backlog_issues: number;
|
||||||
@ -68,6 +64,10 @@ export type ModuleLink = {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SelectModuleType = (IModule & { actionType: "edit" | "delete" | "create-issue" }) | undefined;
|
export type SelectModuleType =
|
||||||
|
| (IModule & { actionType: "edit" | "delete" | "create-issue" })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
export type SelectIssue = (TIssue & { actionType: "edit" | "delete" | "create" }) | undefined;
|
export type SelectIssue =
|
||||||
|
| (TIssue & { actionType: "edit" | "delete" | "create" })
|
||||||
|
| undefined;
|
||||||
|
26
packages/types/src/users.d.ts
vendored
26
packages/types/src/users.d.ts
vendored
@ -1,5 +1,9 @@
|
|||||||
import { EUserProjectRoles } from "constants/project";
|
import {
|
||||||
import { IIssueActivity, IIssueLite, TStateGroups } from ".";
|
IIssueActivity,
|
||||||
|
TIssuePriorities,
|
||||||
|
TStateGroups,
|
||||||
|
EUserProjectRoles,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
export interface IUser {
|
export interface IUser {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,7 +21,6 @@ export interface IUser {
|
|||||||
is_onboarded: boolean;
|
is_onboarded: boolean;
|
||||||
is_password_autoset: boolean;
|
is_password_autoset: boolean;
|
||||||
is_tour_completed: boolean;
|
is_tour_completed: boolean;
|
||||||
is_password_autoset: boolean;
|
|
||||||
mobile_number: string | null;
|
mobile_number: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
onboarding_step: {
|
onboarding_step: {
|
||||||
@ -80,7 +83,7 @@ export interface IUserActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserPriorityDistribution {
|
export interface IUserPriorityDistribution {
|
||||||
priority: string;
|
priority: TIssuePriorities;
|
||||||
priority_count: number;
|
priority_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,21 +92,6 @@ export interface IUserStateDistribution {
|
|||||||
state_count: number;
|
state_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserWorkspaceDashboard {
|
|
||||||
assigned_issues_count: number;
|
|
||||||
completed_issues_count: number;
|
|
||||||
issue_activities: IUserActivity[];
|
|
||||||
issues_due_week_count: number;
|
|
||||||
overdue_issues: IIssueLite[];
|
|
||||||
completed_issues: {
|
|
||||||
week_in_month: number;
|
|
||||||
completed_count: number;
|
|
||||||
}[];
|
|
||||||
pending_issues_count: number;
|
|
||||||
state_distribution: IUserStateDistribution[];
|
|
||||||
upcoming_issues: IIssueLite[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IUserActivityResponse {
|
export interface IUserActivityResponse {
|
||||||
count: number;
|
count: number;
|
||||||
extra_stats: null;
|
extra_stats: null;
|
||||||
|
@ -1,10 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { CalendarDays } from "lucide-react";
|
||||||
// ui
|
// ui
|
||||||
import { CustomSelect, CalendarAfterIcon, CalendarBeforeIcon } from "@plane/ui";
|
import { CustomSelect, CalendarAfterIcon, CalendarBeforeIcon } from "@plane/ui";
|
||||||
// icons
|
|
||||||
import { CalendarDays } from "lucide-react";
|
|
||||||
// fetch-keys
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
title: string;
|
title: string;
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
// helpers
|
// helpers
|
||||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
import { EDurationFilters, TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
@ -30,8 +30,9 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TAssignedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
|
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||||
|
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||||
|
|
||||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||||
if (!widgetDetails) return;
|
if (!widgetDetails) return;
|
||||||
@ -43,7 +44,10 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
const filterDates = getCustomDates(
|
||||||
|
filters.duration ?? selectedDurationFilter,
|
||||||
|
filters.custom_dates ?? selectedCustomDates
|
||||||
|
);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
issue_type: filters.tab ?? selectedTab,
|
issue_type: filters.tab ?? selectedTab,
|
||||||
@ -53,7 +57,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterDates = getCustomDates(selectedDurationFilter);
|
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||||
|
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
@ -81,8 +85,17 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
Assigned to you
|
Assigned to you
|
||||||
</Link>
|
</Link>
|
||||||
<DurationFilterDropdown
|
<DurationFilterDropdown
|
||||||
|
customDates={selectedCustomDates}
|
||||||
value={selectedDurationFilter}
|
value={selectedDurationFilter}
|
||||||
onChange={(val) => {
|
onChange={(val, customDates) => {
|
||||||
|
if (val === "custom" && customDates) {
|
||||||
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
custom_dates: customDates,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (val === selectedDurationFilter) return;
|
if (val === selectedDurationFilter) return;
|
||||||
|
|
||||||
let newTab = selectedTab;
|
let newTab = selectedTab;
|
||||||
|
@ -15,7 +15,7 @@ import {
|
|||||||
// helpers
|
// helpers
|
||||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
import { EDurationFilters, TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
@ -30,8 +30,9 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TCreatedIssuesWidgetResponse>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? "none";
|
const selectedDurationFilter = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||||
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
const selectedTab = getTabKey(selectedDurationFilter, widgetDetails?.widget_filters.tab);
|
||||||
|
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||||
|
|
||||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||||
if (!widgetDetails) return;
|
if (!widgetDetails) return;
|
||||||
@ -43,7 +44,10 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
const filterDates = getCustomDates(
|
||||||
|
filters.duration ?? selectedDurationFilter,
|
||||||
|
filters.custom_dates ?? selectedCustomDates
|
||||||
|
);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
issue_type: filters.tab ?? selectedTab,
|
issue_type: filters.tab ?? selectedTab,
|
||||||
@ -52,7 +56,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterDates = getCustomDates(selectedDurationFilter);
|
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||||
|
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
@ -78,8 +82,17 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
Created by you
|
Created by you
|
||||||
</Link>
|
</Link>
|
||||||
<DurationFilterDropdown
|
<DurationFilterDropdown
|
||||||
|
customDates={selectedCustomDates}
|
||||||
value={selectedDurationFilter}
|
value={selectedDurationFilter}
|
||||||
onChange={(val) => {
|
onChange={(val, customDates) => {
|
||||||
|
if (val === "custom" && customDates) {
|
||||||
|
handleUpdateFilters({
|
||||||
|
duration: val,
|
||||||
|
custom_dates: customDates,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (val === selectedDurationFilter) return;
|
if (val === selectedDurationFilter) return;
|
||||||
|
|
||||||
let newTab = selectedTab;
|
let newTab = selectedTab;
|
||||||
|
@ -1,25 +1,40 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { ChevronDown } from "lucide-react";
|
import { ChevronDown } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { DateFilterModal } from "components/core";
|
||||||
// ui
|
// ui
|
||||||
import { CustomMenu } from "@plane/ui";
|
import { CustomMenu } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { getDurationFilterDropdownLabel } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TDurationFilterOptions } from "@plane/types";
|
import { EDurationFilters } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onChange: (value: TDurationFilterOptions) => void;
|
customDates?: string[];
|
||||||
value: TDurationFilterOptions;
|
onChange: (value: EDurationFilters, customDates?: string[]) => void;
|
||||||
|
value: EDurationFilters;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||||
const { onChange, value } = props;
|
const { customDates, onChange, value } = props;
|
||||||
|
// states
|
||||||
|
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<DateFilterModal
|
||||||
|
isOpen={isDateFilterModalOpen}
|
||||||
|
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||||
|
onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)}
|
||||||
|
title="Due date"
|
||||||
|
/>
|
||||||
<CustomMenu
|
<CustomMenu
|
||||||
className="flex-shrink-0"
|
className="flex-shrink-0"
|
||||||
customButton={
|
customButton={
|
||||||
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
|
<div className="px-3 py-2 border-[0.5px] border-custom-border-300 hover:bg-custom-background-80 focus:bg-custom-background-80 text-xs font-medium whitespace-nowrap rounded-md outline-none flex items-center gap-2">
|
||||||
{DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label}
|
{getDurationFilterDropdownLabel(value, customDates ?? [])}
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -27,10 +42,17 @@ export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
|||||||
closeOnSelect
|
closeOnSelect
|
||||||
>
|
>
|
||||||
{DURATION_FILTER_OPTIONS.map((option) => (
|
{DURATION_FILTER_OPTIONS.map((option) => (
|
||||||
<CustomMenu.MenuItem key={option.key} onClick={() => onChange(option.key)}>
|
<CustomMenu.MenuItem
|
||||||
|
key={option.key}
|
||||||
|
onClick={() => {
|
||||||
|
if (option.key === "custom") setIsDateFilterModalOpen(true);
|
||||||
|
else onChange(option.key);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</CustomMenu.MenuItem>
|
</CustomMenu.MenuItem>
|
||||||
))}
|
))}
|
||||||
</CustomMenu>
|
</CustomMenu>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,12 +3,12 @@ import { Tab } from "@headlessui/react";
|
|||||||
// helpers
|
// helpers
|
||||||
import { cn } from "helpers/common.helper";
|
import { cn } from "helpers/common.helper";
|
||||||
// types
|
// types
|
||||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
import { EDurationFilters, TIssuesListTypes } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
durationFilter: TDurationFilterOptions;
|
durationFilter: EDurationFilters;
|
||||||
selectedTab: TIssuesListTypes;
|
selectedTab: TIssuesListTypes;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -48,7 +48,7 @@ export const TabsList: React.FC<Props> = observer((props) => {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
|
||||||
{
|
{
|
||||||
"text-custom-text-100 bg-custom-background-100": selectedTab === tab.key,
|
"text-custom-text-100": selectedTab === tab.key,
|
||||||
"hover:text-custom-text-300": selectedTab !== tab.key,
|
"hover:text-custom-text-300": selectedTab !== tab.key,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
@ -1,82 +1,36 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
import { useDashboard } from "hooks/store";
|
import { useDashboard } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { MarimekkoGraph } from "components/ui";
|
|
||||||
import {
|
import {
|
||||||
DurationFilterDropdown,
|
DurationFilterDropdown,
|
||||||
IssuesByPriorityEmptyState,
|
IssuesByPriorityEmptyState,
|
||||||
WidgetLoader,
|
WidgetLoader,
|
||||||
WidgetProps,
|
WidgetProps,
|
||||||
} from "components/dashboard/widgets";
|
} from "components/dashboard/widgets";
|
||||||
// ui
|
|
||||||
import { PriorityIcon } from "@plane/ui";
|
|
||||||
// helpers
|
// helpers
|
||||||
import { getCustomDates } from "helpers/dashboard.helper";
|
import { getCustomDates } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
|
import { EDurationFilters, TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard";
|
import { IssuesByPriorityGraph } from "components/graphs";
|
||||||
import { ISSUE_PRIORITIES } from "constants/issue";
|
|
||||||
|
|
||||||
const TEXT_COLORS = {
|
|
||||||
urgent: "#F4A9AA",
|
|
||||||
high: "#AB4800",
|
|
||||||
medium: "#AB6400",
|
|
||||||
low: "#1F2D5C",
|
|
||||||
none: "#60646C",
|
|
||||||
};
|
|
||||||
|
|
||||||
const CustomBar = (props: any) => {
|
|
||||||
const { bar, workspaceSlug } = props;
|
|
||||||
// states
|
|
||||||
const [isMouseOver, setIsMouseOver] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/${workspaceSlug}/workspace-views/assigned?priority=${bar?.id}`}>
|
|
||||||
<g
|
|
||||||
transform={`translate(${bar?.x},${bar?.y})`}
|
|
||||||
onMouseEnter={() => setIsMouseOver(true)}
|
|
||||||
onMouseLeave={() => setIsMouseOver(false)}
|
|
||||||
>
|
|
||||||
<rect
|
|
||||||
x={0}
|
|
||||||
y={isMouseOver ? -6 : 0}
|
|
||||||
width={bar?.width}
|
|
||||||
height={isMouseOver ? bar?.height + 6 : bar?.height}
|
|
||||||
fill={bar?.fill}
|
|
||||||
stroke={bar?.borderColor}
|
|
||||||
strokeWidth={bar?.borderWidth}
|
|
||||||
rx={4}
|
|
||||||
ry={4}
|
|
||||||
className="duration-300"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
x={-bar?.height + 10}
|
|
||||||
y={18}
|
|
||||||
fill={TEXT_COLORS[bar?.id as keyof typeof TEXT_COLORS]}
|
|
||||||
className="capitalize font-medium text-lg -rotate-90"
|
|
||||||
dominantBaseline="text-bottom"
|
|
||||||
>
|
|
||||||
{bar?.id}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const WIDGET_KEY = "issues_by_priority";
|
const WIDGET_KEY = "issues_by_priority";
|
||||||
|
|
||||||
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
|
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||||
const { dashboardId, workspaceSlug } = props;
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
// store hooks
|
// store hooks
|
||||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
|
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
|
||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TIssuesByPriorityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
|
const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||||
|
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||||
|
|
||||||
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
|
const handleUpdateFilters = async (filters: Partial<TIssuesByPriorityWidgetFilters>) => {
|
||||||
if (!widgetDetails) return;
|
if (!widgetDetails) return;
|
||||||
@ -86,7 +40,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
|
const filterDates = getCustomDates(
|
||||||
|
filters.duration ?? selectedDuration,
|
||||||
|
filters.custom_dates ?? selectedCustomDates
|
||||||
|
);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
@ -94,7 +51,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterDates = getCustomDates(selectedDuration);
|
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
@ -105,32 +62,11 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
|||||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
|
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
|
||||||
const chartData = widgetStats
|
const chartData = widgetStats.map((item) => ({
|
||||||
.filter((i) => i.count !== 0)
|
|
||||||
.map((item) => ({
|
|
||||||
priority: item?.priority,
|
priority: item?.priority,
|
||||||
percentage: (item?.count / totalCount) * 100,
|
priority_count: item?.count,
|
||||||
urgent: item?.priority === "urgent" ? 1 : 0,
|
|
||||||
high: item?.priority === "high" ? 1 : 0,
|
|
||||||
medium: item?.priority === "medium" ? 1 : 0,
|
|
||||||
low: item?.priority === "low" ? 1 : 0,
|
|
||||||
none: item?.priority === "none" ? 1 : 0,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const CustomBarsLayer = (props: any) => {
|
|
||||||
const { bars } = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
{bars
|
|
||||||
?.filter((b: any) => b?.value === 1) // render only bars with value 1
|
|
||||||
.map((bar: any) => (
|
|
||||||
<CustomBar key={bar?.key} bar={bar} workspaceSlug={workspaceSlug} />
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden min-h-96 flex flex-col">
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 overflow-hidden min-h-96 flex flex-col">
|
||||||
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
|
<div className="flex items-center justify-between gap-2 pl-7 pr-6">
|
||||||
@ -141,60 +77,27 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
|||||||
Assigned by priority
|
Assigned by priority
|
||||||
</Link>
|
</Link>
|
||||||
<DurationFilterDropdown
|
<DurationFilterDropdown
|
||||||
|
customDates={selectedCustomDates}
|
||||||
value={selectedDuration}
|
value={selectedDuration}
|
||||||
onChange={(val) =>
|
onChange={(val, customDates) =>
|
||||||
handleUpdateFilters({
|
handleUpdateFilters({
|
||||||
duration: val,
|
duration: val,
|
||||||
|
...(val === "custom" ? { custom_dates: customDates } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{totalCount > 0 ? (
|
{totalCount > 0 ? (
|
||||||
<div className="flex items-center px-11 h-full">
|
<div className="flex items-center h-full">
|
||||||
<div className="w-full -mt-[11px]">
|
<div className="w-full -mt-[11px]">
|
||||||
<MarimekkoGraph
|
<IssuesByPriorityGraph
|
||||||
data={chartData}
|
data={chartData}
|
||||||
id="priority"
|
onBarClick={(datum) => {
|
||||||
value="percentage"
|
router.push(
|
||||||
dimensions={ISSUE_PRIORITIES.map((p) => ({
|
`/${workspaceSlug}/workspace-views/assigned?priority=${`${datum.data.priority}`.toLowerCase()}`
|
||||||
id: p.key,
|
);
|
||||||
value: p.key,
|
|
||||||
}))}
|
|
||||||
axisBottom={null}
|
|
||||||
axisLeft={null}
|
|
||||||
height="119px"
|
|
||||||
margin={{
|
|
||||||
top: 11,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
}}
|
}}
|
||||||
defs={PRIORITY_GRAPH_GRADIENTS}
|
|
||||||
fill={ISSUE_PRIORITIES.map((p) => ({
|
|
||||||
match: {
|
|
||||||
id: p.key,
|
|
||||||
},
|
|
||||||
id: `gradient${p.title}`,
|
|
||||||
}))}
|
|
||||||
tooltip={() => <></>}
|
|
||||||
enableGridX={false}
|
|
||||||
enableGridY={false}
|
|
||||||
layers={[CustomBarsLayer]}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1 w-full mt-3 text-sm font-semibold text-custom-text-300">
|
|
||||||
{chartData.map((item) => (
|
|
||||||
<p
|
|
||||||
key={item.priority}
|
|
||||||
className="flex items-center gap-1 flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
width: `${item.percentage}%`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PriorityIcon priority={item.priority} withContainer />
|
|
||||||
{item.percentage.toFixed(0)}%
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -15,7 +15,12 @@ import {
|
|||||||
// helpers
|
// helpers
|
||||||
import { getCustomDates } from "helpers/dashboard.helper";
|
import { getCustomDates } from "helpers/dashboard.helper";
|
||||||
// types
|
// types
|
||||||
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
|
import {
|
||||||
|
EDurationFilters,
|
||||||
|
TIssuesByStateGroupsWidgetFilters,
|
||||||
|
TIssuesByStateGroupsWidgetResponse,
|
||||||
|
TStateGroups,
|
||||||
|
} from "@plane/types";
|
||||||
// constants
|
// constants
|
||||||
import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard";
|
import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||||
import { STATE_GROUPS } from "constants/state";
|
import { STATE_GROUPS } from "constants/state";
|
||||||
@ -34,7 +39,8 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
|||||||
// derived values
|
// derived values
|
||||||
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TIssuesByStateGroupsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
const selectedDuration = widgetDetails?.widget_filters.duration ?? "none";
|
const selectedDuration = widgetDetails?.widget_filters.duration ?? EDurationFilters.NONE;
|
||||||
|
const selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||||
|
|
||||||
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
|
const handleUpdateFilters = async (filters: Partial<TIssuesByStateGroupsWidgetFilters>) => {
|
||||||
if (!widgetDetails) return;
|
if (!widgetDetails) return;
|
||||||
@ -44,7 +50,10 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
|||||||
filters,
|
filters,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
|
const filterDates = getCustomDates(
|
||||||
|
filters.duration ?? selectedDuration,
|
||||||
|
filters.custom_dates ?? selectedCustomDates
|
||||||
|
);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
@ -53,7 +62,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
|||||||
|
|
||||||
// fetch widget stats
|
// fetch widget stats
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const filterDates = getCustomDates(selectedDuration);
|
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
widget_key: WIDGET_KEY,
|
widget_key: WIDGET_KEY,
|
||||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||||
@ -139,10 +148,12 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
|||||||
Assigned by state
|
Assigned by state
|
||||||
</Link>
|
</Link>
|
||||||
<DurationFilterDropdown
|
<DurationFilterDropdown
|
||||||
|
customDates={selectedCustomDates}
|
||||||
value={selectedDuration}
|
value={selectedDuration}
|
||||||
onChange={(val) =>
|
onChange={(val, customDates) =>
|
||||||
handleUpdateFilters({
|
handleUpdateFilters({
|
||||||
duration: val,
|
duration: val,
|
||||||
|
...(val === "custom" ? { custom_dates: customDates } : {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
@ -2,17 +2,16 @@
|
|||||||
import { Loader } from "@plane/ui";
|
import { Loader } from "@plane/ui";
|
||||||
|
|
||||||
export const RecentCollaboratorsWidgetLoader = () => (
|
export const RecentCollaboratorsWidgetLoader = () => (
|
||||||
<Loader className="bg-custom-background-100 rounded-xl p-6 space-y-9">
|
<>
|
||||||
<Loader.Item height="17px" width="20%" />
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2">
|
|
||||||
{Array.from({ length: 8 }).map((_, index) => (
|
{Array.from({ length: 8 }).map((_, index) => (
|
||||||
<div key={index} className="space-y-11 flex flex-col items-center">
|
<Loader key={index} className="bg-custom-background-100 rounded-xl px-6 pb-12">
|
||||||
|
<div className="space-y-11 flex flex-col items-center">
|
||||||
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
|
<div className="rounded-full overflow-hidden h-[69px] w-[69px]">
|
||||||
<Loader.Item height="69px" width="69px" />
|
<Loader.Item height="69px" width="69px" />
|
||||||
</div>
|
</div>
|
||||||
<Loader.Item height="11px" width="70%" />
|
<Loader.Item height="11px" width="70%" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Loader>
|
</Loader>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
|
@ -8,11 +8,12 @@ import { useDashboard, useUser } from "hooks/store";
|
|||||||
import { ActivityIcon, ActivityMessage, IssueLink } from "components/core";
|
import { ActivityIcon, ActivityMessage, IssueLink } from "components/core";
|
||||||
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||||
// ui
|
// ui
|
||||||
import { Avatar } from "@plane/ui";
|
import { Avatar, getButtonStyling } from "@plane/ui";
|
||||||
// helpers
|
// helpers
|
||||||
import { calculateTimeAgo } from "helpers/date-time.helper";
|
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { TRecentActivityWidgetResponse } from "@plane/types";
|
import { TRecentActivityWidgetResponse } from "@plane/types";
|
||||||
|
import { cn } from "helpers/common.helper";
|
||||||
|
|
||||||
const WIDGET_KEY = "recent_activity";
|
const WIDGET_KEY = "recent_activity";
|
||||||
|
|
||||||
@ -23,6 +24,7 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
// derived values
|
// derived values
|
||||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||||
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||||
|
const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
@ -35,7 +37,7 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 min-h-96">
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full py-6 hover:shadow-custom-shadow-4xl duration-300 min-h-96">
|
||||||
<Link href="/profile/activity" className="text-lg font-semibold text-custom-text-300 mx-7 hover:underline">
|
<Link href={redirectionLink} className="text-lg font-semibold text-custom-text-300 mx-7 hover:underline">
|
||||||
Your issue activities
|
Your issue activities
|
||||||
</Link>
|
</Link>
|
||||||
{widgetStats.length > 0 ? (
|
{widgetStats.length > 0 ? (
|
||||||
@ -83,6 +85,15 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
<Link
|
||||||
|
href={redirectionLink}
|
||||||
|
className={cn(
|
||||||
|
getButtonStyling("link-primary", "sm"),
|
||||||
|
"w-min mx-auto py-1 px-2 text-xs hover:bg-custom-primary-100/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full grid place-items-center">
|
<div className="h-full grid place-items-center">
|
||||||
|
@ -1,94 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { observer } from "mobx-react-lite";
|
|
||||||
// hooks
|
|
||||||
import { useDashboard, useMember, useUser } from "hooks/store";
|
|
||||||
// components
|
|
||||||
import { RecentCollaboratorsEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
|
||||||
// ui
|
|
||||||
import { Avatar } from "@plane/ui";
|
|
||||||
// types
|
|
||||||
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
|
|
||||||
|
|
||||||
type CollaboratorListItemProps = {
|
|
||||||
issueCount: number;
|
|
||||||
userId: string;
|
|
||||||
workspaceSlug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const WIDGET_KEY = "recent_collaborators";
|
|
||||||
|
|
||||||
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
|
|
||||||
const { issueCount, userId, workspaceSlug } = props;
|
|
||||||
// store hooks
|
|
||||||
const { currentUser } = useUser();
|
|
||||||
const { getUserDetails } = useMember();
|
|
||||||
// derived values
|
|
||||||
const userDetails = getUserDetails(userId);
|
|
||||||
const isCurrentUser = userId === currentUser?.id;
|
|
||||||
|
|
||||||
if (!userDetails) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<Avatar
|
|
||||||
src={userDetails.avatar}
|
|
||||||
name={isCurrentUser ? "You" : userDetails.display_name}
|
|
||||||
size={69}
|
|
||||||
className="!text-3xl !font-medium"
|
|
||||||
showTooltip={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h6 className="mt-6 text-xs font-semibold group-hover:underline truncate">
|
|
||||||
{isCurrentUser ? "You" : userDetails?.display_name}
|
|
||||||
</h6>
|
|
||||||
<p className="text-sm mt-2">
|
|
||||||
{issueCount} active issue{issueCount > 1 ? "s" : ""}
|
|
||||||
</p>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = observer((props) => {
|
|
||||||
const { dashboardId, workspaceSlug } = props;
|
|
||||||
// store hooks
|
|
||||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
|
||||||
const widgetStats = getWidgetStats<TRecentCollaboratorsWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
|
||||||
widget_key: WIDGET_KEY,
|
|
||||||
});
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300">
|
|
||||||
<div className="px-7 pt-6">
|
|
||||||
<h4 className="text-lg font-semibold text-custom-text-300">Most active members</h4>
|
|
||||||
<p className="mt-2 text-xs font-medium text-custom-text-300">
|
|
||||||
Top eight active members in your project by last activity
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{widgetStats.length > 1 ? (
|
|
||||||
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
|
||||||
{widgetStats.map((user) => (
|
|
||||||
<CollaboratorListItem
|
|
||||||
key={user.user_id}
|
|
||||||
issueCount={user.active_issue_count}
|
|
||||||
userId={user.user_id}
|
|
||||||
workspaceSlug={workspaceSlug}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="h-full grid place-items-center">
|
|
||||||
<RecentCollaboratorsEmptyState />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
@ -0,0 +1,120 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// store hooks
|
||||||
|
import { useDashboard, useMember, useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { WidgetLoader } from "../loaders";
|
||||||
|
// ui
|
||||||
|
import { Avatar } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { TRecentCollaboratorsWidgetResponse } from "@plane/types";
|
||||||
|
|
||||||
|
type CollaboratorListItemProps = {
|
||||||
|
issueCount: number;
|
||||||
|
userId: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CollaboratorListItem: React.FC<CollaboratorListItemProps> = observer((props) => {
|
||||||
|
const { issueCount, userId, workspaceSlug } = props;
|
||||||
|
// store hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
const { getUserDetails } = useMember();
|
||||||
|
// derived values
|
||||||
|
const userDetails = getUserDetails(userId);
|
||||||
|
const isCurrentUser = userId === currentUser?.id;
|
||||||
|
|
||||||
|
if (!userDetails) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={`/${workspaceSlug}/profile/${userId}`} className="group text-center">
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Avatar
|
||||||
|
src={userDetails.avatar}
|
||||||
|
name={isCurrentUser ? "You" : userDetails.display_name}
|
||||||
|
size={69}
|
||||||
|
className="!text-3xl !font-medium"
|
||||||
|
showTooltip={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h6 className="mt-6 text-xs font-semibold group-hover:underline truncate">
|
||||||
|
{isCurrentUser ? "You" : userDetails?.display_name}
|
||||||
|
</h6>
|
||||||
|
<p className="text-sm mt-2">
|
||||||
|
{issueCount} active issue{issueCount > 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
type CollaboratorsListProps = {
|
||||||
|
cursor: string;
|
||||||
|
dashboardId: string;
|
||||||
|
perPage: number;
|
||||||
|
searchQuery?: string;
|
||||||
|
updateIsLoading?: (isLoading: boolean) => void;
|
||||||
|
updateResultsCount: (count: number) => void;
|
||||||
|
updateTotalPages: (count: number) => void;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const WIDGET_KEY = "recent_collaborators";
|
||||||
|
|
||||||
|
export const CollaboratorsList: React.FC<CollaboratorsListProps> = (props) => {
|
||||||
|
const {
|
||||||
|
cursor,
|
||||||
|
dashboardId,
|
||||||
|
perPage,
|
||||||
|
searchQuery = "",
|
||||||
|
updateIsLoading,
|
||||||
|
updateResultsCount,
|
||||||
|
updateTotalPages,
|
||||||
|
workspaceSlug,
|
||||||
|
} = props;
|
||||||
|
// store hooks
|
||||||
|
const { fetchWidgetStats } = useDashboard();
|
||||||
|
|
||||||
|
const { data: widgetStats } = useSWR(
|
||||||
|
workspaceSlug && dashboardId && cursor
|
||||||
|
? `WIDGET_STATS_${workspaceSlug}_${dashboardId}_${cursor}_${searchQuery}`
|
||||||
|
: null,
|
||||||
|
workspaceSlug && dashboardId && cursor
|
||||||
|
? () =>
|
||||||
|
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||||
|
cursor,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery,
|
||||||
|
widget_key: WIDGET_KEY,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
) as {
|
||||||
|
data: TRecentCollaboratorsWidgetResponse | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateIsLoading?.(true);
|
||||||
|
|
||||||
|
if (!widgetStats) return;
|
||||||
|
|
||||||
|
updateIsLoading?.(false);
|
||||||
|
updateTotalPages(widgetStats.total_pages);
|
||||||
|
updateResultsCount(widgetStats.results.length);
|
||||||
|
}, [updateIsLoading, updateResultsCount, updateTotalPages, widgetStats]);
|
||||||
|
|
||||||
|
if (!widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{widgetStats?.results.map((user) => (
|
||||||
|
<CollaboratorListItem
|
||||||
|
key={user.user_id}
|
||||||
|
issueCount={user.active_issue_count}
|
||||||
|
userId={user.user_id}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,59 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
// components
|
||||||
|
import { CollaboratorsList } from "./collaborators-list";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dashboardId: string;
|
||||||
|
perPage: number;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DefaultCollaboratorsList: React.FC<Props> = (props) => {
|
||||||
|
const { dashboardId, perPage, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [pageCount, setPageCount] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [resultsCount, setResultsCount] = useState(0);
|
||||||
|
|
||||||
|
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||||
|
|
||||||
|
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||||
|
|
||||||
|
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||||
|
|
||||||
|
const collaboratorsPages: JSX.Element[] = [];
|
||||||
|
for (let i = 0; i < pageCount; i++)
|
||||||
|
collaboratorsPages.push(
|
||||||
|
<CollaboratorsList
|
||||||
|
key={i}
|
||||||
|
dashboardId={dashboardId}
|
||||||
|
cursor={`${perPage}:${i}:0`}
|
||||||
|
perPage={perPage}
|
||||||
|
updateResultsCount={updateResultsCount}
|
||||||
|
updateTotalPages={updateTotalPages}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||||
|
{collaboratorsPages}
|
||||||
|
</div>
|
||||||
|
{pageCount < totalPages && resultsCount !== 0 && (
|
||||||
|
<div className="flex items-center justify-center text-xs w-full">
|
||||||
|
<Button
|
||||||
|
variant="link-primary"
|
||||||
|
size="sm"
|
||||||
|
className="my-3 hover:bg-custom-primary-100/20"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from "./root";
|
@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
// components
|
||||||
|
import { DefaultCollaboratorsList } from "./default-list";
|
||||||
|
import { SearchedCollaboratorsList } from "./search-list";
|
||||||
|
8;
|
||||||
|
// types
|
||||||
|
import { WidgetProps } from "components/dashboard/widgets";
|
||||||
|
|
||||||
|
const PER_PAGE = 8;
|
||||||
|
|
||||||
|
export const RecentCollaboratorsWidget: React.FC<WidgetProps> = (props) => {
|
||||||
|
const { dashboardId, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-custom-background-100 rounded-xl border-[0.5px] border-custom-border-200 w-full hover:shadow-custom-shadow-4xl duration-300">
|
||||||
|
<div className="px-7 pt-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-lg font-semibold text-custom-text-300">Most active members</h4>
|
||||||
|
<p className="mt-2 text-xs font-medium text-custom-text-300">
|
||||||
|
Top eight active members in your project by last activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-start gap-2 rounded-md border border-custom-border-200 px-2.5 py-1.5 placeholder:text-custom-text-400 min-w-72">
|
||||||
|
<Search className="h-3.5 w-3.5 text-custom-text-400" />
|
||||||
|
<input
|
||||||
|
className="w-full border-none bg-transparent text-sm focus:outline-none"
|
||||||
|
placeholder="Search for collaborators"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{searchQuery.trim() !== "" ? (
|
||||||
|
<SearchedCollaboratorsList
|
||||||
|
dashboardId={dashboardId}
|
||||||
|
perPage={PER_PAGE}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DefaultCollaboratorsList dashboardId={dashboardId} perPage={PER_PAGE} workspaceSlug={workspaceSlug} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
// components
|
||||||
|
import { CollaboratorsList } from "./collaborators-list";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// assets
|
||||||
|
import DarkImage from "public/empty-state/dashboard/dark/recent-collaborators-1.svg";
|
||||||
|
import LightImage from "public/empty-state/dashboard/light/recent-collaborators-1.svg";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
dashboardId: string;
|
||||||
|
perPage: number;
|
||||||
|
searchQuery: string;
|
||||||
|
workspaceSlug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SearchedCollaboratorsList: React.FC<Props> = (props) => {
|
||||||
|
const { dashboardId, perPage, searchQuery, workspaceSlug } = props;
|
||||||
|
// states
|
||||||
|
const [pageCount, setPageCount] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [resultsCount, setResultsCount] = useState(0);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
// next-themes
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||||
|
|
||||||
|
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||||
|
|
||||||
|
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||||
|
|
||||||
|
const collaboratorsPages: JSX.Element[] = [];
|
||||||
|
for (let i = 0; i < pageCount; i++)
|
||||||
|
collaboratorsPages.push(
|
||||||
|
<CollaboratorsList
|
||||||
|
key={i}
|
||||||
|
dashboardId={dashboardId}
|
||||||
|
cursor={`${perPage}:${i}:0`}
|
||||||
|
perPage={perPage}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
updateIsLoading={setIsLoading}
|
||||||
|
updateResultsCount={updateResultsCount}
|
||||||
|
updateTotalPages={updateTotalPages}
|
||||||
|
workspaceSlug={workspaceSlug}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="mt-7 mb-6 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 gap-2 gap-y-8">
|
||||||
|
{collaboratorsPages}
|
||||||
|
</div>
|
||||||
|
{!isLoading && totalPages === 0 && (
|
||||||
|
<div className="flex flex-col items-center gap-6 mb-8">
|
||||||
|
<div className="h-24 w-24 flex-shrink-0">
|
||||||
|
<Image src={emptyStateImage} className="w-full h-full" alt="Recent collaborators" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-sm">No matching member</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pageCount < totalPages && resultsCount !== 0 && (
|
||||||
|
<div className="flex items-center justify-center text-xs w-full">
|
||||||
|
<Button
|
||||||
|
variant="link-primary"
|
||||||
|
size="sm"
|
||||||
|
className="my-3 hover:bg-custom-primary-100/20"
|
||||||
|
onClick={handleLoadMore}
|
||||||
|
>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
1
web/components/graphs/index.ts
Normal file
1
web/components/graphs/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./issues-by-priority";
|
103
web/components/graphs/issues-by-priority.tsx
Normal file
103
web/components/graphs/issues-by-priority.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Theme } from "@nivo/core";
|
||||||
|
import { ComputedDatum } from "@nivo/bar";
|
||||||
|
// components
|
||||||
|
import { BarGraph } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { capitalizeFirstLetter } from "helpers/string.helper";
|
||||||
|
// types
|
||||||
|
import { TIssuePriorities } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||||
|
import { ISSUE_PRIORITIES } from "constants/issue";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
borderRadius?: number;
|
||||||
|
data: {
|
||||||
|
priority: TIssuePriorities;
|
||||||
|
priority_count: number;
|
||||||
|
}[];
|
||||||
|
height?: number;
|
||||||
|
onBarClick?: (
|
||||||
|
datum: ComputedDatum<any> & {
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
padding?: number;
|
||||||
|
theme?: Theme;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PRIORITY_TEXT_COLORS = {
|
||||||
|
urgent: "#CE2C31",
|
||||||
|
high: "#AB4800",
|
||||||
|
medium: "#AB6400",
|
||||||
|
low: "#1F2D5C",
|
||||||
|
none: "#60646C",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IssuesByPriorityGraph: React.FC<Props> = (props) => {
|
||||||
|
const { borderRadius = 8, data, height = 300, onBarClick, padding = 0.05, theme } = props;
|
||||||
|
|
||||||
|
const chartData = data.map((priority) => ({
|
||||||
|
priority: capitalizeFirstLetter(priority.priority),
|
||||||
|
value: priority.priority_count,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BarGraph
|
||||||
|
data={chartData}
|
||||||
|
height={`${height}px`}
|
||||||
|
indexBy="priority"
|
||||||
|
keys={["value"]}
|
||||||
|
borderRadius={borderRadius}
|
||||||
|
padding={padding}
|
||||||
|
customYAxisTickValues={data.map((p) => p.priority_count)}
|
||||||
|
axisBottom={{
|
||||||
|
tickPadding: 8,
|
||||||
|
tickSize: 0,
|
||||||
|
}}
|
||||||
|
tooltip={(datum) => (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
||||||
|
<span
|
||||||
|
className="h-3 w-3 rounded"
|
||||||
|
style={{
|
||||||
|
backgroundColor: PRIORITY_TEXT_COLORS[`${datum.data.priority}`.toLowerCase() as TIssuePriorities],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
|
||||||
|
<span>{datum.value}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
colors={({ data }) => `url(#gradient${data.priority})`}
|
||||||
|
defs={PRIORITY_GRAPH_GRADIENTS}
|
||||||
|
fill={ISSUE_PRIORITIES.map((p) => ({
|
||||||
|
match: {
|
||||||
|
id: p.key,
|
||||||
|
},
|
||||||
|
id: `gradient${p.title}`,
|
||||||
|
}))}
|
||||||
|
onClick={(datum) => {
|
||||||
|
if (onBarClick) onBarClick(datum);
|
||||||
|
}}
|
||||||
|
theme={{
|
||||||
|
axis: {
|
||||||
|
domain: {
|
||||||
|
line: {
|
||||||
|
stroke: "transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
text: {
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
line: {
|
||||||
|
stroke: "transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...theme,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
@ -17,7 +17,7 @@ import { Button, TOAST_TYPE, setToast } from "@plane/ui";
|
|||||||
// icons
|
// icons
|
||||||
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react";
|
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react";
|
||||||
// types
|
// types
|
||||||
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
|
import type { TInboxDetailedStatus } from "@plane/types";
|
||||||
import { EUserProjectRoles } from "constants/project";
|
import { EUserProjectRoles } from "constants/project";
|
||||||
import { ISSUE_DELETED } from "constants/event-tracker";
|
import { ISSUE_DELETED } from "constants/event-tracker";
|
||||||
|
|
||||||
@ -29,7 +29,7 @@ type TInboxIssueActionsHeader = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TInboxIssueOperations = {
|
type TInboxIssueOperations = {
|
||||||
updateInboxIssueStatus: (data: TInboxStatus) => Promise<void>;
|
updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise<void>;
|
||||||
removeInboxIssue: () => Promise<void>;
|
removeInboxIssue: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
162
web/components/profile/activity/activity-list.tsx
Normal file
162
web/components/profile/activity/activity-list.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import { History, MessageSquare } from "lucide-react";
|
||||||
|
// editor
|
||||||
|
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
|
||||||
|
// hooks
|
||||||
|
import { useUser } from "hooks/store";
|
||||||
|
// components
|
||||||
|
import { ActivityIcon, ActivityMessage, IssueLink } from "components/core";
|
||||||
|
// ui
|
||||||
|
import { ActivitySettingsLoader } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||||
|
// types
|
||||||
|
import { IUserActivityResponse } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
activity: IUserActivityResponse | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ActivityList: React.FC<Props> = observer((props) => {
|
||||||
|
const { activity } = props;
|
||||||
|
// store hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
|
||||||
|
// TODO: refactor this component
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{activity ? (
|
||||||
|
<ul role="list">
|
||||||
|
{activity.results.map((activityItem: any) => {
|
||||||
|
if (activityItem.field === "comment")
|
||||||
|
return (
|
||||||
|
<div key={activityItem.id} className="mt-2">
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<div className="relative px-1">
|
||||||
|
{activityItem.field ? (
|
||||||
|
activityItem.new_value === "restore" && <History className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
|
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={activityItem.actor_detail.avatar}
|
||||||
|
alt={activityItem.actor_detail.display_name}
|
||||||
|
height={30}
|
||||||
|
width={30}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white capitalize">
|
||||||
|
{activityItem.actor_detail.display_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ring-6 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
|
||||||
|
<MessageSquare className="h-6 w-6 !text-2xl text-custom-text-200" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">
|
||||||
|
{activityItem.actor_detail.is_bot
|
||||||
|
? activityItem.actor_detail.first_name + " Bot"
|
||||||
|
: activityItem.actor_detail.display_name}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||||
|
Commented {calculateTimeAgo(activityItem.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="issue-comments-section p-0">
|
||||||
|
<RichReadOnlyEditor
|
||||||
|
value={activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value}
|
||||||
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
|
noBorder
|
||||||
|
borderOnFocus={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const message =
|
||||||
|
activityItem.verb === "created" &&
|
||||||
|
!["cycles", "modules", "attachment", "link", "estimate"].includes(activityItem.field) &&
|
||||||
|
!activityItem.field ? (
|
||||||
|
<span>
|
||||||
|
created <IssueLink activity={activityItem} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ActivityMessage activity={activityItem} showIssue />
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("field" in activityItem && activityItem.field !== "updated_by")
|
||||||
|
return (
|
||||||
|
<li key={activityItem.id}>
|
||||||
|
<div className="relative pb-1">
|
||||||
|
<div className="relative flex items-center space-x-2">
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="relative px-1.5">
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center">
|
||||||
|
{activityItem.field ? (
|
||||||
|
activityItem.new_value === "restore" ? (
|
||||||
|
<History className="h-5 w-5 text-custom-text-200" />
|
||||||
|
) : (
|
||||||
|
<ActivityIcon activity={activityItem} />
|
||||||
|
)
|
||||||
|
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={activityItem.actor_detail.avatar}
|
||||||
|
alt={activityItem.actor_detail.display_name}
|
||||||
|
height={24}
|
||||||
|
width={24}
|
||||||
|
className="h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-6 w-6 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white capitalize">
|
||||||
|
{activityItem.actor_detail.display_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
||||||
|
<div className="flex gap-1 break-words text-sm text-custom-text-200">
|
||||||
|
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
|
||||||
|
<span className="text-gray font-medium">Plane</span>
|
||||||
|
) : activityItem.actor_detail.is_bot ? (
|
||||||
|
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
|
||||||
|
>
|
||||||
|
<span className="text-gray font-medium">
|
||||||
|
{currentUser?.id === activityItem.actor_detail.id
|
||||||
|
? "You"
|
||||||
|
: activityItem.actor_detail.display_name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}{" "}
|
||||||
|
<div className="flex gap-1 truncate">
|
||||||
|
{message}{" "}
|
||||||
|
<span className="flex-shrink-0 whitespace-nowrap">
|
||||||
|
{calculateTimeAgo(activityItem.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<ActivitySettingsLoader />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
57
web/components/profile/activity/download-button.tsx
Normal file
57
web/components/profile/activity/download-button.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
// services
|
||||||
|
import { UserService } from "services/user.service";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// helpers
|
||||||
|
import { renderFormattedPayloadDate } from "helpers/date-time.helper";
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
export const DownloadActivityButton = () => {
|
||||||
|
// states
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, userId } = router.query;
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
const today = renderFormattedPayloadDate(new Date());
|
||||||
|
|
||||||
|
if (!workspaceSlug || !userId || !today) return;
|
||||||
|
|
||||||
|
setIsDownloading(true);
|
||||||
|
|
||||||
|
const csv = await userService
|
||||||
|
.downloadProfileActivity(workspaceSlug.toString(), userId.toString(), {
|
||||||
|
date: today,
|
||||||
|
})
|
||||||
|
.finally(() => setIsDownloading(false));
|
||||||
|
|
||||||
|
// create a Blob object
|
||||||
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
|
||||||
|
// create URL for the Blob object
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// create a link element
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `profile-activity-${Date.now()}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
|
||||||
|
// simulate click on the link element to trigger download
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
document.body.removeChild(a);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button onClick={handleDownload} loading={isDownloading}>
|
||||||
|
{isDownloading ? "Downloading" : "Download today's activity"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
4
web/components/profile/activity/index.ts
Normal file
4
web/components/profile/activity/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from "./activity-list";
|
||||||
|
export * from "./download-button";
|
||||||
|
export * from "./profile-activity-list";
|
||||||
|
export * from "./workspace-activity-list";
|
190
web/components/profile/activity/profile-activity-list.tsx
Normal file
190
web/components/profile/activity/profile-activity-list.tsx
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { History, MessageSquare } from "lucide-react";
|
||||||
|
// hooks
|
||||||
|
import { useUser } from "hooks/store";
|
||||||
|
// services
|
||||||
|
import { UserService } from "services/user.service";
|
||||||
|
// editor
|
||||||
|
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
|
||||||
|
// components
|
||||||
|
import { ActivityIcon, ActivityMessage, IssueLink } from "components/core";
|
||||||
|
// ui
|
||||||
|
import { ActivitySettingsLoader } from "components/ui";
|
||||||
|
// helpers
|
||||||
|
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||||
|
// fetch-keys
|
||||||
|
import { USER_ACTIVITY } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// services
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cursor: string;
|
||||||
|
perPage: number;
|
||||||
|
updateResultsCount: (count: number) => void;
|
||||||
|
updateTotalPages: (count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfileActivityListPage: React.FC<Props> = observer((props) => {
|
||||||
|
const { cursor, perPage, updateResultsCount, updateTotalPages } = props;
|
||||||
|
// store hooks
|
||||||
|
const { currentUser } = useUser();
|
||||||
|
|
||||||
|
const { data: userProfileActivity } = useSWR(
|
||||||
|
USER_ACTIVITY({
|
||||||
|
cursor,
|
||||||
|
}),
|
||||||
|
() =>
|
||||||
|
userService.getUserActivity({
|
||||||
|
cursor,
|
||||||
|
per_page: perPage,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userProfileActivity) return;
|
||||||
|
|
||||||
|
updateTotalPages(userProfileActivity.total_pages);
|
||||||
|
updateResultsCount(userProfileActivity.results.length);
|
||||||
|
}, [updateResultsCount, updateTotalPages, userProfileActivity]);
|
||||||
|
|
||||||
|
// TODO: refactor this component
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{userProfileActivity ? (
|
||||||
|
<ul role="list">
|
||||||
|
{userProfileActivity.results.map((activityItem: any) => {
|
||||||
|
if (activityItem.field === "comment")
|
||||||
|
return (
|
||||||
|
<div key={activityItem.id} className="mt-2">
|
||||||
|
<div className="relative flex items-start space-x-3">
|
||||||
|
<div className="relative px-1">
|
||||||
|
{activityItem.field ? (
|
||||||
|
activityItem.new_value === "restore" && <History className="h-3.5 w-3.5 text-custom-text-200" />
|
||||||
|
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={activityItem.actor_detail.avatar}
|
||||||
|
alt={activityItem.actor_detail.display_name}
|
||||||
|
height={30}
|
||||||
|
width={30}
|
||||||
|
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white capitalize">
|
||||||
|
{activityItem.actor_detail.display_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="ring-6 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
|
||||||
|
<MessageSquare className="h-6 w-6 !text-2xl text-custom-text-200" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs">
|
||||||
|
{activityItem.actor_detail.is_bot
|
||||||
|
? activityItem.actor_detail.first_name + " Bot"
|
||||||
|
: activityItem.actor_detail.display_name}
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-custom-text-200">
|
||||||
|
Commented {calculateTimeAgo(activityItem.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="issue-comments-section p-0">
|
||||||
|
<RichReadOnlyEditor
|
||||||
|
value={activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value}
|
||||||
|
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
||||||
|
noBorder
|
||||||
|
borderOnFocus={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const message =
|
||||||
|
activityItem.verb === "created" &&
|
||||||
|
!["cycles", "modules", "attachment", "link", "estimate"].includes(activityItem.field) &&
|
||||||
|
!activityItem.field ? (
|
||||||
|
<span>
|
||||||
|
created <IssueLink activity={activityItem} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<ActivityMessage activity={activityItem} showIssue />
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("field" in activityItem && activityItem.field !== "updated_by")
|
||||||
|
return (
|
||||||
|
<li key={activityItem.id}>
|
||||||
|
<div className="relative pb-1">
|
||||||
|
<div className="relative flex items-center space-x-2">
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="relative px-1.5">
|
||||||
|
<div className="mt-1.5">
|
||||||
|
<div className="flex h-6 w-6 items-center justify-center">
|
||||||
|
{activityItem.field ? (
|
||||||
|
activityItem.new_value === "restore" ? (
|
||||||
|
<History className="h-5 w-5 text-custom-text-200" />
|
||||||
|
) : (
|
||||||
|
<ActivityIcon activity={activityItem} />
|
||||||
|
)
|
||||||
|
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
|
||||||
|
<img
|
||||||
|
src={activityItem.actor_detail.avatar}
|
||||||
|
alt={activityItem.actor_detail.display_name}
|
||||||
|
height={24}
|
||||||
|
width={24}
|
||||||
|
className="h-full w-full rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid h-6 w-6 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white capitalize">
|
||||||
|
{activityItem.actor_detail.display_name?.[0]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
||||||
|
<div className="flex gap-1 break-words text-sm text-custom-text-200">
|
||||||
|
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
|
||||||
|
<span className="text-gray font-medium">Plane</span>
|
||||||
|
) : activityItem.actor_detail.is_bot ? (
|
||||||
|
<span className="text-gray font-medium">{activityItem.actor_detail.first_name} Bot</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
|
||||||
|
>
|
||||||
|
<span className="text-gray font-medium">
|
||||||
|
{currentUser?.id === activityItem.actor_detail.id
|
||||||
|
? "You"
|
||||||
|
: activityItem.actor_detail.display_name}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
)}{" "}
|
||||||
|
<div className="flex gap-1 truncate">
|
||||||
|
{message}{" "}
|
||||||
|
<span className="flex-shrink-0 whitespace-nowrap">
|
||||||
|
{calculateTimeAgo(activityItem.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<ActivitySettingsLoader />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
50
web/components/profile/activity/workspace-activity-list.tsx
Normal file
50
web/components/profile/activity/workspace-activity-list.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import useSWR from "swr";
|
||||||
|
// services
|
||||||
|
import { UserService } from "services/user.service";
|
||||||
|
// components
|
||||||
|
import { ActivityList } from "./activity-list";
|
||||||
|
// fetch-keys
|
||||||
|
import { USER_PROFILE_ACTIVITY } from "constants/fetch-keys";
|
||||||
|
|
||||||
|
// services
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
cursor: string;
|
||||||
|
perPage: number;
|
||||||
|
updateResultsCount: (count: number) => void;
|
||||||
|
updateTotalPages: (count: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkspaceActivityListPage: React.FC<Props> = (props) => {
|
||||||
|
const { cursor, perPage, updateResultsCount, updateTotalPages } = props;
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { workspaceSlug, userId } = router.query;
|
||||||
|
|
||||||
|
const { data: userProfileActivity } = useSWR(
|
||||||
|
workspaceSlug && userId
|
||||||
|
? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString(), {
|
||||||
|
cursor,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
workspaceSlug && userId
|
||||||
|
? () =>
|
||||||
|
userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString(), {
|
||||||
|
cursor,
|
||||||
|
per_page: perPage,
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userProfileActivity) return;
|
||||||
|
|
||||||
|
updateTotalPages(userProfileActivity.total_pages);
|
||||||
|
updateResultsCount(userProfileActivity.results.length);
|
||||||
|
}, [updateResultsCount, updateTotalPages, userProfileActivity]);
|
||||||
|
|
||||||
|
return <ActivityList activity={userProfileActivity} />;
|
||||||
|
};
|
@ -1,3 +1,4 @@
|
|||||||
|
export * from "./activity";
|
||||||
export * from "./overview";
|
export * from "./overview";
|
||||||
export * from "./navbar";
|
export * from "./navbar";
|
||||||
export * from "./profile-issues-filter";
|
export * from "./profile-issues-filter";
|
||||||
|
@ -27,7 +27,8 @@ export const ProfileNavbar: React.FC<Props> = (props) => {
|
|||||||
{tabsList.map((tab) => (
|
{tabsList.map((tab) => (
|
||||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
||||||
<span
|
<span
|
||||||
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${router.pathname === tab.selected
|
className={`flex whitespace-nowrap border-b-2 p-4 text-sm font-medium outline-none ${
|
||||||
|
router.pathname === tab.selected
|
||||||
? "border-custom-primary-100 text-custom-primary-100"
|
? "border-custom-primary-100 text-custom-primary-100"
|
||||||
: "border-transparent"
|
: "border-transparent"
|
||||||
}`}
|
}`}
|
||||||
|
@ -27,15 +27,18 @@ export const ProfileActivity = observer(() => {
|
|||||||
const { currentUser } = useUser();
|
const { currentUser } = useUser();
|
||||||
|
|
||||||
const { data: userProfileActivity } = useSWR(
|
const { data: userProfileActivity } = useSWR(
|
||||||
workspaceSlug && userId ? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString()) : null,
|
workspaceSlug && userId ? USER_PROFILE_ACTIVITY(workspaceSlug.toString(), userId.toString(), {}) : null,
|
||||||
workspaceSlug && userId
|
workspaceSlug && userId
|
||||||
? () => userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString())
|
? () =>
|
||||||
|
userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString(), {
|
||||||
|
per_page: 10,
|
||||||
|
})
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-lg font-medium">Recent Activity</h3>
|
<h3 className="text-lg font-medium">Recent activity</h3>
|
||||||
<div className="rounded border border-custom-border-100 p-6">
|
<div className="rounded border border-custom-border-100 p-6">
|
||||||
{userProfileActivity ? (
|
{userProfileActivity ? (
|
||||||
userProfileActivity.results.length > 0 ? (
|
userProfileActivity.results.length > 0 ? (
|
||||||
|
@ -1,88 +0,0 @@
|
|||||||
// ui
|
|
||||||
import { BarGraph, ProfileEmptyState } from "components/ui";
|
|
||||||
import { Loader } from "@plane/ui";
|
|
||||||
// image
|
|
||||||
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
|
|
||||||
// helpers
|
|
||||||
import { capitalizeFirstLetter } from "helpers/string.helper";
|
|
||||||
// types
|
|
||||||
import { IUserProfileData } from "@plane/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
userProfile: IUserProfileData | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProfilePriorityDistribution: React.FC<Props> = ({ userProfile }) => (
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<h3 className="text-lg font-medium">Issues by Priority</h3>
|
|
||||||
{userProfile ? (
|
|
||||||
<div className="flex-grow rounded border border-custom-border-100">
|
|
||||||
{userProfile.priority_distribution.length > 0 ? (
|
|
||||||
<BarGraph
|
|
||||||
data={userProfile.priority_distribution.map((priority) => ({
|
|
||||||
priority: capitalizeFirstLetter(priority.priority ?? "None"),
|
|
||||||
value: priority.priority_count,
|
|
||||||
}))}
|
|
||||||
height="300px"
|
|
||||||
indexBy="priority"
|
|
||||||
keys={["value"]}
|
|
||||||
borderRadius={4}
|
|
||||||
padding={0.7}
|
|
||||||
customYAxisTickValues={userProfile.priority_distribution.map((p) => p.priority_count)}
|
|
||||||
tooltip={(datum) => (
|
|
||||||
<div className="flex items-center gap-2 rounded-md border border-custom-border-200 bg-custom-background-80 p-2 text-xs">
|
|
||||||
<span
|
|
||||||
className="h-3 w-3 rounded"
|
|
||||||
style={{
|
|
||||||
backgroundColor: datum.color,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-custom-text-200">{datum.data.priority}:</span>
|
|
||||||
<span>{datum.value}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
colors={(datum) => {
|
|
||||||
if (datum.data.priority === "Urgent") return "#991b1b";
|
|
||||||
else if (datum.data.priority === "High") return "#ef4444";
|
|
||||||
else if (datum.data.priority === "Medium") return "#f59e0b";
|
|
||||||
else if (datum.data.priority === "Low") return "#16a34a";
|
|
||||||
else return "#e5e5e5";
|
|
||||||
}}
|
|
||||||
theme={{
|
|
||||||
axis: {
|
|
||||||
domain: {
|
|
||||||
line: {
|
|
||||||
stroke: "transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
line: {
|
|
||||||
stroke: "transparent",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex-grow p-7">
|
|
||||||
<ProfileEmptyState
|
|
||||||
title="No Data yet"
|
|
||||||
description="Create issues to view the them by priority in the graph for better analysis."
|
|
||||||
image={emptyBarGraph}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid place-items-center p-7">
|
|
||||||
<Loader className="flex items-end gap-12">
|
|
||||||
<Loader.Item width="30px" height="200px" />
|
|
||||||
<Loader.Item width="30px" height="150px" />
|
|
||||||
<Loader.Item width="30px" height="250px" />
|
|
||||||
<Loader.Item width="30px" height="150px" />
|
|
||||||
<Loader.Item width="30px" height="100px" />
|
|
||||||
</Loader>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -0,0 +1 @@
|
|||||||
|
export * from "./priority-distribution";
|
@ -0,0 +1,31 @@
|
|||||||
|
// components
|
||||||
|
import { IssuesByPriorityGraph } from "components/graphs";
|
||||||
|
import { ProfileEmptyState } from "components/ui";
|
||||||
|
// assets
|
||||||
|
import emptyBarGraph from "public/empty-state/empty_bar_graph.svg";
|
||||||
|
// types
|
||||||
|
import { IUserPriorityDistribution } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
priorityDistribution: IUserPriorityDistribution[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PriorityDistributionContent: React.FC<Props> = (props) => {
|
||||||
|
const { priorityDistribution } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-grow rounded border border-custom-border-100">
|
||||||
|
{priorityDistribution.length > 0 ? (
|
||||||
|
<IssuesByPriorityGraph data={priorityDistribution} />
|
||||||
|
) : (
|
||||||
|
<div className="flex-grow p-7">
|
||||||
|
<ProfileEmptyState
|
||||||
|
title="No Data yet"
|
||||||
|
description="Create issues to view the them by priority in the graph for better analysis."
|
||||||
|
image={emptyBarGraph}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,33 @@
|
|||||||
|
// components
|
||||||
|
import { PriorityDistributionContent } from "./main-content";
|
||||||
|
// ui
|
||||||
|
import { Loader } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { IUserPriorityDistribution } from "@plane/types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
priorityDistribution: IUserPriorityDistribution[] | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProfilePriorityDistribution: React.FC<Props> = (props) => {
|
||||||
|
const { priorityDistribution } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col space-y-2">
|
||||||
|
<h3 className="text-lg font-medium">Issues by priority</h3>
|
||||||
|
{priorityDistribution ? (
|
||||||
|
<PriorityDistributionContent priorityDistribution={priorityDistribution} />
|
||||||
|
) : (
|
||||||
|
<div className="grid place-items-center p-7">
|
||||||
|
<Loader className="flex items-end gap-12">
|
||||||
|
<Loader.Item width="30px" height="200px" />
|
||||||
|
<Loader.Item width="30px" height="150px" />
|
||||||
|
<Loader.Item width="30px" height="250px" />
|
||||||
|
<Loader.Item width="30px" height="150px" />
|
||||||
|
<Loader.Item width="30px" height="100px" />
|
||||||
|
</Loader>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -17,7 +17,7 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<h3 className="text-lg font-medium">Issues by State</h3>
|
<h3 className="text-lg font-medium">Issues by state</h3>
|
||||||
<div className="flex-grow rounded border border-custom-border-100 p-7">
|
<div className="flex-grow rounded border border-custom-border-100 p-7">
|
||||||
{userProfile.state_distribution.length > 0 ? (
|
{userProfile.state_distribution.length > 0 ? (
|
||||||
<div className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||||
@ -65,7 +65,7 @@ export const ProfileStateDistribution: React.FC<Props> = ({ stateDistribution, u
|
|||||||
backgroundColor: STATE_GROUPS[group.state_group].color,
|
backgroundColor: STATE_GROUPS[group.state_group].color,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="whitespace-nowrap capitalize">{group.state_group}</div>
|
<div className="whitespace-nowrap">{STATE_GROUPS[group.state_group].label}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>{group.state_count}</div>
|
<div>{group.state_count}</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -21,12 +21,12 @@ export const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => (
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="-mt-1 space-y-1">
|
<div className="-mt-1 space-y-1">
|
||||||
<p className="text-sm capitalize text-custom-text-400">
|
<p className="text-sm text-custom-text-400">
|
||||||
{group.state_group === "unstarted"
|
{group.state_group === "unstarted"
|
||||||
? "Not Started"
|
? "Not started"
|
||||||
: group.state_group === "started"
|
: group.state_group === "started"
|
||||||
? "Working on"
|
? "Working on"
|
||||||
: group.state_group}
|
: STATE_GROUPS[group.state_group].label}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xl font-semibold">{group.state_count}</p>
|
<p className="text-xl font-semibold">{group.state_count}</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { Disclosure, Transition } from "@headlessui/react";
|
import { Disclosure, Transition } from "@headlessui/react";
|
||||||
import { observer } from "mobx-react-lite";
|
import { observer } from "mobx-react-lite";
|
||||||
// hooks
|
// hooks
|
||||||
|
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication, useUser } from "hooks/store";
|
||||||
// services
|
// services
|
||||||
import { UserService } from "services/user.service";
|
import { UserService } from "services/user.service";
|
||||||
@ -18,8 +20,6 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
|||||||
import { renderEmoji } from "helpers/emoji.helper";
|
import { renderEmoji } from "helpers/emoji.helper";
|
||||||
// fetch-keys
|
// fetch-keys
|
||||||
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
|
import { USER_PROFILE_PROJECT_SEGREGATION } from "constants/fetch-keys";
|
||||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
|
|
||||||
// services
|
// services
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
export * from "./bar-graph";
|
export * from "./bar-graph";
|
||||||
export * from "./calendar-graph";
|
export * from "./calendar-graph";
|
||||||
export * from "./line-graph";
|
export * from "./line-graph";
|
||||||
export * from "./marimekko-graph";
|
|
||||||
export * from "./pie-graph";
|
export * from "./pie-graph";
|
||||||
export * from "./scatter-plot-graph";
|
export * from "./scatter-plot-graph";
|
||||||
|
@ -1,48 +0,0 @@
|
|||||||
// nivo
|
|
||||||
import { ResponsiveMarimekko, SvgProps } from "@nivo/marimekko";
|
|
||||||
// helpers
|
|
||||||
import { generateYAxisTickValues } from "helpers/graph.helper";
|
|
||||||
// types
|
|
||||||
import { TGraph } from "./types";
|
|
||||||
// constants
|
|
||||||
import { CHARTS_THEME, DEFAULT_MARGIN } from "constants/graph";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
id: string;
|
|
||||||
value: string;
|
|
||||||
customYAxisTickValues?: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MarimekkoGraph: React.FC<Props & TGraph & Omit<SvgProps<any>, "height" | "width">> = ({
|
|
||||||
id,
|
|
||||||
value,
|
|
||||||
customYAxisTickValues,
|
|
||||||
height = "400px",
|
|
||||||
width = "100%",
|
|
||||||
margin,
|
|
||||||
theme,
|
|
||||||
...rest
|
|
||||||
}) => (
|
|
||||||
<div style={{ height, width }}>
|
|
||||||
<ResponsiveMarimekko
|
|
||||||
id={id}
|
|
||||||
value={value}
|
|
||||||
margin={{ ...DEFAULT_MARGIN, ...(margin ?? {}) }}
|
|
||||||
innerPadding={rest.innerPadding ?? 4}
|
|
||||||
axisLeft={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 10,
|
|
||||||
tickValues: customYAxisTickValues ? generateYAxisTickValues(customYAxisTickValues) : undefined,
|
|
||||||
}}
|
|
||||||
axisBottom={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 10,
|
|
||||||
tickRotation: rest.data.length > 7 ? -45 : 0,
|
|
||||||
}}
|
|
||||||
labelTextColor={{ from: "color", modifiers: [["darker", 1.6]] }}
|
|
||||||
theme={{ ...CHARTS_THEME, ...(theme ?? {}) }}
|
|
||||||
animate
|
|
||||||
{...rest}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
@ -7,7 +7,7 @@ import OverdueIssuesLight from "public/empty-state/dashboard/light/overdue-issue
|
|||||||
import CompletedIssuesDark from "public/empty-state/dashboard/dark/completed-issues.svg";
|
import CompletedIssuesDark from "public/empty-state/dashboard/dark/completed-issues.svg";
|
||||||
import CompletedIssuesLight from "public/empty-state/dashboard/light/completed-issues.svg";
|
import CompletedIssuesLight from "public/empty-state/dashboard/light/completed-issues.svg";
|
||||||
// types
|
// types
|
||||||
import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types";
|
import { EDurationFilters, TIssuesListTypes, TStateGroups } from "@plane/types";
|
||||||
import { Props } from "components/icons/types";
|
import { Props } from "components/icons/types";
|
||||||
// constants
|
// constants
|
||||||
import { EUserWorkspaceRoles } from "./workspace";
|
import { EUserWorkspaceRoles } from "./workspace";
|
||||||
@ -118,29 +118,33 @@ export const STATE_GROUP_GRAPH_COLORS: Record<TStateGroups, string> = {
|
|||||||
|
|
||||||
// filter duration options
|
// filter duration options
|
||||||
export const DURATION_FILTER_OPTIONS: {
|
export const DURATION_FILTER_OPTIONS: {
|
||||||
key: TDurationFilterOptions;
|
key: EDurationFilters;
|
||||||
label: string;
|
label: string;
|
||||||
}[] = [
|
}[] = [
|
||||||
{
|
{
|
||||||
key: "none",
|
key: EDurationFilters.NONE,
|
||||||
label: "None",
|
label: "None",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "today",
|
key: EDurationFilters.TODAY,
|
||||||
label: "Due today",
|
label: "Due today",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "this_week",
|
key: EDurationFilters.THIS_WEEK,
|
||||||
label: "Due this week",
|
label: "Due this week",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "this_month",
|
key: EDurationFilters.THIS_MONTH,
|
||||||
label: "Due this month",
|
label: "Due this month",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "this_year",
|
key: EDurationFilters.THIS_YEAR,
|
||||||
label: "Due this year",
|
label: "Due this year",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: EDurationFilters.CUSTOM,
|
||||||
|
label: "Custom",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// random background colors for project cards
|
// random background colors for project cards
|
||||||
|
@ -164,7 +164,7 @@ export const USER_ISSUES = (workspaceSlug: string, params: any) => {
|
|||||||
|
|
||||||
return `USER_ISSUES_${workspaceSlug.toUpperCase()}_${paramsKey}`;
|
return `USER_ISSUES_${workspaceSlug.toUpperCase()}_${paramsKey}`;
|
||||||
};
|
};
|
||||||
export const USER_ACTIVITY = "USER_ACTIVITY";
|
export const USER_ACTIVITY = (params: { cursor?: string }) => `USER_ACTIVITY_${params?.cursor}`;
|
||||||
export const USER_WORKSPACE_DASHBOARD = (workspaceSlug: string) =>
|
export const USER_WORKSPACE_DASHBOARD = (workspaceSlug: string) =>
|
||||||
`USER_WORKSPACE_DASHBOARD_${workspaceSlug.toUpperCase()}`;
|
`USER_WORKSPACE_DASHBOARD_${workspaceSlug.toUpperCase()}`;
|
||||||
export const USER_PROJECT_VIEW = (projectId: string) => `USER_PROJECT_VIEW_${projectId.toUpperCase()}`;
|
export const USER_PROJECT_VIEW = (projectId: string) => `USER_PROJECT_VIEW_${projectId.toUpperCase()}`;
|
||||||
@ -284,8 +284,13 @@ export const getPaginatedNotificationKey = (index: number, prevData: any, worksp
|
|||||||
// profile
|
// profile
|
||||||
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
|
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
|
||||||
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||||
export const USER_PROFILE_ACTIVITY = (workspaceSlug: string, userId: string) =>
|
export const USER_PROFILE_ACTIVITY = (
|
||||||
`USER_WORKSPACE_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
workspaceSlug: string,
|
||||||
|
userId: string,
|
||||||
|
params: {
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
) => `USER_WORKSPACE_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}_${params?.cursor}`;
|
||||||
export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId: string) =>
|
export const USER_PROFILE_PROJECT_SEGREGATION = (workspaceSlug: string, userId: string) =>
|
||||||
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||||
export const USER_PROFILE_ISSUES = (workspaceSlug: string, userId: string, params: any) => {
|
export const USER_PROFILE_ISSUES = (workspaceSlug: string, userId: string, params: any) => {
|
||||||
|
@ -63,4 +63,9 @@ export const PROFILE_ADMINS_TAB = [
|
|||||||
label: "Subscribed",
|
label: "Subscribed",
|
||||||
selected: "/[workspaceSlug]/profile/[userId]/subscribed",
|
selected: "/[workspaceSlug]/profile/[userId]/subscribed",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: "activity",
|
||||||
|
label: "Activity",
|
||||||
|
selected: "/[workspaceSlug]/profile/[userId]/activity",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
@ -1,36 +1,40 @@
|
|||||||
import { endOfMonth, endOfWeek, endOfYear, startOfMonth, startOfWeek, startOfYear } from "date-fns";
|
import { endOfMonth, endOfWeek, endOfYear, startOfMonth, startOfWeek, startOfYear } from "date-fns";
|
||||||
// helpers
|
// helpers
|
||||||
import { renderFormattedPayloadDate } from "./date-time.helper";
|
import { renderFormattedDate, renderFormattedPayloadDate } from "./date-time.helper";
|
||||||
// types
|
// types
|
||||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
import { EDurationFilters, TIssuesListTypes } from "@plane/types";
|
||||||
|
// constants
|
||||||
|
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description returns date range based on the duration filter
|
* @description returns date range based on the duration filter
|
||||||
* @param duration
|
* @param duration
|
||||||
*/
|
*/
|
||||||
export const getCustomDates = (duration: TDurationFilterOptions): string => {
|
export const getCustomDates = (duration: EDurationFilters, customDates: string[]): string => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
let firstDay, lastDay;
|
let firstDay, lastDay;
|
||||||
|
|
||||||
switch (duration) {
|
switch (duration) {
|
||||||
case "none":
|
case EDurationFilters.NONE:
|
||||||
return "";
|
return "";
|
||||||
case "today":
|
case EDurationFilters.TODAY:
|
||||||
firstDay = renderFormattedPayloadDate(today);
|
firstDay = renderFormattedPayloadDate(today);
|
||||||
lastDay = renderFormattedPayloadDate(today);
|
lastDay = renderFormattedPayloadDate(today);
|
||||||
return `${firstDay};after,${lastDay};before`;
|
return `${firstDay};after,${lastDay};before`;
|
||||||
case "this_week":
|
case EDurationFilters.THIS_WEEK:
|
||||||
firstDay = renderFormattedPayloadDate(startOfWeek(today));
|
firstDay = renderFormattedPayloadDate(startOfWeek(today));
|
||||||
lastDay = renderFormattedPayloadDate(endOfWeek(today));
|
lastDay = renderFormattedPayloadDate(endOfWeek(today));
|
||||||
return `${firstDay};after,${lastDay};before`;
|
return `${firstDay};after,${lastDay};before`;
|
||||||
case "this_month":
|
case EDurationFilters.THIS_MONTH:
|
||||||
firstDay = renderFormattedPayloadDate(startOfMonth(today));
|
firstDay = renderFormattedPayloadDate(startOfMonth(today));
|
||||||
lastDay = renderFormattedPayloadDate(endOfMonth(today));
|
lastDay = renderFormattedPayloadDate(endOfMonth(today));
|
||||||
return `${firstDay};after,${lastDay};before`;
|
return `${firstDay};after,${lastDay};before`;
|
||||||
case "this_year":
|
case EDurationFilters.THIS_YEAR:
|
||||||
firstDay = renderFormattedPayloadDate(startOfYear(today));
|
firstDay = renderFormattedPayloadDate(startOfYear(today));
|
||||||
lastDay = renderFormattedPayloadDate(endOfYear(today));
|
lastDay = renderFormattedPayloadDate(endOfYear(today));
|
||||||
return `${firstDay};after,${lastDay};before`;
|
return `${firstDay};after,${lastDay};before`;
|
||||||
|
case EDurationFilters.CUSTOM:
|
||||||
|
return customDates.join(",");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -58,7 +62,7 @@ export const getRedirectionFilters = (type: TIssuesListTypes): string => {
|
|||||||
* @param duration
|
* @param duration
|
||||||
* @param tab
|
* @param tab
|
||||||
*/
|
*/
|
||||||
export const getTabKey = (duration: TDurationFilterOptions, tab: TIssuesListTypes | undefined): TIssuesListTypes => {
|
export const getTabKey = (duration: EDurationFilters, tab: TIssuesListTypes | undefined): TIssuesListTypes => {
|
||||||
if (!tab) return "completed";
|
if (!tab) return "completed";
|
||||||
|
|
||||||
if (tab === "completed") return tab;
|
if (tab === "completed") return tab;
|
||||||
@ -69,3 +73,21 @@ export const getTabKey = (duration: TDurationFilterOptions, tab: TIssuesListType
|
|||||||
else return "upcoming";
|
else return "upcoming";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description returns the label for the duration filter dropdown
|
||||||
|
* @param duration
|
||||||
|
* @param customDates
|
||||||
|
*/
|
||||||
|
export const getDurationFilterDropdownLabel = (duration: EDurationFilters, customDates: string[]): string => {
|
||||||
|
if (duration !== "custom") return DURATION_FILTER_OPTIONS.find((option) => option.key === duration)?.label ?? "";
|
||||||
|
else {
|
||||||
|
const afterDate = customDates.find((date) => date.includes("after"))?.split(";")[0];
|
||||||
|
const beforeDate = customDates.find((date) => date.includes("before"))?.split(";")[0];
|
||||||
|
|
||||||
|
if (afterDate && beforeDate) return `${renderFormattedDate(afterDate)} - ${renderFormattedDate(beforeDate)}`;
|
||||||
|
else if (afterDate) return `After ${renderFormattedDate(afterDate)}`;
|
||||||
|
else if (beforeDate) return `Before ${renderFormattedDate(beforeDate)}`;
|
||||||
|
else return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -4,6 +4,8 @@ import { observer } from "mobx-react-lite";
|
|||||||
import { useUser } from "hooks/store";
|
import { useUser } from "hooks/store";
|
||||||
// components
|
// components
|
||||||
import { ProfileNavbar, ProfileSidebar } from "components/profile";
|
import { ProfileNavbar, ProfileSidebar } from "components/profile";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -11,27 +13,25 @@ type Props = {
|
|||||||
showProfileIssuesFilter?: boolean;
|
showProfileIssuesFilter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AUTHORIZED_ROLES = [20, 15, 10];
|
const AUTHORIZED_ROLES = [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER, EUserWorkspaceRoles.VIEWER];
|
||||||
|
|
||||||
export const ProfileAuthWrapper: React.FC<Props> = observer((props) => {
|
export const ProfileAuthWrapper: React.FC<Props> = observer((props) => {
|
||||||
const { children, className, showProfileIssuesFilter } = props;
|
const { children, className, showProfileIssuesFilter } = props;
|
||||||
|
// router
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
// store hooks
|
||||||
const {
|
const {
|
||||||
membership: { currentWorkspaceRole },
|
membership: { currentWorkspaceRole },
|
||||||
} = useUser();
|
} = useUser();
|
||||||
|
// derived values
|
||||||
if (!currentWorkspaceRole) return null;
|
const isAuthorized = currentWorkspaceRole && AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
||||||
|
|
||||||
const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
|
||||||
|
|
||||||
const isAuthorizedPath = router.pathname.includes("assigned" || "created" || "subscribed");
|
const isAuthorizedPath = router.pathname.includes("assigned" || "created" || "subscribed");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full md:flex md:flex-row-reverse md:overflow-hidden">
|
<div className="h-full w-full md:flex md:flex-row-reverse md:overflow-hidden">
|
||||||
<ProfileSidebar />
|
<ProfileSidebar />
|
||||||
<div className="flex w-full flex-col md:h-full md:overflow-hidden">
|
<div className="flex w-full flex-col md:h-full md:overflow-hidden">
|
||||||
<ProfileNavbar isAuthorized={isAuthorized} showProfileIssuesFilter={showProfileIssuesFilter} />
|
<ProfileNavbar isAuthorized={!!isAuthorized} showProfileIssuesFilter={showProfileIssuesFilter} />
|
||||||
{isAuthorized || !isAuthorizedPath ? (
|
{isAuthorized || !isAuthorizedPath ? (
|
||||||
<div className={`w-full overflow-hidden md:h-full ${className}`}>{children}</div>
|
<div className={`w-full overflow-hidden md:h-full ${className}`}>{children}</div>
|
||||||
) : (
|
) : (
|
||||||
|
@ -20,7 +20,6 @@
|
|||||||
"@nivo/core": "0.80.0",
|
"@nivo/core": "0.80.0",
|
||||||
"@nivo/legends": "0.80.0",
|
"@nivo/legends": "0.80.0",
|
||||||
"@nivo/line": "0.80.0",
|
"@nivo/line": "0.80.0",
|
||||||
"@nivo/marimekko": "0.80.0",
|
|
||||||
"@nivo/pie": "0.80.0",
|
"@nivo/pie": "0.80.0",
|
||||||
"@nivo/scatterplot": "0.80.0",
|
"@nivo/scatterplot": "0.80.0",
|
||||||
"@plane/document-editor": "*",
|
"@plane/document-editor": "*",
|
||||||
|
84
web/pages/[workspaceSlug]/profile/[userId]/activity.tsx
Normal file
84
web/pages/[workspaceSlug]/profile/[userId]/activity.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { ReactElement, useState } from "react";
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { observer } from "mobx-react";
|
||||||
|
// hooks
|
||||||
|
import { useUser } from "hooks/store";
|
||||||
|
// layouts
|
||||||
|
import { AppLayout } from "layouts/app-layout";
|
||||||
|
import { ProfileAuthWrapper } from "layouts/user-profile-layout";
|
||||||
|
// components
|
||||||
|
import { UserProfileHeader } from "components/headers";
|
||||||
|
import { DownloadActivityButton, WorkspaceActivityListPage } from "components/profile";
|
||||||
|
// ui
|
||||||
|
import { Button } from "@plane/ui";
|
||||||
|
// types
|
||||||
|
import { NextPageWithLayout } from "lib/types";
|
||||||
|
// constants
|
||||||
|
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||||
|
|
||||||
|
const PER_PAGE = 100;
|
||||||
|
|
||||||
|
const ProfileActivityPage: NextPageWithLayout = observer(() => {
|
||||||
|
// states
|
||||||
|
const [pageCount, setPageCount] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [resultsCount, setResultsCount] = useState(0);
|
||||||
|
// router
|
||||||
|
const router = useRouter();
|
||||||
|
const { userId } = router.query;
|
||||||
|
// store hooks
|
||||||
|
const {
|
||||||
|
currentUser,
|
||||||
|
membership: { currentWorkspaceRole },
|
||||||
|
} = useUser();
|
||||||
|
|
||||||
|
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||||
|
|
||||||
|
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||||
|
|
||||||
|
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||||
|
|
||||||
|
const activityPages: JSX.Element[] = [];
|
||||||
|
for (let i = 0; i < pageCount; i++)
|
||||||
|
activityPages.push(
|
||||||
|
<WorkspaceActivityListPage
|
||||||
|
key={i}
|
||||||
|
cursor={`${PER_PAGE}:${i}:0`}
|
||||||
|
perPage={PER_PAGE}
|
||||||
|
updateResultsCount={updateResultsCount}
|
||||||
|
updateTotalPages={updateTotalPages}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const canDownloadActivity =
|
||||||
|
currentUser?.id === userId && !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full w-full px-5 py-5 md:px-9 flex flex-col overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-lg font-medium">Recent activity</h3>
|
||||||
|
{canDownloadActivity && <DownloadActivityButton />}
|
||||||
|
</div>
|
||||||
|
<div className="h-full flex flex-col overflow-y-auto">
|
||||||
|
{activityPages}
|
||||||
|
{pageCount < totalPages && resultsCount !== 0 && (
|
||||||
|
<div className="flex items-center justify-center text-xs w-full">
|
||||||
|
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
|
||||||
|
Load more
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ProfileActivityPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
|
return (
|
||||||
|
<AppLayout header={<UserProfileHeader type="Activity" />}>
|
||||||
|
<ProfileAuthWrapper>{page}</ProfileAuthWrapper>
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProfileActivityPage;
|
@ -49,7 +49,7 @@ const ProfileOverviewPage: NextPageWithLayout = () => {
|
|||||||
<ProfileStats userProfile={userProfile} />
|
<ProfileStats userProfile={userProfile} />
|
||||||
<ProfileWorkload stateDistribution={stateDistribution} />
|
<ProfileWorkload stateDistribution={stateDistribution} />
|
||||||
<div className="grid grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
|
<div className="grid grid-cols-1 items-stretch gap-5 xl:grid-cols-2">
|
||||||
<ProfilePriorityDistribution userProfile={userProfile} />
|
<ProfilePriorityDistribution priorityDistribution={userProfile?.priority_distribution} />
|
||||||
<ProfileStateDistribution stateDistribution={stateDistribution} userProfile={userProfile} />
|
<ProfileStateDistribution stateDistribution={stateDistribution} userProfile={userProfile} />
|
||||||
</div>
|
</div>
|
||||||
<ProfileActivity />
|
<ProfileActivity />
|
||||||
|
@ -1,191 +1,64 @@
|
|||||||
import { ReactElement } from "react";
|
import { ReactElement, useState } from "react";
|
||||||
import useSWR from "swr";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
//hooks
|
//hooks
|
||||||
import { useApplication, useUser } from "hooks/store";
|
import { useApplication } from "hooks/store";
|
||||||
// services
|
|
||||||
import { UserService } from "services/user.service";
|
|
||||||
// layouts
|
// layouts
|
||||||
import { ProfileSettingsLayout } from "layouts/settings-layout";
|
import { ProfileSettingsLayout } from "layouts/settings-layout";
|
||||||
// components
|
// components
|
||||||
import { ActivityIcon, ActivityMessage, IssueLink, PageHead } from "components/core";
|
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||||
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
|
import { ProfileActivityListPage } from "components/profile";
|
||||||
// icons
|
import { PageHead } from "components/core";
|
||||||
import { History, MessageSquare } from "lucide-react";
|
|
||||||
// ui
|
// ui
|
||||||
import { ActivitySettingsLoader } from "components/ui";
|
import { Button } from "@plane/ui";
|
||||||
// fetch-keys
|
|
||||||
import { USER_ACTIVITY } from "constants/fetch-keys";
|
|
||||||
// helper
|
|
||||||
import { calculateTimeAgo } from "helpers/date-time.helper";
|
|
||||||
// type
|
// type
|
||||||
import { NextPageWithLayout } from "lib/types";
|
import { NextPageWithLayout } from "lib/types";
|
||||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
|
||||||
|
|
||||||
const userService = new UserService();
|
const PER_PAGE = 100;
|
||||||
|
|
||||||
const ProfileActivityPage: NextPageWithLayout = observer(() => {
|
const ProfileActivityPage: NextPageWithLayout = observer(() => {
|
||||||
const { data: userActivity } = useSWR(USER_ACTIVITY, () => userService.getUserActivity());
|
// states
|
||||||
|
const [pageCount, setPageCount] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
|
const [resultsCount, setResultsCount] = useState(0);
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentUser } = useUser();
|
|
||||||
const { theme: themeStore } = useApplication();
|
const { theme: themeStore } = useApplication();
|
||||||
|
|
||||||
|
const updateTotalPages = (count: number) => setTotalPages(count);
|
||||||
|
|
||||||
|
const updateResultsCount = (count: number) => setResultsCount(count);
|
||||||
|
|
||||||
|
const handleLoadMore = () => setPageCount((prev) => prev + 1);
|
||||||
|
|
||||||
|
const activityPages: JSX.Element[] = [];
|
||||||
|
for (let i = 0; i < pageCount; i++)
|
||||||
|
activityPages.push(
|
||||||
|
<ProfileActivityListPage
|
||||||
|
key={i}
|
||||||
|
cursor={`${PER_PAGE}:${i}:0`}
|
||||||
|
perPage={PER_PAGE}
|
||||||
|
updateResultsCount={updateResultsCount}
|
||||||
|
updateTotalPages={updateTotalPages}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead title="Profile - Activity" />
|
<PageHead title="Profile - Activity" />
|
||||||
<section className="mx-auto mt-5 md:mt-16 flex h-full w-full flex-col overflow-hidden px-8 pb-8 lg:w-3/5">
|
<section className="mx-auto mt-5 md:mt-16 h-full w-full flex flex-col overflow-hidden px-8 pb-8 lg:w-3/5">
|
||||||
<div className="flex items-center border-b border-custom-border-100 gap-4 pb-3.5">
|
<div className="flex items-center border-b border-custom-border-100 gap-4 pb-3.5">
|
||||||
<SidebarHamburgerToggle onClick={() => themeStore.toggleSidebar()} />
|
<SidebarHamburgerToggle onClick={() => themeStore.toggleSidebar()} />
|
||||||
<h3 className="text-xl font-medium">Activity</h3>
|
<h3 className="text-xl font-medium">Activity</h3>
|
||||||
</div>
|
</div>
|
||||||
{userActivity ? (
|
<div className="h-full flex flex-col overflow-y-auto">
|
||||||
<div className="flex h-full w-full flex-col gap-2 overflow-y-auto vertical-scrollbar scrollbar-md">
|
{activityPages}
|
||||||
<ul role="list" className="-mb-4">
|
{pageCount < totalPages && resultsCount !== 0 && (
|
||||||
{userActivity.results.map((activityItem: any) => {
|
<div className="flex items-center justify-center text-xs w-full">
|
||||||
if (activityItem.field === "comment") {
|
<Button variant="accent-primary" size="sm" onClick={handleLoadMore}>
|
||||||
return (
|
Load more
|
||||||
<div key={activityItem.id} className="mt-2">
|
</Button>
|
||||||
<div className="relative flex items-start space-x-3">
|
|
||||||
<div className="relative px-1">
|
|
||||||
{activityItem.field ? (
|
|
||||||
activityItem.new_value === "restore" && (
|
|
||||||
<History className="h-3.5 w-3.5 text-custom-text-200" />
|
|
||||||
)
|
|
||||||
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
|
|
||||||
<img
|
|
||||||
src={activityItem.actor_detail.avatar}
|
|
||||||
alt={activityItem.actor_detail.display_name}
|
|
||||||
height={30}
|
|
||||||
width={30}
|
|
||||||
className="grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`grid h-7 w-7 place-items-center rounded-full border-2 border-white bg-gray-500 text-white`}
|
|
||||||
>
|
|
||||||
{activityItem.actor_detail.display_name?.charAt(0)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="ring-6 flex h-6 w-6 items-center justify-center rounded-full bg-custom-background-80 text-custom-text-200 ring-white">
|
|
||||||
<MessageSquare className="h-6 w-6 !text-2xl text-custom-text-200" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs">
|
|
||||||
{activityItem.actor_detail.is_bot
|
|
||||||
? activityItem.actor_detail.first_name + " Bot"
|
|
||||||
: activityItem.actor_detail.display_name}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 text-xs text-custom-text-200">
|
|
||||||
Commented {calculateTimeAgo(activityItem.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="issue-comments-section p-0">
|
|
||||||
<RichReadOnlyEditor
|
|
||||||
value={activityItem?.new_value !== "" ? activityItem.new_value : activityItem.old_value}
|
|
||||||
customClassName="text-xs border border-custom-border-200 bg-custom-background-100"
|
|
||||||
noBorder
|
|
||||||
borderOnFocus={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message =
|
|
||||||
activityItem.verb === "created" &&
|
|
||||||
activityItem.field !== "cycles" &&
|
|
||||||
activityItem.field !== "modules" &&
|
|
||||||
activityItem.field !== "attachment" &&
|
|
||||||
activityItem.field !== "link" &&
|
|
||||||
activityItem.field !== "estimate" &&
|
|
||||||
!activityItem.field ? (
|
|
||||||
<span>
|
|
||||||
created <IssueLink activity={activityItem} />
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<ActivityMessage activity={activityItem} showIssue />
|
|
||||||
);
|
|
||||||
|
|
||||||
if ("field" in activityItem && activityItem.field !== "updated_by") {
|
|
||||||
return (
|
|
||||||
<li key={activityItem.id}>
|
|
||||||
<div className="relative pb-1">
|
|
||||||
<div className="relative flex items-center space-x-2">
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<div className="relative px-1.5">
|
|
||||||
<div className="mt-1.5">
|
|
||||||
<div className="flex h-6 w-6 items-center justify-center">
|
|
||||||
{activityItem.field ? (
|
|
||||||
activityItem.new_value === "restore" ? (
|
|
||||||
<History className="h-5 w-5 text-custom-text-200" />
|
|
||||||
) : (
|
|
||||||
<ActivityIcon activity={activityItem} />
|
|
||||||
)
|
|
||||||
) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? (
|
|
||||||
<img
|
|
||||||
src={activityItem.actor_detail.avatar}
|
|
||||||
alt={activityItem.actor_detail.display_name}
|
|
||||||
height={24}
|
|
||||||
width={24}
|
|
||||||
className="h-full w-full rounded-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`grid h-6 w-6 place-items-center rounded-full border-2 border-white bg-gray-700 text-xs text-white`}
|
|
||||||
>
|
|
||||||
{activityItem.actor_detail.display_name?.charAt(0)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1 border-b border-custom-border-100 py-4">
|
|
||||||
<div className="flex gap-1 break-words text-sm text-custom-text-200">
|
|
||||||
{activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? (
|
|
||||||
<span className="text-gray font-medium">Plane</span>
|
|
||||||
) : activityItem.actor_detail.is_bot ? (
|
|
||||||
<span className="text-gray font-medium">
|
|
||||||
{activityItem.actor_detail.first_name} Bot
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
href={`/${activityItem.workspace_detail.slug}/profile/${activityItem.actor_detail.id}`}
|
|
||||||
>
|
|
||||||
<span className="text-gray font-medium">
|
|
||||||
{currentUser?.id === activityItem.actor_detail.id
|
|
||||||
? "You"
|
|
||||||
: activityItem.actor_detail.display_name}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
)}{" "}
|
|
||||||
<div className="flex gap-1 truncate">
|
|
||||||
{message}{" "}
|
|
||||||
<span className="flex-shrink-0 whitespace-nowrap">
|
|
||||||
{calculateTimeAgo(activityItem.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ActivitySettingsLoader />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -9,7 +9,6 @@ import type {
|
|||||||
IUserProfileData,
|
IUserProfileData,
|
||||||
IUserProfileProjectSegregation,
|
IUserProfileProjectSegregation,
|
||||||
IUserSettings,
|
IUserSettings,
|
||||||
IUserWorkspaceDashboard,
|
|
||||||
IUserEmailNotificationSettings,
|
IUserEmailNotificationSettings,
|
||||||
} from "@plane/types";
|
} from "@plane/types";
|
||||||
// helpers
|
// helpers
|
||||||
@ -113,20 +112,8 @@ export class UserService extends APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserActivity(): Promise<IUserActivityResponse> {
|
async getUserActivity(params: { per_page: number; cursor?: string }): Promise<IUserActivityResponse> {
|
||||||
return this.get(`/api/users/me/activities/`)
|
return this.get("/api/users/me/activities/", { params })
|
||||||
.then((response) => response?.data)
|
|
||||||
.catch((error) => {
|
|
||||||
throw error?.response?.data;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async userWorkspaceDashboard(workspaceSlug: string, month: number): Promise<IUserWorkspaceDashboard> {
|
|
||||||
return this.get(`/api/users/me/workspaces/${workspaceSlug}/dashboard/`, {
|
|
||||||
params: {
|
|
||||||
month: month,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
@ -160,8 +147,31 @@ export class UserService extends APIService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUserProfileActivity(workspaceSlug: string, userId: string): Promise<IUserActivityResponse> {
|
async getUserProfileActivity(
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/?per_page=15`)
|
workspaceSlug: string,
|
||||||
|
userId: string,
|
||||||
|
params: {
|
||||||
|
per_page: number;
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
): Promise<IUserActivityResponse> {
|
||||||
|
return this.get(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/`, {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
.then((response) => response?.data)
|
||||||
|
.catch((error) => {
|
||||||
|
throw error?.response?.data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadProfileActivity(
|
||||||
|
workspaceSlug: string,
|
||||||
|
userId: string,
|
||||||
|
data: {
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
): Promise<any> {
|
||||||
|
return this.post(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/export/`, data)
|
||||||
.then((response) => response?.data)
|
.then((response) => response?.data)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data;
|
||||||
|
@ -22,7 +22,6 @@ export interface IUserRootStore {
|
|||||||
fetchCurrentUser: () => Promise<IUser>;
|
fetchCurrentUser: () => Promise<IUser>;
|
||||||
fetchCurrentUserInstanceAdminStatus: () => Promise<boolean>;
|
fetchCurrentUserInstanceAdminStatus: () => Promise<boolean>;
|
||||||
fetchCurrentUserSettings: () => Promise<IUserSettings>;
|
fetchCurrentUserSettings: () => Promise<IUserSettings>;
|
||||||
fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>;
|
|
||||||
// crud actions
|
// crud actions
|
||||||
updateUserOnBoard: () => Promise<void>;
|
updateUserOnBoard: () => Promise<void>;
|
||||||
updateTourCompleted: () => Promise<void>;
|
updateTourCompleted: () => Promise<void>;
|
||||||
@ -68,7 +67,6 @@ export class UserRootStore implements IUserRootStore {
|
|||||||
fetchCurrentUser: action,
|
fetchCurrentUser: action,
|
||||||
fetchCurrentUserInstanceAdminStatus: action,
|
fetchCurrentUserInstanceAdminStatus: action,
|
||||||
fetchCurrentUserSettings: action,
|
fetchCurrentUserSettings: action,
|
||||||
fetchUserDashboardInfo: action,
|
|
||||||
updateUserOnBoard: action,
|
updateUserOnBoard: action,
|
||||||
updateTourCompleted: action,
|
updateTourCompleted: action,
|
||||||
updateCurrentUser: action,
|
updateCurrentUser: action,
|
||||||
@ -130,22 +128,6 @@ export class UserRootStore implements IUserRootStore {
|
|||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches the current user dashboard info
|
|
||||||
* @returns Promise<IUserWorkspaceDashboard>
|
|
||||||
*/
|
|
||||||
fetchUserDashboardInfo = async (workspaceSlug: string, month: number) => {
|
|
||||||
try {
|
|
||||||
const response = await this.userService.userWorkspaceDashboard(workspaceSlug, month);
|
|
||||||
runInAction(() => {
|
|
||||||
this.dashboardInfo = response;
|
|
||||||
});
|
|
||||||
return response;
|
|
||||||
} catch (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the user onboarding status
|
* Updates the user onboarding status
|
||||||
* @returns Promise<void>
|
* @returns Promise<void>
|
||||||
|
13
yarn.lock
13
yarn.lock
@ -1766,19 +1766,6 @@
|
|||||||
"@react-spring/web" "9.4.5"
|
"@react-spring/web" "9.4.5"
|
||||||
d3-shape "^1.3.5"
|
d3-shape "^1.3.5"
|
||||||
|
|
||||||
"@nivo/marimekko@0.80.0":
|
|
||||||
version "0.80.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@nivo/marimekko/-/marimekko-0.80.0.tgz#1eda4207935b3776bd1d7d729dc39a0e42bb1b86"
|
|
||||||
integrity sha512-0u20SryNtbOQhtvhZsbxmgnF7o8Yc2rjDQ/gCYPTXtxeooWCuhSaRZbDCnCeyKQY3B62D7z2mu4Js4KlTEftjA==
|
|
||||||
dependencies:
|
|
||||||
"@nivo/axes" "0.80.0"
|
|
||||||
"@nivo/colors" "0.80.0"
|
|
||||||
"@nivo/legends" "0.80.0"
|
|
||||||
"@nivo/scales" "0.80.0"
|
|
||||||
"@react-spring/web" "9.4.5"
|
|
||||||
d3-shape "^1.3.5"
|
|
||||||
lodash "^4.17.21"
|
|
||||||
|
|
||||||
"@nivo/pie@0.80.0":
|
"@nivo/pie@0.80.0":
|
||||||
version "0.80.0"
|
version "0.80.0"
|
||||||
resolved "https://registry.yarnpkg.com/@nivo/pie/-/pie-0.80.0.tgz#04b35839bf5a2b661fa4e5b677ae76b3c028471e"
|
resolved "https://registry.yarnpkg.com/@nivo/pie/-/pie-0.80.0.tgz#04b35839bf5a2b661fa4e5b677ae76b3c028471e"
|
||||||
|
Loading…
Reference in New Issue
Block a user