From ecc0afb91b23f292098f0ee31107343eb37b8181 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Tue, 4 Feb 2025 15:26:45 +0530 Subject: [PATCH] [WEB-3290] chore: initiative improvements (#2377) * chore: initiative list improvements * chore: epic peek view implemented in initiative detail page * fix: mutation * chore: code refactor * chore: code refactor * chore: code refactor --- web/core/components/core/list/list-item.tsx | 3 + .../components/epics/peek-overview/view.tsx | 90 ++++++------ .../components/initiative-block.tsx | 17 ++- .../components/property-block-wrapper.tsx | 26 ++++ .../components/read-only-properties.tsx | 128 ++++++++++++++++++ .../epics/epic-list-item/properties.tsx | 14 +- .../epics/epic-list-item/root.tsx | 20 ++- .../components/initiatives/details/root.tsx | 2 + web/ee/store/initiatives/initiatives.store.ts | 28 ++-- 9 files changed, 254 insertions(+), 74 deletions(-) create mode 100644 web/ee/components/initiatives/components/property-block-wrapper.tsx create mode 100644 web/ee/components/initiatives/components/read-only-properties.tsx diff --git a/web/core/components/core/list/list-item.tsx b/web/core/components/core/list/list-item.tsx index 223879334b..8aa4f33ce1 100644 --- a/web/core/components/core/list/list-item.tsx +++ b/web/core/components/core/list/list-item.tsx @@ -22,6 +22,7 @@ interface IListItemProps { actionItemContainerClassName?: string; isSidebarOpen?: boolean; quickActionElement?: JSX.Element; + preventDefaultNProgress?: boolean; } export const ListItem: FC = (props) => { @@ -40,6 +41,7 @@ export const ListItem: FC = (props) => { isSidebarOpen = false, quickActionElement, itemClassName = "", + preventDefaultNProgress = false, } = props; // router @@ -70,6 +72,7 @@ export const ListItem: FC = (props) => { target="_self" onClick={handleControlLinkClick} disabled={disableLink} + data-prevent-nprogress={preventDefaultNProgress} >
{prependTitleElement && {prependTitleElement}} diff --git a/web/ee/components/epics/peek-overview/view.tsx b/web/ee/components/epics/peek-overview/view.tsx index 947f4e84d8..28aa3b823c 100644 --- a/web/ee/components/epics/peek-overview/view.tsx +++ b/web/ee/components/epics/peek-overview/view.tsx @@ -109,52 +109,50 @@ export const EpicView: FC = observer((props) => { return ( <> -
- {issueId && ( -
- {isError ? ( -
- -
- ) : ( - isLoading && - )} - {!isLoading && !isError && issue && ( - <> - {/* header */} - setPeekMode(value)} - removeRoutePeekId={removeRoutePeekId} - toggleEditEpicModal={toggleEditEpicModal} - toggleDeleteEpicModal={toggleDeleteEpicModal} - handleRestoreIssue={handleRestore} - isArchived={is_archived} - issueId={issueId} - workspaceSlug={workspaceSlug} - projectId={projectId} - isSubmitting={isSubmitting} - disabled={disabled} - embedIssue={embedIssue} - /> - {/* content */} - - - )} -
- )} -
+ {issueId && ( +
+ {isError ? ( +
+ +
+ ) : ( + isLoading && + )} + {!isLoading && !isError && issue && ( + <> + {/* header */} + setPeekMode(value)} + removeRoutePeekId={removeRoutePeekId} + toggleEditEpicModal={toggleEditEpicModal} + toggleDeleteEpicModal={toggleDeleteEpicModal} + handleRestoreIssue={handleRestore} + isArchived={is_archived} + issueId={issueId} + workspaceSlug={workspaceSlug} + projectId={projectId} + isSubmitting={isSubmitting} + disabled={disabled} + embedIssue={embedIssue} + /> + {/* content */} + + + )} +
+ )} ); }); diff --git a/web/ee/components/initiatives/components/initiative-block.tsx b/web/ee/components/initiatives/components/initiative-block.tsx index 5682049d69..8a887790cd 100644 --- a/web/ee/components/initiatives/components/initiative-block.tsx +++ b/web/ee/components/initiatives/components/initiative-block.tsx @@ -3,20 +3,21 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // Plane import { EUserPermissionsLevel } from "@plane/constants"; -import { CircularProgressIndicator, InitiativeIcon } from "@plane/ui"; +import { CircularProgressIndicator, ControlLink, InitiativeIcon } from "@plane/ui"; import { cn } from "@plane/utils"; // components import { ListItem } from "@/components/core/list"; // hooks import { getProgress } from "@/helpers/common.helper"; import { useAppTheme, useUserPermissions } from "@/hooks/store"; +import { useAppRouter } from "@/hooks/use-app-router"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web import { EUserPermissions } from "@/plane-web/constants/user-permissions"; import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives"; // local components -import { BlockProperties } from "./block-properties"; import { InitiativeQuickActions } from "./quick-actions"; +import { ReadOnlyBlockProperties } from "./read-only-properties"; type Props = { initiativeId: string; @@ -29,6 +30,7 @@ export const InitiativeBlock = observer((props: Props) => { const { workspaceSlug } = useParams(); // hooks + const router = useAppRouter(); const { initiative: { getInitiativeById, getInitiativeStatsById }, } = useInitiatives(); @@ -69,7 +71,16 @@ export const InitiativeBlock = observer((props: Props) => { } quickActionElement={
- + { + router.push(`/${workspaceSlug}/initiatives/${initiative.id}`); + }} + > + +
= (props) => { + const { className = "", children } = props; + return ( +
+
+ {children} +
+
+ ); +}; diff --git a/web/ee/components/initiatives/components/read-only-properties.tsx b/web/ee/components/initiatives/components/read-only-properties.tsx new file mode 100644 index 0000000000..8ed93318da --- /dev/null +++ b/web/ee/components/initiatives/components/read-only-properties.tsx @@ -0,0 +1,128 @@ +"use client"; +import { observer } from "mobx-react"; +import { ArrowRight, Briefcase, CalendarCheck2, CalendarDays, Users } from "lucide-react"; +// plane imports +import { Avatar, EpicIcon, Logo } from "@plane/ui"; +import { cn, renderFormattedDate } from "@plane/utils"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useMember, useProject } from "@/hooks/store"; +// plane Web +import { TProject } from "@/plane-web/types"; +import { TInitiative } from "@/plane-web/types/initiative"; +// local components +import { PropertyBlockWrapper } from "./property-block-wrapper"; + +type Props = { + initiative: TInitiative; + isSidebarCollapsed: boolean | undefined; +}; + +export const ReadOnlyBlockProperties = observer((props: Props) => { + const { initiative, isSidebarCollapsed } = props; + // store hooks + const { getUserDetails } = useMember(); + const { getProjectById } = useProject(); + + // derived values + const lead = getUserDetails(initiative.lead ?? ""); + const startDate = getDate(initiative.start_date); + const endDate = getDate(initiative.end_date); + + // helpers + const getProjectIcon = (value: string | string[] | null) => { + const renderIcon = (projectDetails: TProject) => ( + + + + ); + if (Array.isArray(value)) { + return ( +
+ {value.length > 0 ? ( + value.map((projectId) => { + const projectDetails = getProjectById(projectId); + return projectDetails ? renderIcon(projectDetails) : null; + }) + ) : ( + + )} +
+ ); + } else { + const projectDetails = getProjectById(value); + return projectDetails ? renderIcon(projectDetails) : null; + } + }; + + const getProjectDisplayName = (value: string | string[] | null, placeholder: string = "") => { + if (Array.isArray(value)) { + const firstProject = getProjectById(value[0]); + return value.length ? (value.length === 1 ? firstProject?.name : `${value.length} projects`) : placeholder; + } else { + return value ? (getProjectById(value)?.name ?? placeholder) : placeholder; + } + }; + + const getEpicDisplayName = (value: string | string[] | null, placeholder: string = "") => { + if (Array.isArray(value)) { + return value.length ? `${value.length} epics` : placeholder; + } + }; + + return ( +
+ {/* dates */} + + + {} + {startDate ? renderFormattedDate(startDate) : ""} + + + + {!initiative.end_date && } + {endDate ? renderFormattedDate(endDate) : ""} + + + + {/* lead */} + + {lead ? ( + <> + +
{lead.first_name}
+ + ) : ( + <> + +
Lead
+ + )} +
+ + {/* projects */} + + {getProjectIcon(initiative.project_ids || [])} + + {getProjectDisplayName(initiative.project_ids || [], "Project")} + + + + {/* epics */} + + + {getEpicDisplayName(initiative.epic_ids || [], "Epic")} + +
+ ); +}); diff --git a/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/properties.tsx b/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/properties.tsx index b0552b1332..aa5f7b1756 100644 --- a/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/properties.tsx +++ b/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/properties.tsx @@ -13,14 +13,17 @@ import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper" import { useIssueDetail } from "@/hooks/store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; import { usePlatformOS } from "@/hooks/use-platform-os"; +import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives"; type Props = { + workspaceSlug: string; + initiativeId: string; epicId: string; disabled?: boolean; }; export const EpicProperties: FC = observer((props) => { - const { epicId, disabled = false } = props; + const { workspaceSlug, initiativeId, epicId, disabled = false } = props; // store hooks const { issue: { getIssueById }, @@ -29,6 +32,9 @@ export const EpicProperties: FC = observer((props) => { // hooks const { updateIssue } = useIssuesActions(EIssuesStoreType.EPIC); + const { + initiative: { fetchInitiativeAnalytics }, + } = useInitiatives(); // derived values const epic = getIssueById(epicId); @@ -42,7 +48,11 @@ export const EpicProperties: FC = observer((props) => { }; const handleState = (stateId: string) => { - if (updateIssue) updateIssue(epic.project_id, epic.id, { state_id: stateId }); + if (updateIssue) { + updateIssue(epic.project_id, epic.id, { state_id: stateId }).then(() => { + fetchInitiativeAnalytics(workspaceSlug, initiativeId); + }); + } }; const handlePriority = (value: TIssuePriorities) => { diff --git a/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/root.tsx b/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/root.tsx index 887e5d32c1..fe64edab01 100644 --- a/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/root.tsx +++ b/web/ee/components/initiatives/details/main/collapsible-section/epics/epic-list-item/root.tsx @@ -14,7 +14,7 @@ import { useIssueDetail, useProject } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // plane web components import { IdentifierText } from "@/plane-web/components/issues"; -import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives"; +import { useIssueTypes } from "@/plane-web/hooks/store"; // local components import { EpicProperties } from "./properties"; import { EpicQuickActions } from "./quick-action"; @@ -31,10 +31,9 @@ export const EpicListItem: React.FC = observer((props) => { // store hooks const { issue: { getIssueById }, + setPeekIssue, } = useIssueDetail(EIssueServiceType.EPICS); - const { - initiative: { getInitiativeEpicStatsById }, - } = useInitiatives(); + const { getEpicStatsById } = useIssueTypes(); const project = useProject(); const { isMobile } = usePlatformOS(); @@ -43,14 +42,14 @@ export const EpicListItem: React.FC = observer((props) => { // derived values const issue = getIssueById(epicId); - const initiativeEpicStats = getInitiativeEpicStatsById(epicId); + const initiativeEpicStats = getEpicStatsById(epicId); const projectIdentifier = issue?.project_id ? project.getProjectIdentifierById(issue?.project_id) : ""; const issueSequenceId = issue?.sequence_id; const progress = getProgress(initiativeEpicStats?.completed_issues, initiativeEpicStats?.total_issues); - if (!issue) return <>; + if (!issue || !issue.project_id) return <>; return ( = observer((props) => { } quickActionElement={
- +
= observer((props) => { isMobile={isMobile} parentRef={parentRef} className="last:pb-0 last:border-b-0" + onItemClick={() => setPeekIssue({ workspaceSlug, projectId: issue.project_id ?? "", issueId: issue.id })} + preventDefaultNProgress /> ); }); diff --git a/web/ee/components/initiatives/details/root.tsx b/web/ee/components/initiatives/details/root.tsx index 6bb2cf1e31..4d7896002b 100644 --- a/web/ee/components/initiatives/details/root.tsx +++ b/web/ee/components/initiatives/details/root.tsx @@ -8,6 +8,7 @@ import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants"; import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives"; // local components import { LayoutRoot } from "../../common"; +import { EpicPeekOverview } from "../../epics"; import { InitiativeEmptyState } from "../details/empty-state"; import { WorkspaceEpicsListModal } from "./main/collapsible-section/epics/workspace-epic-modal"; import { InitiativeMainContentRoot } from "./main/root"; @@ -118,6 +119,7 @@ export const InitiativeDetailRoot = observer((props: Props) => { handleAddEpicToInitiative(data.map((epic) => epic.id)); }} /> + ); }); diff --git a/web/ee/store/initiatives/initiatives.store.ts b/web/ee/store/initiatives/initiatives.store.ts index 2d4faa24b7..774bb9c026 100644 --- a/web/ee/store/initiatives/initiatives.store.ts +++ b/web/ee/store/initiatives/initiatives.store.ts @@ -37,7 +37,6 @@ export interface IInitiativeStore { initiativeAnalyticsMap: Record; initiativeEpicLoader: Record; initiativeEpicsMap: Record; - initiativeEpicStatsMap: Record | undefined; // epicId -> stats initiativesLoader: boolean; openCollapsibleSection: InitiativeCollapsible[]; @@ -58,8 +57,6 @@ export interface IInitiativeStore { removeEpicFromInitiative: (workspaceSlug: string, initiativeId: string, epicId: string) => Promise; addEpicsToInitiative: (workspaceSlug: string, initiativeId: string, epicIds: string[]) => Promise; - getInitiativeEpicStatsById: (epicId: string) => TEpicStats | undefined; - fetchInitiatives: (workspaceSlug: string) => Promise; fetchInitiativesStats: (workspaceSlug: string) => Promise; fetchInitiativeEpicStats: (workspaceSlug: string, initiativeId: string) => Promise; @@ -90,7 +87,6 @@ export class InitiativeStore implements IInitiativeStore { initiativeEpicLoader: Record = {}; initiativeEpicsMap: Record = {}; - initiativeEpicStatsMap: Record | undefined = undefined; initiativesLoader: boolean = false; @@ -115,7 +111,6 @@ export class InitiativeStore implements IInitiativeStore { initiativeEpicLoader: observable, initiativeEpicsMap: observable, - initiativeEpicStatsMap: observable, openCollapsibleSection: observable.ref, lastCollapsibleAction: observable.ref, @@ -179,8 +174,6 @@ export class InitiativeStore implements IInitiativeStore { getInitiativeStatsById = computedFn((initiativeId: string) => this.initiativesStatsMap?.[initiativeId]); - getInitiativeEpicStatsById = computedFn((epicId: string) => this.initiativeEpicStatsMap?.[epicId]); - getInitiativeEpicsById = computedFn((initiativeId: string) => this.initiativeEpicsMap?.[initiativeId]); getInitiativeAnalyticsById = computedFn((initiativeId: string) => this.initiativeAnalyticsMap[initiativeId]); @@ -257,27 +250,24 @@ export class InitiativeStore implements IInitiativeStore { fetchInitiativeEpicStats = async (workspaceSlug: string, initiativeId: string): Promise => { try { - runInAction(() => { - this.initiativeEpicStatsMap = undefined; - }); - const response = await this.initiativeService.fetchInitiativeEpicStats(workspaceSlug, initiativeId); runInAction(() => { if (!response) return; - if (!this.initiativeEpicStatsMap) this.initiativeEpicStatsMap = {}; + if (!this.rootStore.issueTypes.epicStatsMap) this.rootStore.issueTypes.epicStatsMap = {}; response.forEach((stats) => { if (!stats) return; - this.initiativeEpicStatsMap![stats.epic_id] = stats; + this.rootStore.issueTypes.epicStatsMap![stats.epic_id] = stats; }); }); return response; } catch (error) { console.error("error while fetching initiatives stats", error); + throw error; } }; @@ -439,12 +429,18 @@ export class InitiativeStore implements IInitiativeStore { this.rootStore.issue.issues.addIssue(transformedResponse); runInAction(() => { - // Update the epicMap by adding the new epicIds update(this.initiativeEpicsMap, [initiativeId], (Ids: string[]) => [...Ids, ...responseIds]); }); - this.fetchInitiativeAnalytics(workspaceSlug, initiativeId); - this.fetchInitiativeEpicStats(workspaceSlug, initiativeId); + try { + await Promise.all([ + this.fetchInitiativeEpicStats(workspaceSlug, initiativeId), + this.fetchInitiativeAnalytics(workspaceSlug, initiativeId), + ]); + } catch (error) { + console.error("Error fetching initiative stats or analytics:", error); + // Not throwing here since the main operation (adding epics) was successful + } } catch (error) { console.error("Error adding epics to initiative", error); throw error;