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,377 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { Fragment, useCallback, useRef, useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { CompletedAtOutline } from "@makeplane/propel/icons";
// headless ui
import { Tab } from "@headlessui/react";
// plane imports
import { Avatar } from "@makeplane/propel/components/avatar";
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { ICycle } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedDateWithoutYear, getFileURL } from "@plane/utils";
// assets
import darkAssigneeAsset from "@/app/assets/empty-state/active-cycle/assignee-dark.webp?url";
import lightAssigneeAsset from "@/app/assets/empty-state/active-cycle/assignee-light.webp?url";
import darkLabelAsset from "@/app/assets/empty-state/active-cycle/label-dark.webp?url";
import lightLabelAsset from "@/app/assets/empty-state/active-cycle/label-light.webp?url";
import darkPriorityAsset from "@/app/assets/empty-state/active-cycle/priority-dark.webp?url";
import lightPriorityAsset from "@/app/assets/empty-state/active-cycle/priority-light.webp?url";
import userImage from "@/app/assets/user.png?url";
// components
import { SingleProgressStats } from "@/components/core/sidebar/single-progress-stats";
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
import { IssueIdentifier } from "@/components/issues/issue-detail/issue-identifier";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useIssues } from "@/hooks/store/use-issues";
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
import useLocalStorage from "@/hooks/use-local-storage";
// store
import type { ActiveCycleIssueDetails } from "@/store/issue/cycle";
export type ActiveCycleStatsProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle | null;
cycleId?: string | null;
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
cycleIssueDetails?: ActiveCycleIssueDetails | { nextPageResults: boolean };
};
export const ActiveCycleStats = observer(function ActiveCycleStats(props: ActiveCycleStatsProps) {
const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate, cycleIssueDetails } = props;
// local storage
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
// refs
const issuesContainerRef = useRef<HTMLDivElement | null>(null);
// states
const [issuesLoaderElement, setIssueLoaderElement] = useState<HTMLDivElement | null>(null);
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// derived values
const priorityResolvedPath = resolvedTheme === "light" ? lightPriorityAsset : darkPriorityAsset;
const assigneesResolvedPath = resolvedTheme === "light" ? lightAssigneeAsset : darkAssigneeAsset;
const labelsResolvedPath = resolvedTheme === "light" ? lightLabelAsset : darkLabelAsset;
const currentValue = (tab: string | null) => {
switch (tab) {
case "Priority-Issues":
return 0;
case "Assignees":
return 1;
case "Labels":
return 2;
default:
return 0;
}
};
const {
issues: { fetchNextActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const {
issue: { getIssueById },
setPeekIssue,
} = useIssueDetail();
const loadMoreIssues = useCallback(() => {
if (!cycleId) return;
fetchNextActiveCycleIssues(workspaceSlug, projectId, cycleId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, cycleId, issuesLoaderElement, cycleIssueDetails?.nextPageResults]);
useIntersectionObserver(issuesContainerRef, issuesLoaderElement, loadMoreIssues, `0% 0% 100% 0%`);
const loaders = (
<Loader className="space-y-3">
<Loader.Item height="30px" />
<Loader.Item height="30px" />
<Loader.Item height="30px" />
</Loader>
);
return cycleId ? (
<div className="col-span-1 flex min-h-[17rem] flex-col gap-4 overflow-hidden rounded-lg border border-subtle bg-surface-1 p-4 lg:col-span-2 xl:col-span-1">
<Tab.Group
as={Fragment}
defaultIndex={currentValue(tab)}
onChange={(i) => {
switch (i) {
case 0:
return setTab("Priority-Issues");
case 1:
return setTab("Assignees");
case 2:
return setTab("Labels");
default:
return setTab("Priority-Issues");
}
}}
>
<Tab.List
as="div"
className="relative grid rounded-sm border-[0.5px] border-subtle bg-layer-1 p-[1px]"
style={{
gridTemplateColumns: `repeat(3, 1fr)`,
}}
>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
{
"bg-surface-1 text-tertiary": selected,
"hover:text-tertiary": !selected,
}
)
}
>
{t("project_cycles.active_cycle.priority_issue")}
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
{
"bg-surface-1 text-tertiary": selected,
"hover:text-tertiary": !selected,
}
)
}
>
{t("project_cycles.active_cycle.assignees")}
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] rounded-[3px] py-1.5 text-11 font-semibold text-placeholder transition duration-500 focus:outline-none",
{
"bg-surface-1 text-tertiary": selected,
"hover:text-tertiary": !selected,
}
)
}
>
{t("project_cycles.active_cycle.labels")}
</Tab>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="vertical-scrollbar scrollbar-sm flex h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
<div
ref={issuesContainerRef}
className="vertical-scrollbar scrollbar-sm flex h-full w-full flex-col gap-1 overflow-y-auto"
>
{cycleIssueDetails && "issueIds" in cycleIssueDetails ? (
cycleIssueDetails.issueCount > 0 ? (
<>
{cycleIssueDetails.issueIds.map((issueId: string) => {
const issue = getIssueById(issueId);
if (!issue) return null;
return (
<div
key={issue.id}
className="group flex cursor-pointer items-center justify-between gap-2 rounded-md p-1 hover:bg-surface-2"
onClick={() => {
if (issue.id) {
setPeekIssue({
workspaceSlug,
projectId,
issueId: issue.id,
isArchived: !!issue.archived_at,
});
handleFiltersUpdate([
{ property: "priority", operator: "in", value: ["urgent", "high"] },
]);
}
}}
>
<div className="flex w-full min-w-24 flex-grow items-center gap-1.5 truncate">
<IssueIdentifier issueId={issue.id} projectId={projectId} size="xs" variant="secondary" />
<Tooltip label={`Title: ${issue.name}`} layout="stacked" align="start">
<span className="truncate text-13 text-primary">{issue.name}</span>
</Tooltip>
</div>
<PriorityIcon priority={issue.priority} withContainer size={12} />
<div className="flex flex-shrink-0 items-center gap-1.5">
<StateDropdown
value={issue.state_id}
onChange={() => {}}
projectId={projectId?.toString() ?? ""}
disabled
buttonVariant="background-with-text"
buttonContainerClassName="cursor-pointer max-w-24"
showTooltip
/>
{issue.target_date && (
<Tooltip label={`Target Date: ${renderFormattedDate(issue.target_date) ?? ""}`}>
<div className="flex h-full cursor-pointer items-center gap-1.5 truncate rounded-sm bg-layer-1 px-2 py-0.5 text-11 group-hover:bg-surface-1">
<CompletedAtOutline className="h-3 w-3 flex-shrink-0" />
<span className="truncate text-11">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</div>
);
})}
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
<div
ref={setIssueLoaderElement}
className={
"relative flex h-11 animate-pulse cursor-pointer items-center gap-3 bg-layer-1 p-3 text-13"
}
/>
)}
</>
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState
title={t("active_cycle.empty_state.priority_issue.title")}
assetPath={priorityResolvedPath}
/>
</div>
)
) : (
loaders
)}
</div>
</Tab.Panel>
<Tab.Panel
as="div"
className="vertical-scrollbar scrollbar-sm flex h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
{cycle && !isEmpty(cycle.distribution) ? (
cycle?.distribution?.assignees && cycle.distribution.assignees.length > 0 ? (
cycle.distribution?.assignees?.map((assignee, index) => {
if (assignee.assignee_id)
return (
<SingleProgressStats
key={assignee.assignee_id}
title={
<div className="flex items-center gap-2">
<Avatar
alt={assignee?.display_name ?? undefined}
fallback={assignee?.display_name?.[0]?.toUpperCase()}
src={getFileURL(assignee?.avatar_url ?? "")}
size="xs"
/>
<span>{assignee.display_name}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
onClick={() => {
if (assignee.assignee_id) {
handleFiltersUpdate([
{ property: "assignee_id", operator: "in", value: [assignee.assignee_id] },
]);
}
}}
/>
);
else
return (
<SingleProgressStats
key={`unassigned-${index}`}
title={
<div className="flex items-center gap-2">
<div className="h-5 w-5 rounded-full border-2 border-subtle bg-layer-1">
<img src={userImage} height="100%" width="100%" className="rounded-full" alt="User" />
</div>
<span>{t("no_assignee")}</span>
</div>
}
completed={assignee.completed_issues}
total={assignee.total_issues}
/>
);
})
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState
title={t("active_cycle.empty_state.assignee.title")}
assetPath={assigneesResolvedPath}
/>
</div>
)
) : (
loaders
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="vertical-scrollbar scrollbar-sm flex h-52 w-full flex-col gap-1 overflow-y-auto text-secondary"
>
{cycle && !isEmpty(cycle.distribution) ? (
cycle?.distribution?.labels && cycle.distribution.labels.length > 0 ? (
cycle.distribution.labels?.map((label, index) => (
<SingleProgressStats
key={label.label_id ?? `no-label-${index}`}
title={
<div className="flex items-center gap-2 truncate">
<span
className="block h-3 w-3 flex-shrink-0 rounded-full"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="truncate text-11 text-ellipsis">{label.label_name ?? "No labels"}</span>
</div>
}
completed={label.completed_issues}
total={label.total_issues}
onClick={
label.label_id
? () => {
if (label.label_id) {
handleFiltersUpdate([{ property: "label_id", operator: "in", value: [label.label_id] }]);
}
}
: undefined
}
/>
))
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState title={t("active_cycle.empty_state.label.title")} assetPath={labelsResolvedPath} />
</div>
)
) : (
loaders
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<Loader className="col-span-1 flex min-h-[17rem] flex-col gap-4 overflow-hidden bg-surface-1 lg:col-span-2 xl:col-span-1">
<Loader.Item width="100%" height="17rem" />
</Loader>
);
});

View File

@@ -0,0 +1,108 @@
/**
* 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";
import Link from "next/link";
import { useTheme } from "next-themes";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { ICycle, TCycleEstimateType } from "@plane/types";
import { Loader } from "@plane/ui";
// assets
import darkChartAsset from "@/app/assets/empty-state/active-cycle/chart-dark.webp?url";
import lightChartAsset from "@/app/assets/empty-state/active-cycle/chart-light.webp?url";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown";
export type ActiveCycleProductivityProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle | null;
};
export const ActiveCycleProductivity = observer(function ActiveCycleProductivity(props: ActiveCycleProductivityProps) {
const { workspaceSlug, projectId, cycle } = props;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// hooks
const { getEstimateTypeByCycleId, setEstimateType } = useCycle();
// derived values
const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues";
const resolvedPath = resolvedTheme === "light" ? lightChartAsset : darkChartAsset;
const onChange = async (value: TCycleEstimateType) => {
if (!workspaceSlug || !projectId || !cycle || !cycle.id) return;
setEstimateType(cycle.id, value);
};
const chartDistributionData =
cycle && estimateType === "points" ? cycle?.estimate_distribution : cycle?.distribution || undefined;
const completionChartDistributionData = chartDistributionData?.completion_chart || undefined;
return cycle && completionChartDistributionData ? (
<div className="flex min-h-[17rem] flex-col gap-5 rounded-lg border border-subtle bg-surface-1 px-3.5 py-4">
<div className="relative flex items-center justify-between gap-4">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
<h3 className="text-14 font-semibold text-tertiary">{t("project_cycles.active_cycle.issue_burndown")}</h3>
</Link>
<EstimateTypeDropdown value={estimateType} onChange={onChange} cycleId={cycle.id} projectId={projectId} />
</div>
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
{cycle.total_issues > 0 ? (
<>
<div className="h-full w-full px-2">
<div className="flex items-center justify-end gap-4 py-1 text-11 text-tertiary">
{estimateType === "points" ? (
<span>{`Pending points - ${cycle.backlog_estimate_points + cycle.unstarted_estimate_points + cycle.started_estimate_points}`}</span>
) : (
<span>{`Pending work items - ${cycle.backlog_issues + cycle.unstarted_issues + cycle.started_issues}`}</span>
)}
</div>
<div className="relative h-full">
{completionChartDistributionData && (
<Fragment>
{estimateType === "points" ? (
<ProgressChart
distribution={completionChartDistributionData}
totalIssues={cycle.total_estimate_points || 0}
plotTitle={"points"}
/>
) : (
<ProgressChart
distribution={completionChartDistributionData}
totalIssues={cycle.total_issues || 0}
plotTitle={"work items"}
/>
)}
</Fragment>
)}
</div>
</div>
</>
) : (
<>
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState title={t("active_cycle.empty_state.chart.title")} assetPath={resolvedPath} />
</div>
</>
)}
</Link>
</div>
) : (
<Loader className="flex min-h-[17rem] flex-col gap-5 rounded-lg border border-subtle bg-surface-1">
<Loader.Item width="100%" height="100%" />
</Loader>
);
});

View File

@@ -0,0 +1,123 @@
/**
* 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 { useTheme } from "next-themes";
// plane imports
import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import { LinearProgress } from "@makeplane/propel/components/linear-progress";
import type { ICycle } from "@plane/types";
import { Loader } from "@plane/ui";
// assets
import darkProgressAsset from "@/app/assets/empty-state/active-cycle/progress-dark.webp?url";
import lightProgressAsset from "@/app/assets/empty-state/active-cycle/progress-light.webp?url";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
export type ActiveCycleProgressProps = {
cycle: ICycle | null;
workspaceSlug: string;
projectId: string;
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
};
export const ActiveCycleProgress = observer(function ActiveCycleProgress(props: ActiveCycleProgressProps) {
const { handleFiltersUpdate, cycle } = props;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// derived values
const closedIssues = cycle ? cycle.completed_issues + cycle.cancelled_issues : 0;
const closableIssues = cycle ? cycle.total_issues - cycle.cancelled_issues : 0;
const progressValue = closableIssues > 0 ? (closedIssues / closableIssues) * 100 : 0;
const groupedIssues: any = cycle
? {
completed: cycle?.completed_issues,
started: cycle?.started_issues,
unstarted: cycle?.unstarted_issues,
backlog: cycle?.backlog_issues,
}
: {};
const resolvedPath = resolvedTheme === "light" ? lightProgressAsset : darkProgressAsset;
return cycle && cycle.hasOwnProperty("started_issues") ? (
<div className="flex min-h-[17rem] flex-col gap-5 rounded-lg border border-subtle bg-surface-1 px-3.5 py-4">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<h3 className="text-14 font-semibold text-tertiary">{t("project_cycles.active_cycle.progress")}</h3>
{cycle.total_issues > 0 && (
<span className="flex gap-1 rounded-xs px-3 py-1 text-13 font-medium whitespace-nowrap text-placeholder">
{`${cycle.completed_issues + cycle.cancelled_issues}/${cycle.total_issues - cycle.cancelled_issues} ${
cycle.completed_issues + cycle.cancelled_issues > 1 ? "Work items" : "Work item"
} closed`}
</span>
)}
</div>
{cycle.total_issues > 0 && (
<LinearProgress
value={progressValue}
size="md"
variant="brand"
showValue={false}
aria-label="Cycle progress"
/>
)}
</div>
{cycle.total_issues > 0 ? (
<div className="flex flex-col gap-5">
{Object.keys(groupedIssues).map((group, index) => (
<>
{groupedIssues[group] > 0 && (
<div key={index}>
<div
className="flex cursor-pointer items-center justify-between gap-2 text-13"
onClick={() => {
handleFiltersUpdate([{ property: "state_group", operator: "in", value: [group] }]);
}}
>
<div className="flex items-center gap-1.5">
<span
className="block h-3 w-3 rounded-full"
style={{
backgroundColor: PROGRESS_STATE_GROUPS_DETAILS[index].color,
}}
/>
<span className="w-16 font-medium text-tertiary capitalize">{group}</span>
</div>
<span className="text-tertiary">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Work items" : "Work item"
}`}</span>
</div>
</div>
)}
</>
))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-13 text-tertiary">
<span>
{`${cycle.cancelled_issues} cancelled ${
cycle.cancelled_issues > 1 ? "work items are" : "work item is"
} excluded from this report.`}{" "}
</span>
</span>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center">
<SimpleEmptyState title={t("active_cycle.empty_state.progress.title")} assetPath={resolvedPath} />
</div>
)}
</div>
) : (
<Loader className="flex min-h-[17rem] flex-col gap-5 rounded-lg border border-subtle bg-surface-1">
<Loader.Item width="100%" height="100%" />
</Loader>
);
});

View File

@@ -0,0 +1,154 @@
/**
* 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 { useTheme } from "next-themes";
import { Disclosure } from "@headlessui/react";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { ICycle } from "@plane/types";
import { Row } from "@plane/ui";
// assets
import darkActiveCycleAsset from "@/app/assets/empty-state/cycle/active-dark.webp?url";
import lightActiveCycleAsset from "@/app/assets/empty-state/cycle/active-light.webp?url";
// components
import { ActiveCycleStats } from "@/components/cycles/active-cycle/cycle-stats";
import { ActiveCycleProductivity } from "@/components/cycles/active-cycle/productivity";
import { ActiveCycleProgress } from "@/components/cycles/active-cycle/progress";
import useCyclesDetails from "@/components/cycles/active-cycle/use-cycles-details";
import { CycleListGroupHeader } from "@/components/cycles/list/cycle-list-group-header";
import { CyclesListItem } from "@/components/cycles/list/cycles-list-item";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import type { ActiveCycleIssueDetails } from "@/store/issue/cycle";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
cycleId?: string;
showHeader?: boolean;
}
type ActiveCyclesComponentProps = {
cycleId: string | null | undefined;
activeCycle: ICycle | null;
activeCycleResolvedPath: string;
workspaceSlug: string;
projectId: string;
handleFiltersUpdate: (filters: any) => void;
cycleIssueDetails?: ActiveCycleIssueDetails | { nextPageResults: boolean };
};
const ActiveCyclesComponent = observer(function ActiveCyclesComponent({
cycleId,
activeCycle,
activeCycleResolvedPath: _activeCycleResolvedPath,
workspaceSlug,
projectId,
handleFiltersUpdate,
cycleIssueDetails,
}: ActiveCyclesComponentProps) {
const { t } = useTranslation();
if (!cycleId || !activeCycle) {
return (
<EmptyStateDetailed
assetKey="cycle"
title={t("project_cycles.empty_state.active.title")}
description={t("project_cycles.empty_state.active.description")}
rootClassName="py-10 h-auto"
/>
);
}
return (
<div className="flex flex-col border-b border-subtle">
<CyclesListItem
key={cycleId}
cycleId={cycleId}
workspaceSlug={workspaceSlug}
projectId={projectId}
className="!border-b-transparent"
/>
<Row className="bg-surface-1 pt-3 pb-6">
<div className="grid grid-cols-1 gap-3 bg-surface-1 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress
handleFiltersUpdate={handleFiltersUpdate}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycle={activeCycle}
/>
<ActiveCycleProductivity workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
<ActiveCycleStats
workspaceSlug={workspaceSlug}
projectId={projectId}
cycle={activeCycle}
cycleId={cycleId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
</div>
</Row>
</div>
);
});
export const ActiveCycleRoot = observer(function ActiveCycleRoot(props: IActiveCycleDetails) {
const { workspaceSlug, projectId, cycleId: propsCycleId, showHeader = true } = props;
// theme hook
const { resolvedTheme } = useTheme();
// plane hooks
const { t } = useTranslation();
// store hooks
const { currentProjectActiveCycleId } = useCycle();
// derived values
const cycleId = propsCycleId ?? currentProjectActiveCycleId;
const activeCycleResolvedPath = resolvedTheme === "light" ? lightActiveCycleAsset : darkActiveCycleAsset;
// fetch cycle details
const {
handleFiltersUpdate,
cycle: activeCycle,
cycleIssueDetails,
} = useCyclesDetails({ workspaceSlug, projectId, cycleId });
return (
<>
{showHeader ? (
<Disclosure as="div" className="flex flex-shrink-0 flex-col" defaultOpen>
{({ open }) => (
<>
<Disclosure.Button className="sticky top-0 z-[2] w-full flex-shrink-0 cursor-pointer border-b border-subtle bg-layer-1">
<CycleListGroupHeader title={t("project_cycles.active_cycle.label")} type="current" isExpanded={open} />
</Disclosure.Button>
<Disclosure.Panel>
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
activeCycleResolvedPath={activeCycleResolvedPath}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
</Disclosure.Panel>
</>
)}
</Disclosure>
) : (
<ActiveCyclesComponent
cycleId={cycleId}
activeCycle={activeCycle}
activeCycleResolvedPath={activeCycleResolvedPath}
workspaceSlug={workspaceSlug}
projectId={projectId}
handleFiltersUpdate={handleFiltersUpdate}
cycleIssueDetails={cycleIssueDetails}
/>
)}
</>
);
});

View File

@@ -0,0 +1,97 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
// plane imports
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import { EIssuesStoreType } from "@plane/types";
// constants
import { CYCLE_ISSUES_WITH_PARAMS } from "@plane/constants";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
import { useWorkItemFilters } from "@/hooks/store/work-item-filters/use-work-item-filters";
interface IActiveCycleDetails {
workspaceSlug: string;
projectId: string;
cycleId: string | null | undefined;
}
const useCyclesDetails = (props: IActiveCycleDetails) => {
// props
const { workspaceSlug, projectId, cycleId } = props;
// router
const router = useRouter();
// store hooks
const {
issuesFilter: { updateFilterExpression },
issues: { getActiveCycleById: getActiveCycleByIdFromIssue, fetchActiveCycleIssues },
} = useIssues(EIssuesStoreType.CYCLE);
const { updateFilterExpressionFromConditions } = useWorkItemFilters();
const { fetchActiveCycleProgress, getCycleById, fetchActiveCycleAnalytics } = useCycle();
// derived values
const cycle = cycleId ? getCycleById(cycleId) : null;
// fetch cycle details
useSWR(
workspaceSlug && projectId && cycle?.id ? `PROJECT_ACTIVE_CYCLE_${projectId}_PROGRESS_${cycle.id}` : null,
workspaceSlug && projectId && cycle?.id ? () => fetchActiveCycleProgress(workspaceSlug, projectId, cycle.id) : null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
useSWR(
workspaceSlug && projectId && cycle?.id && !cycle?.distribution
? `PROJECT_ACTIVE_CYCLE_${projectId}_DURATION_${cycle.id}`
: null,
workspaceSlug && projectId && cycle?.id && !cycle?.distribution
? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "issues")
: null
);
useSWR(
workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution
? `PROJECT_ACTIVE_CYCLE_${projectId}_ESTIMATE_DURATION_${cycle.id}`
: null,
workspaceSlug && projectId && cycle?.id && !cycle?.estimate_distribution
? () => fetchActiveCycleAnalytics(workspaceSlug, projectId, cycle.id, "points")
: null
);
useSWR(
workspaceSlug && projectId && cycle?.id ? CYCLE_ISSUES_WITH_PARAMS(cycle?.id, { priority: "urgent,high" }) : null,
workspaceSlug && projectId && cycle?.id
? () => fetchActiveCycleIssues(workspaceSlug, projectId, 30, cycle?.id)
: null,
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const cycleIssueDetails = cycle?.id ? getActiveCycleByIdFromIssue(cycle?.id) : { nextPageResults: false };
const handleFiltersUpdate = useCallback(
async (conditions: TWorkItemFilterCondition[]) => {
if (!workspaceSlug || !projectId || !cycleId) return;
await updateFilterExpressionFromConditions(
EIssuesStoreType.CYCLE,
cycleId,
conditions,
updateFilterExpression.bind(updateFilterExpression, workspaceSlug, projectId, cycleId)
);
router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`);
},
[workspaceSlug, projectId, cycleId, updateFilterExpressionFromConditions, updateFilterExpression, router]
);
return {
cycle,
cycleId,
router,
handleFiltersUpdate,
cycleIssueDetails,
};
};
export default useCyclesDetails;