diff --git a/apiserver/plane/app/serializers/project.py b/apiserver/plane/app/serializers/project.py index cc7b7fbcb..0cf9c8679 100644 --- a/apiserver/plane/app/serializers/project.py +++ b/apiserver/plane/app/serializers/project.py @@ -90,16 +90,7 @@ class ProjectLiteSerializer(BaseSerializer): class ProjectListSerializer(DynamicBaseSerializer): - total_issues = serializers.IntegerField(read_only=True) - archived_issues = serializers.IntegerField(read_only=True) - archived_sub_issues = serializers.IntegerField(read_only=True) - draft_issues = serializers.IntegerField(read_only=True) - draft_sub_issues = serializers.IntegerField(read_only=True) - sub_issues = serializers.IntegerField(read_only=True) is_favorite = serializers.BooleanField(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) @@ -113,14 +104,9 @@ class ProjectListSerializer(DynamicBaseSerializer): if project_members is not None: # Filter members by the project ID return [ - { - "id": member.id, - "member_id": member.member_id, - "member__display_name": member.member.display_name, - "member__avatar": member.member.avatar, - "member__avatar_url": member.member.avatar_url, - } + member.member_id for member in project_members + if member.is_active and not member.member.is_bot ] return [] @@ -134,9 +120,6 @@ class ProjectDetailSerializer(BaseSerializer): default_assignee = UserLiteSerializer(read_only=True) project_lead = UserLiteSerializer(read_only=True) is_favorite = serializers.BooleanField(read_only=True) - total_members = serializers.IntegerField(read_only=True) - total_cycles = serializers.IntegerField(read_only=True) - total_modules = serializers.IntegerField(read_only=True) is_member = serializers.BooleanField(read_only=True) sort_order = serializers.FloatField(read_only=True) member_role = serializers.IntegerField(read_only=True) diff --git a/apiserver/plane/app/urls/analytic.py b/apiserver/plane/app/urls/analytic.py index 668268350..abe18f2ad 100644 --- a/apiserver/plane/app/urls/analytic.py +++ b/apiserver/plane/app/urls/analytic.py @@ -7,6 +7,7 @@ from plane.app.views import ( SavedAnalyticEndpoint, ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, + ProjectStatsEndpoint, ) @@ -43,4 +44,9 @@ urlpatterns = [ DefaultAnalyticsEndpoint.as_view(), name="default-analytics", ), + path( + "workspaces//project-stats/", + ProjectStatsEndpoint.as_view(), + name="project-analytics", + ), ] diff --git a/apiserver/plane/app/urls/project.py b/apiserver/plane/app/urls/project.py index 4037402ab..d673d191e 100644 --- a/apiserver/plane/app/urls/project.py +++ b/apiserver/plane/app/urls/project.py @@ -23,6 +23,11 @@ urlpatterns = [ ProjectViewSet.as_view({"get": "list", "post": "create"}), name="project", ), + path( + "workspaces//projects/details/", + ProjectViewSet.as_view({"get": "list_detail"}), + name="project", + ), path( "workspaces//projects//", ProjectViewSet.as_view( diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index cb10c879f..04162efd9 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -190,6 +190,7 @@ from .analytic.base import ( SavedAnalyticEndpoint, ExportAnalyticsEndpoint, DefaultAnalyticsEndpoint, + ProjectStatsEndpoint, ) from .notification.base import ( diff --git a/apiserver/plane/app/views/analytic/base.py b/apiserver/plane/app/views/analytic/base.py index a36458406..d4e2357c3 100644 --- a/apiserver/plane/app/views/analytic/base.py +++ b/apiserver/plane/app/views/analytic/base.py @@ -3,7 +3,7 @@ from django.db.models import Count, F, Sum, Q from django.db.models.functions import ExtractMonth from django.utils import timezone from django.db.models.functions import Concat -from django.db.models import Case, When, Value +from django.db.models import Case, When, Value, OuterRef, Func from django.db import models # Third party imports @@ -15,7 +15,16 @@ from plane.app.permissions import WorkSpaceAdminPermission from plane.app.serializers import AnalyticViewSerializer from plane.app.views.base import BaseAPIView, BaseViewSet from plane.bgtasks.analytic_plot_export import analytic_export_task -from plane.db.models import AnalyticView, Issue, Workspace +from plane.db.models import ( + AnalyticView, + Issue, + Workspace, + Project, + ProjectMember, + Cycle, + Module, +) + from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters from plane.app.permissions import allow_permission, ROLE @@ -441,3 +450,75 @@ class DefaultAnalyticsEndpoint(BaseAPIView): }, status=status.HTTP_200_OK, ) + + +class ProjectStatsEndpoint(BaseAPIView): + @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") + def get(self, request, slug): + fields = request.GET.get("fields", "").split(",") + project_ids = request.GET.get("project_ids", "").split(",") + + valid_fields = { + "total_issues", + "completed_issues", + "total_members", + "total_cycles", + "total_modules", + } + requested_fields = set(filter(None, fields)) & valid_fields + + if not requested_fields: + requested_fields = valid_fields + + projects = Project.objects.filter(workspace__slug=slug) + + if project_ids: + projects = projects.filter(id__in=project_ids) + + annotations = {} + if "total_issues" in requested_fields: + annotations["total_issues"] = ( + Issue.issue_objects.filter(project_id=OuterRef("pk")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "completed_issues" in requested_fields: + annotations["completed_issues"] = ( + Issue.issue_objects.filter( + project_id=OuterRef("pk"), state__group="completed" + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_cycles" in requested_fields: + annotations["total_cycles"] = ( + Cycle.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_modules" in requested_fields: + annotations["total_modules"] = ( + Module.objects.filter(project_id=OuterRef("id")) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + if "total_members" in requested_fields: + annotations["total_members"] = ( + ProjectMember.objects.filter( + project_id=OuterRef("id"), member__is_bot=False, is_active=True + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + + projects = projects.annotate(**annotations).values("id", *requested_fields) + return Response(projects, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 8988fc3ee..d92922d03 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -6,7 +6,7 @@ import json # Django imports from django.db import IntegrityError -from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery +from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery from django.core.serializers.json import DjangoJSONEncoder # Third Party imports @@ -25,12 +25,9 @@ from plane.app.serializers import ( from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE from plane.db.models import ( UserFavorite, - Cycle, Intake, DeployBoard, IssueUserProperty, - Issue, - Module, Project, ProjectIdentifier, ProjectMember, @@ -83,26 +80,6 @@ class ProjectViewSet(BaseViewSet): ) ) ) - .annotate( - total_members=ProjectMember.objects.filter( - project_id=OuterRef("id"), member__is_bot=False, is_active=True - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - total_cycles=Cycle.objects.filter(project_id=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - total_modules=Module.objects.filter(project_id=OuterRef("id")) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) .annotate( member_role=ProjectMember.objects.filter( project_id=OuterRef("pk"), @@ -133,7 +110,7 @@ class ProjectViewSet(BaseViewSet): @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) - def list(self, request, slug): + def list_detail(self, request, slug): fields = [field for field in request.GET.get("fields", "").split(",") if field] projects = self.get_queryset().order_by("sort_order", "name") if WorkspaceMember.objects.filter( @@ -170,6 +147,76 @@ class ProjectViewSet(BaseViewSet): ).data return Response(projects, status=status.HTTP_200_OK) + @allow_permission( + allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" + ) + def list(self, request, slug): + sort_order = ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ).values("sort_order") + + projects = ( + Project.objects.filter(workspace__slug=self.kwargs.get("slug")) + .select_related( + "workspace", "workspace__owner", "default_assignee", "project_lead" + ) + .annotate( + is_member=Exists( + ProjectMember.objects.filter( + member=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + is_active=True, + ) + ) + ) + .annotate(inbox_view=F("intake_view")) + .annotate(sort_order=Subquery(sort_order)) + .distinct() + ).values( + "id", + "name", + "identifier", + "sort_order", + "logo_props", + "is_member", + "archived_at", + "workspace", + "cycle_view", + "issue_views_view", + "module_view", + "page_view", + "inbox_view", + "project_lead", + "created_at", + "updated_at", + "created_by", + "updated_by", + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=5 + ).exists(): + projects = projects.filter( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + + if WorkspaceMember.objects.filter( + member=request.user, workspace__slug=slug, is_active=True, role=15 + ).exists(): + projects = projects.filter( + Q( + project_projectmember__member=self.request.user, + project_projectmember__is_active=True, + ) + | Q(network=2) + ) + return Response(projects, status=status.HTTP_200_OK) + @allow_permission( allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" ) @@ -182,58 +229,6 @@ class ProjectViewSet(BaseViewSet): ) .filter(archived_at__isnull=True) .filter(pk=pk) - .annotate( - total_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("pk") - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - sub_issues=Issue.issue_objects.filter( - project_id=self.kwargs.get("pk"), parent__isnull=False - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - archived_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), archived_at__isnull=False - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - archived_sub_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), - archived_at__isnull=False, - parent__isnull=False, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - draft_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), is_draft=True - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) - .annotate( - draft_sub_issues=Issue.objects.filter( - project_id=self.kwargs.get("pk"), - is_draft=True, - parent__isnull=False, - ) - .order_by() - .annotate(count=Func(F("id"), function="Count")) - .values("count") - ) ).first() if project is None: diff --git a/packages/types/src/common.d.ts b/packages/types/src/common.d.ts index 7e755fcc2..c45236a9f 100644 --- a/packages/types/src/common.d.ts +++ b/packages/types/src/common.d.ts @@ -24,3 +24,5 @@ export type TLogoProps = { }; export type TNameDescriptionLoader = "submitting" | "submitted" | "saved"; + +export type TFetchStatus = "partial" | "complete" | undefined; diff --git a/packages/types/src/project/projects.d.ts b/packages/types/src/project/projects.d.ts index d992bc710..269e1477a 100644 --- a/packages/types/src/project/projects.d.ts +++ b/packages/types/src/project/projects.d.ts @@ -10,58 +10,65 @@ import type { } from ".."; import { TUserPermissions } from "../enums"; -export interface IProject { - archive_in: number; +export interface IPartialProject { + id: string; + name: string; + identifier: string; + sort_order: number | null; + logo_props: TLogoProps; + is_member: boolean; archived_at: string | null; - archived_issues: number; - archived_sub_issues: number; - completed_issues: number; - close_in: number; - created_at: Date; - created_by: string; - // only for uploading the cover image - cover_image_asset?: null; - cover_image?: string; - // only for rendering the cover image - cover_image_url: readonly string; + workspace: IWorkspace | string; cycle_view: boolean; issue_views_view: boolean; module_view: boolean; page_view: boolean; inbox_view: boolean; - default_assignee: IUser | string | null; - default_state: string | null; - description: string; - draft_issues: number; - draft_sub_issues: number; - estimate: string | null; - guest_view_all_features: boolean; - id: string; - identifier: string; - anchor: string | null; - is_favorite: boolean; - is_issue_type_enabled: boolean; - is_member: boolean; - is_time_tracking_enabled: boolean; - logo_props: TLogoProps; - member_role: TUserPermissions | null; - members: IProjectMemberLite[]; - name: string; - network: number; - project_lead: IUserLite | string | null; - sort_order: number | null; - sub_issues: number; - total_cycles: number; - total_issues: number; - total_members: number; - total_modules: number; - updated_at: Date; - updated_by: string; - workspace: IWorkspace | string; - workspace_detail: IWorkspaceLite; - timezone: string; + project_lead?: IUserLite | string | null; + // Timestamps + created_at?: Date; + updated_at?: Date; + // actor + created_by?: string; + updated_by?: string; } +export interface IProject extends IPartialProject { + archive_in?: number; + close_in?: number; + // only for uploading the cover image + cover_image_asset?: null; + cover_image?: string; + // only for rendering the cover image + readonly cover_image_url?: string; + default_assignee?: IUser | string | null; + default_state?: string | null; + description?: string; + estimate?: string | null; + guest_view_all_features?: boolean; + anchor?: string | null; + is_favorite?: boolean; + is_issue_type_enabled?: boolean; + is_time_tracking_enabled?: boolean; + member_role?: TUserPermissions | null; + members?: string[]; + network?: number; + timezone?: string; +} + +export type TProjectAnalyticsCountParams = { + project_ids?: string; + fields?: string; +}; + +export type TProjectAnalyticsCount = Pick & { + total_issues?: number; + completed_issues?: number; + total_cycles?: number; + total_members?: number; + total_modules?: number; +}; + export interface IProjectLite { id: string; name: string; diff --git a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx index 3193efa03..5b5a36530 100644 --- a/web/app/[workspaceSlug]/(projects)/analytics/page.tsx +++ b/web/app/[workspaceSlug]/(projects)/analytics/page.tsx @@ -33,7 +33,7 @@ const AnalyticsPage = observer(() => { {workspaceProjectIds && ( <> - {workspaceProjectIds.length > 0 || loader ? ( + {workspaceProjectIds.length > 0 || loader === "init-loader" ? (
diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx index 639da9a96..8ad2198e6 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/archives/header.tsx @@ -65,7 +65,7 @@ export const ProjectArchivesHeader: FC = observer((props: TProps) => {
- + { return (
- + {
- + { return (
- + { // router const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; @@ -83,17 +84,13 @@ export const ProjectDraftIssueHeader: FC = observer(() => { [workspaceSlug, projectId, updateFilters] ); - const issueCount = currentProjectDetails - ? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.draft_sub_issues - ? currentProjectDetails.draft_issues - currentProjectDetails.draft_sub_issues - : currentProjectDetails.draft_issues - : undefined; + const issueCount = undefined; return (
- + {
- + { />
- + { const { setTrackElement } = useEventTracker(); const { allowPermissions } = useUserPermissions(); - const { loader } = useProject(); + const { loader } = useProject(); // auth const canUserCreateModule = allowPermissions( @@ -34,8 +34,8 @@ export const ModulesListHeader: React.FC = observer(() => {
- - + + } />} diff --git a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx index ebb4af3bd..3ca5ba4b8 100644 --- a/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx +++ b/web/app/[workspaceSlug]/(projects)/projects/(detail)/[projectId]/pages/(detail)/header.tsx @@ -63,7 +63,7 @@ export const PageDetailsHeader = observer(() => {
- + {
- + {
- +
{ return (
- + { <>
- + {
- router.back()} isLoading={loader}> + router.back()} isLoading={loader === "init-loader"}> = (props) => { return (
onChange(lead === value ? null : lead)} placeholder={t("lead")} multiple={false} diff --git a/web/ce/components/projects/settings/intake/header.tsx b/web/ce/components/projects/settings/intake/header.tsx index 9ed39733b..38270f3c9 100644 --- a/web/ce/components/projects/settings/intake/header.tsx +++ b/web/ce/components/projects/settings/intake/header.tsx @@ -36,7 +36,7 @@ export const ProjectInboxHeader: FC = observer(() => {
- + = observer((props) => { - const { projectIds } = props; - - const { getProjectById } = useProject(); + const { projectIds, isLoading, isUpdating } = props; + // store hooks + const { getProjectById, getProjectAnalyticsCountById } = useProject(); return (
-

Selected Projects

+
+

Selected Projects

+ {isUpdating && } +
{projectIds.map((projectId) => { const project = getProjectById(projectId); + const projectAnalyticsCount = getProjectAnalyticsCountById(projectId); if (!project) return; @@ -38,27 +46,37 @@ export const CustomAnalyticsSidebarProjectsList: React.FC = observer((pro
-
-
- -
Total members
-
- {project.total_members} -
-
-
- -
Total cycles
-
- {project.total_cycles} -
-
-
- -
Total modules
-
- {project.total_modules} -
+ {isLoading ? ( + + + + + + ) : ( + <> +
+
+ +
Total members
+
+ {projectAnalyticsCount?.total_members} +
+
+
+ +
Total cycles
+
+ {projectAnalyticsCount?.total_cycles} +
+
+
+ +
Total modules
+
+ {projectAnalyticsCount?.total_modules} +
+ + )}
); diff --git a/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx b/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx index 639a830cd..170d3ed68 100644 --- a/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx +++ b/web/core/components/analytics/custom-analytics/sidebar/sidebar.tsx @@ -3,7 +3,7 @@ import { useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { mutate } from "swr"; +import useSWR, { mutate } from "swr"; // icons import { CalendarDays, Download, RefreshCw } from "lucide-react"; // types @@ -30,19 +30,27 @@ type Props = { const analyticsService = new AnalyticsService(); +const PROJECT_ANALYTICS_COUNT_PARAMS = { + fields: "total_members,total_cycles,total_modules", +}; + export const CustomAnalyticsSidebar: React.FC = observer((props) => { const { analytics, params, isProjectLevel = false } = props; // router const { workspaceSlug, projectId, cycleId, moduleId } = useParams(); // store hooks const { data: currentUser } = useUser(); - const { workspaceProjectIds, getProjectById } = useProject(); + const { workspaceProjectIds, getProjectById, fetchProjectAnalyticsCount } = useProject(); const { getWorkspaceById } = useWorkspace(); - const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchModuleDetails, getModuleById } = useModule(); + // fetch project analytics count + const { isLoading: isProjectAnalyticsLoading, isValidating: isProjectAnalyticsUpdating } = useSWR( + workspaceSlug ? ["projectAnalyticsCount", workspaceSlug] : null, + workspaceSlug ? () => fetchProjectAnalyticsCount(workspaceSlug.toString(), PROJECT_ANALYTICS_COUNT_PARAMS) : null + ); - const projectDetails = projectId ? getProjectById(projectId.toString()) ?? undefined : undefined; + const projectDetails = projectId ? (getProjectById(projectId.toString()) ?? undefined) : undefined; const trackExportAnalytics = () => { if (!currentUser) return; @@ -171,7 +179,11 @@ export const CustomAnalyticsSidebar: React.FC = observer((props) => {
<> {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( - + )} diff --git a/web/core/components/auth-screens/project/join-project.tsx b/web/core/components/auth-screens/project/join-project.tsx index 4ec94acab..b815f8738 100644 --- a/web/core/components/auth-screens/project/join-project.tsx +++ b/web/core/components/auth-screens/project/join-project.tsx @@ -16,7 +16,7 @@ export const JoinProject: React.FC = () => { const [isJoiningProject, setIsJoiningProject] = useState(false); // store hooks const { joinProject } = useUserPermissions(); - const { fetchProjects } = useProject(); + const { fetchProjectDetails } = useProject(); const { workspaceSlug, projectId } = useParams(); @@ -26,7 +26,7 @@ export const JoinProject: React.FC = () => { setIsJoiningProject(true); joinProject(workspaceSlug.toString(), projectId.toString()) - .then(() => fetchProjects(workspaceSlug.toString())) + .then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString())) .finally(() => setIsJoiningProject(false)); }; diff --git a/web/core/components/automation/auto-archive-automation.tsx b/web/core/components/automation/auto-archive-automation.tsx index 8ef488da5..84544a1f8 100644 --- a/web/core/components/automation/auto-archive-automation.tsx +++ b/web/core/components/automation/auto-archive-automation.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; import { ArchiveRestore } from "lucide-react"; // types import { IProject } from "@plane/types"; @@ -23,6 +24,8 @@ const initialValues: Partial = { archive_in: 1 }; export const AutoArchiveAutomation: React.FC = observer((props) => { const { handleChange } = props; + // router + const { workspaceSlug } = useParams(); // states const [monthModal, setmonthModal] = useState(false); // store hooks @@ -33,9 +36,9 @@ export const AutoArchiveAutomation: React.FC = observer((props) => { const isAdmin = allowPermissions( [EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, - currentProjectDetails?.workspace_detail?.slug, + workspaceSlug?.toString(), currentProjectDetails?.id - ); +); return ( <> diff --git a/web/core/components/automation/auto-close-automation.tsx b/web/core/components/automation/auto-close-automation.tsx index dce5764d4..4a5899bde 100644 --- a/web/core/components/automation/auto-close-automation.tsx +++ b/web/core/components/automation/auto-close-automation.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; // icons import { ArchiveX } from "lucide-react"; // types @@ -22,6 +23,8 @@ type Props = { export const AutoCloseAutomation: React.FC = observer((props) => { const { handleChange } = props; + // router + const { workspaceSlug } = useParams(); // states const [monthModal, setmonthModal] = useState(false); // store hooks @@ -59,7 +62,7 @@ export const AutoCloseAutomation: React.FC = observer((props) => { const isAdmin = allowPermissions( [EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, - currentProjectDetails?.workspace_detail?.slug, + workspaceSlug?.toString(), currentProjectDetails?.id ); diff --git a/web/core/components/automation/select-month-modal.tsx b/web/core/components/automation/select-month-modal.tsx index 9a0ebb05f..b35b9885e 100644 --- a/web/core/components/automation/select-month-modal.tsx +++ b/web/core/components/automation/select-month-modal.tsx @@ -93,7 +93,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, id="close_in" name="close_in" type="number" - value={value.toString()} + value={value?.toString()} onChange={onChange} ref={ref} hasError={Boolean(errors.close_in)} @@ -127,7 +127,7 @@ export const SelectMonthModal: React.FC = ({ type, initialValues, isOpen, id="archive_in" name="archive_in" type="number" - value={value.toString()} + value={value?.toString()} onChange={onChange} ref={ref} hasError={Boolean(errors.archive_in)} diff --git a/web/core/components/dashboard/widgets/recent-projects.tsx b/web/core/components/dashboard/widgets/recent-projects.tsx index 525590871..6420d4828 100644 --- a/web/core/components/dashboard/widgets/recent-projects.tsx +++ b/web/core/components/dashboard/widgets/recent-projects.tsx @@ -16,7 +16,14 @@ import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard"; // helpers import { getFileURL } from "@/helpers/file.helper"; // hooks -import { useEventTracker, useDashboard, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; +import { + useEventTracker, + useDashboard, + useProject, + useCommandPalette, + useUserPermissions, + useMember, +} from "@/hooks/store"; // plane web constants import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; @@ -31,6 +38,8 @@ const ProjectListItem: React.FC = observer((props) => { const { projectId, workspaceSlug } = props; // store hooks const { getProjectById } = useProject(); + const { getUserDetails } = useMember(); + // derived values const projectDetails = getProjectById(projectId); const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)]; @@ -52,13 +61,13 @@ const ProjectListItem: React.FC = observer((props) => {
- {projectDetails.members?.map((member) => ( - - ))} + {projectDetails.members?.map((memberId) => { + const userDetails = getUserDetails(memberId); + if (!userDetails) return null; + return ( + + ); + })}
diff --git a/web/core/components/home/widgets/recents/index.tsx b/web/core/components/home/widgets/recents/index.tsx index 83b3a4592..cb4338480 100644 --- a/web/core/components/home/widgets/recents/index.tsx +++ b/web/core/components/home/widgets/recents/index.tsx @@ -79,7 +79,7 @@ export const RecentActivityWidget: React.FC = observer((prop } }; - if (!loader && !isWikiApp && joinedProjectIds?.length === 0) return ; + if (loader === "loaded" && !isWikiApp && joinedProjectIds?.length === 0) return ; if (!isLoading && recents?.length === 0) return ( diff --git a/web/core/components/page-views/workspace-dashboard.tsx b/web/core/components/page-views/workspace-dashboard.tsx index f4df757cd..8db841344 100644 --- a/web/core/components/page-views/workspace-dashboard.tsx +++ b/web/core/components/page-views/workspace-dashboard.tsx @@ -19,15 +19,11 @@ import useSize from "@/hooks/use-window-size"; export const WorkspaceDashboardView = observer(() => { // store hooks - const { - // captureEvent, - setTrackElement, - } = useEventTracker(); + const { captureEvent, setTrackElement } = useEventTracker(); const { toggleCreateProjectModal } = useCommandPalette(); const { workspaceSlug } = useParams(); const { data: currentUser } = useUser(); const { data: currentUserProfile, updateTourCompleted } = useUserProfile(); - const { captureEvent } = useEventTracker(); const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard(); const { joinedProjectIds, loader } = useProject(); @@ -63,7 +59,7 @@ export const WorkspaceDashboardView = observer(() => { )} {homeDashboardId && joinedProjectIds && ( <> - {joinedProjectIds.length > 0 || loader ? ( + {joinedProjectIds.length > 0 || loader === "init-loader" ? ( <> { const { toggleCreateProjectModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const { + loader, + fetchStatus, workspaceProjectIds: storeWorkspaceProjectIds, filteredProjectIds: storeFilteredProjectIds, getProjectById, - loader, } = useProject(); const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter(); // derived values const workspaceProjectIds = totalProjectIdsProps ?? storeWorkspaceProjectIds; const filteredProjectIds = filteredProjectIdsProps ?? storeFilteredProjectIds; - if (!filteredProjectIds || !workspaceProjectIds || loader) return ; + if (!filteredProjectIds || !workspaceProjectIds || loader === "init-loader" || fetchStatus !== "complete") + return ; if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects) return ( diff --git a/web/core/components/project/card.tsx b/web/core/components/project/card.tsx index d755ce007..341ddcf8f 100644 --- a/web/core/components/project/card.tsx +++ b/web/core/components/project/card.tsx @@ -29,7 +29,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper"; import { getFileURL } from "@/helpers/file.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks -import { useProject, useUserPermissions } from "@/hooks/store"; +import { useMember, useProject, useUserPermissions } from "@/hooks/store"; import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane-web constants @@ -51,12 +51,13 @@ export const ProjectCard: React.FC = observer((props) => { const router = useAppRouter(); const { workspaceSlug } = useParams(); // store hooks + const { getUserDetails } = useMember(); const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); const { allowPermissions } = useUserPermissions(); // hooks const { isMobile } = usePlatformOS(); // derived values - const projectMembersIds = project.members?.map((member) => member.member_id); + const projectMembersIds = project.members; const shouldRenderFavorite = allowPermissions( [EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.WORKSPACE @@ -249,7 +250,7 @@ export const ProjectCard: React.FC = observer((props) => { if (project.is_favorite) handleRemoveFromFavorites(); else handleAddToFavorites(); }} - selected={project.is_favorite} + selected={!!project.is_favorite} /> )}
@@ -281,14 +282,10 @@ export const ProjectCard: React.FC = observer((props) => {
{projectMembersIds.map((memberId) => { - const member = project.members?.find((m) => m.member_id === memberId); + const member = getUserDetails(memberId); if (!member) return null; return ( - + ); })} diff --git a/web/core/components/project/create/header.tsx b/web/core/components/project/create/header.tsx index 19142c729..efe93e757 100644 --- a/web/core/components/project/create/header.tsx +++ b/web/core/components/project/create/header.tsx @@ -53,7 +53,7 @@ const ProjectCreateHeader: React.FC = (props) => { label={t("change_cover")} onChange={onChange} control={control} - value={value} + value={value ?? null} tabIndex={getIndex("cover_image")} /> )} diff --git a/web/core/components/project/form.tsx b/web/core/components/project/form.tsx index c5acb0b56..a7264ea7a 100644 --- a/web/core/components/project/form.tsx +++ b/web/core/components/project/form.tsx @@ -222,7 +222,7 @@ export const ProjectDetailsForm: FC = (props) => { label="Change cover" control={control} onChange={onChange} - value={value} + value={value ?? null} disabled={!isAdmin} projectId={project.id} /> @@ -263,7 +263,7 @@ export const ProjectDetailsForm: FC = (props) => { {errors?.name?.message}
-

Description

+

Summary

= (props) => { id="description" name="description" value={value} - placeholder="Enter project description" + placeholder="Enter project summary" onChange={onChange} className="min-h-[102px] text-sm font-medium" hasError={Boolean(errors?.description)} diff --git a/web/core/components/project/join-project-modal.tsx b/web/core/components/project/join-project-modal.tsx index 2006c491c..d46cf4d0b 100644 --- a/web/core/components/project/join-project-modal.tsx +++ b/web/core/components/project/join-project-modal.tsx @@ -25,7 +25,7 @@ export const JoinProjectModal: React.FC = (props) => { const [isJoiningLoading, setIsJoiningLoading] = useState(false); // store hooks const { joinProject } = useUserPermissions(); - const { fetchProjects } = useProject(); + const { fetchProjectDetails } = useProject(); // router const router = useAppRouter(); @@ -35,7 +35,7 @@ export const JoinProjectModal: React.FC = (props) => { joinProject(workspaceSlug, project.id) .then(() => { router.push(`/${workspaceSlug}/projects/${project.id}/issues`); - fetchProjects(workspaceSlug); + fetchProjectDetails(workspaceSlug, project.id); handleClose(); }) .finally(() => { diff --git a/web/core/components/project/member-list-item.tsx b/web/core/components/project/member-list-item.tsx index 3b115c493..a2dfea824 100644 --- a/web/core/components/project/member-list-item.tsx +++ b/web/core/components/project/member-list-item.tsx @@ -27,7 +27,7 @@ export const ProjectMemberListItem: React.FC = observer((props) => { // store hooks const { leaveProject } = useUserPermissions(); const { data: currentUser } = useUser(); - const { fetchProjects } = useProject(); + const { fetchProjectDetails } = useProject(); const { project: { removeMemberFromProject }, } = useMember(); @@ -45,7 +45,7 @@ export const ProjectMemberListItem: React.FC = observer((props) => { state: "SUCCESS", element: "Project settings members page", }); - await fetchProjects(workspaceSlug.toString()); + await fetchProjectDetails(workspaceSlug.toString(), projectId.toString()); }) .catch((err) => setToast({ diff --git a/web/core/components/project/project-settings-member-defaults.tsx b/web/core/components/project/project-settings-member-defaults.tsx index 4b6c3cd39..f4aeb63b5 100644 --- a/web/core/components/project/project-settings-member-defaults.tsx +++ b/web/core/components/project/project-settings-member-defaults.tsx @@ -34,7 +34,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => { const isAdmin = allowPermissions( [EUserPermissions.ADMIN], EUserPermissionsLevel.PROJECT, - currentProjectDetails?.workspace_detail?.slug, + workspaceSlug?.toString(), currentProjectDetails?.id ); // form info @@ -176,7 +176,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {

toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)} disabled={!isAdmin} size="md" diff --git a/web/core/components/workspace/sidebar/project-navigation.tsx b/web/core/components/workspace/sidebar/project-navigation.tsx index e248f27c8..9e7cde6a2 100644 --- a/web/core/components/workspace/sidebar/project-navigation.tsx +++ b/web/core/components/workspace/sidebar/project-navigation.tsx @@ -38,13 +38,13 @@ export const ProjectNavigation: FC = observer((props) => { // store hooks const { t } = useTranslation(); const { sidebarCollapsed: isSidebarCollapsed, toggleSidebar } = useAppTheme(); - const { getProjectById } = useProject(); + const { getPartialProjectById } = useProject(); const { isMobile } = usePlatformOS(); const { allowPermissions } = useUserPermissions(); // pathname const pathname = usePathname(); // derived values - const project = getProjectById(projectId); + const project = getPartialProjectById(projectId); // handlers const handleProjectClick = () => { if (window.innerWidth < 768) { diff --git a/web/core/components/workspace/sidebar/projects-list-item.tsx b/web/core/components/workspace/sidebar/projects-list-item.tsx index d0f42daf4..f105fb72a 100644 --- a/web/core/components/workspace/sidebar/projects-list-item.tsx +++ b/web/core/components/workspace/sidebar/projects-list-item.tsx @@ -52,7 +52,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme(); const { t } = useTranslation(); const { setTrackElement } = useEventTracker(); - const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); + const { addProjectToFavorites, removeProjectFromFavorites, getPartialProjectById } = useProject(); const { isMobile } = usePlatformOS(); const { allowPermissions } = useUserPermissions(); // states @@ -70,7 +70,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId: URLProjectId } = useParams(); // derived values - const project = getProjectById(projectId); + const project = getPartialProjectById(projectId); // auth const isAdmin = allowPermissions( [EUserPermissions.ADMIN], @@ -337,7 +337,8 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { placement="bottom-start" useCaptureForOutsideClick > - {isAuthorized && ( + {/* TODO: Removed is_favorite logic due to the optimization in projects API */} + {/* {isAuthorized && ( @@ -350,7 +351,7 @@ export const SidebarProjectsListItem: React.FC = observer((props) => { {project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")} - )} + )} */} {/* publish project settings */} {isAdmin && ( @@ -359,20 +360,10 @@ export const SidebarProjectsListItem: React.FC = observer((props) => {
-
{project.anchor ? t("publish_settings") : t("publish")}
+
{t("publish_settings")}
)} - {/* {isAuthorized && ( - - -
- - Draft issues -
- -
- )} */} diff --git a/web/core/components/workspace/sidebar/projects-list.tsx b/web/core/components/workspace/sidebar/projects-list.tsx index d5737fafb..e929bccb0 100644 --- a/web/core/components/workspace/sidebar/projects-list.tsx +++ b/web/core/components/workspace/sidebar/projects-list.tsx @@ -9,7 +9,7 @@ import { Briefcase, ChevronRight, Plus } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; import { useTranslation } from "@plane/i18n"; // ui -import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; +import { Loader, TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; // components import { CreateProjectModal } from "@/components/project"; import { SidebarProjectsListItem } from "@/components/workspace"; @@ -41,7 +41,7 @@ export const SidebarProjectsList: FC = observer(() => { const { setTrackElement } = useEventTracker(); const { allowPermissions } = useUserPermissions(); - const { getProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); + const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); // router params const { workspaceSlug } = useParams(); const pathname = usePathname(); @@ -72,7 +72,7 @@ export const SidebarProjectsList: FC = observer(() => { const joinedProjectsList: TProject[] = []; joinedProjects.map((projectId) => { - const projectDetails = getProjectById(projectId); + const projectDetails = getPartialProjectById(projectId); if (projectDetails) joinedProjectsList.push(projectDetails); }); @@ -232,6 +232,13 @@ export const SidebarProjectsList: FC = observer(() => { leaveFrom="transform scale-100 opacity-100" leaveTo="transform scale-95 opacity-0" > + {loader === "init-loader" && ( + + {Array.from({ length: 4 }).map((_, index) => ( + + ))} + + )} {isAllProjectsListOpen && ( = observer((props) => { if (projectExists && projectId && hasPermissionToCurrentProject === false) return ; // check if the project info is not found. - if (!loader && !projectExists && projectId && !!hasPermissionToCurrentProject === false) + if (loader === "loaded" && !projectExists && projectId && !!hasPermissionToCurrentProject === false) return (
= observer((props) const { resolvedTheme } = useTheme(); // store hooks const { signOut, data: currentUser } = useUser(); - const { fetchProjects } = useProject(); + const { fetchPartialProjects } = useProject(); const { fetchFavorite } = useFavorite(); const { workspace: { fetchWorkspaceMembers }, @@ -74,8 +74,8 @@ export const WorkspaceAuthWrapper: FC = observer((props) // fetching workspace projects useSWR( - workspaceSlug && currentWorkspace ? `WORKSPACE_PROJECTS_${workspaceSlug}` : null, - workspaceSlug && currentWorkspace ? () => fetchProjects(workspaceSlug.toString()) : null, + workspaceSlug && currentWorkspace ? `WORKSPACE_PARTIAL_PROJECTS_${workspaceSlug}` : null, + workspaceSlug && currentWorkspace ? () => fetchPartialProjects(workspaceSlug.toString()) : null, { revalidateIfStale: false, revalidateOnFocus: false } ); // fetch workspace members diff --git a/web/core/services/project/project.service.ts b/web/core/services/project/project.service.ts index f9c2af8b6..fc8ed9442 100644 --- a/web/core/services/project/project.service.ts +++ b/web/core/services/project/project.service.ts @@ -1,8 +1,14 @@ -import type { GithubRepositoriesResponse, ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; +import type { + GithubRepositoriesResponse, + ISearchIssueResponse, + TProjectAnalyticsCount, + TProjectAnalyticsCountParams, + TProjectIssuesSearchParams, +} from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // plane web types -import { TProject } from "@/plane-web/types"; +import { TProject, TPartialProject } from "@/plane-web/types"; // services import { APIService } from "@/services/api.service"; @@ -31,7 +37,7 @@ export class ProjectService extends APIService { }); } - async getProjects(workspaceSlug: string): Promise { + async getProjectsLite(workspaceSlug: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/`) .then((response) => response?.data) .catch((error) => { @@ -39,6 +45,14 @@ export class ProjectService extends APIService { }); } + async getProjects(workspaceSlug: string): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/details/`) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async getProject(workspaceSlug: string, projectId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`) .then((response) => response?.data) @@ -47,6 +61,19 @@ export class ProjectService extends APIService { }); } + async getProjectAnalyticsCount( + workspaceSlug: string, + params?: TProjectAnalyticsCountParams + ): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/project-stats/`, { + params, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async updateProject(workspaceSlug: string, projectId: string, data: Partial): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`, data) .then((response) => response?.data) @@ -63,14 +90,6 @@ export class ProjectService extends APIService { }); } - async fetchProjectEpicProperties(workspaceSlug: string, projectId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/epic-properties/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - async setProjectView( workspaceSlug: string, projectId: string, diff --git a/web/core/store/member/project-member.store.ts b/web/core/store/member/project-member.store.ts index 22a20ba7c..e61516182 100644 --- a/web/core/store/member/project-member.store.ts +++ b/web/core/store/member/project-member.store.ts @@ -1,15 +1,11 @@ import set from "lodash/set"; import sortBy from "lodash/sortBy"; +import uniq from "lodash/uniq"; +import update from "lodash/update"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; // types -import { - IProjectBulkAddFormData, - IProjectMember, - IProjectMemberLite, - IProjectMembership, - IUserLite, -} from "@plane/types"; +import { IProjectBulkAddFormData, IProjectMember, IProjectMembership, IUserLite } from "@plane/types"; // plane-web constants import { EUserPermissions } from "@/plane-web/constants/user-permissions"; // services @@ -166,8 +162,11 @@ export class ProjectMemberStore implements IProjectMemberStore { set(this.projectMemberMap, [projectId, member.member], member); }); }); - this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members.concat( - data.members as unknown as IProjectMemberLite[] + update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) => + uniq([...memberIds, ...data.members.map((m) => m.member_id)]) + ); + this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.concat( + data.members.map((m) => m.member_id) ); return response; @@ -218,8 +217,8 @@ export class ProjectMemberStore implements IProjectMemberStore { runInAction(() => { delete this.projectMemberMap?.[projectId]?.[userId]; }); - this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members.filter( - (member) => member.id !== userId + this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.filter( + (memberId) => memberId !== userId ); }); }; diff --git a/web/core/store/project/project.store.ts b/web/core/store/project/project.store.ts index 119cb01b2..33a371004 100644 --- a/web/core/store/project/project.store.ts +++ b/web/core/store/project/project.store.ts @@ -1,11 +1,15 @@ +import cloneDeep from "lodash/cloneDeep"; import set from "lodash/set"; import sortBy from "lodash/sortBy"; +import update from "lodash/update"; import { observable, action, computed, makeObservable, runInAction } from "mobx"; import { computedFn } from "mobx-utils"; +// plane imports +import { TFetchStatus, TLoader, TProjectAnalyticsCount, TProjectAnalyticsCountParams } from "@plane/types"; // helpers import { orderProjects, shouldFilterProject } from "@/helpers/project.helper"; // services -import { TProject } from "@/plane-web/types/projects"; +import { TProject, TPartialProject } from "@/plane-web/types/projects"; import { IssueLabelService, IssueService } from "@/services/issue"; import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project"; // store @@ -16,10 +20,10 @@ type ProjectOverviewCollapsible = "links" | "attachments"; export interface IProjectStore { // observables isUpdatingProject: boolean; - loader: boolean; - projectMap: { - [projectId: string]: TProject; // projectId: project Info - }; + loader: TLoader; + fetchStatus: TFetchStatus; + projectMap: Record; // projectId: project info + projectAnalyticsCountMap: Record; // projectId: project analytics count // computed filteredProjectIds: string[] | undefined; workspaceProjectIds: string[] | undefined; @@ -30,7 +34,9 @@ export interface IProjectStore { currentProjectDetails: TProject | undefined; // actions getProjectById: (projectId: string | undefined | null) => TProject | undefined; + getPartialProjectById: (projectId: string | undefined | null) => TPartialProject | undefined; getProjectIdentifierById: (projectId: string | undefined | null) => string; + getProjectAnalyticsCountById: (projectId: string | undefined | null) => TProjectAnalyticsCount | undefined; // collapsible openCollapsibleSection: ProjectOverviewCollapsible[]; lastCollapsibleAction: ProjectOverviewCollapsible | null; @@ -40,8 +46,13 @@ export interface IProjectStore { toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void; // fetch actions + fetchPartialProjects: (workspaceSlug: string) => Promise; fetchProjects: (workspaceSlug: string) => Promise; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; + fetchProjectAnalyticsCount: ( + workspaceSlug: string, + params?: TProjectAnalyticsCountParams + ) => Promise; // favorites actions addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise; @@ -59,10 +70,10 @@ export interface IProjectStore { export class ProjectStore implements IProjectStore { // observables isUpdatingProject: boolean = false; - loader: boolean = false; - projectMap: { - [projectId: string]: TProject; // projectId: project Info - } = {}; + loader: TLoader = "init-loader"; + fetchStatus: TFetchStatus = undefined; + projectMap: Record = {}; + projectAnalyticsCountMap: Record = {}; openCollapsibleSection: ProjectOverviewCollapsible[] = []; lastCollapsibleAction: ProjectOverviewCollapsible | null = null; @@ -80,7 +91,9 @@ export class ProjectStore implements IProjectStore { // observables isUpdatingProject: observable, loader: observable.ref, + fetchStatus: observable.ref, projectMap: observable, + projectAnalyticsCountMap: observable, openCollapsibleSection: observable.ref, lastCollapsibleAction: observable.ref, // computed @@ -92,8 +105,10 @@ export class ProjectStore implements IProjectStore { joinedProjectIds: computed, favoriteProjectIds: computed, // fetch actions + fetchPartialProjects: action, fetchProjects: action, fetchProjectDetails: action, + fetchProjectAnalyticsCount: action, // favorites actions addProjectToFavorites: action, removeProjectFromFavorites: action, @@ -241,6 +256,31 @@ export class ProjectStore implements IProjectStore { } }; + /** + * get Workspace projects partial data using workspace slug + * @param workspaceSlug + * @returns Promise + * + */ + fetchPartialProjects = async (workspaceSlug: string) => { + try { + this.loader = "init-loader"; + const projectsResponse = await this.projectService.getProjectsLite(workspaceSlug); + runInAction(() => { + projectsResponse.forEach((project) => { + update(this.projectMap, [project.id], (p) => ({ ...p, ...project })); + }); + this.loader = "loaded"; + this.fetchStatus = "partial"; + }); + return projectsResponse; + } catch (error) { + console.log("Failed to fetch project from workspace store"); + this.loader = "loaded"; + throw error; + } + }; + /** * get Workspace projects using workspace slug * @param workspaceSlug @@ -249,18 +289,23 @@ export class ProjectStore implements IProjectStore { */ fetchProjects = async (workspaceSlug: string) => { try { - this.loader = true; + if (this.workspaceProjectIds && this.workspaceProjectIds.length > 0) { + this.loader = "mutation"; + } else { + this.loader = "init-loader"; + } const projectsResponse = await this.projectService.getProjects(workspaceSlug); runInAction(() => { projectsResponse.forEach((project) => { - set(this.projectMap, [project.id], project); + update(this.projectMap, [project.id], (p) => ({ ...p, ...project })); }); - this.loader = false; + this.loader = "loaded"; + this.fetchStatus = "complete"; }); return projectsResponse; } catch (error) { console.log("Failed to fetch project from workspace store"); - this.loader = false; + this.loader = "loaded"; throw error; } }; @@ -275,7 +320,7 @@ export class ProjectStore implements IProjectStore { try { const response = await this.projectService.getProject(workspaceSlug, projectId); runInAction(() => { - set(this.projectMap, [projectId], response); + update(this.projectMap, [projectId], (p) => ({ ...p, ...response })); }); return response; } catch (error) { @@ -284,6 +329,30 @@ export class ProjectStore implements IProjectStore { } }; + /** + * Fetches project analytics count using workspace slug and project id + * @param workspaceSlug + * @param params TProjectAnalyticsCountParams + * @returns Promise + */ + fetchProjectAnalyticsCount = async ( + workspaceSlug: string, + params?: TProjectAnalyticsCountParams + ): Promise => { + try { + const response = await this.projectService.getProjectAnalyticsCount(workspaceSlug, params); + runInAction(() => { + for (const analyticsData of response) { + set(this.projectAnalyticsCountMap, [analyticsData.id], analyticsData); + } + }); + return response; + } catch (error) { + console.log("Failed to fetch project analytics count", error); + throw error; + } + }; + /** * Returns project details using project id * @param projectId @@ -294,6 +363,17 @@ export class ProjectStore implements IProjectStore { return projectInfo; }); + /** + * Returns project lite using project id + * This method is used just for type safety + * @param projectId + * @returns TPartialProject | null + */ + getPartialProjectById = computedFn((projectId: string | undefined | null) => { + const projectInfo = this.projectMap[projectId ?? ""] || undefined; + return projectInfo; + }); + /** * Returns project identifier using project id * @param projectId @@ -304,6 +384,16 @@ export class ProjectStore implements IProjectStore { return projectInfo?.identifier; }); + /** + * Returns project analytics count using project id + * @param projectId + * @returns TProjectAnalyticsCount[] + */ + getProjectAnalyticsCountById = computedFn((projectId: string | undefined | null) => { + if (!projectId) return undefined; + return this.projectAnalyticsCountMap?.[projectId]; + }); + /** * Adds project to favorites and updates project favorite status in the store * @param workspaceSlug @@ -415,8 +505,8 @@ export class ProjectStore implements IProjectStore { * @returns Promise */ updateProject = async (workspaceSlug: string, projectId: string, data: Partial) => { + const projectDetails = cloneDeep(this.getProjectById(projectId)); try { - const projectDetails = this.getProjectById(projectId); runInAction(() => { set(this.projectMap, [projectId], { ...projectDetails, ...data }); this.isUpdatingProject = true; @@ -428,9 +518,8 @@ export class ProjectStore implements IProjectStore { return response; } catch (error) { console.log("Failed to create project from project store"); - this.fetchProjects(workspaceSlug); - this.fetchProjectDetails(workspaceSlug, projectId); runInAction(() => { + set(this.projectMap, [projectId], projectDetails); this.isUpdatingProject = false; }); throw error; @@ -454,7 +543,6 @@ export class ProjectStore implements IProjectStore { }); } catch (error) { console.log("Failed to delete project from project store"); - this.fetchProjects(workspaceSlug); throw error; } }; @@ -476,8 +564,6 @@ export class ProjectStore implements IProjectStore { }) .catch((error) => { console.log("Failed to archive project from project store"); - this.fetchProjects(workspaceSlug); - this.fetchProjectDetails(workspaceSlug, projectId); throw error; }); }; @@ -498,8 +584,6 @@ export class ProjectStore implements IProjectStore { }) .catch((error) => { console.log("Failed to restore project from project store"); - this.fetchProjects(workspaceSlug); - this.fetchProjectDetails(workspaceSlug, projectId); throw error; }); }; diff --git a/web/helpers/project.helper.ts b/web/helpers/project.helper.ts index 9b370a217..5f0312e84 100644 --- a/web/helpers/project.helper.ts +++ b/web/helpers/project.helper.ts @@ -77,8 +77,8 @@ export const shouldFilterProject = ( if (filterKey === "lead" && filters.lead && filters.lead.length > 0) fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`); if (filterKey === "members" && filters.members && filters.members.length > 0) { - const memberIds = project.members.map((member) => member.member_id); - fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); + const memberIds = project.members; + fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds?.includes(memberId)); } if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) { const createdDate = getDate(project.created_at); @@ -109,8 +109,8 @@ export const orderProjects = (projects: TProject[], orderByKey: TProjectOrderByO if (orderByKey === "-name") orderedProjects = sortBy(projects, [(p) => p.name.toLowerCase()]).reverse(); if (orderByKey === "created_at") orderedProjects = sortBy(projects, [(p) => p.created_at]); if (orderByKey === "-created_at") orderedProjects = sortBy(projects, [(p) => !p.created_at]); - if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]); - if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]).reverse(); + if (orderByKey === "members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]); + if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]).reverse(); return orderedProjects; };