[WEB-3251] improvement: optimize projects API (#6542)

This commit is contained in:
Prateek Shourya 2025-02-04 16:02:07 +05:30 committed by GitHub
parent c14fb814c4
commit 10b5c625ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 535 additions and 316 deletions

View file

@ -90,16 +90,7 @@ class ProjectLiteSerializer(BaseSerializer):
class ProjectListSerializer(DynamicBaseSerializer): 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) 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) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True) sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True) member_role = serializers.IntegerField(read_only=True)
@ -113,14 +104,9 @@ class ProjectListSerializer(DynamicBaseSerializer):
if project_members is not None: if project_members is not None:
# Filter members by the project ID # Filter members by the project ID
return [ return [
{ member.member_id
"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,
}
for member in project_members for member in project_members
if member.is_active and not member.member.is_bot
] ]
return [] return []
@ -134,9 +120,6 @@ class ProjectDetailSerializer(BaseSerializer):
default_assignee = UserLiteSerializer(read_only=True) default_assignee = UserLiteSerializer(read_only=True)
project_lead = UserLiteSerializer(read_only=True) project_lead = UserLiteSerializer(read_only=True)
is_favorite = serializers.BooleanField(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) is_member = serializers.BooleanField(read_only=True)
sort_order = serializers.FloatField(read_only=True) sort_order = serializers.FloatField(read_only=True)
member_role = serializers.IntegerField(read_only=True) member_role = serializers.IntegerField(read_only=True)

View file

@ -7,6 +7,7 @@ from plane.app.views import (
SavedAnalyticEndpoint, SavedAnalyticEndpoint,
ExportAnalyticsEndpoint, ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
) )
@ -43,4 +44,9 @@ urlpatterns = [
DefaultAnalyticsEndpoint.as_view(), DefaultAnalyticsEndpoint.as_view(),
name="default-analytics", name="default-analytics",
), ),
path(
"workspaces/<str:slug>/project-stats/",
ProjectStatsEndpoint.as_view(),
name="project-analytics",
),
] ]

View file

@ -23,6 +23,11 @@ urlpatterns = [
ProjectViewSet.as_view({"get": "list", "post": "create"}), ProjectViewSet.as_view({"get": "list", "post": "create"}),
name="project", name="project",
), ),
path(
"workspaces/<str:slug>/projects/details/",
ProjectViewSet.as_view({"get": "list_detail"}),
name="project",
),
path( path(
"workspaces/<str:slug>/projects/<uuid:pk>/", "workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectViewSet.as_view( ProjectViewSet.as_view(

View file

@ -190,6 +190,7 @@ from .analytic.base import (
SavedAnalyticEndpoint, SavedAnalyticEndpoint,
ExportAnalyticsEndpoint, ExportAnalyticsEndpoint,
DefaultAnalyticsEndpoint, DefaultAnalyticsEndpoint,
ProjectStatsEndpoint,
) )
from .notification.base import ( from .notification.base import (

View file

@ -3,7 +3,7 @@ from django.db.models import Count, F, Sum, Q
from django.db.models.functions import ExtractMonth from django.db.models.functions import ExtractMonth
from django.utils import timezone from django.utils import timezone
from django.db.models.functions import Concat 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 from django.db import models
# Third party imports # Third party imports
@ -15,7 +15,16 @@ from plane.app.permissions import WorkSpaceAdminPermission
from plane.app.serializers import AnalyticViewSerializer from plane.app.serializers import AnalyticViewSerializer
from plane.app.views.base import BaseAPIView, BaseViewSet from plane.app.views.base import BaseAPIView, BaseViewSet
from plane.bgtasks.analytic_plot_export import analytic_export_task 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.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
@ -441,3 +450,75 @@ class DefaultAnalyticsEndpoint(BaseAPIView):
}, },
status=status.HTTP_200_OK, 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)

View file

