[WEB-3190] Chore epics initiatives (#2257)

* chore: added epics to initiatives properties

* fix: added modals for epics and projects

* fix: added epic id to epic relations search query

* fix: relates to returning same epic

---------

Co-authored-by: sangeethailango <sangeethailango21@gmail.com>
This commit is contained in:
Akshita Goyal
2025-01-22 16:47:15 +05:30
committed by GitHub
parent 6ee16212ed
commit f8f429afe7
7 changed files with 147 additions and 140 deletions

View File

@@ -73,7 +73,7 @@ class IssueSearchEndpoint(BaseAPIView):
if issue_relation == "true" and issue_id:
issues = search_issues(query, issues_and_epics)
issue = Issue.issue_objects.filter(pk=issue_id).first()
issue = Issue.objects.filter(pk=issue_id).first()
related_issue_ids = (
IssueRelation.objects.filter(Q(related_issue=issue) | Q(issue=issue))

View File

@@ -174,7 +174,7 @@ export const EpicOverviewWidgetModals: FC<Props> = observer((props) => {
projectId={projectId}
isOpen={isRelationModalOpen?.issueId === epicId && isRelationModalOpen?.relationType === relationKey}
handleClose={handleRelationOnClose}
searchParams={{ issue_relation: true }}
searchParams={{ issue_relation: true, issue_id: epicId }}
handleOnSubmit={handleExistingIssueModalOnSubmit}
workspaceLevelToggle
/>

View File

@@ -1,21 +1,14 @@
"use client";
import React, { FC, useState } from "react";
import React, { FC } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// ui
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { ProjectMultiSelectModal } from "@/components/project";
// hooks
import { useProject } from "@/hooks/store";
// plane web
import { CollapsibleDetailSection } from "@/plane-web/components/common/layout/main/sections/collapsible-root";
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
// local components
import { InitiativeAttachmentRoot } from "./collapsible-section/attachment";
import { InitiativeEpicsCollapsibleContent } from "./collapsible-section/epics/content";
import { WorkspaceEpicsListModal } from "./collapsible-section/epics/workspace-epic-modal";
import { InitiativeLinksCollapsibleContent } from "./collapsible-section/links/link-components/content";
import { InitiativeProjectsCollapsibleContent } from "./collapsible-section/projects/content";
import { InitiativeAttachmentActionButton } from "./info-section/attachment-button";
@@ -25,28 +18,24 @@ type Props = {
workspaceSlug: string;
initiativeId: string;
disabled: boolean;
toggleEpicModal: (value?: boolean) => void;
toggleProjectModal: (value?: boolean) => void;
};
export const InitiativeCollapsibleSection: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, disabled } = props;
// states
const [isProjectsOpen, setIsProjectsOpen] = useState(false);
const [isEpicModalOpen, setIsEpicModalOpen] = useState(false);
const { workspaceSlug, initiativeId, disabled, toggleEpicModal, toggleProjectModal } = props;
// store hooks
const {
initiative: {
getInitiativeEpicsById,
getInitiativeById,
updateInitiative,
initiativeLinks: { getInitiativeLinks },
initiativeAttachments: { getAttachmentsByInitiativeId },
openCollapsibleSection,
toggleOpenCollapsibleSection,
addEpicsToInitiative,
},
} = useInitiatives();
const { workspaceProjectIds } = useProject();
// derived values
const initiative = getInitiativeById(initiativeId);
@@ -72,56 +61,6 @@ export const InitiativeCollapsibleSection: FC<Props> = observer((props) => {
const epicCount = initiativeEpics?.length ?? 0;
// handlers
const handleProjectsUpdate = async (initiativeProjectIds: string[]) => {
if (!initiativeId) return;
if (initiativeProjectIds.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one project.",
});
return;
}
await updateInitiative(workspaceSlug?.toString(), initiativeId, { project_ids: initiativeProjectIds })
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `Initiative projects updated successfully.`,
});
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? `Failed to update initiative projects. Please try again!`,
});
});
};
const handleAddEpicToInitiative = async (epicIds: string[]) => {
try {
addEpicsToInitiative(workspaceSlug?.toString(), initiativeId, epicIds).then(() => {
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: `Epic${epicIds.length > 1 ? "s" : ""} added to Initiative successfully.`,
});
});
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Epic addition to Initiative failed. Please try again later.",
});
}
};
const toggleEpicModal = (value: boolean) => setIsEpicModalOpen(value);
return (
<>
{shouldRenderProjects && (
@@ -134,7 +73,7 @@ export const InitiativeCollapsibleSection: FC<Props> = observer((props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsProjectsOpen(!isProjectsOpen);
toggleProjectModal(true);
}}
disabled={disabled}
>
@@ -165,7 +104,7 @@ export const InitiativeCollapsibleSection: FC<Props> = observer((props) => {
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsEpicModalOpen(!isEpicModalOpen);
toggleEpicModal();
}}
disabled={disabled}
>
@@ -226,25 +165,6 @@ export const InitiativeCollapsibleSection: FC<Props> = observer((props) => {
onToggle={() => toggleOpenCollapsibleSection("attachments")}
/>
)}
<ProjectMultiSelectModal
isOpen={isProjectsOpen}
onClose={() => setIsProjectsOpen(false)}
onSubmit={handleProjectsUpdate}
selectedProjectIds={projectsIds ?? []}
projectIds={workspaceProjectIds || []}
/>
<WorkspaceEpicsListModal
workspaceSlug={workspaceSlug}
isOpen={isEpicModalOpen}
searchParams={{
initiative_id: initiativeId,
}}
handleClose={() => setIsEpicModalOpen(false)}
handleOnSubmit={async (data) => {
handleAddEpicToInitiative(data.map((epic) => epic.id));
}}
/>
</>
);
});

