forked from github/plane
[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,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
WorkspaceModulesEndpoint,
|
||||
WorkspaceCyclesEndpoint,
|
||||
)
|
||||
@ -191,6 +192,11 @@ urlpatterns = [
|
||||
WorkspaceUserActivityEndpoint.as_view(),
|
||||
name="workspace-user-activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/user-activity/<uuid:user_id>/export/",
|
||||
ExportWorkspaceUserActivityEndpoint.as_view(),
|
||||
name="export-workspace-user-activity",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/user-profile/<uuid:user_id>/",
|
||||
WorkspaceUserProfileEndpoint.as_view(),
|
||||
|
@ -49,6 +49,7 @@ from .workspace import (
|
||||
WorkspaceUserPropertiesEndpoint,
|
||||
WorkspaceStatesEndpoint,
|
||||
WorkspaceEstimatesEndpoint,
|
||||
ExportWorkspaceUserActivityEndpoint,
|
||||
WorkspaceModulesEndpoint,
|
||||
WorkspaceCyclesEndpoint,
|
||||
)
|
||||
|
@ -14,6 +14,7 @@ from django.db.models import (
|
||||
JSONField,
|
||||
Func,
|
||||
Prefetch,
|
||||
IntegerField,
|
||||
)
|
||||
from django.contrib.postgres.aggregates import ArrayAgg
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
@ -38,6 +39,8 @@ from plane.db.models import (
|
||||
IssueLink,
|
||||
IssueAttachment,
|
||||
IssueRelation,
|
||||
IssueAssignee,
|
||||
User,
|
||||
)
|
||||
from plane.app.serializers import (
|
||||
IssueActivitySerializer,
|
||||
@ -212,11 +215,11 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
target_date__lt=timezone.now(),
|
||||
).count()
|
||||
overdue_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
target_date__lt=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
@ -231,11 +234,11 @@ def dashboard_assigned_issues(self, request, slug):
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
target_date__gte=timezone.now(),
|
||||
).count()
|
||||
upcoming_issues = assigned_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
target_date__gte=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
@ -365,11 +368,11 @@ def dashboard_created_issues(self, request, slug):
|
||||
if issue_type == "overdue":
|
||||
overdue_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
target_date__lt=timezone.now(),
|
||||
).count()
|
||||
overdue_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__lt=timezone.now()
|
||||
target_date__lt=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
@ -382,11 +385,11 @@ def dashboard_created_issues(self, request, slug):
|
||||
if issue_type == "upcoming":
|
||||
upcoming_issues_count = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
target_date__gte=timezone.now(),
|
||||
).count()
|
||||
upcoming_issues = created_issues.filter(
|
||||
state__group__in=["backlog", "unstarted", "started"],
|
||||
target_date__gte=timezone.now()
|
||||
target_date__gte=timezone.now(),
|
||||
)[:5]
|
||||
return Response(
|
||||
{
|
||||
@ -503,7 +506,9 @@ def dashboard_recent_projects(self, request, slug):
|
||||
).exclude(id__in=unique_project_ids)
|
||||
|
||||
# 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(
|
||||
list(unique_project_ids)[:4],
|
||||
@ -512,90 +517,97 @@ def dashboard_recent_projects(self, request, slug):
|
||||
|
||||
|
||||
def dashboard_recent_collaborators(self, request, slug):
|
||||
# Fetch all project IDs where the user belongs to
|
||||
user_projects = Project.objects.filter(
|
||||
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 = (
|
||||
# Subquery to count activities for each project member
|
||||
activity_count_subquery = (
|
||||
IssueActivity.objects.filter(
|
||||
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")
|
||||
.exclude(actor=request.user)
|
||||
.annotate(num_activities=Count("actor"))
|
||||
.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
|
||||
active_issue_count = Issue.objects.filter(
|
||||
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(
|
||||
~Q(member=request.user),
|
||||
project_id__in=user_projects,
|
||||
workspace__slug=slug,
|
||||
)
|
||||
.exclude(
|
||||
member__in=[
|
||||
user["actor"] for user in users_with_activities
|
||||
]
|
||||
)
|
||||
.values_list("member", flat=True)
|
||||
)
|
||||
)
|
||||
|
||||
additional_collaborators = additional_collaborators[
|
||||
:additional_collaborators_needed
|
||||
]
|
||||
|
||||
# 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},
|
||||
.annotate(num_activities=Count("pk"))
|
||||
.values("num_activities")
|
||||
)
|
||||
|
||||
return Response(users_with_active_issues, status=status.HTTP_200_OK)
|
||||
# Get all project members and annotate them with activity counts
|
||||
project_members_with_activities = (
|
||||
ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project__project_projectmember__member=request.user,
|
||||
project__project_projectmember__is_active=True,
|
||||
)
|
||||
.annotate(
|
||||
num_activities=Coalesce(
|
||||
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)
|
||||
.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)
|
||||
)
|
||||
)
|
||||
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=project_members_with_activities,
|
||||
controller=self.get_results_controller,
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
serializer = DashboardSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
@ -622,7 +634,9 @@ class DashboardEndpoint(BaseAPIView):
|
||||
dashboard_type = request.GET.get("dashboard_type", None)
|
||||
if dashboard_type == "home":
|
||||
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:
|
||||
@ -639,7 +653,9 @@ class DashboardEndpoint(BaseAPIView):
|
||||
|
||||
updated_dashboard_widgets = []
|
||||
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:
|
||||
updated_dashboard_widgets.append(
|
||||
DashboardWidget(
|
||||
|
@ -1,9 +1,12 @@
|
||||
# Python imports
|
||||
import jwt
|
||||
import csv
|
||||
import io
|
||||
from datetime import date, datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
# Django imports
|
||||
from django.http import HttpResponse
|
||||
from django.db import IntegrityError
|
||||
from django.conf import settings
|
||||
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):
|
||||
def get(self, request, slug, 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 {
|
||||
IUser,
|
||||
TIssue,
|
||||
IProjectLite,
|
||||
IWorkspaceLite,
|
||||
IIssueFilterOptions,
|
||||
IUserLite,
|
||||
} from "@plane/types";
|
||||
import type { TIssue, IIssueFilterOptions } from "@plane/types";
|
||||
|
||||
export type TCycleView = "all" | "active" | "upcoming" | "completed" | "draft";
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { IIssueActivity, TIssuePriorities } from "./issues";
|
||||
import { TIssue } from "./issues/issue";
|
||||
import { TIssueRelationTypes } from "./issues/issue_relation";
|
||||
import { TStateGroups } from "./state";
|
||||
import { IIssueActivity, TIssuePriorities } from "../issues";
|
||||
import { TIssue } from "../issues/issue";
|
||||
import { TIssueRelationTypes } from "../issues/issue_relation";
|
||||
import { TStateGroups } from "../state";
|
||||
import { EDurationFilters } from "./enums";
|
||||
|
||||
export type TWidgetKeys =
|
||||
| "overview_stats"
|
||||
@ -15,30 +16,27 @@ export type TWidgetKeys =
|
||||
|
||||
export type TIssuesListTypes = "pending" | "upcoming" | "overdue" | "completed";
|
||||
|
||||
export type TDurationFilterOptions =
|
||||
| "none"
|
||||
| "today"
|
||||
| "this_week"
|
||||
| "this_month"
|
||||
| "this_year";
|
||||
|
||||
// widget filters
|
||||
export type TAssignedIssuesWidgetFilters = {
|
||||
duration?: TDurationFilterOptions;
|
||||
custom_dates?: string[];
|
||||
duration?: EDurationFilters;
|
||||
tab?: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export type TCreatedIssuesWidgetFilters = {
|
||||
duration?: TDurationFilterOptions;
|
||||
custom_dates?: string[];
|
||||
duration?: EDurationFilters;
|
||||
tab?: TIssuesListTypes;
|
||||
};
|
||||
|
||||
export type TIssuesByStateGroupsWidgetFilters = {
|
||||
duration?: TDurationFilterOptions;
|
||||
duration?: EDurationFilters;
|
||||
custom_dates?: string[];
|
||||
};
|
||||
|
||||
export type TIssuesByPriorityWidgetFilters = {
|
||||
duration?: TDurationFilterOptions;
|
||||
custom_dates?: string[];
|
||||
duration?: EDurationFilters;
|
||||
};
|
||||
|
||||
export type TWidgetFiltersFormData =
|
||||
@ -97,6 +95,12 @@ export type TWidgetStatsRequestParams =
|
||||
| {
|
||||
target_date: string;
|
||||
widget_key: "issues_by_priority";
|
||||
}
|
||||
| {
|
||||
cursor: string;
|
||||
per_page: number;
|
||||
search?: string;
|
||||
widget_key: "recent_collaborators";
|
||||
};
|
||||
|
||||
export type TWidgetIssue = TIssue & {
|
||||
@ -141,8 +145,17 @@ export type TRecentActivityWidgetResponse = IIssueActivity;
|
||||
export type TRecentProjectsWidgetResponse = string[];
|
||||
|
||||
export type TRecentCollaboratorsWidgetResponse = {
|
||||
active_issue_count: number;
|
||||
user_id: string;
|
||||
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;
|
||||
user_id: string;
|
||||
}[];
|
||||
total_pages: number;
|
||||
};
|
||||
|
||||
export type TWidgetStatsResponse =
|
||||
@ -153,7 +166,7 @@ export type TWidgetStatsResponse =
|
||||
| TCreatedIssuesWidgetResponse
|
||||
| TRecentActivityWidgetResponse[]
|
||||
| TRecentProjectsWidgetResponse
|
||||
| TRecentCollaboratorsWidgetResponse[];
|
||||
| TRecentCollaboratorsWidgetResponse;
|
||||
|
||||
// dashboard
|
||||
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 type { IProjectLite } from "./projects";
|
||||
import { TIssue } from "../issues/base";
|
||||
import type { IProjectLite } from "../projects";
|
||||
|
||||
export type TInboxIssueExtended = {
|
||||
completed_at: string | null;
|
||||
@ -33,34 +33,6 @@ export interface IInbox {
|
||||
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 {
|
||||
priority?: string[] | 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-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 "./projects";
|
||||
export * from "./state";
|
||||
export * from "./invitation";
|
||||
export * from "./issues";
|
||||
export * from "./modules";
|
||||
export * from "./views";
|
||||
@ -15,7 +14,6 @@ export * from "./estimate";
|
||||
export * from "./importer";
|
||||
|
||||
// FIXME: Remove this after development and the refactor/mobx-store-issue branch is stable
|
||||
export * from "./inbox";
|
||||
export * from "./inbox/root";
|
||||
|
||||
export * from "./analytics";
|
||||
@ -32,6 +30,8 @@ export * from "./api_token";
|
||||
export * from "./instance";
|
||||
export * from "./app";
|
||||
|
||||
export * from "./enums";
|
||||
|
||||
export type NestedKeyOf<ObjectType extends object> = {
|
||||
[Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object
|
||||
? 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 {
|
||||
IUser,
|
||||
IUserLite,
|
||||
TIssue,
|
||||
IProject,
|
||||
IWorkspace,
|
||||
IWorkspaceLite,
|
||||
IProjectLite,
|
||||
IIssueFilterOptions,
|
||||
ILinkDetails,
|
||||
} from "@plane/types";
|
||||
import type { TIssue, 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 {
|
||||
backlog_issues: number;
|
||||
@ -68,6 +64,10 @@ export type ModuleLink = {
|
||||
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 { IIssueActivity, IIssueLite, TStateGroups } from ".";
|
||||
import {
|
||||
IIssueActivity,
|
||||
TIssuePriorities,
|
||||
TStateGroups,
|
||||
EUserProjectRoles,
|
||||
} from ".";
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
@ -17,7 +21,6 @@ export interface IUser {
|
||||
is_onboarded: boolean;
|
||||
is_password_autoset: boolean;
|
||||
is_tour_completed: boolean;
|
||||
is_password_autoset: boolean;
|
||||
mobile_number: string | null;
|
||||
role: string | null;
|
||||
onboarding_step: {
|
||||
@ -80,7 +83,7 @@ export interface IUserActivity {
|
||||
}
|
||||
|
||||
export interface IUserPriorityDistribution {
|
||||
priority: string;
|
||||
priority: TIssuePriorities;
|
||||
priority_count: number;
|
||||
}
|
||||
|
||||
@ -89,21 +92,6 @@ export interface IUserStateDistribution {
|
||||
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 {
|
||||
count: number;
|
||||
extra_stats: null;
|
||||
|
@ -1,10 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// ui
|
||||
import { CustomSelect, CalendarAfterIcon, CalendarBeforeIcon } from "@plane/ui";
|
||||
// icons
|
||||
import { CalendarDays } from "lucide-react";
|
||||
// fetch-keys
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
@ -22,17 +19,17 @@ const dueDateRange: DueDate[] = [
|
||||
{
|
||||
name: "before",
|
||||
value: "before",
|
||||
icon: <CalendarBeforeIcon className="h-4 w-4 " />,
|
||||
icon: <CalendarBeforeIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
name: "after",
|
||||
value: "after",
|
||||
icon: <CalendarAfterIcon className="h-4 w-4 " />,
|
||||
icon: <CalendarAfterIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
name: "range",
|
||||
value: "range",
|
||||
icon: <CalendarDays className="h-4 w-4 " />,
|
||||
icon: <CalendarDays className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||
import { EDurationFilters, TAssignedIssuesWidgetFilters, TAssignedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
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
|
||||
const widgetDetails = getWidgetDetails(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 selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TAssignedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -43,7 +44,10 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDurationFilter,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
@ -53,7 +57,7 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDurationFilter);
|
||||
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
@ -81,8 +85,17 @@ export const AssignedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
Assigned to you
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDurationFilter}
|
||||
onChange={(val) => {
|
||||
onChange={(val, customDates) => {
|
||||
if (val === "custom" && customDates) {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
custom_dates: customDates,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
|
@ -15,7 +15,7 @@ import {
|
||||
// helpers
|
||||
import { getCustomDates, getRedirectionFilters, getTabKey } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
import { EDurationFilters, TCreatedIssuesWidgetFilters, TCreatedIssuesWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
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
|
||||
const widgetDetails = getWidgetDetails(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 selectedCustomDates = widgetDetails?.widget_filters.custom_dates ?? [];
|
||||
|
||||
const handleUpdateFilters = async (filters: Partial<TCreatedIssuesWidgetFilters>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -43,7 +44,10 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDurationFilter);
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDurationFilter,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
issue_type: filters.tab ?? selectedTab,
|
||||
@ -52,7 +56,7 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDurationFilter);
|
||||
const filterDates = getCustomDates(selectedDurationFilter, selectedCustomDates);
|
||||
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
@ -78,8 +82,17 @@ export const CreatedIssuesWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
Created by you
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDurationFilter}
|
||||
onChange={(val) => {
|
||||
onChange={(val, customDates) => {
|
||||
if (val === "custom" && customDates) {
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
custom_dates: customDates,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (val === selectedDurationFilter) return;
|
||||
|
||||
let newTab = selectedTab;
|
||||
|
@ -1,36 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
// components
|
||||
import { DateFilterModal } from "components/core";
|
||||
// ui
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
// helpers
|
||||
import { getDurationFilterDropdownLabel } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions } from "@plane/types";
|
||||
import { EDurationFilters } from "@plane/types";
|
||||
// constants
|
||||
import { DURATION_FILTER_OPTIONS } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
onChange: (value: TDurationFilterOptions) => void;
|
||||
value: TDurationFilterOptions;
|
||||
customDates?: string[];
|
||||
onChange: (value: EDurationFilters, customDates?: string[]) => void;
|
||||
value: EDurationFilters;
|
||||
};
|
||||
|
||||
export const DurationFilterDropdown: React.FC<Props> = (props) => {
|
||||
const { onChange, value } = props;
|
||||
const { customDates, onChange, value } = props;
|
||||
// states
|
||||
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
className="flex-shrink-0"
|
||||
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">
|
||||
{DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
>
|
||||
<>
|
||||
<DateFilterModal
|
||||
isOpen={isDateFilterModalOpen}
|
||||
handleClose={() => setIsDateFilterModalOpen(false)}
|
||||
onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)}
|
||||
title="Due date"
|
||||
/>
|
||||
<CustomMenu
|
||||
className="flex-shrink-0"
|
||||
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">
|
||||
{getDurationFilterDropdownLabel(value, customDates ?? [])}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</div>
|
||||
}
|
||||
placement="bottom-end"
|
||||
closeOnSelect
|
||||
>
|
||||
{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}
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
</CustomMenu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -3,12 +3,12 @@ import { Tab } from "@headlessui/react";
|
||||
// helpers
|
||||
import { cn } from "helpers/common.helper";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssuesListTypes } from "@plane/types";
|
||||
import { EDurationFilters, TIssuesListTypes } from "@plane/types";
|
||||
// constants
|
||||
import { FILTERED_ISSUES_TABS_LIST, UNFILTERED_ISSUES_TABS_LIST } from "constants/dashboard";
|
||||
|
||||
type Props = {
|
||||
durationFilter: TDurationFilterOptions;
|
||||
durationFilter: EDurationFilters;
|
||||
selectedTab: TIssuesListTypes;
|
||||
};
|
||||
|
||||
@ -48,7 +48,7 @@ export const TabsList: React.FC<Props> = observer((props) => {
|
||||
className={cn(
|
||||
"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,
|
||||
}
|
||||
)}
|
||||
|
@ -1,82 +1,36 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useDashboard } from "hooks/store";
|
||||
// components
|
||||
import { MarimekkoGraph } from "components/ui";
|
||||
import {
|
||||
DurationFilterDropdown,
|
||||
IssuesByPriorityEmptyState,
|
||||
WidgetLoader,
|
||||
WidgetProps,
|
||||
} from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { PriorityIcon } from "@plane/ui";
|
||||
// helpers
|
||||
import { getCustomDates } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
|
||||
import { EDurationFilters, TIssuesByPriorityWidgetFilters, TIssuesByPriorityWidgetResponse } from "@plane/types";
|
||||
// constants
|
||||
import { PRIORITY_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||
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>
|
||||
);
|
||||
};
|
||||
import { IssuesByPriorityGraph } from "components/graphs";
|
||||
|
||||
const WIDGET_KEY = "issues_by_priority";
|
||||
|
||||
export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
const { dashboardId, workspaceSlug } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
// store hooks
|
||||
const { fetchWidgetStats, getWidgetDetails, getWidgetStats, updateDashboardWidgetFilters } = useDashboard();
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(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>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -86,7 +40,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDuration,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -94,7 +51,7 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDuration);
|
||||
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -105,31 +62,10 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
if (!widgetDetails || !widgetStats) return <WidgetLoader widgetKey={WIDGET_KEY} />;
|
||||
|
||||
const totalCount = widgetStats.reduce((acc, item) => acc + item?.count, 0);
|
||||
const chartData = widgetStats
|
||||
.filter((i) => i.count !== 0)
|
||||
.map((item) => ({
|
||||
priority: item?.priority,
|
||||
percentage: (item?.count / totalCount) * 100,
|
||||
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>
|
||||
);
|
||||
};
|
||||
const chartData = widgetStats.map((item) => ({
|
||||
priority: item?.priority,
|
||||
priority_count: item?.count,
|
||||
}));
|
||||
|
||||
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">
|
||||
@ -141,60 +77,27 @@ export const IssuesByPriorityWidget: React.FC<WidgetProps> = observer((props) =>
|
||||
Assigned by priority
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDuration}
|
||||
onChange={(val) =>
|
||||
onChange={(val, customDates) =>
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
...(val === "custom" ? { custom_dates: customDates } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{totalCount > 0 ? (
|
||||
<div className="flex items-center px-11 h-full">
|
||||
<div className="flex items-center h-full">
|
||||
<div className="w-full -mt-[11px]">
|
||||
<MarimekkoGraph
|
||||
<IssuesByPriorityGraph
|
||||
data={chartData}
|
||||
id="priority"
|
||||
value="percentage"
|
||||
dimensions={ISSUE_PRIORITIES.map((p) => ({
|
||||
id: p.key,
|
||||
value: p.key,
|
||||
}))}
|
||||
axisBottom={null}
|
||||
axisLeft={null}
|
||||
height="119px"
|
||||
margin={{
|
||||
top: 11,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
onBarClick={(datum) => {
|
||||
router.push(
|
||||
`/${workspaceSlug}/workspace-views/assigned?priority=${`${datum.data.priority}`.toLowerCase()}`
|
||||
);
|
||||
}}
|
||||
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>
|
||||
) : (
|
||||
|
@ -15,7 +15,12 @@ import {
|
||||
// helpers
|
||||
import { getCustomDates } from "helpers/dashboard.helper";
|
||||
// types
|
||||
import { TIssuesByStateGroupsWidgetFilters, TIssuesByStateGroupsWidgetResponse, TStateGroups } from "@plane/types";
|
||||
import {
|
||||
EDurationFilters,
|
||||
TIssuesByStateGroupsWidgetFilters,
|
||||
TIssuesByStateGroupsWidgetResponse,
|
||||
TStateGroups,
|
||||
} from "@plane/types";
|
||||
// constants
|
||||
import { STATE_GROUP_GRAPH_COLORS, STATE_GROUP_GRAPH_GRADIENTS } from "constants/dashboard";
|
||||
import { STATE_GROUPS } from "constants/state";
|
||||
@ -34,7 +39,8 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
// derived values
|
||||
const widgetDetails = getWidgetDetails(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>) => {
|
||||
if (!widgetDetails) return;
|
||||
@ -44,7 +50,10 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
filters,
|
||||
});
|
||||
|
||||
const filterDates = getCustomDates(filters.duration ?? selectedDuration);
|
||||
const filterDates = getCustomDates(
|
||||
filters.duration ?? selectedDuration,
|
||||
filters.custom_dates ?? selectedCustomDates
|
||||
);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -53,7 +62,7 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
|
||||
// fetch widget stats
|
||||
useEffect(() => {
|
||||
const filterDates = getCustomDates(selectedDuration);
|
||||
const filterDates = getCustomDates(selectedDuration, selectedCustomDates);
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
widget_key: WIDGET_KEY,
|
||||
...(filterDates.trim() !== "" ? { target_date: filterDates } : {}),
|
||||
@ -139,10 +148,12 @@ export const IssuesByStateGroupWidget: React.FC<WidgetProps> = observer((props)
|
||||
Assigned by state
|
||||
</Link>
|
||||
<DurationFilterDropdown
|
||||
customDates={selectedCustomDates}
|
||||
value={selectedDuration}
|
||||
onChange={(val) =>
|
||||
onChange={(val, customDates) =>
|
||||
handleUpdateFilters({
|
||||
duration: val,
|
||||
...(val === "custom" ? { custom_dates: customDates } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
|
@ -2,17 +2,16 @@
|
||||
import { Loader } from "@plane/ui";
|
||||
|
||||
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) => (
|
||||
<div key={index} className="space-y-11 flex flex-col items-center">
|
||||
<>
|
||||
{Array.from({ length: 8 }).map((_, index) => (
|
||||
<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]">
|
||||
<Loader.Item height="69px" width="69px" />
|
||||
</div>
|
||||
<Loader.Item height="11px" width="70%" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Loader>
|
||||
</Loader>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
@ -8,11 +8,12 @@ import { useDashboard, useUser } from "hooks/store";
|
||||
import { ActivityIcon, ActivityMessage, IssueLink } from "components/core";
|
||||
import { RecentActivityEmptyState, WidgetLoader, WidgetProps } from "components/dashboard/widgets";
|
||||
// ui
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { Avatar, getButtonStyling } from "@plane/ui";
|
||||
// helpers
|
||||
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||
// types
|
||||
import { TRecentActivityWidgetResponse } from "@plane/types";
|
||||
import { cn } from "helpers/common.helper";
|
||||
|
||||
const WIDGET_KEY = "recent_activity";
|
||||
|
||||
@ -23,6 +24,7 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
// derived values
|
||||
const { fetchWidgetStats, getWidgetStats } = useDashboard();
|
||||
const widgetStats = getWidgetStats<TRecentActivityWidgetResponse[]>(workspaceSlug, dashboardId, WIDGET_KEY);
|
||||
const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`;
|
||||
|
||||
useEffect(() => {
|
||||
fetchWidgetStats(workspaceSlug, dashboardId, {
|
||||
@ -35,7 +37,7 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
|
||||
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">
|
||||
<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
|
||||
</Link>
|
||||
{widgetStats.length > 0 ? (
|
||||
@ -83,6 +85,15 @@ export const RecentActivityWidget: React.FC<WidgetProps> = observer((props) => {
|
||||
</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 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
|
||||
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Trash2, XCircle } from "lucide-react";
|
||||
// types
|
||||
import type { TInboxStatus, TInboxDetailedStatus } from "@plane/types";
|
||||
import type { TInboxDetailedStatus } from "@plane/types";
|
||||
import { EUserProjectRoles } from "constants/project";
|
||||
import { ISSUE_DELETED } from "constants/event-tracker";
|
||||
|
||||
@ -29,7 +29,7 @@ type TInboxIssueActionsHeader = {
|
||||
};
|
||||
|
||||
type TInboxIssueOperations = {
|
||||
updateInboxIssueStatus: (data: TInboxStatus) => Promise<void>;
|
||||
updateInboxIssueStatus: (data: TInboxDetailedStatus) => 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 "./navbar";
|
||||
export * from "./profile-issues-filter";
|
||||
|
@ -27,10 +27,11 @@ export const ProfileNavbar: React.FC<Props> = (props) => {
|
||||
{tabsList.map((tab) => (
|
||||
<Link key={tab.route} href={`/${workspaceSlug}/profile/${userId}/${tab.route}`}>
|
||||
<span
|
||||
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-transparent"
|
||||
}`}
|
||||
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-transparent"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</span>
|
||||
|
@ -27,15 +27,18 @@ export const ProfileActivity = observer(() => {
|
||||
const { currentUser } = useUser();
|
||||
|
||||
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
|
||||
? () => userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString())
|
||||
? () =>
|
||||
userService.getUserProfileActivity(workspaceSlug.toString(), userId.toString(), {
|
||||
per_page: 10,
|
||||
})
|
||||
: null
|
||||
);
|
||||
|
||||
return (
|
||||
<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">
|
||||
{userProfileActivity ? (
|
||||
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 (
|
||||
<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">
|
||||
{userProfile.state_distribution.length > 0 ? (
|
||||
<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,
|
||||
}}
|
||||
/>
|
||||
<div className="whitespace-nowrap capitalize">{group.state_group}</div>
|
||||
<div className="whitespace-nowrap">{STATE_GROUPS[group.state_group].label}</div>
|
||||
</div>
|
||||
<div>{group.state_count}</div>
|
||||
</div>
|
||||
|
@ -21,12 +21,12 @@ export const ProfileWorkload: React.FC<Props> = ({ stateDistribution }) => (
|
||||
}}
|
||||
/>
|
||||
<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"
|
||||
? "Not Started"
|
||||
? "Not started"
|
||||
: group.state_group === "started"
|
||||
? "Working on"
|
||||
: group.state_group}
|
||||
: STATE_GROUPS[group.state_group].label}
|
||||
</p>
|
||||
<p className="text-xl font-semibold">{group.state_count}</p>
|
||||
</div>
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import Link from "next/link";
|
||||
import useSWR from "swr";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// services
|
||||
import { UserService } from "services/user.service";
|
||||
@ -18,8 +20,6 @@ import { renderFormattedDate } from "helpers/date-time.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// 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
|
||||
const userService = new UserService();
|
||||
|
@ -1,6 +1,5 @@
|
||||
export * from "./bar-graph";
|
||||
export * from "./calendar-graph";
|
||||
export * from "./line-graph";
|
||||
export * from "./marimekko-graph";
|
||||
export * from "./pie-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 CompletedIssuesLight from "public/empty-state/dashboard/light/completed-issues.svg";
|
||||
// types
|
||||
import { TDurationFilterOptions, TIssuesListTypes, TStateGroups } from "@plane/types";
|
||||
import { EDurationFilters, TIssuesListTypes, TStateGroups } from "@plane/types";
|
||||
import { Props } from "components/icons/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "./workspace";
|
||||
@ -118,29 +118,33 @@ export const STATE_GROUP_GRAPH_COLORS: Record<TStateGroups, string> = {
|
||||
|
||||
// filter duration options
|
||||
export const DURATION_FILTER_OPTIONS: {
|
||||
key: TDurationFilterOptions;
|
||||
key: EDurationFilters;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "none",
|
||||
key: EDurationFilters.NONE,
|
||||
label: "None",
|
||||
},
|
||||
{
|
||||
key: "today",
|
||||
key: EDurationFilters.TODAY,
|
||||
label: "Due today",
|
||||
},
|
||||
{
|
||||
key: "this_week",
|
||||
label: " Due this week",
|
||||
key: EDurationFilters.THIS_WEEK,
|
||||
label: "Due this week",
|
||||
},
|
||||
{
|
||||
key: "this_month",
|
||||
key: EDurationFilters.THIS_MONTH,
|
||||
label: "Due this month",
|
||||
},
|
||||
{
|
||||
key: "this_year",
|
||||
key: EDurationFilters.THIS_YEAR,
|
||||
label: "Due this year",
|
||||
},
|
||||
{
|
||||
key: EDurationFilters.CUSTOM,
|
||||
label: "Custom",
|
||||
},
|
||||
];
|
||||
|
||||
// random background colors for project cards
|
||||
|
@ -164,7 +164,7 @@ export const USER_ISSUES = (workspaceSlug: string, params: any) => {
|
||||
|
||||
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) =>
|
||||
`USER_WORKSPACE_DASHBOARD_${workspaceSlug.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
|
||||
export const USER_PROFILE_DATA = (workspaceSlug: string, userId: string) =>
|
||||
`USER_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||
export const USER_PROFILE_ACTIVITY = (workspaceSlug: string, userId: string) =>
|
||||
`USER_WORKSPACE_PROFILE_ACTIVITY_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||
export const USER_PROFILE_ACTIVITY = (
|
||||
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) =>
|
||||
`USER_PROFILE_PROJECT_SEGREGATION_${workspaceSlug.toUpperCase()}_${userId.toUpperCase()}`;
|
||||
export const USER_PROFILE_ISSUES = (workspaceSlug: string, userId: string, params: any) => {
|
||||
|
@ -63,4 +63,9 @@ export const PROFILE_ADMINS_TAB = [
|
||||
label: "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";
|
||||
// helpers
|
||||
import { renderFormattedPayloadDate } from "./date-time.helper";
|
||||
import { renderFormattedDate, renderFormattedPayloadDate } from "./date-time.helper";
|
||||
// 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
|
||||
* @param duration
|
||||
*/
|
||||
export const getCustomDates = (duration: TDurationFilterOptions): string => {
|
||||
export const getCustomDates = (duration: EDurationFilters, customDates: string[]): string => {
|
||||
const today = new Date();
|
||||
let firstDay, lastDay;
|
||||
|
||||
switch (duration) {
|
||||
case "none":
|
||||
case EDurationFilters.NONE:
|
||||
return "";
|
||||
case "today":
|
||||
case EDurationFilters.TODAY:
|
||||
firstDay = renderFormattedPayloadDate(today);
|
||||
lastDay = renderFormattedPayloadDate(today);
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
case "this_week":
|
||||
case EDurationFilters.THIS_WEEK:
|
||||
firstDay = renderFormattedPayloadDate(startOfWeek(today));
|
||||
lastDay = renderFormattedPayloadDate(endOfWeek(today));
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
case "this_month":
|
||||
case EDurationFilters.THIS_MONTH:
|
||||
firstDay = renderFormattedPayloadDate(startOfMonth(today));
|
||||
lastDay = renderFormattedPayloadDate(endOfMonth(today));
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
case "this_year":
|
||||
case EDurationFilters.THIS_YEAR:
|
||||
firstDay = renderFormattedPayloadDate(startOfYear(today));
|
||||
lastDay = renderFormattedPayloadDate(endOfYear(today));
|
||||
return `${firstDay};after,${lastDay};before`;
|
||||
case EDurationFilters.CUSTOM:
|
||||
return customDates.join(",");
|
||||
}
|
||||
};
|
||||
|
||||
@ -58,7 +62,7 @@ export const getRedirectionFilters = (type: TIssuesListTypes): string => {
|
||||
* @param duration
|
||||
* @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 === "completed") return tab;
|
||||
@ -69,3 +73,21 @@ export const getTabKey = (duration: TDurationFilterOptions, tab: TIssuesListType
|
||||
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";
|
||||
// components
|
||||
import { ProfileNavbar, ProfileSidebar } from "components/profile";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@ -11,27 +13,25 @@ type Props = {
|
||||
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) => {
|
||||
const { children, className, showProfileIssuesFilter } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
// store hooks
|
||||
const {
|
||||
membership: { currentWorkspaceRole },
|
||||
} = useUser();
|
||||
|
||||
if (!currentWorkspaceRole) return null;
|
||||
|
||||
const isAuthorized = AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
||||
|
||||
// derived values
|
||||
const isAuthorized = currentWorkspaceRole && AUTHORIZED_ROLES.includes(currentWorkspaceRole);
|
||||
const isAuthorizedPath = router.pathname.includes("assigned" || "created" || "subscribed");
|
||||
|
||||
return (
|
||||
<div className="h-full w-full md:flex md:flex-row-reverse md:overflow-hidden">
|
||||
<ProfileSidebar />
|
||||
<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 ? (
|
||||
<div className={`w-full overflow-hidden md:h-full ${className}`}>{children}</div>
|
||||
) : (
|
||||
|
@ -20,7 +20,6 @@
|
||||
"@nivo/core": "0.80.0",
|
||||
"@nivo/legends": "0.80.0",
|
||||
"@nivo/line": "0.80.0",
|
||||
"@nivo/marimekko": "0.80.0",
|
||||
"@nivo/pie": "0.80.0",
|
||||
"@nivo/scatterplot": "0.80.0",
|
||||
"@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} />
|
||||
<ProfileWorkload stateDistribution={stateDistribution} />
|
||||
<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} />
|
||||
</div>
|
||||
<ProfileActivity />
|
||||
|
@ -1,191 +1,64 @@
|
||||
import { ReactElement } from "react";
|
||||
import useSWR from "swr";
|
||||
import Link from "next/link";
|
||||
import { ReactElement, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
//hooks
|
||||
import { useApplication, useUser } from "hooks/store";
|
||||
// services
|
||||
import { UserService } from "services/user.service";
|
||||
import { useApplication } from "hooks/store";
|
||||
// layouts
|
||||
import { ProfileSettingsLayout } from "layouts/settings-layout";
|
||||
// components
|
||||
import { ActivityIcon, ActivityMessage, IssueLink, PageHead } from "components/core";
|
||||
import { RichReadOnlyEditor } from "@plane/rich-text-editor";
|
||||
// icons
|
||||
import { History, MessageSquare } from "lucide-react";
|
||||
import { SidebarHamburgerToggle } from "components/core/sidebar/sidebar-menu-hamburger-toggle";
|
||||
import { ProfileActivityListPage } from "components/profile";
|
||||
import { PageHead } from "components/core";
|
||||
// ui
|
||||
import { ActivitySettingsLoader } from "components/ui";
|
||||
// fetch-keys
|
||||
import { USER_ACTIVITY } from "constants/fetch-keys";
|
||||
// helper
|
||||
import { calculateTimeAgo } from "helpers/date-time.helper";
|
||||
import { Button } from "@plane/ui";
|
||||
// type
|
||||
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 { 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
|
||||
const { currentUser } = useUser();
|
||||
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 (
|
||||
<>
|
||||
<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">
|
||||
<SidebarHamburgerToggle onClick={() => themeStore.toggleSidebar()} />
|
||||
<h3 className="text-xl font-medium">Activity</h3>
|
||||
</div>
|
||||
{userActivity ? (
|
||||
<div className="flex h-full w-full flex-col gap-2 overflow-y-auto vertical-scrollbar scrollbar-md">
|
||||
<ul role="list" className="-mb-4">
|
||||
{userActivity.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`}
|
||||
>
|
||||
{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 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 />
|
||||
)}
|
||||
<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>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
@ -9,7 +9,6 @@ import type {
|
||||
IUserProfileData,
|
||||
IUserProfileProjectSegregation,
|
||||
IUserSettings,
|
||||
IUserWorkspaceDashboard,
|
||||
IUserEmailNotificationSettings,
|
||||
} from "@plane/types";
|
||||
// helpers
|
||||
@ -113,20 +112,8 @@ export class UserService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserActivity(): Promise<IUserActivityResponse> {
|
||||
return this.get(`/api/users/me/activities/`)
|
||||
.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,
|
||||
},
|
||||
})
|
||||
async getUserActivity(params: { per_page: number; cursor?: string }): Promise<IUserActivityResponse> {
|
||||
return this.get("/api/users/me/activities/", { params })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
@ -160,8 +147,31 @@ export class UserService extends APIService {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserProfileActivity(workspaceSlug: string, userId: string): Promise<IUserActivityResponse> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/?per_page=15`)
|
||||
async getUserProfileActivity(
|
||||
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)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
|
@ -22,7 +22,6 @@ export interface IUserRootStore {
|
||||
fetchCurrentUser: () => Promise<IUser>;
|
||||
fetchCurrentUserInstanceAdminStatus: () => Promise<boolean>;
|
||||
fetchCurrentUserSettings: () => Promise<IUserSettings>;
|
||||
fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise<any>;
|
||||
// crud actions
|
||||
updateUserOnBoard: () => Promise<void>;
|
||||
updateTourCompleted: () => Promise<void>;
|
||||
@ -68,7 +67,6 @@ export class UserRootStore implements IUserRootStore {
|
||||
fetchCurrentUser: action,
|
||||
fetchCurrentUserInstanceAdminStatus: action,
|
||||
fetchCurrentUserSettings: action,
|
||||
fetchUserDashboardInfo: action,
|
||||
updateUserOnBoard: action,
|
||||
updateTourCompleted: action,
|
||||
updateCurrentUser: action,
|
||||
@ -130,22 +128,6 @@ export class UserRootStore implements IUserRootStore {
|
||||
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
|
||||
* @returns Promise<void>
|
||||
|
13
yarn.lock
13
yarn.lock
@ -1766,19 +1766,6 @@
|
||||
"@react-spring/web" "9.4.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":
|
||||
version "0.80.0"
|
||||
resolved "https://registry.yarnpkg.com/@nivo/pie/-/pie-0.80.0.tgz#04b35839bf5a2b661fa4e5b677ae76b3c028471e"
|
||||
|
Loading…
Reference in New Issue
Block a user