[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
This commit is contained in:
Anmol Singh Bhatia
2025-02-04 15:26:45 +05:30
committed by GitHub
parent bc4d183835
commit ecc0afb91b
9 changed files with 254 additions and 74 deletions

View File

@@ -22,6 +22,7 @@ interface IListItemProps {
actionItemContainerClassName?: string;
isSidebarOpen?: boolean;
quickActionElement?: JSX.Element;
preventDefaultNProgress?: boolean;
}
export const ListItem: FC<IListItemProps> = (props) => {
@@ -40,6 +41,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
isSidebarOpen = false,
quickActionElement,
itemClassName = "",
preventDefaultNProgress = false,
} = props;
// router
@@ -70,6 +72,7 @@ export const ListItem: FC<IListItemProps> = (props) => {
target="_self"
onClick={handleControlLinkClick}
disabled={disableLink}
data-prevent-nprogress={preventDefaultNProgress}
>
<div className="flex items-center gap-4 truncate">
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}

View File

@@ -109,52 +109,50 @@ export const EpicView: FC<IEpicView> = observer((props) => {
return (
<>
<div className="w-full !text-base">
{issueId && (
<div
ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={{
boxShadow:
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
}}
>
{isError ? (
<div className="relative h-screen w-full overflow-hidden">
<EpicPeekOverviewError removeRoutePeekId={removeRoutePeekId} />
</div>
) : (
isLoading && <EpicPeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />
)}
{!isLoading && !isError && issue && (
<>
{/* header */}
<EpicPeekOverviewHeader
peekMode={peekMode}
setPeekMode={(value) => 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 */}
<EpicDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
epicId={issueId.toString()}
/>
</>
)}
</div>
)}
</div>
{issueId && (
<div
ref={issuePeekOverviewRef}
className={peekOverviewIssueClassName}
style={{
boxShadow:
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
}}
>
{isError ? (
<div className="relative h-screen w-full overflow-hidden">
<EpicPeekOverviewError removeRoutePeekId={removeRoutePeekId} />
</div>
) : (
isLoading && <EpicPeekOverviewLoader removeRoutePeekId={removeRoutePeekId} />
)}
{!isLoading && !isError && issue && (
<>
{/* header */}
<EpicPeekOverviewHeader
peekMode={peekMode}
setPeekMode={(value) => 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 */}
<EpicDetailRoot
workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()}
epicId={issueId.toString()}
/>
</>
)}
</div>
)}
</>
);
});

View File