View File

@@ -16,10 +16,12 @@ type Props = {
workspaceSlug: string;
initiativeId: string;
disabled?: boolean;
toggleEpicModal: (value?: boolean) => void;
toggleProjectModal: (value?: boolean) => void;
};
export const InitiativeMainContentRoot: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, disabled = false } = props;
const { workspaceSlug, initiativeId, disabled = false, toggleEpicModal, toggleProjectModal } = props;
// store hooks
const { initiativesSidebarCollapsed } = useAppTheme();
@@ -27,7 +29,13 @@ export const InitiativeMainContentRoot: FC<Props> = observer((props) => {
<MainWrapper isSidebarOpen={!initiativesSidebarCollapsed}>
<InitiativeInfoSection workspaceSlug={workspaceSlug} initiativeId={initiativeId} disabled={disabled} />
<InitiativeProgressSection initiativeId={initiativeId} />
<InitiativeCollapsibleSection workspaceSlug={workspaceSlug} initiativeId={initiativeId} disabled={disabled} />
<InitiativeCollapsibleSection
workspaceSlug={workspaceSlug}
initiativeId={initiativeId}
disabled={disabled}
toggleEpicModal={toggleEpicModal}
toggleProjectModal={toggleProjectModal}
/>
<InitiativeModalsRoot workspaceSlug={workspaceSlug} initiativeId={initiativeId} />
</MainWrapper>
);

View File

@@ -1,11 +1,15 @@
import { useState } from "react";
import { observer } from "mobx-react";
// hooks
import { useUserPermissions } from "@/hooks/store";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { ProjectMultiSelectModal } from "@/components/project";
import { useProject, useUserPermissions } from "@/hooks/store";
import { EUserPermissions, EUserPermissionsLevel } from "@/plane-web/constants";
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
// local components
import { LayoutRoot } from "../../common";
import { InitiativeEmptyState } from "../details/empty-state";
import { WorkspaceEpicsListModal } from "./main/collapsible-section/epics/workspace-epic-modal";
import { InitiativeMainContentRoot } from "./main/root";
import { InitiativeSidebarRoot } from "./sidebar/root";
@@ -16,27 +20,113 @@ type Props = {
export const InitiativeDetailRoot = observer((props: Props) => {
const { workspaceSlug, initiativeId } = props;
// states
const [isProjectsOpen, setIsProjectsOpen] = useState(false);
const [isEpicModalOpen, setIsEpicModalOpen] = useState(false);
// store hooks
const {
initiative: { getInitiativeById },
initiative: { getInitiativeById, updateInitiative, addEpicsToInitiative },
} = useInitiatives();
const { workspaceProjectIds } = useProject();
const { allowPermissions } = useUserPermissions();
// derived values
const initiative = getInitiativeById(initiativeId);
const projectsIds = initiative?.project_ids ?? [];
const isEditable = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.WORKSPACE
);
// handlers
const handleProjectsUpdate = async (initiativeProjectIds: string[]) => {
if (!initiativeId) return;
if (initiativeProjectIds.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one project.",
});
return;
}
await updateInitiative(workspaceSlug?.toString(), initiativeId, { project_ids: initiativeProjectIds })
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `Initiative projects updated successfully.`,
});
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? `Failed to update initiative projects. Please try again!`,
});
});
};
const handleAddEpicToInitiative = async (epicIds: string[]) => {
try {
addEpicsToInitiative(workspaceSlug?.toString(), initiativeId, epicIds).then(() => {
setToast({
title: "Success!",
type: TOAST_TYPE.SUCCESS,
message: `Epic${epicIds.length > 1 ? "s" : ""} added to Initiative successfully.`,
});
});
} catch {
setToast({
title: "Error!",
type: TOAST_TYPE.ERROR,
message: "Epic addition to Initiative failed. Please try again later.",
});
}
};
const toggleEpicModal = (value?: boolean) => setIsEpicModalOpen(value || !isEpicModalOpen);
const toggleProjectsModal = (value?: boolean) => setIsProjectsOpen(value || !isProjectsOpen);
return (
<LayoutRoot
renderEmptyState={!initiative}
emptyStateComponent={<InitiativeEmptyState workspaceSlug={workspaceSlug} />}
>
<InitiativeMainContentRoot workspaceSlug={workspaceSlug} initiativeId={initiativeId} disabled={!isEditable} />
<InitiativeSidebarRoot workspaceSlug={workspaceSlug} initiativeId={initiativeId} disabled={!isEditable} />
<InitiativeMainContentRoot
workspaceSlug={workspaceSlug}
initiativeId={initiativeId}
disabled={!isEditable}
toggleEpicModal={toggleEpicModal}
toggleProjectModal={toggleProjectsModal}
/>
<InitiativeSidebarRoot
workspaceSlug={workspaceSlug}
initiativeId={initiativeId}
disabled={!isEditable}
toggleEpicModal={toggleEpicModal}
toggleProjectModal={toggleProjectsModal}
/>
<ProjectMultiSelectModal
isOpen={isProjectsOpen}
onClose={() => setIsProjectsOpen(false)}
onSubmit={handleProjectsUpdate}
selectedProjectIds={projectsIds ?? []}
projectIds={workspaceProjectIds || []}
/>
<WorkspaceEpicsListModal
workspaceSlug={workspaceSlug}
isOpen={isEpicModalOpen}
searchParams={{
initiative_id: initiativeId,
}}
handleClose={() => setIsEpicModalOpen(false)}
handleOnSubmit={async (data) => {
handleAddEpicToInitiative(data.map((epic) => epic.id));
}}
/>
</LayoutRoot>
);
});

