snapshot of https://github.com/makeplane/plane.git @ d868349a55de79979eb8b98701bf1702c67ad634 (2026-09-15)
Some checks failed
CodeQL / Analyze (javascript) (push) Failing after 16s
CodeQL / Analyze (python) (push) Failing after 3s
i18n sync check / check:sync (push) Failing after 3s
React Doctor / react-doctor (push) Failing after 16s
Branch Build CE / Build-Push Admin Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Admin Manifest (push) Has been cancelled
Branch Build CE / Build-Push Web Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Web Manifest (push) Has been cancelled
Branch Build CE / Build-Push Space Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Proxy Manifest (push) Has been cancelled
Branch Build CE / Merge Space Manifest (push) Has been cancelled
Branch Build CE / Prepare AIO Assets (push) Has been cancelled
Branch Build CE / Build Setup (push) Has been cancelled
Branch Build CE / Build-Push Live Collaboration Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge Live Collaboration Manifest (push) Has been cancelled
Branch Build CE / Build-Push API Server Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge API Server Manifest (push) Has been cancelled
Branch Build CE / Build-Push Proxy Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Build-Push AIO Docker Image (${{ matrix.arch }}) (push) Has been cancelled
Branch Build CE / Merge AIO Manifest (push) Has been cancelled
Branch Build CE / Upload Build Assets (push) Has been cancelled
Branch Build CE / Build Release (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-15 15:14:58 +05:00
commit 23059a1de3
5252 changed files with 573834 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export * from "./root";

View File

@@ -0,0 +1,175 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useMemo } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { ChevronDownOutline, ChevronUpOutline } from "@makeplane/propel/icons";
import type { ICycle, TCyclePlotType, TProgressSnapshot } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { getDate } from "@plane/utils";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
// plane web components
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
// local imports
import { CycleProgressStats } from "./progress-stats";
import { SidebarChart } from "./sidebar-chart";
type TCycleAnalyticsProgress = {
workspaceSlug: string;
projectId: string;
cycleId: string;
};
type Options = {
value: string;
label: string;
};
export const cycleEstimateOptions: Options[] = [
{ value: "issues", label: "Work items" },
{ value: "points", label: "Estimates" },
];
export const cycleChartOptions: Options[] = [
{ value: "burndown", label: "Burn-down" },
{ value: "burnup", label: "Burn-up" },
];
export const validateCycleSnapshot = (cycleDetails: ICycle | null): ICycle | null => {
if (!cycleDetails || cycleDetails === null) return cycleDetails;
const updatedCycleDetails: any = { ...cycleDetails };
if (!isEmpty(cycleDetails.progress_snapshot)) {
Object.keys(cycleDetails.progress_snapshot || {}).forEach((key) => {
const currentKey = key as keyof TProgressSnapshot;
if (!isEmpty(cycleDetails.progress_snapshot) && !isEmpty(updatedCycleDetails)) {
updatedCycleDetails[currentKey as keyof ICycle] = cycleDetails?.progress_snapshot?.[currentKey];
}
});
}
return updatedCycleDetails;
};
export const CycleAnalyticsProgress = observer(function CycleAnalyticsProgress(props: TCycleAnalyticsProgress) {
// props
const { workspaceSlug, projectId, cycleId } = props;
// router
const searchParams = useSearchParams();
const peekCycle = searchParams.get("peekCycle") || undefined;
// plane hooks
const { t } = useTranslation();
// store hooks
const { getPlotTypeByCycleId, getEstimateTypeByCycleId, getCycleById } = useCycle();
const { getFilter, updateFilterValueFromSidebar } = useWorkItemFilters();
// derived values
const cycleFilter = getFilter(EIssuesStoreType.CYCLE, cycleId);
const selectedAssignees = cycleFilter?.findFirstConditionByPropertyAndOperator("assignee_id", "in");
const selectedLabels = cycleFilter?.findFirstConditionByPropertyAndOperator("label_id", "in");
const selectedStateGroups = cycleFilter?.findFirstConditionByPropertyAndOperator("state_group", "in");
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
const plotType: TCyclePlotType = getPlotTypeByCycleId(cycleId);
const estimateType = getEstimateTypeByCycleId(cycleId);
const totalIssues = cycleDetails?.total_issues || 0;
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
const chartDistributionData =
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const groupedIssues = useMemo(
() => ({
backlog:
estimateType === "points" ? cycleDetails?.backlog_estimate_points || 0 : cycleDetails?.backlog_issues || 0,
unstarted:
estimateType === "points" ? cycleDetails?.unstarted_estimate_points || 0 : cycleDetails?.unstarted_issues || 0,
started:
estimateType === "points" ? cycleDetails?.started_estimate_points || 0 : cycleDetails?.started_issues || 0,
completed:
estimateType === "points" ? cycleDetails?.completed_estimate_points || 0 : cycleDetails?.completed_issues || 0,
cancelled:
estimateType === "points" ? cycleDetails?.cancelled_estimate_points || 0 : cycleDetails?.cancelled_issues || 0,
}),
[estimateType, cycleDetails]
);
const cycleStartDate = getDate(cycleDetails?.start_date);
const cycleEndDate = getDate(cycleDetails?.end_date);
const isCycleStartDateValid = cycleStartDate && cycleStartDate <= new Date();
const isCycleEndDateValid = cycleStartDate && cycleEndDate && cycleEndDate >= cycleStartDate;
const isCycleDateValid = isCycleStartDateValid && isCycleEndDateValid;
if (!cycleDetails) return <></>;
return (
<div className="space-y-4 border-t border-subtle py-5">
<Disclosure defaultOpen>
{({ open }) => (
<div className="flex flex-col">
{/* progress bar header */}
{isCycleDateValid ? (
<div className="relative flex w-full items-center justify-between gap-2">
<Disclosure.Button className="relative flex w-full items-center gap-2">
<div className="text-13 font-medium text-secondary">{t("project_cycles.active_cycle.progress")}</div>
</Disclosure.Button>
<Disclosure.Button className="ml-auto">
{open ? (
<ChevronUpOutline className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDownOutline className="h-3.5 w-3.5" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
) : (
<div className="relative flex w-full items-center justify-between gap-2">
<div className="text-13 font-medium text-secondary">{t("project_cycles.active_cycle.progress")}</div>
</div>
)}
<Transition as="div" show={open}>
<Disclosure.Panel className="flex flex-col divide-y divide-subtle-1">
{cycleStartDate && cycleEndDate ? (
<>
{isCycleDateValid && (
<SidebarChart workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
)}
{/* progress detailed view */}
{chartDistributionData && (
<div className="w-full py-4">
<CycleProgressStats
cycleId={cycleId}
distribution={chartDistributionData}
groupedIssues={groupedIssues}
handleFiltersUpdate={updateFilterValueFromSidebar.bind(
updateFilterValueFromSidebar,
EIssuesStoreType.CYCLE,
cycleId
)}
isEditable={Boolean(!peekCycle) && cycleFilter !== undefined}
noBackground={false}
plotType={plotType}
roundedTab={false}
selectedFilters={{
assignees: selectedAssignees,
labels: selectedLabels,
stateGroups: selectedStateGroups,
}}
size="xs"
totalIssuesCount={estimateType === "points" ? totalEstimatePoints || 0 : totalIssues || 0}
/>
</div>
)}
</>
) : (
<div className="my-2 w-full rounded-md bg-surface-2 px-2 py-2 text-13 text-tertiary">
{t("no_data_yet")}
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
);
});

View File

@@ -0,0 +1,181 @@
import { Fragment } from "react";
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { TCycleDistribution, TCycleEstimateDistribution, TCyclePlotType } 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";
// helpers
// hooks
import useLocalStorage from "@/hooks/use-local-storage";
type TCycleProgressStats = {
cycleId: string;
distribution: TCycleDistribution | TCycleEstimateDistribution | undefined;
groupedIssues: Record<string, number>;
handleFiltersUpdate: (condition: TWorkItemFilterCondition) => void;
isEditable?: boolean;
noBackground?: boolean;
plotType: TCyclePlotType;
roundedTab?: boolean;
selectedFilters: TSelectedFilterProgressStats;
size?: "xs" | "sm";
totalIssuesCount: number;
};
export const CycleProgressStats = observer(function CycleProgressStats(props: TCycleProgressStats) {
const {
cycleId,
distribution,
groupedIssues,
handleFiltersUpdate,
isEditable = false,
noBackground = false,
plotType,
roundedTab = false,
selectedFilters,
size = "sm",
totalIssuesCount,
} = props;
// plane imports
const { t } = useTranslation();
// store imports
const { storedValue: currentTab, setValue: setCycleTab } = useLocalStorage(
`cycle-analytics-tab-${cycleId}`,
"stat-assignees"
);
// derived values
const currentTabIndex = (tab: string): number => PROGRESS_STATS.findIndex((stat) => stat.key === tab);
const currentDistribution = distribution as TCycleDistribution;
const currentEstimateDistribution = distribution as TCycleEstimateDistribution;
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 as={Fragment} 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-layer-2`,
size === "xs" ? `text-11` : `text-13`
)}
>
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`w-full cursor-pointer p-1 text-primary transition-all outline-none focus:outline-none`,
roundedTab ? `rounded-3xl border border-subtle` : `rounded-sm`,
stat.key === currentTab
? "bg-layer-transparent-active text-secondary"
: "text-placeholder hover:text-secondary"
)}
key={stat.key}
onClick={() => setCycleTab(stat.key)}
>
{t(stat.i18n_title)}
</Tab>
))}
</Tab.List>
<Tab.Panels className="py-3 text-secondary">
<Tab.Panel key={"stat-states"}>
<StateGroupStatComponent
distribution={distributionStateData}
handleStateGroupFiltersUpdate={handleStateGroupFiltersUpdate}
isEditable={isEditable}
selectedStateGroups={selectedStateGroups}
totalIssuesCount={totalIssuesCount}
/>
</Tab.Panel>
<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.Panels>
</Tab.Group>
</div>
);
});

View File

@@ -0,0 +1,68 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { observer } from "mobx-react";
// plane imports
import { Loader } from "@plane/ui";
// local imports
import useCyclesDetails from "../active-cycle/use-cycles-details";
import { CycleAnalyticsProgress } from "./issue-progress";
import { CycleSidebarDetails } from "./sidebar-details";
import { CycleSidebarHeader } from "./sidebar-header";
type Props = {
handleClose: () => void;
isArchived?: boolean;
cycleId: string;
projectId: string;
workspaceSlug: string;
};
export const CycleDetailsSidebar = observer(function CycleDetailsSidebar(props: Props) {
const { handleClose, isArchived, projectId, workspaceSlug, cycleId } = props;
// store hooks
const { cycle: cycleDetails } = useCyclesDetails({
workspaceSlug,
projectId,
cycleId,
});
if (!cycleDetails)
return (
<Loader className="px-5">
<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>
);
return (
<div className="relative pb-2">
<div className="flex w-full flex-col gap-5">
<CycleSidebarHeader
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleDetails={cycleDetails}
isArchived={isArchived}
handleClose={handleClose}
/>
<CycleSidebarDetails projectId={projectId} cycleDetails={cycleDetails} />
</div>
{workspaceSlug && projectId && cycleDetails?.id && (
<CycleAnalyticsProgress workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleDetails?.id} />
)}
</div>
);
});

View File

@@ -0,0 +1,90 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment } from "react";
import { observer } from "mobx-react";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { TCycleEstimateType } from "@plane/types";
import { Loader } from "@plane/ui";
import { getDate } from "@plane/utils";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { validateCycleSnapshot } from "@/components/cycles/analytics-sidebar/issue-progress";
import { EstimateTypeDropdown } from "@/components/cycles/dropdowns";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
type ProgressChartProps = {
workspaceSlug: string;
projectId: string;
cycleId: string;
};
export const SidebarChart = observer(function SidebarChart(props: ProgressChartProps) {
const { workspaceSlug, projectId, cycleId } = props;
// hooks
const { getEstimateTypeByCycleId, getCycleById, fetchCycleDetails, fetchArchivedCycleDetails, setEstimateType } =
useCycle();
const { t } = useTranslation();
// derived data
const cycleDetails = validateCycleSnapshot(getCycleById(cycleId));
const cycleStartDate = getDate(cycleDetails?.start_date);
const cycleEndDate = getDate(cycleDetails?.end_date);
const totalEstimatePoints = cycleDetails?.total_estimate_points || 0;
const totalIssues = cycleDetails?.total_issues || 0;
const estimateType = getEstimateTypeByCycleId(cycleId);
const chartDistributionData =
estimateType === "points" ? cycleDetails?.estimate_distribution : cycleDetails?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
if (!workspaceSlug || !projectId || !cycleId) return null;
const isArchived = !!cycleDetails?.archived_at;
// handlers
const onChange = async (value: TCycleEstimateType) => {
setEstimateType(cycleId, value);
if (!workspaceSlug || !projectId || !cycleId) return;
try {
if (isArchived) {
await fetchArchivedCycleDetails(workspaceSlug, projectId, cycleId);
} else {
await fetchCycleDetails(workspaceSlug, projectId, cycleId);
}
} catch (err) {
console.error(err);
setEstimateType(cycleId, estimateType);
}
};
return (
<div>
<div className="relative flex items-center justify-between gap-2 pt-4">
<EstimateTypeDropdown value={estimateType} onChange={onChange} cycleId={cycleId} projectId={projectId} />
</div>
<div className="py-4">
<div>
{cycleStartDate && cycleEndDate && completionChartDistributionData ? (
<Fragment>
<ProgressChart
distribution={completionChartDistributionData}
totalIssues={estimateType === "points" ? totalEstimatePoints : totalIssues}
plotTitle={estimateType === "points" ? t("points") : t("work_items")}
/>
</Fragment>
) : (
<Loader className="mt-4 h-[160px] w-full">
<Loader.Item width="100%" height="100%" />
</Loader>
)}
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,150 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { MembersOutline, UserAltOutline, WorkItemsOutline } from "@makeplane/propel/icons";
// plane types
import { EEstimateSystem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Avatar } from "@makeplane/propel/components/avatar";
import { TextArea } from "@makeplane/propel/components/text-area";
import type { ICycle } from "@plane/types";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
import { AvatarGroupOverflow } from "@/components/common/avatar-group-overflow";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useMember } from "@/hooks/store/use-member";
// plane web constants
type Props = {
projectId: string;
cycleDetails: ICycle;
};
export const CycleSidebarDetails = observer(function CycleSidebarDetails(props: Props) {
const { projectId, cycleDetails } = props;
// hooks
const { getUserDetails } = useMember();
const { areEstimateEnabledByProjectId, currentActiveEstimateId, estimateById } = useProjectEstimates();
const { t } = useTranslation();
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const issueCount =
isCompleted && !isEmpty(cycleDetails?.progress_snapshot)
? cycleDetails?.progress_snapshot?.total_issues === 0
? `0 ${t("common.work_item")}`
: `${cycleDetails?.progress_snapshot?.completed_issues}/${cycleDetails?.progress_snapshot?.total_issues}`
: cycleDetails?.total_issues === 0
? `0 ${t("common.work_item")}`
: `${cycleDetails?.completed_issues}/${cycleDetails?.total_issues}`;
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const cycleOwnerDetails = cycleDetails ? getUserDetails(cycleDetails.owned_by_id) : undefined;
const isEstimatePointValid = isEmpty(cycleDetails?.progress_snapshot || {})
? estimateType && estimateType?.type == EEstimateSystem.POINTS
? true
: false
: isEmpty(cycleDetails?.progress_snapshot?.estimate_distribution || {})
? false
: true;
const issueEstimatePointCount =
isCompleted && !isEmpty(cycleDetails?.progress_snapshot)
? cycleDetails?.progress_snapshot.total_issues === 0
? `0 ${t("common.work_item")}`
: `${cycleDetails?.progress_snapshot.completed_estimate_points}/${cycleDetails?.progress_snapshot.total_estimate_points}`
: cycleDetails?.total_issues === 0
? `0 ${t("common.work_item")}`
: `${cycleDetails?.completed_estimate_points}/${cycleDetails?.total_estimate_points}`;
return (
<div className="flex w-full flex-col gap-5">
{cycleDetails?.description && (
<TextArea size="lg" surface="inline" autoResize value={cycleDetails.description} disabled />
)}
<div className="flex flex-col gap-5 pt-2.5 pb-6">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-tertiary">
<UserAltOutline className="h-4 w-4" />
<span className="text-14">{t("lead")}</span>
</div>
<div className="flex w-3/5 items-center rounded-xs">
<div className="flex items-center gap-2.5">
<Avatar
alt={cycleOwnerDetails?.display_name}
fallback={cycleOwnerDetails?.display_name?.[0]?.toUpperCase()}
src={getFileURL(cycleOwnerDetails?.avatar_url ?? "")}
size="xs"
/>
<span className="text-13 text-secondary">{cycleOwnerDetails?.display_name}</span>
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-tertiary">
<MembersOutline className="h-4 w-4" />
<span className="text-14">{t("members")}</span>
</div>
<div className="flex w-3/5 items-center rounded-xs">
<div className="flex items-center gap-2.5">
{cycleDetails?.assignee_ids && cycleDetails.assignee_ids.length > 0 ? (
<>
<AvatarGroupOverflow size="xs">
{cycleDetails.assignee_ids.map((member) => {
const memberDetails = getUserDetails(member);
return (
<Avatar
key={memberDetails?.id}
alt={memberDetails?.display_name ?? ""}
fallback={memberDetails?.display_name?.[0]?.toUpperCase()}
src={getFileURL(memberDetails?.avatar_url ?? "")}
/>
);
})}
</AvatarGroupOverflow>
</>
) : (
<span className="px-1.5 text-13 text-tertiary">{t("no_assignee")}</span>
)}
</div>
</div>
</div>
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-tertiary">
<WorkItemsOutline className="h-4 w-4" />
<span className="text-14">{t("work_items")}</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-13 text-tertiary">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && !isCompleted && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-tertiary">
<WorkItemsOutline className="h-4 w-4" />
<span className="text-14">{t("points")}</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-13 text-tertiary">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,199 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
// Plane Imports
import { CYCLE_STATUS, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ChevronRightOutline } from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ICycle } from "@plane/types";
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useUserPermissions } from "@/hooks/store/user";
import { useTimeZoneConverter } from "@/hooks/use-timezone-converter";
// services
import { CycleService } from "@/services/cycle.service";
type Props = {
workspaceSlug: string;
projectId: string;
cycleDetails: ICycle;
handleClose: () => void;
isArchived?: boolean;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
const cycleService = new CycleService();
export const CycleSidebarHeader = observer(function CycleSidebarHeader(props: Props) {
const { workspaceSlug, projectId, cycleDetails, handleClose, isArchived = false } = props;
// hooks
const { allowPermissions } = useUserPermissions();
const { updateCycleDetails } = useCycle();
const { t } = useTranslation();
const { renderFormattedDateInUserTimezone, getProjectUTCOffset } = useTimeZoneConverter(projectId);
// derived values
const projectUTCOffset = getProjectUTCOffset();
// form info
const { control, reset } = useForm({
defaultValues,
});
const cycleStatus = cycleDetails?.status?.toLocaleLowerCase();
const isCompleted = cycleStatus === "completed";
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const submitChanges = async (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleDetails.id) return;
await updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleDetails.id.toString(), data);
};
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug, projectId, payload);
return res.status;
} catch (_err) {
return false;
}
};
const handleDateChange = async (startDate: Date | undefined, endDate: Date | undefined) => {
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate) || null,
end_date: renderFormattedPayloadDate(endDate) || null,
};
if (payload?.start_date && payload.end_date) {
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
} else {
isDateValid = true;
}
if (isDateValid) {
submitChanges(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_cycles.action.update.success.title"),
message: t("project_cycles.action.update.success.description"),
});
} else {
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_cycles.action.update.failed.title"),
message: t("project_cycles.action.update.error.already_exists"),
});
}
return isDateValid;
};
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT
);
return (
<>
<div className="sticky top-0 z-10 flex items-center justify-between bg-surface-1 pt-2">
<div className="flex size-5 items-center justify-center">
<button
className="flex size-6 flex-shrink-0 items-center justify-center rounded-full bg-layer-3 hover:bg-layer-3-hover"
onClick={() => handleClose()}
>
<ChevronRightOutline className="size-4 text-secondary" />
</button>
</div>
</div>
<div className="flex w-full flex-col gap-2">
<div className="flex items-start justify-between gap-3 pt-2">
<h4 className="w-full text-18 font-semibold break-words text-primary">{cycleDetails.name}</h4>
{currentCycle && (
<span
className="flex h-6 min-w-20 items-center justify-center truncate rounded-sm px-3 text-center text-11 font-medium whitespace-nowrap"
style={{
color: currentCycle.color,
backgroundColor: `${currentCycle.color}20`,
}}
>
{t(currentCycle.i18n_title)}
</span>
)}
</div>
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<div className="flex items-center gap-2">
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
className="h-7"
buttonVariant="border-with-text"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={async (val) => {
const isDateValid = await handleDateChange(val?.from, val?.to);
if (isDateValid) {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
}
}}
placeholder={{
from: t("project_cycles.start_date"),
to: t("project_cycles.end_date"),
}}
customTooltipHeading={t("project_cycles.in_your_timezone")}
customTooltipContent={`${renderFormattedDateInUserTimezone(
cycleDetails.start_date ?? ""
)} → ${renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}`}
mergeDates
showTooltip={!!cycleDetails.start_date && !!cycleDetails.end_date} // show tooltip only if both start and end date are present
required={cycleDetails.status !== "draft"}
disabled={!isEditingAllowed || isArchived || isCompleted}
/>
)}
/>
{projectUTCOffset && (
<span className="cursor-default rounded-md bg-layer-1 px-2 py-1 text-11 text-tertiary">
{projectUTCOffset}
</span>
)}
</div>
)}
/>
</div>
</>
);
});