@@ -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={
<div className="flex shrink-0 items-center gap-2">
<BlockProperties initiative={initiative} isSidebarCollapsed={isSidebarCollapsed} disabled />
<ControlLink
className="relative flex w-full items-center gap-3 overflow-hidden"
href={`/${workspaceSlug}/initiatives/${initiative.id}`}
target="_self"
onClick={() => {
router.push(`/${workspaceSlug}/initiatives/${initiative.id}`);
}}
>
<ReadOnlyBlockProperties initiative={initiative} isSidebarCollapsed={isSidebarCollapsed} />
</ControlLink>
<div
className={cn("hidden", {
"md:flex": isSidebarCollapsed,

View File

@@ -0,0 +1,26 @@
"use client";
import React, { FC } from "react";
// plane imports
import { cn } from "@plane/utils";
type Props = {
className?: string;
children: React.ReactNode;
};
export const PropertyBlockWrapper: FC<Props> = (props) => {
const { className = "", children } = props;
return (
<div className="h-5">
<div
className={cn(
"h-full text-xs px-2 flex items-center gap-2 text-custom-text-200 border-[0.5px] border-custom-border-300 rounded",
className
)}
>
{children}
</div>
</div>
);
};

View File

@@ -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) => (
<span className="grid place-items-center flex-shrink-0 h-4 w-4">
<Logo logo={projectDetails.logo_props} size={14} />
</span>
);
if (Array.isArray(value)) {
return (
<div className="flex items-center gap-0.5">
{value.length > 0 ? (
value.map((projectId) => {
const projectDetails = getProjectById(projectId);
return projectDetails ? renderIcon(projectDetails) : null;
})
) : (
<Briefcase className="size-3 text-custom-text-300" />
)}
</div>
);
} 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 (
<div
className={`relative flex flex-wrap ${isSidebarCollapsed ? "md:flex-grow md:flex-shrink-0" : "lg:flex-grow lg:flex-shrink-0"} items-center gap-2 whitespace-nowrap`}
>
{/* dates */}
<PropertyBlockWrapper>
<span className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow")}>
{<CalendarDays className="h-3 w-3 flex-shrink-0" />}
{startDate ? renderFormattedDate(startDate) : ""}
</span>
<ArrowRight className="h-3 w-3 flex-shrink-0" />
<span className={cn("h-full flex items-center justify-center gap-1 rounded-sm flex-grow")}>
{!initiative.end_date && <CalendarCheck2 className="h-3 w-3 flex-shrink-0" />}
{endDate ? renderFormattedDate(endDate) : ""}
</span>
</PropertyBlockWrapper>
{/* lead */}
<PropertyBlockWrapper>
{lead ? (
<>
<Avatar
key={lead.id}
name={lead.display_name}
src={getFileURL(lead.avatar_url)}
size={14}
className="text-[9px]"
/>
<div>{lead.first_name}</div>
</>
) : (
<>
<Users className="h-3 w-3 flex-shrink-0" />
<div>Lead</div>
</>
)}
</PropertyBlockWrapper>
{/* projects */}
<PropertyBlockWrapper>
{getProjectIcon(initiative.project_ids || [])}
<span className="flex-grow truncate max-w-40">
{getProjectDisplayName(initiative.project_ids || [], "Project")}
</span>
</PropertyBlockWrapper>
{/* epics */}
<PropertyBlockWrapper>
<EpicIcon className="h-4 w-4 text-custom-text-300" />
<span className="flex-grow truncate max-w-40">{getEpicDisplayName(initiative.epic_ids || [], "Epic")}</span>
</PropertyBlockWrapper>
</div>
);
});

View File

@@ -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<Props> = 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<Props> = 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<Props> = 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) => {

View File

@@ -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<Props> = 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<Props> = 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 (
<ListItem
title={issue.name}
@@ -75,7 +74,12 @@ export const EpicListItem: React.FC<Props> = observer((props) => {
}
quickActionElement={
<div className="flex shrink-0 items-center gap-2">
<EpicProperties epicId={epicId} disabled={disabled} />
<EpicProperties
workspaceSlug={workspaceSlug}
initiativeId={initiativeId}
epicId={epicId}
disabled={disabled}
/>
<div className={cn("hidden md:flex")}>
<EpicQuickActions
workspaceSlug={workspaceSlug}
@@ -90,6 +94,8 @@ export const EpicListItem: React.FC<Props> = 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
/>
);
});

View File

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

View File

@@ -37,7 +37,6 @@ export interface IInitiativeStore {
initiativeAnalyticsMap: Record<string, TInitiativeAnalytics>;
initiativeEpicLoader: Record<string, TLoader>;
initiativeEpicsMap: Record<string, string[]>;
initiativeEpicStatsMap: Record<string, TEpicStats> | undefined; // epicId -> stats
initiativesLoader: boolean;
openCollapsibleSection: InitiativeCollapsible[];
@@ -58,8 +57,6 @@ export interface IInitiativeStore {
removeEpicFromInitiative: (workspaceSlug: string, initiativeId: string, epicId: string) => Promise<void>;
addEpicsToInitiative: (workspaceSlug: string, initiativeId: string, epicIds: string[]) => Promise<void>;
getInitiativeEpicStatsById: (epicId: string) => TEpicStats | undefined;
fetchInitiatives: (workspaceSlug: string) => Promise<TInitiative[] | undefined>;
fetchInitiativesStats: (workspaceSlug: string) => Promise<TInitiativeStats[] | undefined>;
fetchInitiativeEpicStats: (workspaceSlug: string, initiativeId: string) => Promise<TEpicStats[] | undefined>;
@@ -90,7 +87,6 @@ export class InitiativeStore implements IInitiativeStore {
initiativeEpicLoader: Record<string, TLoader> = {};
initiativeEpicsMap: Record<string, string[]> = {};
initiativeEpicStatsMap: Record<string, TEpicStats> | 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<TEpicStats[] | undefined> => {
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;