View File

@@ -3,9 +3,9 @@
import { FC } from "react";
import { observer } from "mobx-react";
import { Briefcase, Calendar, UserCircle2 } from "lucide-react";
import { setToast, TOAST_TYPE } from "@plane/ui";
import { EpicIcon } from "@plane/ui";
// components
import { DateRangeDropdown, MemberDropdown, ProjectDropdown } from "@/components/dropdowns";
import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
// helpers
import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
@@ -14,22 +14,26 @@ import { useMember } from "@/hooks/store";
// plane web components
import { SidebarContentWrapper } from "@/plane-web/components/common/layout/sidebar/content-wrapper";
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
type Props = {
workspaceSlug: string;
initiativeId: string;
disabled: boolean;
toggleEpicModal: (value?: boolean) => void;
toggleProjectModal: (value?: boolean) => void;
};
export const InitiativeSidebarPropertiesRoot: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, disabled } = props;
const { workspaceSlug, initiativeId, disabled, toggleEpicModal, toggleProjectModal } = props;
const {
initiative: { getInitiativeById, updateInitiative },
initiative: { getInitiativeById, updateInitiative, getInitiativeEpicsById },
} = useInitiatives();
const { getUserDetails } = useMember();
// derived values
const initiativeEpicIds = getInitiativeEpicsById(initiativeId) ?? [];
const initiative = initiativeId ? getInitiativeById(initiativeId) : undefined;
const initiativeProjectIds = initiative?.project_ids || [];
const createdByDetails = initiative ? getUserDetails(initiative?.created_by) : undefined;
if (!initiative) return <></>;
@@ -46,37 +50,6 @@ export const InitiativeSidebarPropertiesRoot: FC<Props> = observer((props) => {
lead: id,
});
const handleProjects = (ids: string | string[]) => {
const projectIds = ids ? (Array.isArray(ids) ? ids : [ids]) : [];
if (projectIds.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one project.",
});
return;
}
updateInitiative(workspaceSlug.toString(), initiative.id, {
project_ids: projectIds,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Initiative projects updated successfully.",
});
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? "Failed to update initiative projects. Please try again!",
});
});
};
return (
<SidebarContentWrapper title="Properties">
<div className={`mb-2 space-y-2.5 ${disabled ? "opacity-60" : ""}`}>
@@ -86,13 +59,12 @@ export const InitiativeSidebarPropertiesRoot: FC<Props> = observer((props) => {
<Briefcase className="h-4 w-4 flex-shrink-0" />
<span>Projects</span>
</div>
<ProjectDropdown
buttonVariant={"border-with-text"}
onChange={handleProjects}
value={initiative.project_ids || []}
multiple
showTooltip
/>
<button
className="text-xs font-medium text-custom-text-300 border-[0.5px] px-2 py-1 border-custom-border-300 hover:bg-custom-background-80 rounded cursor-pointer"
onClick={() => toggleProjectModal(true)}
>
{initiativeProjectIds?.length} {initiativeProjectIds?.length === 1 ? "project" : "projects"}
</button>
</div>
{/* Lead Drop down*/}
<div className="flex h-8 items-center gap-2">
@@ -111,6 +83,19 @@ export const InitiativeSidebarPropertiesRoot: FC<Props> = observer((props) => {
showUserDetails
/>
</div>
{/* Epics dropdown */}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
<EpicIcon className="h-4 w-4 text-custom-text-300" />
<span>Epic</span>
</div>
<button
className="text-xs font-medium text-custom-text-300 border-[0.5px] px-2 py-1 border-custom-border-300 hover:bg-custom-background-80 rounded cursor-pointer"
onClick={() => toggleEpicModal(true)}
>
{initiativeEpicIds?.length} {initiativeEpicIds?.length === 1 ? "epic" : "epics"}
</button>
</div>
{/* Dates Drop down*/}
<div className="flex h-8 items-center gap-2">
<div className="flex w-2/5 flex-shrink-0 items-center gap-1 text-sm text-custom-text-300">
@@ -119,7 +104,7 @@ export const InitiativeSidebarPropertiesRoot: FC<Props> = observer((props) => {
</div>
<DateRangeDropdown
buttonVariant="border-with-text"
className="h-5"
buttonClassName="px-2 py-1 h-fit"
value={{
from: getDate(initiative.start_date),
to: getDate(initiative.end_date),

View File

@@ -18,10 +18,12 @@ type Props = {
workspaceSlug: string;
initiativeId: string;
disabled?: boolean;
toggleEpicModal: (value?: boolean) => void;
toggleProjectModal: (value?: boolean) => void;
};
export const InitiativeSidebarRoot: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, disabled = false } = props;
const { workspaceSlug, initiativeId, disabled = false, toggleEpicModal, toggleProjectModal } = props;
// store hooks
const { initiativesSidebarCollapsed } = useAppTheme();
@@ -34,6 +36,8 @@ export const InitiativeSidebarRoot: FC<Props> = observer((props) => {
workspaceSlug={workspaceSlug}
initiativeId={initiativeId}
disabled={disabled}
toggleEpicModal={toggleEpicModal}
toggleProjectModal={toggleProjectModal}
/>
),
},