"use client"; import React, { useCallback, useEffect, useState } from "react"; import isEqual from "lodash/isEqual"; import { observer } from "mobx-react-lite"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { Controller, useForm } from "react-hook-form"; import { AlertCircle, ArchiveRestoreIcon, CalendarClock, ChevronDown, ChevronRight, Info, LinkIcon, Plus, SquareUser, Trash2, Users, } from "lucide-react"; import { Disclosure, Transition } from "@headlessui/react"; import { IIssueFilterOptions, ILinkDetails, IModule, ModuleLink } from "@plane/types"; // ui import { CustomMenu, Loader, LayersIcon, CustomSelect, ModuleStatusIcon, TOAST_TYPE, setToast, ArchiveIcon, TextArea, } from "@plane/ui"; // components import { LinkModal, LinksList, SidebarProgressStats } from "@/components/core"; import ProgressChart from "@/components/core/sidebar/progress-chart"; import { DateRangeDropdown, MemberDropdown } from "@/components/dropdowns"; import { ArchiveModuleModal, DeleteModuleModal } from "@/components/modules"; // constant import { MODULE_LINK_CREATED, MODULE_LINK_DELETED, MODULE_LINK_UPDATED, MODULE_UPDATED, } from "@/constants/event-tracker"; import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { MODULE_STATUS } from "@/constants/module"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { copyUrlToClipboard } from "@/helpers/string.helper"; // hooks import { useModule, useUser, useEventTracker, useIssues } from "@/hooks/store"; // types const defaultValues: Partial = { lead_id: "", member_ids: [], start_date: null, target_date: null, status: "backlog", }; type Props = { moduleId: string; handleClose: () => void; isArchived?: boolean; }; // TODO: refactor this component export const ModuleDetailsSidebar: React.FC = observer((props) => { const { moduleId, handleClose, isArchived } = props; // states const [moduleDeleteModal, setModuleDeleteModal] = useState(false); const [archiveModuleModal, setArchiveModuleModal] = useState(false); const [moduleLinkModal, setModuleLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); // router const router = useRouter(); const { workspaceSlug, projectId } = useParams(); const searchParams = useSearchParams(); const peekModule = searchParams.get("peekModule"); // store hooks const { membership: { currentProjectRole }, } = useUser(); const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink, restoreModule } = useModule(); const { setTrackElement, captureModuleEvent, captureEvent } = useEventTracker(); const { issuesFilter: { issueFilters, updateFilters }, } = useIssues(EIssuesStoreType.MODULE); const moduleDetails = getModuleById(moduleId); const moduleState = moduleDetails?.status?.toLocaleLowerCase(); const isInArchivableGroup = !!moduleState && ["completed", "cancelled"].includes(moduleState); const { reset, control } = useForm({ defaultValues, }); const submitChanges = (data: Partial) => { if (!workspaceSlug || !projectId || !moduleId) return; updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data) .then((res) => { captureModuleEvent({ eventName: MODULE_UPDATED, payload: { ...res, changed_properties: Object.keys(data)[0], element: "Right side-peek", state: "SUCCESS" }, }); }) .catch(() => { captureModuleEvent({ eventName: MODULE_UPDATED, payload: { ...data, state: "FAILED" }, }); }); }; const handleCreateLink = async (formData: ModuleLink) => { if (!workspaceSlug || !projectId || !moduleId) return; const payload = { metadata: {}, ...formData }; createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload) .then(() => { captureEvent(MODULE_LINK_CREATED, { module_id: moduleId, state: "SUCCESS", }); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Module link created successfully.", }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Some error occurred", }); }); }; const handleUpdateLink = async (formData: ModuleLink, linkId: string) => { if (!workspaceSlug || !projectId || !module) return; const payload = { metadata: {}, ...formData }; updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload) .then(() => { captureEvent(MODULE_LINK_UPDATED, { module_id: moduleId, state: "SUCCESS", }); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Module link updated successfully.", }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Some error occurred", }); }); }; const handleDeleteLink = async (linkId: string) => { if (!workspaceSlug || !projectId || !module) return; deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId) .then(() => { captureEvent(MODULE_LINK_DELETED, { module_id: moduleId, state: "SUCCESS", }); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Module link deleted successfully.", }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Some error occurred", }); }); }; const handleCopyText = () => { copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${moduleId}`) .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Link copied", message: "Module link copied to clipboard", }); }) .catch(() => { setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Some error occurred", }); }); }; const handleDateChange = async (startDate: Date | undefined, targetDate: Date | undefined) => { submitChanges({ start_date: startDate ? renderFormattedPayloadDate(startDate) : null, target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null, }); setToast({ type: TOAST_TYPE.SUCCESS, title: "Success!", message: "Module updated successfully.", }); }; const handleRestoreModule = async (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!workspaceSlug || !projectId || !moduleId) return; await restoreModule(workspaceSlug.toString(), projectId.toString(), moduleId) .then(() => { setToast({ type: TOAST_TYPE.SUCCESS, title: "Restore success", message: "Your module can be found in project modules.", }); router.push(`/${workspaceSlug}/projects/${projectId}/archives/modules`); }) .catch(() => setToast({ type: TOAST_TYPE.ERROR, title: "Error!", message: "Module could not be restored. Please try again.", }) ); }; useEffect(() => { if (moduleDetails) reset({ ...moduleDetails, }); }, [moduleDetails, reset]); const handleFiltersUpdate = useCallback( (key: keyof IIssueFilterOptions, value: string | string[]) => { if (!workspaceSlug || !projectId) return; let newValues = issueFilters?.filters?.[key] ?? []; if (Array.isArray(value)) { if (key === "state") { if (isEqual(newValues, value)) newValues = []; else newValues = value; } else { value.forEach((val) => { if (!newValues.includes(val)) newValues.push(val); else newValues.splice(newValues.indexOf(val), 1); }); } } else { if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1); else newValues.push(value); } updateFilters( workspaceSlug.toString(), projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }, moduleId ); }, [workspaceSlug, projectId, moduleId, issueFilters, updateFilters] ); const startDate = getDate(moduleDetails?.start_date); const endDate = getDate(moduleDetails?.target_date); const isStartValid = startDate && startDate <= new Date(); const isEndValid = startDate && endDate && endDate >= startDate; const progressPercentage = moduleDetails ? Math.round((moduleDetails.completed_issues / moduleDetails.total_issues) * 100) : null; const handleEditLink = (link: ILinkDetails) => { setSelectedLinkToUpdate(link); setModuleLinkModal(true); }; if (!moduleDetails) return (
); const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status); const issueCount = moduleDetails.total_issues === 0 ? "0 Issue" : `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`; const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return (
{ setModuleLinkModal(false); setSelectedLinkToUpdate(null); }} data={selectedLinkToUpdate} status={selectedLinkToUpdate ? true : false} createIssueLink={handleCreateLink} updateIssueLink={handleUpdateLink} /> {workspaceSlug && projectId && ( setArchiveModuleModal(false)} /> )} setModuleDeleteModal(false)} data={moduleDetails} /> <>
{!isArchived && ( )} {isEditingAllowed && ( {!isArchived && ( setArchiveModuleModal(true)} disabled={!isInArchivableGroup}> {isInArchivableGroup ? (
Archive module
) : (

Archive module

Only completed or cancelled
module can be archived.

)}
)} {isArchived && ( Restore module )} { setTrackElement("Module peek-overview"); setModuleDeleteModal(true); }} > Delete module
)}
( {moduleStatus?.label ?? "Backlog"} } value={value} onChange={(value: any) => { submitChanges({ status: value }); }} disabled={!isEditingAllowed || isArchived} > {MODULE_STATUS.map((status) => (
{status.label}
))}
)} />

{moduleDetails.name}

{moduleDetails.description && (