mirror of
https://github.com/makeplane/plane.git
synced 2026-07-13 05:49:40 +02:00
[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:
@@ -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))
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user