@ -6,7 +6,7 @@ import json
# Django imports # Django imports
from django.db import IntegrityError 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 from django.core.serializers.json import DjangoJSONEncoder
# Third Party imports # Third Party imports
@ -25,12 +25,9 @@ from plane.app.serializers import (
from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE from plane.app.permissions import ProjectMemberPermission, allow_permission, ROLE
from plane.db.models import ( from plane.db.models import (
UserFavorite, UserFavorite,
Cycle,
Intake, Intake,
DeployBoard, DeployBoard,
IssueUserProperty, IssueUserProperty,
Issue,
Module,
Project, Project,
ProjectIdentifier, ProjectIdentifier,
ProjectMember, 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( .annotate(
member_role=ProjectMember.objects.filter( member_role=ProjectMember.objects.filter(
project_id=OuterRef("pk"), project_id=OuterRef("pk"),
@ -133,7 +110,7 @@ class ProjectViewSet(BaseViewSet):
@allow_permission( @allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" 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] fields = [field for field in request.GET.get("fields", "").split(",") if field]
projects = self.get_queryset().order_by("sort_order", "name") projects = self.get_queryset().order_by("sort_order", "name")
if WorkspaceMember.objects.filter( if WorkspaceMember.objects.filter(
@ -170,6 +147,76 @@ class ProjectViewSet(BaseViewSet):
).data ).data
return Response(projects, status=status.HTTP_200_OK) 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( @allow_permission(
allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE" allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE"
) )
@ -182,58 +229,6 @@ class ProjectViewSet(BaseViewSet):
) )
.filter(archived_at__isnull=True) .filter(archived_at__isnull=True)
.filter(pk=pk) .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() ).first()
if project is None: if project is None:

View file

@ -24,3 +24,5 @@ export type TLogoProps = {
}; };
export type TNameDescriptionLoader = "submitting" | "submitted" | "saved"; export type TNameDescriptionLoader = "submitting" | "submitted" | "saved";
export type TFetchStatus = "partial" | "complete" | undefined;

View file

@ -10,58 +10,65 @@ import type {
} from ".."; } from "..";
import { TUserPermissions } from "../enums"; import { TUserPermissions } from "../enums";
export interface IProject { export interface IPartialProject {
archive_in: number; id: string;
name: string;
identifier: string;
sort_order: number | null;
logo_props: TLogoProps;
is_member: boolean;
archived_at: string | null; archived_at: string | null;
archived_issues: number; workspace: IWorkspace | string;
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;
cycle_view: boolean; cycle_view: boolean;
issue_views_view: boolean; issue_views_view: boolean;
module_view: boolean; module_view: boolean;
page_view: boolean; page_view: boolean;
inbox_view: boolean; inbox_view: boolean;
default_assignee: IUser | string | null; project_lead?: IUserLite | string | null;
default_state: string | null; // Timestamps
description: string; created_at?: Date;
draft_issues: number; updated_at?: Date;
draft_sub_issues: number; // actor
estimate: string | null; created_by?: string;
guest_view_all_features: boolean; updated_by?: string;
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;
} }
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<IProject, "id"> & {
total_issues?: number;
completed_issues?: number;
total_cycles?: number;
total_members?: number;
total_modules?: number;
};
export interface IProjectLite { export interface IProjectLite {
id: string; id: string;
name: string; name: string;

View file

@ -33,7 +33,7 @@ const AnalyticsPage = observer(() => {
<PageHead title={pageTitle} /> <PageHead title={pageTitle} />
{workspaceProjectIds && ( {workspaceProjectIds && (
<> <>
{workspaceProjectIds.length > 0 || loader ? ( {workspaceProjectIds.length > 0 || loader === "init-loader" ? (
<div className="flex h-full flex-col overflow-hidden bg-custom-background-100"> <div className="flex h-full flex-col overflow-hidden bg-custom-background-100">
<Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}> <Tab.Group as={Fragment} defaultIndex={analytics_tab === "custom" ? 1 : 0}>
<Header variant={EHeaderVariant.SECONDARY}> <Header variant={EHeaderVariant.SECONDARY}>

View file

@ -65,7 +65,7 @@ export const ProjectArchivesHeader: FC<TProps> = observer((props: TProps) => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"

View file

@ -35,7 +35,7 @@ export const ProjectArchivedIssueDetailsHeader = observer(() => {
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs isLoading={loader}> <Breadcrumbs isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"

View file

@ -163,7 +163,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={

View file

@ -32,7 +32,7 @@ export const CyclesListHeader: FC = observer(() => {
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"

View file

@ -22,6 +22,7 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// plane web // plane web
import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs"; import { ProjectBreadcrumb } from "@/plane-web/components/breadcrumbs";
// FIXME: Deprecated. Remove it
export const ProjectDraftIssueHeader: FC = observer(() => { export const ProjectDraftIssueHeader: FC = observer(() => {
// router // router
const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string }; const { workspaceSlug, projectId } = useParams() as { workspaceSlug: string; projectId: string };
@ -83,17 +84,13 @@ export const ProjectDraftIssueHeader: FC = observer(() => {
[workspaceSlug, projectId, updateFilters] [workspaceSlug, projectId, updateFilters]
); );
const issueCount = currentProjectDetails const issueCount = undefined;
? !issueFilters?.displayFilters?.sub_issue && currentProjectDetails.draft_sub_issues
? currentProjectDetails.draft_issues - currentProjectDetails.draft_sub_issues
: currentProjectDetails.draft_issues
: undefined;
return ( return (
<div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4"> <div className="relative z-10 flex h-[3.75rem] w-full flex-shrink-0 flex-row items-center justify-between gap-x-2 gap-y-4 bg-custom-sidebar-background-100 p-4">
<div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap"> <div className="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Breadcrumbs isLoading={loader}> <Breadcrumbs isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem

View file

@ -29,7 +29,7 @@ export const ProjectIssueDetailsHeader = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div> <div>
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem

View file

@ -162,7 +162,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
/> />
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={

View file

@ -22,7 +22,7 @@ export const ModulesListHeader: React.FC = observer(() => {
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { loader } = useProject(); const { loader } = useProject();
// auth // auth
const canUserCreateModule = allowPermissions( const canUserCreateModule = allowPermissions(
@ -34,8 +34,8 @@ export const ModulesListHeader: React.FC = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div> <div>
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={<BreadcrumbLink label="Modules" icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />} />} link={<BreadcrumbLink label="Modules" icon={<DiceIcon className="h-4 w-4 text-custom-text-300" />} />}

View file

@ -63,7 +63,7 @@ export const PageDetailsHeader = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div> <div>
<Breadcrumbs isLoading={loader}> <Breadcrumbs isLoading={loader === "init-loader"}>
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"
link={ link={

View file

@ -57,7 +57,7 @@ export const PagesListHeader = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div> <div>
<Breadcrumbs isLoading={loader}> <Breadcrumbs isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"

View file

@ -29,7 +29,7 @@ export const ProjectSettingHeader: FC = observer(() => {
<Header.LeftItem> <Header.LeftItem>
<div> <div>
<div className="z-50"> <div className="z-50">
<Breadcrumbs onBack={router.back} isLoading={loader}> <Breadcrumbs onBack={router.back} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<div className="hidden sm:hidden md:block lg:block"> <div className="hidden sm:hidden md:block lg:block">
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem

View file

@ -141,7 +141,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
return ( return (
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs isLoading={loader}> <Breadcrumbs isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"

View file

@ -21,7 +21,7 @@ export const ProjectViewsHeader = observer(() => {
<> <>
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<Breadcrumbs isLoading={loader}> <Breadcrumbs isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem
type="text" type="text"

View file

@ -52,7 +52,7 @@ export const IssuesHeader = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Breadcrumbs onBack={() => router.back()} isLoading={loader}> <Breadcrumbs onBack={() => router.back()} isLoading={loader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem

View file

@ -77,7 +77,7 @@ const ProjectAttributes: FC<Props> = (props) => {
return ( return (
<div className="flex-shrink-0 h-7" tabIndex={getIndex("lead")}> <div className="flex-shrink-0 h-7" tabIndex={getIndex("lead")}>
<MemberDropdown <MemberDropdown
value={value} value={value ?? null}
onChange={(lead) => onChange(lead === value ? null : lead)} onChange={(lead) => onChange(lead === value ? null : lead)}
placeholder={t("lead")} placeholder={t("lead")}
multiple={false} multiple={false}

View file

@ -36,7 +36,7 @@ export const ProjectInboxHeader: FC = observer(() => {
<Header> <Header>
<Header.LeftItem> <Header.LeftItem>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Breadcrumbs isLoading={currentProjectDetailsLoader}> <Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<ProjectBreadcrumb /> <ProjectBreadcrumb />
<Breadcrumbs.BreadcrumbItem <Breadcrumbs.BreadcrumbItem

View file

@ -20,8 +20,8 @@ type BlockData = {
id: string; id: string;
name: string; name: string;
sort_order: number | null; sort_order: number | null;
start_date: string | undefined | null; start_date?: string | undefined | null;
target_date: string | undefined | null; target_date?: string | undefined | null;
}; };
export interface IBaseTimelineStore { export interface IBaseTimelineStore {

View file

@ -1,3 +1,5 @@
import { IProject } from "@plane/types"; import { IPartialProject, IProject } from "@plane/types";
export type TProject = IProject; export type TPartialProject = IPartialProject;
export type TProject = TPartialProject & IProject;

View file

@ -1,6 +1,8 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// icons // icons
import { Contrast, LayoutGrid, Users } from "lucide-react"; import { Contrast, LayoutGrid, Users, Loader as Spinner } from "lucide-react";
// plane imports
import { Loader } from "@plane/ui";
// components // components
import { Logo } from "@/components/common"; import { Logo } from "@/components/common";
// helpers // helpers
@ -10,19 +12,25 @@ import { useProject } from "@/hooks/store";
type Props = { type Props = {
projectIds: string[]; projectIds: string[];
isLoading: boolean;
isUpdating: boolean;
}; };
export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((props) => { export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((props) => {
const { projectIds } = props; const { projectIds, isLoading, isUpdating } = props;
// store hooks
const { getProjectById } = useProject(); const { getProjectById, getProjectAnalyticsCountById } = useProject();
return ( return (
<div className="relative flex flex-col gap-4 h-full"> <div className="relative flex flex-col gap-4 h-full">
<h4 className="font-medium">Selected Projects</h4> <div className="flex gap-2 items-center">
<h4 className="font-medium">Selected Projects</h4>
{isUpdating && <Spinner className="animate-spin size-3" />}
</div>
<div className="relative space-y-6 overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"> <div className="relative space-y-6 overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md">
{projectIds.map((projectId) => { {projectIds.map((projectId) => {
const project = getProjectById(projectId); const project = getProjectById(projectId);
const projectAnalyticsCount = getProjectAnalyticsCountById(projectId);
if (!project) return; if (!project) return;
@ -38,27 +46,37 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
</h5> </h5>
</div> </div>
<div className="mt-4 w-full space-y-3 px-2"> <div className="mt-4 w-full space-y-3 px-2">
<div className="flex items-center justify-between gap-2 text-xs"> {isLoading ? (
<div className="flex items-center gap-2"> <Loader className="space-y-3">
<Users className="text-custom-text-200" size={14} strokeWidth={2} /> <Loader.Item height="16px" />
<h6>Total members</h6> <Loader.Item height="16px" />
</div> <Loader.Item height="16px" />
<span className="text-custom-text-200">{project.total_members}</span> </Loader>
</div> ) : (
<div className="flex items-center justify-between gap-2 text-xs"> <>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-2 text-xs">
<Contrast className="text-custom-text-200" size={14} strokeWidth={2} /> <div className="flex items-center gap-2">
<h6>Total cycles</h6> <Users className="text-custom-text-200" size={14} strokeWidth={2} />
</div> <h6>Total members</h6>
<span className="text-custom-text-200">{project.total_cycles}</span> </div>
</div> <span className="text-custom-text-200">{projectAnalyticsCount?.total_members}</span>
<div className="flex items-center justify-between gap-2 text-xs"> </div>
<div className="flex items-center gap-2"> <div className="flex items-center justify-between gap-2 text-xs">
<LayoutGrid className="text-custom-text-200" size={14} strokeWidth={2} /> <div className="flex items-center gap-2">
<h6>Total modules</h6> <Contrast className="text-custom-text-200" size={14} strokeWidth={2} />
</div> <h6>Total cycles</h6>
<span className="text-custom-text-200">{project.total_modules}</span> </div>
</div> <span className="text-custom-text-200">{projectAnalyticsCount?.total_cycles}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<LayoutGrid className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total modules</h6>
</div>
<span className="text-custom-text-200">{projectAnalyticsCount?.total_modules}</span>
</div>
</>
)}
</div> </div>
</div> </div>
); );

View file

@ -3,7 +3,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { mutate } from "swr"; import useSWR, { mutate } from "swr";
// icons // icons
import { CalendarDays, Download, RefreshCw } from "lucide-react"; import { CalendarDays, Download, RefreshCw } from "lucide-react";
// types // types
@ -30,19 +30,27 @@ type Props = {
const analyticsService = new AnalyticsService(); const analyticsService = new AnalyticsService();
const PROJECT_ANALYTICS_COUNT_PARAMS = {
fields: "total_members,total_cycles,total_modules",
};
export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => { export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
const { analytics, params, isProjectLevel = false } = props; const { analytics, params, isProjectLevel = false } = props;
// router // router
const { workspaceSlug, projectId, cycleId, moduleId } = useParams(); const { workspaceSlug, projectId, cycleId, moduleId } = useParams();
// store hooks // store hooks
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { workspaceProjectIds, getProjectById } = useProject(); const { workspaceProjectIds, getProjectById, fetchProjectAnalyticsCount } = useProject();
const { getWorkspaceById } = useWorkspace(); const { getWorkspaceById } = useWorkspace();
const { fetchCycleDetails, getCycleById } = useCycle(); const { fetchCycleDetails, getCycleById } = useCycle();
const { fetchModuleDetails, getModuleById } = useModule(); 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 = () => { const trackExportAnalytics = () => {
if (!currentUser) return; if (!currentUser) return;
@ -171,7 +179,11 @@ export const CustomAnalyticsSidebar: React.FC<Props> = observer((props) => {
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}> <div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<> <>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && ( {!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} /> <CustomAnalyticsSidebarProjectsList
projectIds={selectedProjects}
isLoading={isProjectAnalyticsLoading}
isUpdating={isProjectAnalyticsUpdating}
/>
)} )}
<CustomAnalyticsSidebarHeader /> <CustomAnalyticsSidebarHeader />
</> </>

View file

@ -16,7 +16,7 @@ export const JoinProject: React.FC = () => {
const [isJoiningProject, setIsJoiningProject] = useState(false); const [isJoiningProject, setIsJoiningProject] = useState(false);
// store hooks // store hooks
const { joinProject } = useUserPermissions(); const { joinProject } = useUserPermissions();
const { fetchProjects } = useProject(); const { fetchProjectDetails } = useProject();
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
@ -26,7 +26,7 @@ export const JoinProject: React.FC = () => {
setIsJoiningProject(true); setIsJoiningProject(true);
joinProject(workspaceSlug.toString(), projectId.toString()) joinProject(workspaceSlug.toString(), projectId.toString())
.then(() => fetchProjects(workspaceSlug.toString())) .then(() => fetchProjectDetails(workspaceSlug.toString(), projectId.toString()))
.finally(() => setIsJoiningProject(false)); .finally(() => setIsJoiningProject(false));
}; };

View file

@ -2,6 +2,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { ArchiveRestore } from "lucide-react"; import { ArchiveRestore } from "lucide-react";
// types // types
import { IProject } from "@plane/types"; import { IProject } from "@plane/types";
@ -23,6 +24,8 @@ const initialValues: Partial<IProject> = { archive_in: 1 };
export const AutoArchiveAutomation: React.FC<Props> = observer((props) => { export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
const { handleChange } = props; const { handleChange } = props;
// router
const { workspaceSlug } = useParams();
// states // states
const [monthModal, setmonthModal] = useState(false); const [monthModal, setmonthModal] = useState(false);
// store hooks // store hooks
@ -33,9 +36,9 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
const isAdmin = allowPermissions( const isAdmin = allowPermissions(
[EUserPermissions.ADMIN], [EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT, EUserPermissionsLevel.PROJECT,
currentProjectDetails?.workspace_detail?.slug, workspaceSlug?.toString(),
currentProjectDetails?.id currentProjectDetails?.id
); );
return ( return (
<> <>

View file

@ -2,6 +2,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons // icons
import { ArchiveX } from "lucide-react"; import { ArchiveX } from "lucide-react";
// types // types
@ -22,6 +23,8 @@ type Props = {
export const AutoCloseAutomation: React.FC<Props> = observer((props) => { export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
const { handleChange } = props; const { handleChange } = props;
// router
const { workspaceSlug } = useParams();
// states // states
const [monthModal, setmonthModal] = useState(false); const [monthModal, setmonthModal] = useState(false);
// store hooks // store hooks
@ -59,7 +62,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
const isAdmin = allowPermissions( const isAdmin = allowPermissions(
[EUserPermissions.ADMIN], [EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT, EUserPermissionsLevel.PROJECT,
currentProjectDetails?.workspace_detail?.slug, workspaceSlug?.toString(),
currentProjectDetails?.id currentProjectDetails?.id
); );

View file

@ -93,7 +93,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
id="close_in" id="close_in"
name="close_in" name="close_in"
type="number" type="number"
value={value.toString()} value={value?.toString()}
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.close_in)} hasError={Boolean(errors.close_in)}
@ -127,7 +127,7 @@ export const SelectMonthModal: React.FC<Props> = ({ type, initialValues, isOpen,
id="archive_in" id="archive_in"
name="archive_in" name="archive_in"
type="number" type="number"
value={value.toString()} value={value?.toString()}
onChange={onChange} onChange={onChange}
ref={ref} ref={ref}
hasError={Boolean(errors.archive_in)} hasError={Boolean(errors.archive_in)}

View file

@ -16,7 +16,14 @@ import { PROJECT_BACKGROUND_COLORS } from "@/constants/dashboard";
// helpers // helpers
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
// hooks // hooks
import { useEventTracker, useDashboard, useProject, useCommandPalette, useUserPermissions } from "@/hooks/store"; import {
useEventTracker,
useDashboard,
useProject,
useCommandPalette,
useUserPermissions,
useMember,
} from "@/hooks/store";
// plane web constants // plane web constants
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions"; import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants/user-permissions";
@ -31,6 +38,8 @@ const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
const { projectId, workspaceSlug } = props; const { projectId, workspaceSlug } = props;
// store hooks // store hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { getUserDetails } = useMember();
// derived values
const projectDetails = getProjectById(projectId); const projectDetails = getProjectById(projectId);
const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)]; const randomBgColor = PROJECT_BACKGROUND_COLORS[Math.floor(Math.random() * PROJECT_BACKGROUND_COLORS.length)];
@ -52,13 +61,13 @@ const ProjectListItem: React.FC<ProjectListItemProps> = observer((props) => {
</h6> </h6>
<div className="mt-2"> <div className="mt-2">
<AvatarGroup> <AvatarGroup>
{projectDetails.members?.map((member) => ( {projectDetails.members?.map((memberId) => {
<Avatar const userDetails = getUserDetails(memberId);
key={member.member_id} if (!userDetails) return null;
src={getFileURL(member.member__avatar_url)} return (
name={member.member__display_name} <Avatar key={userDetails.id} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
/> );
))} })}
</AvatarGroup> </AvatarGroup>
</div> </div>
</div> </div>

View file

@ -79,7 +79,7 @@ export const RecentActivityWidget: React.FC<TRecentWidgetProps> = observer((prop
} }
}; };
if (!loader && !isWikiApp && joinedProjectIds?.length === 0) return <NoProjectsEmptyState />; if (loader === "loaded" && !isWikiApp && joinedProjectIds?.length === 0) return <NoProjectsEmptyState />;
if (!isLoading && recents?.length === 0) if (!isLoading && recents?.length === 0)
return ( return (

View file

@ -19,15 +19,11 @@ import useSize from "@/hooks/use-window-size";
export const WorkspaceDashboardView = observer(() => { export const WorkspaceDashboardView = observer(() => {
// store hooks // store hooks
const { const { captureEvent, setTrackElement } = useEventTracker();
// captureEvent,
setTrackElement,
} = useEventTracker();
const { toggleCreateProjectModal } = useCommandPalette(); const { toggleCreateProjectModal } = useCommandPalette();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { data: currentUserProfile, updateTourCompleted } = useUserProfile(); const { data: currentUserProfile, updateTourCompleted } = useUserProfile();
const { captureEvent } = useEventTracker();
const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard(); const { homeDashboardId, fetchHomeDashboardWidgets } = useDashboard();
const { joinedProjectIds, loader } = useProject(); const { joinedProjectIds, loader } = useProject();
@ -63,7 +59,7 @@ export const WorkspaceDashboardView = observer(() => {
)} )}
{homeDashboardId && joinedProjectIds && ( {homeDashboardId && joinedProjectIds && (
<> <>
{joinedProjectIds.length > 0 || loader ? ( {joinedProjectIds.length > 0 || loader === "init-loader" ? (
<> <>
<IssuePeekOverview /> <IssuePeekOverview />
<ContentWrapper <ContentWrapper

View file

@ -24,17 +24,19 @@ export const ProjectCardList = observer((props: TProjectCardListProps) => {
const { toggleCreateProjectModal } = useCommandPalette(); const { toggleCreateProjectModal } = useCommandPalette();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { const {
loader,
fetchStatus,
workspaceProjectIds: storeWorkspaceProjectIds, workspaceProjectIds: storeWorkspaceProjectIds,
filteredProjectIds: storeFilteredProjectIds, filteredProjectIds: storeFilteredProjectIds,
getProjectById, getProjectById,
loader,
} = useProject(); } = useProject();
const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter(); const { searchQuery, currentWorkspaceDisplayFilters } = useProjectFilter();
// derived values // derived values
const workspaceProjectIds = totalProjectIdsProps ?? storeWorkspaceProjectIds; const workspaceProjectIds = totalProjectIdsProps ?? storeWorkspaceProjectIds;
const filteredProjectIds = filteredProjectIdsProps ?? storeFilteredProjectIds; const filteredProjectIds = filteredProjectIdsProps ?? storeFilteredProjectIds;
if (!filteredProjectIds || !workspaceProjectIds || loader) return <ProjectsLoader />; if (!filteredProjectIds || !workspaceProjectIds || loader === "init-loader" || fetchStatus !== "complete")
return <ProjectsLoader />;
if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects) if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
return ( return (

View file

@ -29,7 +29,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper";
import { getFileURL } from "@/helpers/file.helper"; import { getFileURL } from "@/helpers/file.helper";
import { copyUrlToClipboard } from "@/helpers/string.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper";
// hooks // hooks
import { useProject, useUserPermissions } from "@/hooks/store"; import { useMember, useProject, useUserPermissions } from "@/hooks/store";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os"; import { usePlatformOS } from "@/hooks/use-platform-os";
// plane-web constants // plane-web constants
@ -51,12 +51,13 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
const router = useAppRouter(); const router = useAppRouter();
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
// store hooks // store hooks
const { getUserDetails } = useMember();
const { addProjectToFavorites, removeProjectFromFavorites } = useProject(); const { addProjectToFavorites, removeProjectFromFavorites } = useProject();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// derived values // derived values
const projectMembersIds = project.members?.map((member) => member.member_id); const projectMembersIds = project.members;
const shouldRenderFavorite = allowPermissions( const shouldRenderFavorite = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER], [EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE EUserPermissionsLevel.WORKSPACE
@ -249,7 +250,7 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
if (project.is_favorite) handleRemoveFromFavorites(); if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites(); else handleAddToFavorites();
}} }}
selected={project.is_favorite} selected={!!project.is_favorite}
/> />
)} )}
</div> </div>
@ -281,14 +282,10 @@ export const ProjectCard: React.FC<Props> = observer((props) => {
<div className="flex cursor-pointer items-center gap-2 text-custom-text-200"> <div className="flex cursor-pointer items-center gap-2 text-custom-text-200">
<AvatarGroup showTooltip={false}> <AvatarGroup showTooltip={false}>
{projectMembersIds.map((memberId) => { {projectMembersIds.map((memberId) => {
const member = project.members?.find((m) => m.member_id === memberId); const member = getUserDetails(memberId);
if (!member) return null; if (!member) return null;
return ( return (
<Avatar <Avatar key={member.id} name={member.display_name} src={getFileURL(member.avatar_url)} />
key={member.id}
name={member.member__display_name}
src={getFileURL(member.member__avatar_url)}
/>
); );
})} })}
</AvatarGroup> </AvatarGroup>

View file

@ -53,7 +53,7 @@ const ProjectCreateHeader: React.FC<Props> = (props) => {
label={t("change_cover")} label={t("change_cover")}
onChange={onChange} onChange={onChange}
control={control} control={control}
value={value} value={value ?? null}
tabIndex={getIndex("cover_image")} tabIndex={getIndex("cover_image")}
/> />
)} )}

View file

@ -222,7 +222,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
label="Change cover" label="Change cover"
control={control} control={control}
onChange={onChange} onChange={onChange}
value={value} value={value ?? null}
disabled={!isAdmin} disabled={!isAdmin}
projectId={project.id} projectId={project.id}
/> />
@ -263,7 +263,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
<span className="text-xs text-red-500">{errors?.name?.message}</span> <span className="text-xs text-red-500">{errors?.name?.message}</span>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4> <h4 className="text-sm">Summary</h4>
<Controller <Controller
name="description" name="description"
control={control} control={control}
@ -272,7 +272,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (props) => {
id="description" id="description"
name="description" name="description"
value={value} value={value}
placeholder="Enter project description" placeholder="Enter project summary"
onChange={onChange} onChange={onChange}
className="min-h-[102px] text-sm font-medium" className="min-h-[102px] text-sm font-medium"
hasError={Boolean(errors?.description)} hasError={Boolean(errors?.description)}

View file

@ -25,7 +25,7 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
const [isJoiningLoading, setIsJoiningLoading] = useState(false); const [isJoiningLoading, setIsJoiningLoading] = useState(false);
// store hooks // store hooks
const { joinProject } = useUserPermissions(); const { joinProject } = useUserPermissions();
const { fetchProjects } = useProject(); const { fetchProjectDetails } = useProject();
// router // router
const router = useAppRouter(); const router = useAppRouter();
@ -35,7 +35,7 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (props) => {
joinProject(workspaceSlug, project.id) joinProject(workspaceSlug, project.id)
.then(() => { .then(() => {
router.push(`/${workspaceSlug}/projects/${project.id}/issues`); router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
fetchProjects(workspaceSlug); fetchProjectDetails(workspaceSlug, project.id);
handleClose(); handleClose();
}) })
.finally(() => { .finally(() => {

View file

@ -27,7 +27,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
// store hooks // store hooks
const { leaveProject } = useUserPermissions(); const { leaveProject } = useUserPermissions();
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { fetchProjects } = useProject(); const { fetchProjectDetails } = useProject();
const { const {
project: { removeMemberFromProject }, project: { removeMemberFromProject },
} = useMember(); } = useMember();
@ -45,7 +45,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
state: "SUCCESS", state: "SUCCESS",
element: "Project settings members page", element: "Project settings members page",
}); });
await fetchProjects(workspaceSlug.toString()); await fetchProjectDetails(workspaceSlug.toString(), projectId.toString());
}) })
.catch((err) => .catch((err) =>
setToast({ setToast({

View file

@ -34,7 +34,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
const isAdmin = allowPermissions( const isAdmin = allowPermissions(
[EUserPermissions.ADMIN], [EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT, EUserPermissionsLevel.PROJECT,
currentProjectDetails?.workspace_detail?.slug, workspaceSlug?.toString(),
currentProjectDetails?.id currentProjectDetails?.id
); );
// form info // form info
@ -176,7 +176,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
</p> </p>
</div> </div>
<ToggleSwitch <ToggleSwitch
value={currentProjectDetails?.guest_view_all_features} value={!!currentProjectDetails?.guest_view_all_features}
onChange={() => toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)} onChange={() => toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)}
disabled={!isAdmin} disabled={!isAdmin}
size="md" size="md"

View file

@ -38,13 +38,13 @@ export const ProjectNavigation: FC<TProjectItemsProps> = observer((props) => {
// store hooks // store hooks
const { t } = useTranslation(); const { t } = useTranslation();
const { sidebarCollapsed: isSidebarCollapsed, toggleSidebar } = useAppTheme(); const { sidebarCollapsed: isSidebarCollapsed, toggleSidebar } = useAppTheme();
const { getProjectById } = useProject(); const { getPartialProjectById } = useProject();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
// pathname // pathname
const pathname = usePathname(); const pathname = usePathname();
// derived values // derived values
const project = getProjectById(projectId); const project = getPartialProjectById(projectId);
// handlers // handlers
const handleProjectClick = () => { const handleProjectClick = () => {
if (window.innerWidth < 768) { if (window.innerWidth < 768) {

View file

@ -52,7 +52,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme(); const { sidebarCollapsed: isSidebarCollapsed } = useAppTheme();
const { t } = useTranslation(); const { t } = useTranslation();
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { addProjectToFavorites, removeProjectFromFavorites, getProjectById } = useProject(); const { addProjectToFavorites, removeProjectFromFavorites, getPartialProjectById } = useProject();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
// states // states
@ -70,7 +70,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId: URLProjectId } = useParams(); const { workspaceSlug, projectId: URLProjectId } = useParams();
// derived values // derived values
const project = getProjectById(projectId); const project = getPartialProjectById(projectId);
// auth // auth
const isAdmin = allowPermissions( const isAdmin = allowPermissions(
[EUserPermissions.ADMIN], [EUserPermissions.ADMIN],
@ -337,7 +337,8 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
placement="bottom-start" placement="bottom-start"
useCaptureForOutsideClick useCaptureForOutsideClick
> >
{isAuthorized && ( {/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
<CustomMenu.MenuItem <CustomMenu.MenuItem
onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites} onClick={project.is_favorite ? handleRemoveFromFavorites : handleAddToFavorites}
> >
@ -350,7 +351,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span> <span>{project.is_favorite ? t("remove_from_favorites") : t("add_to_favorites")}</span>
</span> </span>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )} */}
{/* publish project settings */} {/* publish project settings */}
{isAdmin && ( {isAdmin && (
@ -359,20 +360,10 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
<div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80"> <div className="flex h-4 w-4 cursor-pointer items-center justify-center rounded text-custom-sidebar-text-200 transition-all duration-300 hover:bg-custom-sidebar-background-80">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" /> <Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div> </div>
<div>{project.anchor ? t("publish_settings") : t("publish")}</div> <div>{t("publish_settings")}</div>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}
{/* {isAuthorized && (
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/draft-issues/`}>
<div className="flex items-center justify-start gap-2">
<PenSquare className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
<span>Draft issues</span>
</div>
</Link>
</CustomMenu.MenuItem>
)} */}
<CustomMenu.MenuItem onClick={handleCopyText}> <CustomMenu.MenuItem onClick={handleCopyText}>
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" /> <LinkIcon className="h-3.5 w-3.5 stroke-[1.5]" />

View file

@ -9,7 +9,7 @@ import { Briefcase, ChevronRight, Plus } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
// ui // ui
import { TOAST_TYPE, Tooltip, setToast } from "@plane/ui"; import { Loader, TOAST_TYPE, Tooltip, setToast } from "@plane/ui";
// components // components
import { CreateProjectModal } from "@/components/project"; import { CreateProjectModal } from "@/components/project";
import { SidebarProjectsListItem } from "@/components/workspace"; import { SidebarProjectsListItem } from "@/components/workspace";
@ -41,7 +41,7 @@ export const SidebarProjectsList: FC = observer(() => {
const { setTrackElement } = useEventTracker(); const { setTrackElement } = useEventTracker();
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { getProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject(); const { loader, getPartialProjectById, joinedProjectIds: joinedProjects, updateProjectView } = useProject();
// router params // router params
const { workspaceSlug } = useParams(); const { workspaceSlug } = useParams();
const pathname = usePathname(); const pathname = usePathname();
@ -72,7 +72,7 @@ export const SidebarProjectsList: FC = observer(() => {
const joinedProjectsList: TProject[] = []; const joinedProjectsList: TProject[] = [];
joinedProjects.map((projectId) => { joinedProjects.map((projectId) => {
const projectDetails = getProjectById(projectId); const projectDetails = getPartialProjectById(projectId);
if (projectDetails) joinedProjectsList.push(projectDetails); if (projectDetails) joinedProjectsList.push(projectDetails);
}); });
@ -232,6 +232,13 @@ export const SidebarProjectsList: FC = observer(() => {
leaveFrom="transform scale-100 opacity-100" leaveFrom="transform scale-100 opacity-100"
leaveTo="transform scale-95 opacity-0" leaveTo="transform scale-95 opacity-0"
> >
{loader === "init-loader" && (
<Loader className="w-full space-y-1.5">
{Array.from({ length: 4 }).map((_, index) => (
<Loader.Item key={index} height="28px" />
))}
</Loader>
)}
{isAllProjectsListOpen && ( {isAllProjectsListOpen && (
<Disclosure.Panel <Disclosure.Panel
as="div" as="div"

View file

@ -165,7 +165,7 @@ export const ProjectAuthWrapper: FC<IProjectAuthWrapper> = observer((props) => {
if (projectExists && projectId && hasPermissionToCurrentProject === false) return <JoinProject />; if (projectExists && projectId && hasPermissionToCurrentProject === false) return <JoinProject />;
// check if the project info is not found. // check if the project info is not found.
if (!loader && !projectExists && projectId && !!hasPermissionToCurrentProject === false) if (loader === "loaded" && !projectExists && projectId && !!hasPermissionToCurrentProject === false)
return ( return (
<div className="grid h-screen place-items-center bg-custom-background-100"> <div className="grid h-screen place-items-center bg-custom-background-100">
<EmptyState <EmptyState

View file

@ -39,7 +39,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
// store hooks // store hooks
const { signOut, data: currentUser } = useUser(); const { signOut, data: currentUser } = useUser();
const { fetchProjects } = useProject(); const { fetchPartialProjects } = useProject();
const { fetchFavorite } = useFavorite(); const { fetchFavorite } = useFavorite();
const { const {
workspace: { fetchWorkspaceMembers }, workspace: { fetchWorkspaceMembers },
@ -74,8 +74,8 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = observer((props)
// fetching workspace projects // fetching workspace projects
useSWR( useSWR(
workspaceSlug && currentWorkspace ? `WORKSPACE_PROJECTS_${workspaceSlug}` : null, workspaceSlug && currentWorkspace ? `WORKSPACE_PARTIAL_PROJECTS_${workspaceSlug}` : null,
workspaceSlug && currentWorkspace ? () => fetchProjects(workspaceSlug.toString()) : null, workspaceSlug && currentWorkspace ? () => fetchPartialProjects(workspaceSlug.toString()) : null,
{ revalidateIfStale: false, revalidateOnFocus: false } { revalidateIfStale: false, revalidateOnFocus: false }
); );
// fetch workspace members // fetch workspace members

View file

@ -1,8 +1,14 @@
import type { GithubRepositoriesResponse, ISearchIssueResponse, TProjectIssuesSearchParams } from "@plane/types"; import type {
GithubRepositoriesResponse,
ISearchIssueResponse,
TProjectAnalyticsCount,
TProjectAnalyticsCountParams,
TProjectIssuesSearchParams,
} from "@plane/types";
// helpers // helpers
import { API_BASE_URL } from "@/helpers/common.helper"; import { API_BASE_URL } from "@/helpers/common.helper";
// plane web types // plane web types
import { TProject } from "@/plane-web/types"; import { TProject, TPartialProject } from "@/plane-web/types";
// services // services
import { APIService } from "@/services/api.service"; import { APIService } from "@/services/api.service";
@ -31,7 +37,7 @@ export class ProjectService extends APIService {
}); });
} }
async getProjects(workspaceSlug: string): Promise<TProject[]> { async getProjectsLite(workspaceSlug: string): Promise<TPartialProject[]> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/`)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
@ -39,6 +45,14 @@ export class ProjectService extends APIService {
}); });
} }
async getProjects(workspaceSlug: string): Promise<TProject[]> {
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<TProject> { async getProject(workspaceSlug: string, projectId: string): Promise<TProject> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`)
.then((response) => response?.data) .then((response) => response?.data)
@ -47,6 +61,19 @@ export class ProjectService extends APIService {
}); });
} }
async getProjectAnalyticsCount(
workspaceSlug: string,
params?: TProjectAnalyticsCountParams
): Promise<TProjectAnalyticsCount[]> {
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<TProject>): Promise<TProject> { async updateProject(workspaceSlug: string, projectId: string, data: Partial<TProject>): Promise<TProject> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`, data) return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/`, data)
.then((response) => response?.data) .then((response) => response?.data)
@ -63,14 +90,6 @@ export class ProjectService extends APIService {
}); });
} }
async fetchProjectEpicProperties(workspaceSlug: string, projectId: string): Promise<any> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/epic-properties/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async setProjectView( async setProjectView(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,

View file

@ -1,15 +1,11 @@
import set from "lodash/set"; import set from "lodash/set";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
import uniq from "lodash/uniq";
import update from "lodash/update";
import { action, computed, makeObservable, observable, runInAction } from "mobx"; import { action, computed, makeObservable, observable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// types // types
import { import { IProjectBulkAddFormData, IProjectMember, IProjectMembership, IUserLite } from "@plane/types";
IProjectBulkAddFormData,
IProjectMember,
IProjectMemberLite,
IProjectMembership,
IUserLite,
} from "@plane/types";
// plane-web constants // plane-web constants
import { EUserPermissions } from "@/plane-web/constants/user-permissions"; import { EUserPermissions } from "@/plane-web/constants/user-permissions";
// services // services
@ -166,8 +162,11 @@ export class ProjectMemberStore implements IProjectMemberStore {
set(this.projectMemberMap, [projectId, member.member], member); set(this.projectMemberMap, [projectId, member.member], member);
}); });
}); });
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members.concat( update(this.projectRoot.projectMap, [projectId, "members"], (memberIds) =>
data.members as unknown as IProjectMemberLite[] 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; return response;
@ -218,8 +217,8 @@ export class ProjectMemberStore implements IProjectMemberStore {
runInAction(() => { runInAction(() => {
delete this.projectMemberMap?.[projectId]?.[userId]; delete this.projectMemberMap?.[projectId]?.[userId];
}); });
this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members.filter( this.projectRoot.projectMap[projectId].members = this.projectRoot.projectMap?.[projectId]?.members?.filter(
(member) => member.id !== userId (memberId) => memberId !== userId
); );
}); });
}; };

View file

@ -1,11 +1,15 @@
import cloneDeep from "lodash/cloneDeep";
import set from "lodash/set"; import set from "lodash/set";
import sortBy from "lodash/sortBy"; import sortBy from "lodash/sortBy";
import update from "lodash/update";
import { observable, action, computed, makeObservable, runInAction } from "mobx"; import { observable, action, computed, makeObservable, runInAction } from "mobx";
import { computedFn } from "mobx-utils"; import { computedFn } from "mobx-utils";
// plane imports
import { TFetchStatus, TLoader, TProjectAnalyticsCount, TProjectAnalyticsCountParams } from "@plane/types";
// helpers // helpers
import { orderProjects, shouldFilterProject } from "@/helpers/project.helper"; import { orderProjects, shouldFilterProject } from "@/helpers/project.helper";
// services // services
import { TProject } from "@/plane-web/types/projects"; import { TProject, TPartialProject } from "@/plane-web/types/projects";
import { IssueLabelService, IssueService } from "@/services/issue"; import { IssueLabelService, IssueService } from "@/services/issue";
import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project"; import { ProjectService, ProjectStateService, ProjectArchiveService } from "@/services/project";
// store // store
@ -16,10 +20,10 @@ type ProjectOverviewCollapsible = "links" | "attachments";
export interface IProjectStore { export interface IProjectStore {
// observables // observables
isUpdatingProject: boolean; isUpdatingProject: boolean;
loader: boolean; loader: TLoader;
projectMap: { fetchStatus: TFetchStatus;
[projectId: string]: TProject; // projectId: project Info projectMap: Record<string, TProject>; // projectId: project info
}; projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount>; // projectId: project analytics count
// computed // computed
filteredProjectIds: string[] | undefined; filteredProjectIds: string[] | undefined;
workspaceProjectIds: string[] | undefined; workspaceProjectIds: string[] | undefined;
@ -30,7 +34,9 @@ export interface IProjectStore {
currentProjectDetails: TProject | undefined; currentProjectDetails: TProject | undefined;
// actions // actions
getProjectById: (projectId: string | undefined | null) => TProject | undefined; getProjectById: (projectId: string | undefined | null) => TProject | undefined;
getPartialProjectById: (projectId: string | undefined | null) => TPartialProject | undefined;
getProjectIdentifierById: (projectId: string | undefined | null) => string; getProjectIdentifierById: (projectId: string | undefined | null) => string;
getProjectAnalyticsCountById: (projectId: string | undefined | null) => TProjectAnalyticsCount | undefined;
// collapsible // collapsible
openCollapsibleSection: ProjectOverviewCollapsible[]; openCollapsibleSection: ProjectOverviewCollapsible[];
lastCollapsibleAction: ProjectOverviewCollapsible | null; lastCollapsibleAction: ProjectOverviewCollapsible | null;
@ -40,8 +46,13 @@ export interface IProjectStore {
toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void; toggleOpenCollapsibleSection: (section: ProjectOverviewCollapsible) => void;
// fetch actions // fetch actions
fetchPartialProjects: (workspaceSlug: string) => Promise<TPartialProject[]>;
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>; fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>;
fetchProjectAnalyticsCount: (
workspaceSlug: string,
params?: TProjectAnalyticsCountParams
) => Promise<TProjectAnalyticsCount[]>;
// favorites actions // favorites actions
addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; addProjectToFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>; removeProjectFromFavorites: (workspaceSlug: string, projectId: string) => Promise<any>;
@ -59,10 +70,10 @@ export interface IProjectStore {
export class ProjectStore implements IProjectStore { export class ProjectStore implements IProjectStore {
// observables // observables
isUpdatingProject: boolean = false; isUpdatingProject: boolean = false;
loader: boolean = false; loader: TLoader = "init-loader";
projectMap: { fetchStatus: TFetchStatus = undefined;
[projectId: string]: TProject; // projectId: project Info projectMap: Record<string, TProject> = {};
} = {}; projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount> = {};
openCollapsibleSection: ProjectOverviewCollapsible[] = []; openCollapsibleSection: ProjectOverviewCollapsible[] = [];
lastCollapsibleAction: ProjectOverviewCollapsible | null = null; lastCollapsibleAction: ProjectOverviewCollapsible | null = null;
@ -80,7 +91,9 @@ export class ProjectStore implements IProjectStore {
// observables // observables
isUpdatingProject: observable, isUpdatingProject: observable,
loader: observable.ref, loader: observable.ref,
fetchStatus: observable.ref,
projectMap: observable, projectMap: observable,
projectAnalyticsCountMap: observable,
openCollapsibleSection: observable.ref, openCollapsibleSection: observable.ref,
lastCollapsibleAction: observable.ref, lastCollapsibleAction: observable.ref,
// computed // computed
@ -92,8 +105,10 @@ export class ProjectStore implements IProjectStore {
joinedProjectIds: computed, joinedProjectIds: computed,
favoriteProjectIds: computed, favoriteProjectIds: computed,
// fetch actions // fetch actions
fetchPartialProjects: action,
fetchProjects: action, fetchProjects: action,
fetchProjectDetails: action, fetchProjectDetails: action,
fetchProjectAnalyticsCount: action,
// favorites actions // favorites actions
addProjectToFavorites: action, addProjectToFavorites: action,
removeProjectFromFavorites: action, removeProjectFromFavorites: action,
@ -241,6 +256,31 @@ export class ProjectStore implements IProjectStore {
} }
}; };
/**
* get Workspace projects partial data using workspace slug
* @param workspaceSlug
* @returns Promise<TPartialProject[]>
*
*/
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 * get Workspace projects using workspace slug
* @param workspaceSlug * @param workspaceSlug
@ -249,18 +289,23 @@ export class ProjectStore implements IProjectStore {
*/ */
fetchProjects = async (workspaceSlug: string) => { fetchProjects = async (workspaceSlug: string) => {
try { 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); const projectsResponse = await this.projectService.getProjects(workspaceSlug);
runInAction(() => { runInAction(() => {
projectsResponse.forEach((project) => { 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; return projectsResponse;
} catch (error) { } catch (error) {
console.log("Failed to fetch project from workspace store"); console.log("Failed to fetch project from workspace store");
this.loader = false; this.loader = "loaded";
throw error; throw error;
} }
}; };
@ -275,7 +320,7 @@ export class ProjectStore implements IProjectStore {
try { try {
const response = await this.projectService.getProject(workspaceSlug, projectId); const response = await this.projectService.getProject(workspaceSlug, projectId);
runInAction(() => { runInAction(() => {
set(this.projectMap, [projectId], response); update(this.projectMap, [projectId], (p) => ({ ...p, ...response }));
}); });
return response; return response;
} catch (error) { } 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<TProjectAnalyticsCount[]>
*/
fetchProjectAnalyticsCount = async (
workspaceSlug: string,
params?: TProjectAnalyticsCountParams
): Promise<TProjectAnalyticsCount[]> => {
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 * Returns project details using project id
* @param projectId * @param projectId
@ -294,6 +363,17 @@ export class ProjectStore implements IProjectStore {
return projectInfo; 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 * Returns project identifier using project id
* @param projectId * @param projectId
@ -304,6 +384,16 @@ export class ProjectStore implements IProjectStore {
return projectInfo?.identifier; 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 * Adds project to favorites and updates project favorite status in the store
* @param workspaceSlug * @param workspaceSlug
@ -415,8 +505,8 @@ export class ProjectStore implements IProjectStore {
* @returns Promise<TProject> * @returns Promise<TProject>
*/ */
updateProject = async (workspaceSlug: string, projectId: string, data: Partial<TProject>) => { updateProject = async (workspaceSlug: string, projectId: string, data: Partial<TProject>) => {
const projectDetails = cloneDeep(this.getProjectById(projectId));
try { try {
const projectDetails = this.getProjectById(projectId);
runInAction(() => { runInAction(() => {
set(this.projectMap, [projectId], { ...projectDetails, ...data }); set(this.projectMap, [projectId], { ...projectDetails, ...data });
this.isUpdatingProject = true; this.isUpdatingProject = true;
@ -428,9 +518,8 @@ export class ProjectStore implements IProjectStore {
return response; return response;
} catch (error) { } catch (error) {
console.log("Failed to create project from project store"); console.log("Failed to create project from project store");
this.fetchProjects(workspaceSlug);
this.fetchProjectDetails(workspaceSlug, projectId);
runInAction(() => { runInAction(() => {
set(this.projectMap, [projectId], projectDetails);
this.isUpdatingProject = false; this.isUpdatingProject = false;
}); });
throw error; throw error;
@ -454,7 +543,6 @@ export class ProjectStore implements IProjectStore {
}); });
} catch (error) { } catch (error) {
console.log("Failed to delete project from project store"); console.log("Failed to delete project from project store");
this.fetchProjects(workspaceSlug);
throw error; throw error;
} }
}; };
@ -476,8 +564,6 @@ export class ProjectStore implements IProjectStore {
}) })
.catch((error) => { .catch((error) => {
console.log("Failed to archive project from project store"); console.log("Failed to archive project from project store");
this.fetchProjects(workspaceSlug);
this.fetchProjectDetails(workspaceSlug, projectId);
throw error; throw error;
}); });
}; };
@ -498,8 +584,6 @@ export class ProjectStore implements IProjectStore {
}) })
.catch((error) => { .catch((error) => {
console.log("Failed to restore project from project store"); console.log("Failed to restore project from project store");
this.fetchProjects(workspaceSlug);
this.fetchProjectDetails(workspaceSlug, projectId);
throw error; throw error;
}); });
}; };

View file

@ -77,8 +77,8 @@ export const shouldFilterProject = (
if (filterKey === "lead" && filters.lead && filters.lead.length > 0) if (filterKey === "lead" && filters.lead && filters.lead.length > 0)
fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`); fallsInFilters = fallsInFilters && filters.lead.includes(`${project.project_lead}`);
if (filterKey === "members" && filters.members && filters.members.length > 0) { if (filterKey === "members" && filters.members && filters.members.length > 0) {
const memberIds = project.members.map((member) => member.member_id); const memberIds = project.members;
fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds.includes(memberId)); fallsInFilters = fallsInFilters && filters.members.some((memberId) => memberIds?.includes(memberId));
} }
if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) { if (filterKey === "created_at" && filters.created_at && filters.created_at.length > 0) {
const createdDate = getDate(project.created_at); 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 === "-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 === "-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]);
if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members.length]).reverse(); if (orderByKey === "-members_length") orderedProjects = sortBy(projects, [(p) => p.members?.length]).reverse();
return orderedProjects; return orderedProjects;
}; };