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

Synced from upstream: 8853637e981ed7d8a6cff32bd98e7afe20f54362
This commit is contained in:
chuan
2025-11-07 00:00:52 +08:00
commit 8ebde8aa05
4886 changed files with 462270 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
export * from "./root";
export * from "./issue-progress";
export * from "./progress-stats";

View File

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

View File

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

View 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>
);
});