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
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:
@@ -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";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
68
apps/web/core/components/cycles/analytics-sidebar/root.tsx
Normal file
68
apps/web/core/components/cycles/analytics-sidebar/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user