Initial commit: Plane
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Some checks failed
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Admin Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Codespell / Check for spelling errors (push) Has been cancelled
Sync Repositories / sync_changes (push) Has been cancelled
Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export * from "./root";
|
||||
export * from "./issue-progress";
|
||||
export * from "./progress-stats";
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { EEstimateSystem } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { ChevronUpIcon, ChevronDownIcon } from "@plane/propel/icons";
|
||||
import type { TModulePlotType } from "@plane/types";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
import { CustomSelect, Spinner } from "@plane/ui";
|
||||
// components
|
||||
// constants
|
||||
// helpers
|
||||
import { getDate } from "@plane/utils";
|
||||
import ProgressChart from "@/components/core/sidebar/progress-chart";
|
||||
import { ModuleProgressStats } from "@/components/modules";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
|
||||
// plane web constants
|
||||
type TModuleAnalyticsProgress = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
|
||||
const moduleBurnDownChartOptions = [
|
||||
{ value: "burndown", i18n_label: "issues" },
|
||||
{ value: "points", i18n_label: "points" },
|
||||
];
|
||||
|
||||
export const ModuleAnalyticsProgress: FC<TModuleAnalyticsProgress> = observer((props) => {
|
||||
// props
|
||||
const { workspaceSlug, projectId, moduleId } = props;
|
||||
// router
|
||||
const searchParams = useSearchParams();
|
||||
const peekModule = searchParams.get("peekModule") || undefined;
|
||||
// plane hooks
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
const { getPlotTypeByModuleId, setPlotType, getModuleById, fetchModuleDetails, fetchArchivedModuleDetails } =
|
||||
useModule();
|
||||
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
|
||||
// state
|
||||
const [loader, setLoader] = useState(false);
|
||||
// derived values
|
||||
const moduleFilter = getFilter(EIssuesStoreType.MODULE, moduleId);
|
||||
const selectedAssignees = moduleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
|
||||
const selectedLabels = moduleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
|
||||
const selectedStateGroups = moduleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const plotType: TModulePlotType = getPlotTypeByModuleId(moduleId);
|
||||
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
|
||||
const estimateDetails =
|
||||
isCurrentProjectEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||
const isCurrentEstimateTypeIsPoints = estimateDetails && estimateDetails?.type === EEstimateSystem.POINTS;
|
||||
const completedIssues = moduleDetails?.completed_issues || 0;
|
||||
const totalIssues = moduleDetails?.total_issues || 0;
|
||||
const completedEstimatePoints = moduleDetails?.completed_estimate_points || 0;
|
||||
const totalEstimatePoints = moduleDetails?.total_estimate_points || 0;
|
||||
const progressHeaderPercentage = moduleDetails
|
||||
? plotType === "points"
|
||||
? completedEstimatePoints != 0 && totalEstimatePoints != 0
|
||||
? Math.round((completedEstimatePoints / totalEstimatePoints) * 100)
|
||||
: 0
|
||||
: completedIssues != 0 && completedIssues != 0
|
||||
? Math.round((completedIssues / totalIssues) * 100)
|
||||
: 0
|
||||
: 0;
|
||||
const chartDistributionData =
|
||||
plotType === "points" ? moduleDetails?.estimate_distribution : moduleDetails?.distribution || undefined;
|
||||
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
|
||||
const groupedIssues = useMemo(
|
||||
() => ({
|
||||
backlog: plotType === "points" ? moduleDetails?.backlog_estimate_points || 0 : moduleDetails?.backlog_issues || 0,
|
||||
unstarted:
|
||||
plotType === "points" ? moduleDetails?.unstarted_estimate_points || 0 : moduleDetails?.unstarted_issues || 0,
|
||||
started: plotType === "points" ? moduleDetails?.started_estimate_points || 0 : moduleDetails?.started_issues || 0,
|
||||
completed:
|
||||
plotType === "points" ? moduleDetails?.completed_estimate_points || 0 : moduleDetails?.completed_issues || 0,
|
||||
cancelled:
|
||||
plotType === "points" ? moduleDetails?.cancelled_estimate_points || 0 : moduleDetails?.cancelled_issues || 0,
|
||||
}),
|
||||
[plotType, moduleDetails]
|
||||
);
|
||||
const moduleStartDate = getDate(moduleDetails?.start_date);
|
||||
const moduleEndDate = getDate(moduleDetails?.target_date);
|
||||
const isModuleStartDateValid = moduleStartDate && moduleStartDate <= new Date();
|
||||
const isModuleEndDateValid = moduleStartDate && moduleEndDate && moduleEndDate >= moduleStartDate;
|
||||
const isModuleDateValid = isModuleStartDateValid && isModuleEndDateValid;
|
||||
const isArchived = !!moduleDetails?.archived_at;
|
||||
|
||||
// handlers
|
||||
const onChange = async (value: TModulePlotType) => {
|
||||
setPlotType(moduleId, value);
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
try {
|
||||
setLoader(true);
|
||||
if (isArchived) {
|
||||
await fetchArchivedModuleDetails(workspaceSlug, projectId, moduleId);
|
||||
} else {
|
||||
await fetchModuleDetails(workspaceSlug, projectId, moduleId);
|
||||
}
|
||||
setLoader(false);
|
||||
} catch (error) {
|
||||
setLoader(false);
|
||||
setPlotType(moduleId, plotType);
|
||||
}
|
||||
};
|
||||
|
||||
if (!moduleDetails) return <></>;
|
||||
return (
|
||||
<div className="border-t border-custom-border-200 space-y-4 py-4 px-3">
|
||||
<Disclosure defaultOpen={isModuleDateValid ? true : false}>
|
||||
{({ open }) => (
|
||||
<div className="space-y-6">
|
||||
{/* progress bar header */}
|
||||
{isModuleDateValid ? (
|
||||
<div className="relative w-full flex justify-between items-center gap-2">
|
||||
<Disclosure.Button className="relative flex items-center gap-2 w-full">
|
||||
<div className="font-medium text-custom-text-200 text-sm">{t("progress")}</div>
|
||||
{progressHeaderPercentage > 0 && (
|
||||
<div className="flex h-5 w-9 items-center justify-center rounded bg-amber-500/20 text-xs font-medium text-amber-500">{`${progressHeaderPercentage}%`}</div>
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
{isCurrentEstimateTypeIsPoints && (
|
||||
<>
|
||||
<div>
|
||||
<CustomSelect
|
||||
value={plotType}
|
||||
label={
|
||||
<span>
|
||||
{t(moduleBurnDownChartOptions.find((v) => v.value === plotType)?.i18n_label || "none")}
|
||||
</span>
|
||||
}
|
||||
onChange={onChange}
|
||||
maxHeight="lg"
|
||||
>
|
||||
{moduleBurnDownChartOptions.map((item) => (
|
||||
<CustomSelect.Option key={item.value} value={item.value}>
|
||||
{t(item.i18n_label)}
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
</div>
|
||||
{loader && <Spinner className="h-3 w-3" />}
|
||||
</>
|
||||
)}
|
||||
<Disclosure.Button className="ml-auto">
|
||||
{open ? (
|
||||
<ChevronUpIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
</Disclosure.Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full flex justify-between items-center gap-2">
|
||||
<div className="font-medium text-custom-text-200 text-sm">Progress</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<AlertCircle height={14} width={14} className="text-custom-text-200" />
|
||||
<span className="text-xs italic text-custom-text-200">
|
||||
{moduleDetails?.start_date && moduleDetails?.target_date
|
||||
? t("project_module.empty_state.sidebar.in_active")
|
||||
: t("project_module.empty_state.sidebar.invalid_date")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel className="space-y-4">
|
||||
{/* progress burndown chart */}
|
||||
<div>
|
||||
{moduleStartDate && moduleEndDate && completionChartDistributionData && (
|
||||
<Fragment>
|
||||
{plotType === "points" ? (
|
||||
<ProgressChart
|
||||
distribution={completionChartDistributionData}
|
||||
totalIssues={totalEstimatePoints}
|
||||
plotTitle={"points"}
|
||||
/>
|
||||
) : (
|
||||
<ProgressChart
|
||||
distribution={completionChartDistributionData}
|
||||
totalIssues={totalIssues}
|
||||
plotTitle={"work items"}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* progress detailed view */}
|
||||
{chartDistributionData && (
|
||||
<div className="w-full border-t border-custom-border-200 pt-5">
|
||||
<ModuleProgressStats
|
||||
distribution={chartDistributionData}
|
||||
groupedIssues={groupedIssues}
|
||||
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
|
||||
updateFilterValueFromSidebar,
|
||||
EIssuesStoreType.MODULE,
|
||||
moduleId
|
||||
)}
|
||||
isEditable={Boolean(!peekModule) && moduleFilter !== undefined}
|
||||
moduleId={moduleId}
|
||||
noBackground={false}
|
||||
plotType={plotType}
|
||||
roundedTab={false}
|
||||
selectedFilters={{
|
||||
assignees: selectedAssignees,
|
||||
labels: selectedLabels,
|
||||
stateGroups: selectedStateGroups,
|
||||
}}
|
||||
size="xs"
|
||||
totalIssuesCount={plotType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
|
||||
import type { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import type { TModuleDistribution, TModuleEstimateDistribution, TModulePlotType } from "@plane/types";
|
||||
import { cn, toFilterArray } from "@plane/utils";
|
||||
// components
|
||||
import type { TAssigneeData } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
import { AssigneeStatComponent } from "@/components/core/sidebar/progress-stats/assignee";
|
||||
import type { TLabelData } from "@/components/core/sidebar/progress-stats/label";
|
||||
import { LabelStatComponent } from "@/components/core/sidebar/progress-stats/label";
|
||||
import type { TSelectedFilterProgressStats } from "@/components/core/sidebar/progress-stats/shared";
|
||||
import { createFilterUpdateHandler, PROGRESS_STATS } from "@/components/core/sidebar/progress-stats/shared";
|
||||
import type { TStateGroupData } from "@/components/core/sidebar/progress-stats/state_group";
|
||||
import { StateGroupStatComponent } from "@/components/core/sidebar/progress-stats/state_group";
|
||||
// hooks
|
||||
import useLocalStorage from "@/hooks/use-local-storage";
|
||||
|
||||
type TModuleProgressStats = {
|
||||
distribution: TModuleDistribution | TModuleEstimateDistribution | undefined;
|
||||
groupedIssues: Record<string, number>;
|
||||
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
|
||||
isEditable?: boolean;
|
||||
moduleId: string;
|
||||
noBackground?: boolean;
|
||||
plotType: TModulePlotType;
|
||||
roundedTab?: boolean;
|
||||
selectedFilters: TSelectedFilterProgressStats;
|
||||
size?: "xs" | "sm";
|
||||
totalIssuesCount: number;
|
||||
};
|
||||
|
||||
export const ModuleProgressStats: FC<TModuleProgressStats> = observer((props) => {
|
||||
const {
|
||||
distribution,
|
||||
groupedIssues,
|
||||
handleFiltersUpdate,
|
||||
isEditable = false,
|
||||
moduleId,
|
||||
noBackground = false,
|
||||
plotType,
|
||||
roundedTab = false,
|
||||
selectedFilters,
|
||||
size = "sm",
|
||||
totalIssuesCount,
|
||||
} = props;
|
||||
// plane imports
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { storedValue: currentTab, setValue: setModuleTab } = useLocalStorage(
|
||||
`module-analytics-tab-${moduleId}`,
|
||||
"stat-assignees"
|
||||
);
|
||||
// derived values
|
||||
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
|
||||
const currentDistribution = distribution as TModuleDistribution;
|
||||
const currentEstimateDistribution = distribution as TModuleEstimateDistribution;
|
||||
const selectedAssigneeIds = toFilterArray(selectedFilters?.assignees?.value || []) as string[];
|
||||
const selectedLabelIds = toFilterArray(selectedFilters?.labels?.value || []) as string[];
|
||||
const selectedStateGroups = toFilterArray(selectedFilters?.stateGroups?.value || []) as string[];
|
||||
|
||||
const distributionAssigneeData: TAssigneeData =
|
||||
plotType === "burndown"
|
||||
? (currentDistribution?.assignees || []).map((assignee) => ({
|
||||
id: assignee?.assignee_id || undefined,
|
||||
title: assignee?.display_name || undefined,
|
||||
avatar_url: assignee?.avatar_url || undefined,
|
||||
completed: assignee.completed_issues,
|
||||
total: assignee.total_issues,
|
||||
}))
|
||||
: (currentEstimateDistribution?.assignees || []).map((assignee) => ({
|
||||
id: assignee?.assignee_id || undefined,
|
||||
title: assignee?.display_name || undefined,
|
||||
avatar_url: assignee?.avatar_url || undefined,
|
||||
completed: assignee.completed_estimates,
|
||||
total: assignee.total_estimates,
|
||||
}));
|
||||
|
||||
const distributionLabelData: TLabelData =
|
||||
plotType === "burndown"
|
||||
? (currentDistribution?.labels || []).map((label) => ({
|
||||
id: label?.label_id || undefined,
|
||||
title: label?.label_name || undefined,
|
||||
color: label?.color || undefined,
|
||||
completed: label.completed_issues,
|
||||
total: label.total_issues,
|
||||
}))
|
||||
: (currentEstimateDistribution?.labels || []).map((label) => ({
|
||||
id: label?.label_id || undefined,
|
||||
title: label?.label_name || undefined,
|
||||
color: label?.color || undefined,
|
||||
completed: label.completed_estimates,
|
||||
total: label.total_estimates,
|
||||
}));
|
||||
|
||||
const distributionStateData: TStateGroupData = Object.keys(groupedIssues || {}).map((state) => ({
|
||||
state: state,
|
||||
completed: groupedIssues?.[state] || 0,
|
||||
total: totalIssuesCount || 0,
|
||||
}));
|
||||
|
||||
const handleAssigneeFiltersUpdate = createFilterUpdateHandler(
|
||||
"assignee_id",
|
||||
selectedAssigneeIds,
|
||||
handleFiltersUpdate
|
||||
);
|
||||
const handleLabelFiltersUpdate = createFilterUpdateHandler("label_id", selectedLabelIds, handleFiltersUpdate);
|
||||
const handleStateGroupFiltersUpdate = createFilterUpdateHandler(
|
||||
"state_group",
|
||||
selectedStateGroups,
|
||||
handleFiltersUpdate
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tab.Group defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
|
||||
<Tab.List
|
||||
as="div"
|
||||
className={cn(
|
||||
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
|
||||
roundedTab ? `rounded-3xl` : `rounded-md`,
|
||||
noBackground ? `` : `bg-custom-background-90`,
|
||||
size === "xs" ? `text-xs` : `text-sm`
|
||||
)}
|
||||
>
|
||||
{PROGRESS_STATS.map((stat) => (
|
||||
<Tab
|
||||
className={cn(
|
||||
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
|
||||
roundedTab ? `rounded-3xl border border-custom-border-200` : `rounded`,
|
||||
stat.key === currentTab
|
||||
? "bg-custom-background-100 text-custom-text-300"
|
||||
: "text-custom-text-400 hover:text-custom-text-300"
|
||||
)}
|
||||
key={stat.key}
|
||||
onClick={() => setModuleTab(stat.key)}
|
||||
>
|
||||
{t(stat.i18n_title)}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="py-3 text-custom-text-200">
|
||||
<Tab.Panel key={"stat-assignees"}>
|
||||
<AssigneeStatComponent
|
||||
distribution={distributionAssigneeData}
|
||||
handleAssigneeFiltersUpdate={handleAssigneeFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedAssigneeIds={selectedAssigneeIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-labels"}>
|
||||
<LabelStatComponent
|
||||
distribution={distributionLabelData}
|
||||
handleLabelFiltersUpdate={handleLabelFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedLabelIds={selectedLabelIds}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
<Tab.Panel key={"stat-states"}>
|
||||
<StateGroupStatComponent
|
||||
distribution={distributionStateData}
|
||||
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
|
||||
isEditable={isEditable}
|
||||
selectedStateGroups={selectedStateGroups}
|
||||
totalIssuesCount={totalIssuesCount}
|
||||
/>
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
512
apps/web/core/components/modules/analytics-sidebar/root.tsx
Normal file
512
apps/web/core/components/modules/analytics-sidebar/root.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { Info, Plus, SquareUser } from "lucide-react";
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import {
|
||||
MODULE_STATUS,
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
EEstimateSystem,
|
||||
MODULE_TRACKER_EVENTS,
|
||||
MODULE_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
// plane types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import {
|
||||
MembersPropertyIcon,
|
||||
ModuleStatusIcon,
|
||||
WorkItemsIcon,
|
||||
StartDatePropertyIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { ILinkDetails, IModule, ModuleLink } from "@plane/types";
|
||||
// plane ui
|
||||
import { Loader, CustomSelect, TextArea } from "@plane/ui";
|
||||
// components
|
||||
// helpers
|
||||
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { CreateUpdateModuleLinkModal, ModuleAnalyticsProgress, ModuleLinksList } from "@/components/modules";
|
||||
import { captureElementAndEvent, captureSuccess, captureError } from "@/helpers/event-tracker.helper";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useModule } from "@/hooks/store/use-module";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// plane web constants
|
||||
const defaultValues: Partial<IModule> = {
|
||||
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 ModuleAnalyticsSidebar: React.FC<Props> = observer((props) => {
|
||||
const { moduleId, handleClose, isArchived } = props;
|
||||
// states
|
||||
const [moduleLinkModal, setModuleLinkModal] = useState(false);
|
||||
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
|
||||
// store hooks
|
||||
const { t } = useTranslation();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
|
||||
const { getModuleById, updateModuleDetails, createModuleLink, updateModuleLink, deleteModuleLink } = useModule();
|
||||
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
|
||||
|
||||
// derived values
|
||||
const moduleDetails = getModuleById(moduleId);
|
||||
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
|
||||
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
|
||||
const isEstimatePointValid = estimateType && estimateType?.type == EEstimateSystem.POINTS ? true : false;
|
||||
|
||||
const { reset, control } = useForm({
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const submitChanges = (data: Partial<IModule>) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), data)
|
||||
.then((res) => {
|
||||
captureElementAndEvent({
|
||||
element: {
|
||||
elementName: MODULE_TRACKER_ELEMENTS.RIGHT_SIDEBAR,
|
||||
},
|
||||
event: {
|
||||
eventName: MODULE_TRACKER_EVENTS.update,
|
||||
payload: { id: res.id },
|
||||
state: "SUCCESS",
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: MODULE_TRACKER_EVENTS.update,
|
||||
payload: { id: moduleId },
|
||||
error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateLink = async (formData: ModuleLink) => {
|
||||
if (!workspaceSlug || !projectId || !moduleId) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
await createModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), payload)
|
||||
.then(() =>
|
||||
captureSuccess({
|
||||
eventName: MODULE_TRACKER_EVENTS.link.create,
|
||||
payload: { id: moduleId },
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: MODULE_TRACKER_EVENTS.link.create,
|
||||
payload: { id: moduleId },
|
||||
error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateLink = async (formData: ModuleLink, linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !module) return;
|
||||
|
||||
const payload = { metadata: {}, ...formData };
|
||||
|
||||
await updateModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId, payload)
|
||||
.then(() =>
|
||||
captureSuccess({
|
||||
eventName: MODULE_TRACKER_EVENTS.link.update,
|
||||
payload: { id: moduleId },
|
||||
})
|
||||
)
|
||||
.catch((error) => {
|
||||
captureError({
|
||||
eventName: MODULE_TRACKER_EVENTS.link.update,
|
||||
payload: { id: moduleId },
|
||||
error,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteLink = async (linkId: string) => {
|
||||
if (!workspaceSlug || !projectId || !module) return;
|
||||
|
||||
deleteModuleLink(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), linkId)
|
||||
.then(() => {
|
||||
captureSuccess({
|
||||
eventName: MODULE_TRACKER_EVENTS.link.delete,
|
||||
payload: { id: moduleId },
|
||||
});
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Module link deleted successfully.",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Some error occurred",
|
||||
});
|
||||
captureError({
|
||||
eventName: MODULE_TRACKER_EVENTS.link.delete,
|
||||
payload: { id: moduleId },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
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.",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (moduleDetails)
|
||||
reset({
|
||||
...moduleDetails,
|
||||
});
|
||||
}, [moduleDetails, reset]);
|
||||
|
||||
const handleEditLink = (link: ILinkDetails) => {
|
||||
setSelectedLinkToUpdate(link);
|
||||
setModuleLinkModal(true);
|
||||
};
|
||||
|
||||
if (!moduleDetails)
|
||||
return (
|
||||
<Loader>
|
||||
<div className="space-y-2">
|
||||
<Loader.Item height="15px" width="50%" />
|
||||
<Loader.Item height="15px" width="30%" />
|
||||
</div>
|
||||
<div className="mt-8 space-y-3">
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
<Loader.Item height="30px" />
|
||||
</div>
|
||||
</Loader>
|
||||
);
|
||||
|
||||
const moduleStatus = MODULE_STATUS.find((status) => status.value === moduleDetails.status);
|
||||
|
||||
const issueCount =
|
||||
moduleDetails.total_issues === 0
|
||||
? "0 work items"
|
||||
: `${moduleDetails.completed_issues}/${moduleDetails.total_issues}`;
|
||||
|
||||
const issueEstimatePointCount =
|
||||
moduleDetails.total_estimate_points === 0
|
||||
? "0 work items"
|
||||
: `${moduleDetails.completed_estimate_points}/${moduleDetails.total_estimate_points}`;
|
||||
|
||||
const isEditingAllowed = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.PROJECT
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<CreateUpdateModuleLinkModal
|
||||
isOpen={moduleLinkModal}
|
||||
handleClose={() => {
|
||||
setModuleLinkModal(false);
|
||||
setTimeout(() => {
|
||||
setSelectedLinkToUpdate(null);
|
||||
}, 500);
|
||||
}}
|
||||
data={selectedLinkToUpdate}
|
||||
createLink={handleCreateLink}
|
||||
updateLink={handleUpdateLink}
|
||||
/>
|
||||
<>
|
||||
<div
|
||||
className={`sticky z-10 top-0 flex items-center justify-between bg-custom-sidebar-background-100 pb-5 pt-5`}
|
||||
>
|
||||
<div>
|
||||
<button
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-custom-border-300"
|
||||
onClick={() => handleClose()}
|
||||
>
|
||||
<ChevronRightIcon className="h-3 w-3 stroke-2 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-5 pt-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="status"
|
||||
render={({ field: { value } }) => (
|
||||
<CustomSelect
|
||||
customButton={
|
||||
<span
|
||||
className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-xs ${
|
||||
isEditingAllowed && !isArchived ? "cursor-pointer" : "cursor-not-allowed"
|
||||
}`}
|
||||
style={{
|
||||
color: moduleStatus ? moduleStatus.color : "#a3a3a2",
|
||||
backgroundColor: moduleStatus ? `${moduleStatus.color}20` : "#a3a3a220",
|
||||
}}
|
||||
>
|
||||
{(moduleStatus && t(moduleStatus?.i18n_label)) ?? t("project_modules.status.backlog")}
|
||||
</span>
|
||||
}
|
||||
value={value}
|
||||
onChange={(value: any) => {
|
||||
submitChanges({ status: value });
|
||||
}}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
>
|
||||
{MODULE_STATUS.map((status) => (
|
||||
<CustomSelect.Option key={status.value} value={status.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModuleStatusIcon status={status.value} />
|
||||
{t(status.i18n_label)}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{moduleDetails.name}</h4>
|
||||
</div>
|
||||
|
||||
{moduleDetails.description && (
|
||||
<TextArea
|
||||
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
|
||||
value={moduleDetails.description}
|
||||
disabled
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-5 pb-6 pt-2.5">
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||
<StartDatePropertyIcon className="h-4 w-4" />
|
||||
<span className="text-base">{t("date_range")}</span>
|
||||
</div>
|
||||
<div className="h-7">
|
||||
<Controller
|
||||
control={control}
|
||||
name="start_date"
|
||||
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
|
||||
<Controller
|
||||
control={control}
|
||||
name="target_date"
|
||||
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => {
|
||||
const startDate = getDate(startDateValue);
|
||||
const endDate = getDate(endDateValue);
|
||||
return (
|
||||
<DateRangeDropdown
|
||||
buttonContainerClassName="w-full"
|
||||
buttonVariant="background-with-text"
|
||||
value={{
|
||||
from: startDate,
|
||||
to: endDate,
|
||||
}}
|
||||
onSelect={(val) => {
|
||||
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
|
||||
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
|
||||
handleDateChange(val?.from, val?.to);
|
||||
}}
|
||||
placeholder={{
|
||||
from: t("start_date"),
|
||||
to: t("end_date"),
|
||||
}}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||
<SquareUser className="h-4 w-4" />
|
||||
<span className="text-base">{t("lead")}</span>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="lead_id"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="h-7 w-3/5">
|
||||
<MemberDropdown
|
||||
value={value ?? null}
|
||||
onChange={(val) => {
|
||||
submitChanges({ lead_id: val });
|
||||
}}
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
multiple={false}
|
||||
buttonVariant="background-with-text"
|
||||
placeholder={t("lead")}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
icon={SquareUser}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||
<MembersPropertyIcon className="h-4 w-4" />
|
||||
<span className="text-base">{t("members")}</span>
|
||||
</div>
|
||||
<Controller
|
||||
control={control}
|
||||
name="member_ids"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="h-7 w-3/5">
|
||||
<MemberDropdown
|
||||
value={value ?? []}
|
||||
onChange={(val: string[]) => {
|
||||
submitChanges({ member_ids: val });
|
||||
}}
|
||||
multiple
|
||||
projectId={projectId?.toString() ?? ""}
|
||||
buttonVariant={value && value?.length > 0 ? "transparent-without-text" : "background-with-text"}
|
||||
buttonClassName={value && value.length > 0 ? "hover:bg-transparent px-0" : ""}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||
<WorkItemsIcon className="h-4 w-4" />
|
||||
<span className="text-base">{t("issues")}</span>
|
||||
</div>
|
||||
<div className="flex h-7 w-3/5 items-center">
|
||||
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/**
|
||||
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
|
||||
*/}
|
||||
{isEstimatePointValid && (
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
|
||||
<WorkItemsIcon className="h-4 w-4" />
|
||||
<span className="text-base">{t("points")}</span>
|
||||
</div>
|
||||
<div className="flex h-7 w-3/5 items-center">
|
||||
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{workspaceSlug && projectId && moduleDetails?.id && (
|
||||
<ModuleAnalyticsProgress
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
moduleId={moduleDetails?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="flex w-full flex-col items-center justify-start gap-2 border-t border-custom-border-200 px-1.5 py-5">
|
||||
{/* Accessing link outside the disclosure as mobx is not considering the children inside Disclosure as part of the component hence not observing their state change*/}
|
||||
<Disclosure defaultOpen={!!moduleDetails?.link_module?.length}>
|
||||
{({ open }) => (
|
||||
<div className={`relative flex h-full w-full flex-col ${open ? "" : "flex-row"}`}>
|
||||
<Disclosure.Button className="flex w-full items-center justify-between gap-2 p-1.5">
|
||||
<div className="flex items-center justify-start gap-2 text-sm">
|
||||
<span className="font-medium text-custom-text-200">{t("common.links")}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ChevronDownIcon
|
||||
className={`h-3.5 w-3.5 ${open ? "rotate-180 transform" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</Disclosure.Button>
|
||||
<Transition show={open}>
|
||||
<Disclosure.Panel>
|
||||
<div className="mt-2 flex min-h-72 w-full flex-col space-y-3 overflow-y-auto">
|
||||
{isEditingAllowed && moduleDetails.link_module && moduleDetails.link_module.length > 0 ? (
|
||||
<>
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("add_link")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{moduleId && (
|
||||
<ModuleLinksList
|
||||
moduleId={moduleId}
|
||||
handleEditLink={handleEditLink}
|
||||
handleDeleteLink={handleDeleteLink}
|
||||
disabled={!isEditingAllowed || isArchived}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-3.5 w-3.5 stroke-[1.5] text-custom-text-300" />
|
||||
<span className="p-0.5 text-xs text-custom-text-300">
|
||||
{t("common.no_links_added_yet")}
|
||||
</span>
|
||||
</div>
|
||||
{isEditingAllowed && !isArchived && (
|
||||
<button
|
||||
className="flex items-center gap-1.5 text-sm font-medium text-custom-primary-100"
|
||||
onClick={() => setModuleLinkModal(true)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{t("add_link")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Disclosure.Panel>
|
||||
</Transition>
|
||||
</div>
|
||||
)}
|
||||
</Disclosure>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user