diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index a70ff18e535..8b21bb9e1b2 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -22,6 +22,7 @@ WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + ExportWorkspaceUserActivityEndpoint, WorkspaceModulesEndpoint, WorkspaceCyclesEndpoint, ) @@ -191,6 +192,11 @@ WorkspaceUserActivityEndpoint.as_view(), name="workspace-user-activity", ), + path( + "workspaces//user-activity//export/", + ExportWorkspaceUserActivityEndpoint.as_view(), + name="export-workspace-user-activity", + ), path( "workspaces//user-profile//", WorkspaceUserProfileEndpoint.as_view(), diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index d4a13e49749..6af60ff9c18 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -49,6 +49,7 @@ WorkspaceUserPropertiesEndpoint, WorkspaceStatesEndpoint, WorkspaceEstimatesEndpoint, + ExportWorkspaceUserActivityEndpoint, WorkspaceModulesEndpoint, WorkspaceCyclesEndpoint, ) diff --git a/apiserver/plane/app/views/dashboard.py b/apiserver/plane/app/views/dashboard.py index 62ce0d910fe..9078d2ab548 100644 --- a/apiserver/plane/app/views/dashboard.py +++ b/apiserver/plane/app/views/dashboard.py @@ -14,6 +14,7 @@ JSONField, Func, Prefetch, + IntegerField, ) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField @@ -38,6 +39,8 @@ 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} + .annotate(num_activities=Count("pk")) + .values("num_activities") + ) + + # 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) + ) ) - # 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() + return self.paginate( + request=request, + queryset=project_members_with_activities, + controller=self.get_results_controller, + ) - 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 - ] + +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_list("member", flat=True) ) + .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 + } - additional_collaborators = additional_collaborators[ - :additional_collaborators_needed + # 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 - # Append additional collaborators to the list - for collaborator_id in additional_collaborators: - active_issue_count = Issue.objects.filter( - assignees__in=[collaborator_id], - state__group__in=["unstarted", "started"], - ).count() - users_with_active_issues.append( - { - "user_id": str(collaborator_id), - "active_issue_count": active_issue_count, - } - ) - - users_with_active_issues.insert( - 0, - {"user_id": request.user.id, "active_issue_count": active_issue_count}, - ) - - return Response(users_with_active_issues, status=status.HTTP_200_OK) - - -class DashboardEndpoint(BaseAPIView): def create(self, request, slug): serializer = DashboardSerializer(data=request.data) if serializer.is_valid(): @@ -622,7 +634,9 @@ def get(self, request, slug, dashboard_id=None): 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 @@ def get(self, request, slug, dashboard_id=None): 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( diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 47de86a1c17..84ba125bac1 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -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 @@ def get(self, request, slug, user_id): ) +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) diff --git a/packages/types/src/cycles.d.ts b/packages/types/src/cycles.d.ts index e7ec66ae212..25b7427f519 100644 --- a/packages/types/src/cycles.d.ts +++ b/packages/types/src/cycles.d.ts @@ -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"; diff --git a/packages/types/src/dashboard.d.ts b/packages/types/src/dashboard/dashboard.d.ts similarity index 79% rename from packages/types/src/dashboard.d.ts rename to packages/types/src/dashboard/dashboard.d.ts index 407b5cd794e..d565f668867 100644 --- a/packages/types/src/dashboard.d.ts +++ b/packages/types/src/dashboard/dashboard.d.ts @@ -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 = { diff --git a/packages/types/src/dashboard/enums.ts b/packages/types/src/dashboard/enums.ts new file mode 100644 index 00000000000..2c9efd5c35d --- /dev/null +++ b/packages/types/src/dashboard/enums.ts @@ -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", +} diff --git a/packages/types/src/dashboard/index.ts b/packages/types/src/dashboard/index.ts new file mode 100644 index 00000000000..dec14aea6a9 --- /dev/null +++ b/packages/types/src/dashboard/index.ts @@ -0,0 +1,2 @@ +export * from "./dashboard"; +export * from "./enums"; diff --git a/packages/types/src/enums.ts b/packages/types/src/enums.ts new file mode 100644 index 00000000000..259f13e9bc2 --- /dev/null +++ b/packages/types/src/enums.ts @@ -0,0 +1,6 @@ +export enum EUserProjectRoles { + GUEST = 5, + VIEWER = 10, + MEMBER = 15, + ADMIN = 20, +} diff --git a/packages/types/src/inbox.d.ts b/packages/types/src/inbox/inbox-types.d.ts similarity index 65% rename from packages/types/src/inbox.d.ts rename to packages/types/src/inbox/inbox-types.d.ts index 4d666ae8356..9db71c3ee4c 100644 --- a/packages/types/src/inbox.d.ts +++ b/packages/types/src/inbox/inbox-types.d.ts @@ -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; diff --git a/packages/types/src/inbox/root.d.ts b/packages/types/src/inbox/root.d.ts index 2f10c088def..6fd21a4fe9e 100644 --- a/packages/types/src/inbox/root.d.ts +++ b/packages/types/src/inbox/root.d.ts @@ -1,2 +1,3 @@ -export * from "./inbox"; export * from "./inbox-issue"; +export * from "./inbox-types"; +export * from "./inbox"; diff --git a/packages/types/src/index.d.ts b/packages/types/src/index.d.ts index 6e8ded94296..b1eb38a567f 100644 --- a/packages/types/src/index.d.ts +++ b/packages/types/src/index.d.ts @@ -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 = { [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends object ? ObjectType[Key] extends { pop: any; push: any } diff --git a/packages/types/src/modules.d.ts b/packages/types/src/modules.d.ts index fcf2d86a21a..c532a467c7e 100644 --- a/packages/types/src/modules.d.ts +++ b/packages/types/src/modules.d.ts @@ -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; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 81c8abcd5f0..c428dc7d284 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -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; diff --git a/web/components/core/filters/date-filter-select.tsx b/web/components/core/filters/date-filter-select.tsx index 9bb10f800d0..47207e0ccd8 100644 --- a/web/components/core/filters/date-filter-select.tsx +++ b/web/components/core/filters/date-filter-select.tsx @@ -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: , + icon: , }, { name: "after", value: "after", - icon: , + icon: , }, { name: "range", value: "range", - icon: , + icon: , }, ]; diff --git a/web/components/dashboard/widgets/assigned-issues.tsx b/web/components/dashboard/widgets/assigned-issues.tsx index 7c8fbd2a98e..407ac9ddf24 100644 --- a/web/components/dashboard/widgets/assigned-issues.tsx +++ b/web/components/dashboard/widgets/assigned-issues.tsx @@ -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 = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(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) => { if (!widgetDetails) return; @@ -43,7 +44,10 @@ export const AssignedIssuesWidget: React.FC = 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 = 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 = observer((props) => { Assigned to you { + onChange={(val, customDates) => { + if (val === "custom" && customDates) { + handleUpdateFilters({ + duration: val, + custom_dates: customDates, + }); + return; + } + if (val === selectedDurationFilter) return; let newTab = selectedTab; diff --git a/web/components/dashboard/widgets/created-issues.tsx b/web/components/dashboard/widgets/created-issues.tsx index e7832883bc9..23e7bee2771 100644 --- a/web/components/dashboard/widgets/created-issues.tsx +++ b/web/components/dashboard/widgets/created-issues.tsx @@ -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 = observer((props) => { // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(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) => { if (!widgetDetails) return; @@ -43,7 +44,10 @@ export const CreatedIssuesWidget: React.FC = 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 = 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 = observer((props) => { Created by you { + onChange={(val, customDates) => { + if (val === "custom" && customDates) { + handleUpdateFilters({ + duration: val, + custom_dates: customDates, + }); + return; + } + if (val === selectedDurationFilter) return; let newTab = selectedTab; diff --git a/web/components/dashboard/widgets/dropdowns/duration-filter.tsx b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx index 4844ea406e5..fbdac4f00e0 100644 --- a/web/components/dashboard/widgets/dropdowns/duration-filter.tsx +++ b/web/components/dashboard/widgets/dropdowns/duration-filter.tsx @@ -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) => { - const { onChange, value } = props; + const { customDates, onChange, value } = props; + // states + const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false); return ( - - {DURATION_FILTER_OPTIONS.find((option) => option.key === value)?.label} - - - } - placement="bottom-end" - closeOnSelect - > + <> + setIsDateFilterModalOpen(false)} + onSelect={(val) => onChange(EDurationFilters.CUSTOM, val)} + title="Due date" + /> + + {getDurationFilterDropdownLabel(value, customDates ?? [])} + + + } + placement="bottom-end" + closeOnSelect + > {DURATION_FILTER_OPTIONS.map((option) => ( - onChange(option.key)}> + { + if (option.key === "custom") setIsDateFilterModalOpen(true); + else onChange(option.key); + }} + > {option.label} ))} - + + ); }; diff --git a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx index 306c2fdeb9a..d18f08f2755 100644 --- a/web/components/dashboard/widgets/issue-panels/tabs-list.tsx +++ b/web/components/dashboard/widgets/issue-panels/tabs-list.tsx @@ -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 = 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, } )} diff --git a/web/components/dashboard/widgets/issues-by-priority.tsx b/web/components/dashboard/widgets/issues-by-priority.tsx index 91e321b05f7..3e9823fe4e9 100644 --- a/web/components/dashboard/widgets/issues-by-priority.tsx +++ b/web/components/dashboard/widgets/issues-by-priority.tsx @@ -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 ( - - setIsMouseOver(true)} - onMouseLeave={() => setIsMouseOver(false)} - > - - - {bar?.id} - - - - ); -}; +import { IssuesByPriorityGraph } from "components/graphs"; const WIDGET_KEY = "issues_by_priority"; export const IssuesByPriorityWidget: React.FC = 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(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) => { if (!widgetDetails) return; @@ -86,7 +40,10 @@ export const IssuesByPriorityWidget: React.FC = 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 = 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 = observer((props) => if (!widgetDetails || !widgetStats) return ; 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 ( - - {bars - ?.filter((b: any) => b?.value === 1) // render only bars with value 1 - .map((bar: any) => ( - - ))} - - ); - }; + const chartData = widgetStats.map((item) => ({ + priority: item?.priority, + priority_count: item?.count, + })); return (
@@ -141,60 +77,27 @@ export const IssuesByPriorityWidget: React.FC = observer((props) => Assigned by priority + onChange={(val, customDates) => handleUpdateFilters({ duration: val, + ...(val === "custom" ? { custom_dates: customDates } : {}), }) } />
{totalCount > 0 ? ( -
+
- ({ - 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]} /> -
- {chartData.map((item) => ( -

- - {item.percentage.toFixed(0)}% -

- ))} -
) : ( diff --git a/web/components/dashboard/widgets/issues-by-state-group.tsx b/web/components/dashboard/widgets/issues-by-state-group.tsx index a0eb6c70f8c..b301d30f3fe 100644 --- a/web/components/dashboard/widgets/issues-by-state-group.tsx +++ b/web/components/dashboard/widgets/issues-by-state-group.tsx @@ -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 = observer((props) // derived values const widgetDetails = getWidgetDetails(workspaceSlug, dashboardId, WIDGET_KEY); const widgetStats = getWidgetStats(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) => { if (!widgetDetails) return; @@ -44,7 +50,10 @@ export const IssuesByStateGroupWidget: React.FC = 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 = 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 = observer((props) Assigned by state + onChange={(val, customDates) => handleUpdateFilters({ duration: val, + ...(val === "custom" ? { custom_dates: customDates } : {}), }) } /> diff --git a/web/components/dashboard/widgets/loaders/recent-collaborators.tsx b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx index d838967af76..dc2163128fc 100644 --- a/web/components/dashboard/widgets/loaders/recent-collaborators.tsx +++ b/web/components/dashboard/widgets/loaders/recent-collaborators.tsx @@ -2,17 +2,16 @@ import { Loader } from "@plane/ui"; export const RecentCollaboratorsWidgetLoader = () => ( - - -
- {Array.from({ length: 8 }).map((_, index) => ( -
+ <> + {Array.from({ length: 8 }).map((_, index) => ( + +
- ))} -
- + + ))} + ); diff --git a/web/components/dashboard/widgets/recent-activity.tsx b/web/components/dashboard/widgets/recent-activity.tsx index fc16946d8e1..ec99ca771a2 100644 --- a/web/components/dashboard/widgets/recent-activity.tsx +++ b/web/components/dashboard/widgets/recent-activity.tsx @@ -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 = observer((props) => { // derived values const { fetchWidgetStats, getWidgetStats } = useDashboard(); const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); + const redirectionLink = `/${workspaceSlug}/profile/${currentUser?.id}/activity`; useEffect(() => { fetchWidgetStats(workspaceSlug, dashboardId, { @@ -35,7 +37,7 @@ export const RecentActivityWidget: React.FC = observer((props) => { return (
- + Your issue activities {widgetStats.length > 0 ? ( @@ -83,6 +85,15 @@ export const RecentActivityWidget: React.FC = observer((props) => {
))} + + View all +
) : (
diff --git a/web/components/dashboard/widgets/recent-collaborators.tsx b/web/components/dashboard/widgets/recent-collaborators.tsx deleted file mode 100644 index 2fafbb9acaf..00000000000 --- a/web/components/dashboard/widgets/recent-collaborators.tsx +++ /dev/null @@ -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 = 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 ( - -
- -
-
- {isCurrentUser ? "You" : userDetails?.display_name} -
-

- {issueCount} active issue{issueCount > 1 ? "s" : ""} -

- - ); -}); - -export const RecentCollaboratorsWidget: React.FC = observer((props) => { - const { dashboardId, workspaceSlug } = props; - // store hooks - const { fetchWidgetStats, getWidgetStats } = useDashboard(); - const widgetStats = getWidgetStats(workspaceSlug, dashboardId, WIDGET_KEY); - - useEffect(() => { - fetchWidgetStats(workspaceSlug, dashboardId, { - widget_key: WIDGET_KEY, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - if (!widgetStats) return ; - - return ( -
-
-

Most active members

-

- Top eight active members in your project by last activity -

-
- {widgetStats.length > 1 ? ( -
- {widgetStats.map((user) => ( - - ))} -
- ) : ( -
- -
- )} -
- ); -}); diff --git a/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx new file mode 100644 index 00000000000..48c44807535 --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/collaborators-list.tsx @@ -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 = 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 ( + +
+ +
+
+ {isCurrentUser ? "You" : userDetails?.display_name} +
+

+ {issueCount} active issue{issueCount > 1 ? "s" : ""} +

+ + ); +}); + +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 = (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 ; + + return ( + <> + {widgetStats?.results.map((user) => ( + + ))} + + ); +}; diff --git a/web/components/dashboard/widgets/recent-collaborators/default-list.tsx b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx new file mode 100644 index 00000000000..d3f85782434 --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/default-list.tsx @@ -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) => { + 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( + + ); + + return ( + <> +
+ {collaboratorsPages} +
+ {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} + + ); +}; diff --git a/web/components/dashboard/widgets/recent-collaborators/index.ts b/web/components/dashboard/widgets/recent-collaborators/index.ts new file mode 100644 index 00000000000..1efe34c51ec --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/index.ts @@ -0,0 +1 @@ +export * from "./root"; diff --git a/web/components/dashboard/widgets/recent-collaborators/root.tsx b/web/components/dashboard/widgets/recent-collaborators/root.tsx new file mode 100644 index 00000000000..5f611b46243 --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/root.tsx @@ -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 = (props) => { + const { dashboardId, workspaceSlug } = props; + // states + const [searchQuery, setSearchQuery] = useState(""); + + return ( +
+
+
+

Most active members

+

+ Top eight active members in your project by last activity +

+
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ {searchQuery.trim() !== "" ? ( + + ) : ( + + )} +
+ ); +}; diff --git a/web/components/dashboard/widgets/recent-collaborators/search-list.tsx b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx new file mode 100644 index 00000000000..cd882610049 --- /dev/null +++ b/web/components/dashboard/widgets/recent-collaborators/search-list.tsx @@ -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) => { + 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( + + ); + + const emptyStateImage = resolvedTheme === "dark" ? DarkImage : LightImage; + + return ( + <> +
+ {collaboratorsPages} +
+ {!isLoading && totalPages === 0 && ( +
+
+ Recent collaborators +
+

No matching member

+
+ )} + {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} + + ); +}; diff --git a/web/components/graphs/index.ts b/web/components/graphs/index.ts new file mode 100644 index 00000000000..305c3944ea4 --- /dev/null +++ b/web/components/graphs/index.ts @@ -0,0 +1 @@ +export * from "./issues-by-priority"; diff --git a/web/components/graphs/issues-by-priority.tsx b/web/components/graphs/issues-by-priority.tsx new file mode 100644 index 00000000000..0d4bf37b5fe --- /dev/null +++ b/web/components/graphs/issues-by-priority.tsx @@ -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 & { + 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) => { + 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 ( + p.priority_count)} + axisBottom={{ + tickPadding: 8, + tickSize: 0, + }} + tooltip={(datum) => ( +
+ + {datum.data.priority}: + {datum.value} +
+ )} + 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, + }} + /> + ); +}; diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 200d541abae..4d4cfa0cca6 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -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; + updateInboxIssueStatus: (data: TInboxDetailedStatus) => Promise; removeInboxIssue: () => Promise; }; diff --git a/web/components/profile/activity/activity-list.tsx b/web/components/profile/activity/activity-list.tsx new file mode 100644 index 00000000000..06691272104 --- /dev/null +++ b/web/components/profile/activity/activity-list.tsx @@ -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 = observer((props) => { + const { activity } = props; + // store hooks + const { currentUser } = useUser(); + + // TODO: refactor this component + return ( + <> + {activity ? ( +
    + {activity.results.map((activityItem: any) => { + if (activityItem.field === "comment") + return ( +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" && + ) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} + + + +
    +
    +
    +
    + {activityItem.actor_detail.is_bot + ? activityItem.actor_detail.first_name + " Bot" + : activityItem.actor_detail.display_name} +
    +

    + Commented {calculateTimeAgo(activityItem.created_at)} +

    +
    +
    + +
    +
    +
    +
    + ); + + const message = + activityItem.verb === "created" && + !["cycles", "modules", "attachment", "link", "estimate"].includes(activityItem.field) && + !activityItem.field ? ( + + created + + ) : ( + + ); + + if ("field" in activityItem && activityItem.field !== "updated_by") + return ( +
  • +
    +
    + <> +
    +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" ? ( + + ) : ( + + ) + ) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} +
    +
    +
    +
    +
    +
    + {activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? ( + Plane + ) : activityItem.actor_detail.is_bot ? ( + {activityItem.actor_detail.first_name} Bot + ) : ( + + + {currentUser?.id === activityItem.actor_detail.id + ? "You" + : activityItem.actor_detail.display_name} + + + )}{" "} +
    + {message}{" "} + + {calculateTimeAgo(activityItem.created_at)} + +
    +
    +
    + +
    +
    +
  • + ); + })} +
+ ) : ( + + )} + + ); +}); diff --git a/web/components/profile/activity/download-button.tsx b/web/components/profile/activity/download-button.tsx new file mode 100644 index 00000000000..ff928dc2ad9 --- /dev/null +++ b/web/components/profile/activity/download-button.tsx @@ -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 ( + + ); +}; diff --git a/web/components/profile/activity/index.ts b/web/components/profile/activity/index.ts new file mode 100644 index 00000000000..3b202d6c52f --- /dev/null +++ b/web/components/profile/activity/index.ts @@ -0,0 +1,4 @@ +export * from "./activity-list"; +export * from "./download-button"; +export * from "./profile-activity-list"; +export * from "./workspace-activity-list"; diff --git a/web/components/profile/activity/profile-activity-list.tsx b/web/components/profile/activity/profile-activity-list.tsx new file mode 100644 index 00000000000..3912c85680b --- /dev/null +++ b/web/components/profile/activity/profile-activity-list.tsx @@ -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 = 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 ? ( +
    + {userProfileActivity.results.map((activityItem: any) => { + if (activityItem.field === "comment") + return ( +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" && + ) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} + + + +
    +
    +
    +
    + {activityItem.actor_detail.is_bot + ? activityItem.actor_detail.first_name + " Bot" + : activityItem.actor_detail.display_name} +
    +

    + Commented {calculateTimeAgo(activityItem.created_at)} +

    +
    +
    + +
    +
    +
    +
    + ); + + const message = + activityItem.verb === "created" && + !["cycles", "modules", "attachment", "link", "estimate"].includes(activityItem.field) && + !activityItem.field ? ( + + created + + ) : ( + + ); + + if ("field" in activityItem && activityItem.field !== "updated_by") + return ( +
  • +
    +
    + <> +
    +
    +
    +
    + {activityItem.field ? ( + activityItem.new_value === "restore" ? ( + + ) : ( + + ) + ) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? ( + {activityItem.actor_detail.display_name} + ) : ( +
    + {activityItem.actor_detail.display_name?.[0]} +
    + )} +
    +
    +
    +
    +
    +
    + {activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? ( + Plane + ) : activityItem.actor_detail.is_bot ? ( + {activityItem.actor_detail.first_name} Bot + ) : ( + + + {currentUser?.id === activityItem.actor_detail.id + ? "You" + : activityItem.actor_detail.display_name} + + + )}{" "} +
    + {message}{" "} + + {calculateTimeAgo(activityItem.created_at)} + +
    +
    +
    + +
    +
    +
  • + ); + })} +
+ ) : ( + + )} + + ); +}); diff --git a/web/components/profile/activity/workspace-activity-list.tsx b/web/components/profile/activity/workspace-activity-list.tsx new file mode 100644 index 00000000000..c2c75a19564 --- /dev/null +++ b/web/components/profile/activity/workspace-activity-list.tsx @@ -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) => { + 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 ; +}; diff --git a/web/components/profile/index.ts b/web/components/profile/index.ts index f6d2a3775c6..35ac288adb7 100644 --- a/web/components/profile/index.ts +++ b/web/components/profile/index.ts @@ -1,3 +1,4 @@ +export * from "./activity"; export * from "./overview"; export * from "./navbar"; export * from "./profile-issues-filter"; diff --git a/web/components/profile/navbar.tsx b/web/components/profile/navbar.tsx index 4361b7a9d2f..582f0f26bfb 100644 --- a/web/components/profile/navbar.tsx +++ b/web/components/profile/navbar.tsx @@ -27,10 +27,11 @@ export const ProfileNavbar: React.FC = (props) => { {tabsList.map((tab) => ( {tab.label} diff --git a/web/components/profile/overview/activity.tsx b/web/components/profile/overview/activity.tsx index 58bbb689831..112c073abd2 100644 --- a/web/components/profile/overview/activity.tsx +++ b/web/components/profile/overview/activity.tsx @@ -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 (
-

Recent Activity

+

Recent activity

{userProfileActivity ? ( userProfileActivity.results.length > 0 ? ( diff --git a/web/components/profile/overview/priority-distribution.tsx b/web/components/profile/overview/priority-distribution.tsx deleted file mode 100644 index 8a931183f8c..00000000000 --- a/web/components/profile/overview/priority-distribution.tsx +++ /dev/null @@ -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 = ({ userProfile }) => ( -
-

Issues by Priority

- {userProfile ? ( -
- {userProfile.priority_distribution.length > 0 ? ( - ({ - 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) => ( -
- - {datum.data.priority}: - {datum.value} -
- )} - 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", - }, - }, - }} - /> - ) : ( -
- -
- )} -
- ) : ( -
- - - - - - - -
- )} -
-); diff --git a/web/components/profile/overview/priority-distribution/index.ts b/web/components/profile/overview/priority-distribution/index.ts new file mode 100644 index 00000000000..64d81eb1244 --- /dev/null +++ b/web/components/profile/overview/priority-distribution/index.ts @@ -0,0 +1 @@ +export * from "./priority-distribution"; diff --git a/web/components/profile/overview/priority-distribution/main-content.tsx b/web/components/profile/overview/priority-distribution/main-content.tsx new file mode 100644 index 00000000000..8606f44b1e6 --- /dev/null +++ b/web/components/profile/overview/priority-distribution/main-content.tsx @@ -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) => { + const { priorityDistribution } = props; + + return ( +
+ {priorityDistribution.length > 0 ? ( + + ) : ( +
+ +
+ )} +
+ ); +}; diff --git a/web/components/profile/overview/priority-distribution/priority-distribution.tsx b/web/components/profile/overview/priority-distribution/priority-distribution.tsx new file mode 100644 index 00000000000..63559bdeee6 --- /dev/null +++ b/web/components/profile/overview/priority-distribution/priority-distribution.tsx @@ -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) => { + const { priorityDistribution } = props; + + return ( +
+

Issues by priority

+ {priorityDistribution ? ( + + ) : ( +
+ + + + + + + +
+ )} +
+ ); +}; diff --git a/web/components/profile/overview/state-distribution.tsx b/web/components/profile/overview/state-distribution.tsx index 5664637e9d3..f38283aa738 100644 --- a/web/components/profile/overview/state-distribution.tsx +++ b/web/components/profile/overview/state-distribution.tsx @@ -17,7 +17,7 @@ export const ProfileStateDistribution: React.FC = ({ stateDistribution, u return (
-

Issues by State

+

Issues by state

{userProfile.state_distribution.length > 0 ? (
@@ -65,7 +65,7 @@ export const ProfileStateDistribution: React.FC = ({ stateDistribution, u backgroundColor: STATE_GROUPS[group.state_group].color, }} /> -
{group.state_group}
+
{STATE_GROUPS[group.state_group].label}
{group.state_count}
diff --git a/web/components/profile/overview/workload.tsx b/web/components/profile/overview/workload.tsx index c091a94f77a..86989748d7c 100644 --- a/web/components/profile/overview/workload.tsx +++ b/web/components/profile/overview/workload.tsx @@ -21,12 +21,12 @@ export const ProfileWorkload: React.FC = ({ stateDistribution }) => ( }} />
-

+

{group.state_group === "unstarted" - ? "Not Started" + ? "Not started" : group.state_group === "started" ? "Working on" - : group.state_group} + : STATE_GROUPS[group.state_group].label}

{group.state_count}

diff --git a/web/components/profile/sidebar.tsx b/web/components/profile/sidebar.tsx index 71d935d3c80..48bb7d32382 100644 --- a/web/components/profile/sidebar.tsx +++ b/web/components/profile/sidebar.tsx @@ -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(); diff --git a/web/components/ui/graphs/index.ts b/web/components/ui/graphs/index.ts index 1f40adbffa5..984bb642cf2 100644 --- a/web/components/ui/graphs/index.ts +++ b/web/components/ui/graphs/index.ts @@ -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"; diff --git a/web/components/ui/graphs/marimekko-graph.tsx b/web/components/ui/graphs/marimekko-graph.tsx deleted file mode 100644 index fd460d11b3b..00000000000 --- a/web/components/ui/graphs/marimekko-graph.tsx +++ /dev/null @@ -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, "height" | "width">> = ({ - id, - value, - customYAxisTickValues, - height = "400px", - width = "100%", - margin, - theme, - ...rest -}) => ( -
- 7 ? -45 : 0, - }} - labelTextColor={{ from: "color", modifiers: [["darker", 1.6]] }} - theme={{ ...CHARTS_THEME, ...(theme ?? {}) }} - animate - {...rest} - /> -
-); diff --git a/web/constants/dashboard.ts b/web/constants/dashboard.ts index b1cfa51d73c..6ac4e78174c 100644 --- a/web/constants/dashboard.ts +++ b/web/constants/dashboard.ts @@ -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 = { // 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 diff --git a/web/constants/fetch-keys.ts b/web/constants/fetch-keys.ts index 86386e9683c..3b2e97c38cb 100644 --- a/web/constants/fetch-keys.ts +++ b/web/constants/fetch-keys.ts @@ -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) => { diff --git a/web/constants/profile.ts b/web/constants/profile.ts index 463fd27eed9..4d8e37640a2 100644 --- a/web/constants/profile.ts +++ b/web/constants/profile.ts @@ -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", + }, ]; diff --git a/web/helpers/dashboard.helper.ts b/web/helpers/dashboard.helper.ts index 90319a90b90..a61ec7f782a 100644 --- a/web/helpers/dashboard.helper.ts +++ b/web/helpers/dashboard.helper.ts @@ -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 ""; + } +}; diff --git a/web/layouts/user-profile-layout/layout.tsx b/web/layouts/user-profile-layout/layout.tsx index 60c17d8d4ef..52bfc6fbf8e 100644 --- a/web/layouts/user-profile-layout/layout.tsx +++ b/web/layouts/user-profile-layout/layout.tsx @@ -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 = 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 (
- + {isAuthorized || !isAuthorizedPath ? (
{children}
) : ( diff --git a/web/package.json b/web/package.json index af28cbb3d49..fbec571ef8f 100644 --- a/web/package.json +++ b/web/package.json @@ -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": "*", diff --git a/web/pages/[workspaceSlug]/profile/[userId]/activity.tsx b/web/pages/[workspaceSlug]/profile/[userId]/activity.tsx new file mode 100644 index 00000000000..09269676a8d --- /dev/null +++ b/web/pages/[workspaceSlug]/profile/[userId]/activity.tsx @@ -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( + + ); + + const canDownloadActivity = + currentUser?.id === userId && !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER; + + return ( +
+
+

Recent activity

+ {canDownloadActivity && } +
+
+ {activityPages} + {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} +
+
+ ); +}); + +ProfileActivityPage.getLayout = function getLayout(page: ReactElement) { + return ( + }> + {page} + + ); +}; + +export default ProfileActivityPage; diff --git a/web/pages/[workspaceSlug]/profile/[userId]/index.tsx b/web/pages/[workspaceSlug]/profile/[userId]/index.tsx index 7d24a8b1117..6e8a10b5073 100644 --- a/web/pages/[workspaceSlug]/profile/[userId]/index.tsx +++ b/web/pages/[workspaceSlug]/profile/[userId]/index.tsx @@ -49,7 +49,7 @@ const ProfileOverviewPage: NextPageWithLayout = () => {
- +
diff --git a/web/pages/profile/activity.tsx b/web/pages/profile/activity.tsx index 3ea65ed249b..b0e8bb1a028 100644 --- a/web/pages/profile/activity.tsx +++ b/web/pages/profile/activity.tsx @@ -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( + + ); + return ( <> -
+
themeStore.toggleSidebar()} />

Activity

- {userActivity ? ( -
-
    - {userActivity.results.map((activityItem: any) => { - if (activityItem.field === "comment") { - return ( -
    -
    -
    - {activityItem.field ? ( - activityItem.new_value === "restore" && ( - - ) - ) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? ( - {activityItem.actor_detail.display_name} - ) : ( -
    - {activityItem.actor_detail.display_name?.charAt(0)} -
    - )} - - - -
    -
    -
    -
    - {activityItem.actor_detail.is_bot - ? activityItem.actor_detail.first_name + " Bot" - : activityItem.actor_detail.display_name} -
    -

    - Commented {calculateTimeAgo(activityItem.created_at)} -

    -
    -
    - -
    -
    -
    -
    - ); - } - - const message = - activityItem.verb === "created" && - activityItem.field !== "cycles" && - activityItem.field !== "modules" && - activityItem.field !== "attachment" && - activityItem.field !== "link" && - activityItem.field !== "estimate" && - !activityItem.field ? ( - - created - - ) : ( - - ); - - if ("field" in activityItem && activityItem.field !== "updated_by") { - return ( -
  • -
    -
    - <> -
    -
    -
    -
    - {activityItem.field ? ( - activityItem.new_value === "restore" ? ( - - ) : ( - - ) - ) : activityItem.actor_detail.avatar && activityItem.actor_detail.avatar !== "" ? ( - {activityItem.actor_detail.display_name} - ) : ( -
    - {activityItem.actor_detail.display_name?.charAt(0)} -
    - )} -
    -
    -
    -
    -
    -
    - {activityItem.field === "archived_at" && activityItem.new_value !== "restore" ? ( - Plane - ) : activityItem.actor_detail.is_bot ? ( - - {activityItem.actor_detail.first_name} Bot - - ) : ( - - - {currentUser?.id === activityItem.actor_detail.id - ? "You" - : activityItem.actor_detail.display_name} - - - )}{" "} -
    - {message}{" "} - - {calculateTimeAgo(activityItem.created_at)} - -
    -
    -
    - -
    -
    -
  • - ); - } - })} -
-
- ) : ( - - )} +
+ {activityPages} + {pageCount < totalPages && resultsCount !== 0 && ( +
+ +
+ )} +
); diff --git a/web/services/user.service.ts b/web/services/user.service.ts index 13ffa9c51ec..41111db98c9 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -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 { - return this.get(`/api/users/me/activities/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async userWorkspaceDashboard(workspaceSlug: string, month: number): Promise { - return this.get(`/api/users/me/workspaces/${workspaceSlug}/dashboard/`, { - params: { - month: month, - }, - }) + async getUserActivity(params: { per_page: number; cursor?: string }): Promise { + 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 { - 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 { + 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 { + return this.post(`/api/workspaces/${workspaceSlug}/user-activity/${userId}/export/`, data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/store/user/index.ts b/web/store/user/index.ts index 15f9e57728e..ada2e6be7b9 100644 --- a/web/store/user/index.ts +++ b/web/store/user/index.ts @@ -22,7 +22,6 @@ export interface IUserRootStore { fetchCurrentUser: () => Promise; fetchCurrentUserInstanceAdminStatus: () => Promise; fetchCurrentUserSettings: () => Promise; - fetchUserDashboardInfo: (workspaceSlug: string, month: number) => Promise; // crud actions updateUserOnBoard: () => Promise; updateTourCompleted: () => Promise; @@ -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 - */ - 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 diff --git a/yarn.lock b/yarn.lock index 81e6224e84d..0a21fcee2ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1766,19 +1766,6 @@ "@react-spring/web" "9.4.5" d3-shape "^1.3.5" -"@nivo/marimekko@0.80.0": - version "0.80.0" - resolved "https://registry.yarnpkg.com/@nivo/marimekko/-/marimekko-0.80.0.tgz#1eda4207935b3776bd1d7d729dc39a0e42bb1b86" - integrity sha512-0u20SryNtbOQhtvhZsbxmgnF7o8Yc2rjDQ/gCYPTXtxeooWCuhSaRZbDCnCeyKQY3B62D7z2mu4Js4KlTEftjA== - dependencies: - "@nivo/axes" "0.80.0" - "@nivo/colors" "0.80.0" - "@nivo/legends" "0.80.0" - "@nivo/scales" "0.80.0" - "@react-spring/web" "9.4.5" - d3-shape "^1.3.5" - lodash "^4.17.21" - "@nivo/pie@0.80.0": version "0.80.0" resolved "https://registry.yarnpkg.com/@nivo/pie/-/pie-0.80.0.tgz#04b35839bf5a2b661fa4e5b677ae76b3c028471e"