[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:
Aaryan Khandelwal 2024-03-06 14:24:36 +05:30 committed by GitHub
parent 126d01bdc5
commit 5a32d10f96
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
61 changed files with 1568 additions and 845 deletions

View File

@ -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(),

View File

@ -49,6 +49,7 @@ from .workspace import (
WorkspaceUserPropertiesEndpoint,
WorkspaceStatesEndpoint,
WorkspaceEstimatesEndpoint,
ExportWorkspaceUserActivityEndpoint,
WorkspaceModulesEndpoint,
WorkspaceCyclesEndpoint,
)

View File

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

View File

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

View File

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

View File

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

View 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",
}

View File

@ -0,0 +1,2 @@
export * from "./dashboard";
export * from "./enums";

View File

@ -0,0 +1,6 @@
export enum EUserProjectRoles {
GUEST = 5,
VIEWER = 10,
MEMBER = 15,
ADMIN = 20,
}

View File

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

View File

@ -1,2 +1,3 @@
export * from "./inbox";
export * from "./inbox-issue";
export * from "./inbox-types";
export * from "./inbox";

View File

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

View File

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

View File

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

View File

@ -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" />,
},
];

View File

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

View File

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

View File

@ -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>
</>
);
};

View File

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

View File

@ -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>
) : (

View File

@ -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 } : {}),
})
}
/>

View File

@ -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>
))}
</>
);

View File

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

View File

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

View File

@ -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}
/>
))}
</>
);
};

View File

@ -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>
)}
</>
);
};

View File

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

View File

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

View File

@ -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>
)}
</>
);
};

View File

@ -0,0 +1 @@
export * from "./issues-by-priority";

View 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,
}}
/>
);
};

View File

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

View 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 />
)}
</>
);
});

View 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>
);
};

View File

@ -0,0 +1,4 @@
export * from "./activity-list";
export * from "./download-button";
export * from "./profile-activity-list";
export * from "./workspace-activity-list";

View 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 />
)}
</>
);
});

View 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} />;
};

View File

@ -1,3 +1,4 @@
export * from "./activity";
export * from "./overview";
export * from "./navbar";
export * from "./profile-issues-filter";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();

View File

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

View File

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

View File

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

View File

@ -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) => {

View File

@ -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",
},
];

View File

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

View File

@ -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>
) : (

View File

@ -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": "*",

View 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;

View File

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

View File

@ -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>
</>
);

View File

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

View File

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

View File

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