[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

@ -1,6 +1,8 @@
import { observer } from "mobx-react";
// 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
import { Logo } from "@/components/common";
// helpers
@ -10,19 +12,25 @@ import { useProject } from "@/hooks/store";
type Props = {
projectIds: string[];
isLoading: boolean;
isUpdating: boolean;
};
export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((props) => {
const { projectIds } = props;
const { getProjectById } = useProject();
const { projectIds, isLoading, isUpdating } = props;
// store hooks
const { getProjectById, getProjectAnalyticsCountById } = useProject();
return (
<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">
{projectIds.map((projectId) => {
const project = getProjectById(projectId);
const projectAnalyticsCount = getProjectAnalyticsCountById(projectId);
if (!project) return;
@ -38,27 +46,37 @@ export const CustomAnalyticsSidebarProjectsList: React.FC<Props> = observer((pro
</h5>
</div>
<div className="mt-4 w-full space-y-3 px-2">
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Users className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{project.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Contrast className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total cycles</h6>
</div>
<span className="text-custom-text-200">{project.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">{project.total_modules}</span>
</div>
{isLoading ? (
<Loader className="space-y-3">
<Loader.Item height="16px" />
<Loader.Item height="16px" />
<Loader.Item height="16px" />
</Loader>
) : (
<>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Users className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total members</h6>
</div>
<span className="text-custom-text-200">{projectAnalyticsCount?.total_members}</span>
</div>
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2">
<Contrast className="text-custom-text-200" size={14} strokeWidth={2} />
<h6>Total cycles</h6>
</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>
);

View file

@ -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<Props> = 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<Props> = observer((props) => {
<div className={cn("h-full w-full overflow-hidden", isProjectLevel ? "hidden" : "block")}>
<>
{!isProjectLevel && selectedProjects && selectedProjects.length > 0 && (
<CustomAnalyticsSidebarProjectsList projectIds={selectedProjects} />
<CustomAnalyticsSidebarProjectsList
projectIds={selectedProjects}
isLoading={isProjectAnalyticsLoading}
isUpdating={isProjectAnalyticsUpdating}
/>
)}
<CustomAnalyticsSidebarHeader />
</>

View file

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

View file

@ -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<IProject> = { archive_in: 1 };
export const AutoArchiveAutomation: React.FC<Props> = 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<Props> = observer((props) => {
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
currentProjectDetails?.workspace_detail?.slug,
workspaceSlug?.toString(),
currentProjectDetails?.id
);
);
return (
<>

View file

@ -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<Props> = 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<Props> = observer((props) => {
const isAdmin = allowPermissions(
[EUserPermissions.ADMIN],
EUserPermissionsLevel.PROJECT,
currentProjectDetails?.workspace_detail?.slug,
workspaceSlug?.toString(),
currentProjectDetails?.id
);

View file

@ -93,7 +93,7 @@ export const SelectMonthModal: React.FC<Props> = ({ 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<Props> = ({ 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)}

View file

@ -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<ProjectListItemProps> = 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<ProjectListItemProps> = observer((props) => {
</h6>
<div className="mt-2">
<AvatarGroup>
{projectDetails.members?.map((member) => (
<Avatar
key={member.member_id}
src={getFileURL(member.member__avatar_url)}
name={member.member__display_name}
/>
))}
{projectDetails.members?.map((memberId) => {
const userDetails = getUserDetails(memberId);
if (!userDetails) return null;
return (
<Avatar key={userDetails.id} src={getFileURL(userDetails.avatar_url)} name={userDetails.display_name} />
);
})}
</AvatarGroup>
</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)
return (

View file

@ -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" ? (
<>
<IssuePeekOverview />
<ContentWrapper

View file

@ -24,17 +24,19 @@ export const ProjectCardList = observer((props: TProjectCardListProps) => {
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 <ProjectsLoader />;
if (!filteredProjectIds || !workspaceProjectIds || loader === "init-loader" || fetchStatus !== "complete")
return <ProjectsLoader />;
if (workspaceProjectIds?.length === 0 && !currentWorkspaceDisplayFilters?.archived_projects)
return (

View file

@ -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<Props> = 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<Props> = observer((props) => {
if (project.is_favorite) handleRemoveFromFavorites();
else handleAddToFavorites();
}}
selected={project.is_favorite}
selected={!!project.is_favorite}
/>
)}
</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">
<AvatarGroup showTooltip={false}>
{projectMembersIds.map((memberId) => {
const member = project.members?.find((m) => m.member_id === memberId);
const member = getUserDetails(memberId);
if (!member) return null;
return (
<Avatar
key={member.id}
name={member.member__display_name}
src={getFileURL(member.member__avatar_url)}
/>
<Avatar key={member.id} name={member.display_name} src={getFileURL(member.avatar_url)} />
);
})}
</AvatarGroup>

View file

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

View file

@ -222,7 +222,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (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<IProjectDetailsForm> = (props) => {
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Description</h4>
<h4 className="text-sm">Summary</h4>
<Controller
name="description"
control={control}
@ -272,7 +272,7 @@ export const ProjectDetailsForm: FC<IProjectDetailsForm> = (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)}

View file

@ -25,7 +25,7 @@ export const JoinProjectModal: React.FC<TJoinProjectModalProps> = (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<TJoinProjectModalProps> = (props) => {
joinProject(workspaceSlug, project.id)
.then(() => {
router.push(`/${workspaceSlug}/projects/${project.id}/issues`);
fetchProjects(workspaceSlug);
fetchProjectDetails(workspaceSlug, project.id);
handleClose();
})
.finally(() => {

View file

@ -27,7 +27,7 @@ export const ProjectMemberListItem: React.FC<Props> = 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<Props> = observer((props) => {
state: "SUCCESS",
element: "Project settings members page",
});
await fetchProjects(workspaceSlug.toString());
await fetchProjectDetails(workspaceSlug.toString(), projectId.toString());
})
.catch((err) =>
setToast({

View file

@ -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(() => {
</p>
</div>
<ToggleSwitch
value={currentProjectDetails?.guest_view_all_features}
value={!!currentProjectDetails?.guest_view_all_features}
onChange={() => toggleGuestViewAllIssues(!currentProjectDetails?.guest_view_all_features)}
disabled={!isAdmin}
size="md"

View file

@ -38,13 +38,13 @@ export const ProjectNavigation: FC<TProjectItemsProps> = 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) {

View file

@ -52,7 +52,7 @@ export const SidebarProjectsListItem: React.FC<Props> = 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<Props> = 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<Props> = observer((props) => {
placement="bottom-start"
useCaptureForOutsideClick
>
{isAuthorized && (
{/* TODO: Removed is_favorite logic due to the optimization in projects API */}
{/* {isAuthorized && (
<CustomMenu.MenuItem
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>
</CustomMenu.MenuItem>
)}
)} */}
{/* publish project settings */}
{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">
<Share2 className="h-3.5 w-3.5 stroke-[1.5]" />
</div>
<div>{project.anchor ? t("publish_settings") : t("publish")}</div>
<div>{t("publish_settings")}</div>
</div>
</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}>
<span className="flex items-center justify-start gap-2">
<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 { 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" && (
<Loader className="w-full space-y-1.5">
{Array.from({ length: 4 }).map((_, index) => (
<Loader.Item key={index} height="28px" />
))}
</Loader>
)}
{isAllProjectsListOpen && (
<Disclosure.Panel
as="div"

View file

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

View file

@ -39,7 +39,7 @@ export const WorkspaceAuthWrapper: FC<IWorkspaceAuthWrapper> = 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<IWorkspaceAuthWrapper> = 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

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
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<TProject[]> {
async getProjectsLite(workspaceSlug: string): Promise<TPartialProject[]> {
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<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> {
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<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> {
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<any> {
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,

View file

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

View file

@ -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<string, TProject>; // projectId: project info
projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount>; // 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<TPartialProject[]>;
fetchProjects: (workspaceSlug: string) => Promise<TProject[]>;
fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise<TProject>;
fetchProjectAnalyticsCount: (
workspaceSlug: string,
params?: TProjectAnalyticsCountParams
) => Promise<TProjectAnalyticsCount[]>;
// favorites actions
addProjectToFavorites: (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 {
// observables
isUpdatingProject: boolean = false;
loader: boolean = false;
projectMap: {
[projectId: string]: TProject; // projectId: project Info
} = {};
loader: TLoader = "init-loader";
fetchStatus: TFetchStatus = undefined;
projectMap: Record<string, TProject> = {};
projectAnalyticsCountMap: Record<string, TProjectAnalyticsCount> = {};
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<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
* @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<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
* @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<TProject>
*/
updateProject = async (workspaceSlug: string, projectId: string, data: Partial<TProject>) => {
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;
});
};