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;

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

View File

@@ -0,0 +1,60 @@
/**
* 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";
// helpers
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
import { CloseOutline } from "@makeplane/propel/icons";
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// constants
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters = observer(function AppliedDateFilters(props: Props) {
const { editable, handleRemove, values } = props;
const getDateLabel = (value: string): string => {
let dateLabel = "";
const dateDetails = DATE_AFTER_FILTER_OPTIONS.find((d) => d.value === value);
if (dateDetails) dateLabel = dateDetails.name;
else {
const dateParts = value.split(";");
if (dateParts.length === 2) {
const [date, time] = dateParts;
dateLabel = `${capitalizeFirstLetter(time)} ${renderFormattedDate(date)}`;
}
}
return dateLabel;
};
return (
<>
{values.map((date) => (
<div key={date} className="flex items-center gap-1 rounded-sm bg-layer-3 px-1.5 py-1 text-11">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(date)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
))}
</>
);
});

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,98 @@
/**
* 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";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { PillButton } from "@makeplane/propel/components/pill";
import { CloseOutline } from "@makeplane/propel/icons";
import type { TCycleFilters } from "@plane/types";
import { replaceUnderscoreIfSnakeCase } from "@plane/utils";
// hooks
import { useUserPermissions } from "@/hooks/store/user";
// local imports
import { AppliedDateFilters } from "./date";
import { AppliedStatusFilters } from "./status";
type Props = {
appliedFilters: TCycleFilters;
handleClearAllFilters: () => void;
handleRemoveFilter: (key: keyof TCycleFilters, value: string | null) => void;
alwaysAllowEditing?: boolean;
};
const DATE_FILTERS = ["start_date", "end_date"];
export const CycleAppliedFiltersList = observer(function CycleAppliedFiltersList(props: Props) {
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, alwaysAllowEditing } = props;
// store hooks
const { allowPermissions } = useUserPermissions();
const { t } = useTranslation();
if (!appliedFilters) return null;
if (Object.keys(appliedFilters).length === 0) return null;
const isEditingAllowed =
alwaysAllowEditing ||
allowPermissions([EUserPermissions.ADMIN, EUserPermissions.MEMBER], EUserPermissionsLevel.PROJECT);
return (
<div className="flex flex-wrap items-stretch gap-2 bg-surface-1">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TCycleFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<div
key={filterKey}
className="my-auto flex min-h-9 cursor-pointer flex-wrap items-center gap-1.5 rounded-md border border-subtle p-1.5 text-11 text-tertiary capitalize hover:text-secondary"
>
<span className="text-11 text-tertiary">{replaceUnderscoreIfSnakeCase(filterKey)}</span>
<div className="flex flex-wrap items-center gap-1">
{filterKey === "status" && (
<AppliedStatusFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter("status", val)}
values={value}
/>
)}
{DATE_FILTERS.includes(filterKey) && (
<AppliedDateFilters
editable={isEditingAllowed}
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
values={value}
/>
)}
{isEditingAllowed && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<CloseOutline height={12} width={12} />
</button>
)}
</div>
</div>
);
})}
{isEditingAllowed && (
<PillButton
type="button"
size="md"
variant="outline"
label={t("common.clear_all")}
endIcon={<CloseOutline height={12} width={12} />}
onClick={handleClearAllFilters}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,52 @@
/**
* 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";
// plane imports
import { CYCLE_STATUS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { CloseOutline } from "@makeplane/propel/icons";
import { cn } from "@plane/utils";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedStatusFilters = observer(function AppliedStatusFilters(props: Props) {
const { handleRemove, values, editable } = props;
const { t } = useTranslation();
return (
<>
{values.map((status) => {
const statusDetails = CYCLE_STATUS.find((s) => s.value === status);
return (
<div
key={status}
className={cn(
"flex items-center gap-1 rounded-sm px-1.5 py-1 text-11",
statusDetails?.bgColor,
statusDetails?.textColor
)}
>
{statusDetails && t(statusDetails?.i18n_title)}
{editable && (
<button
type="button"
className="grid place-items-center text-tertiary hover:text-secondary"
onClick={() => handleRemove(status)}
>
<CloseOutline height={10} width={10} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,136 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { CloseOutline, FilterOutline, SearchOutline } from "@makeplane/propel/icons";
import { useOutsideClickDetector } from "@plane/hooks";
// plane helpers
// types
import type { TCycleFilters } from "@plane/types";
import { cn, calculateTotalFilters } from "@plane/utils";
// components
import { ArchiveTabsList } from "@/components/archives";
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
// hooks
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
// local imports
import { CycleFiltersSelection } from "../dropdowns";
export const ArchivedCyclesHeader = observer(function ArchivedCyclesHeader() {
// router
const { projectId } = useParams();
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const { currentProjectArchivedFilters, archivedCyclesSearchQuery, updateFilters, updateArchivedCyclesSearchQuery } =
useCycleFilter();
// states
const [isSearchOpen, setIsSearchOpen] = useState(archivedCyclesSearchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && archivedCyclesSearchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectArchivedFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
if (currentProjectArchivedFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
},
[currentProjectArchivedFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (archivedCyclesSearchQuery && archivedCyclesSearchQuery.trim() !== "") updateArchivedCyclesSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
const isFiltersApplied = calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0;
return (
<div className="group relative flex border-b border-subtle">
<div className="horizontal-scrollbar scrollbar-sm flex w-full items-center gap-2 overflow-x-auto px-4">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="flex h-full items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 grid place-items-center rounded-sm p-2 text-placeholder hover:bg-layer-1"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<SearchOutline className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
{
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
}
)}
>
<SearchOutline className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
placeholder="Search"
value={archivedCyclesSearchQuery}
onChange={(e) => updateArchivedCyclesSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateArchivedCyclesSearchQuery("");
setIsSearchOpen(false);
}}
>
<CloseOutline className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown
icon={<FilterOutline className="h-3 w-3" />}
title="Filters"
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
>
<CycleFiltersSelection
filters={currentProjectArchivedFilters ?? {}}
handleFiltersUpdate={handleFilters}
isArchived
/>
</FiltersDropdown>
</div>
</div>
);
});

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,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
// ui
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useAppRouter } from "@/hooks/use-app-router";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
handleClose: () => void;
isOpen: boolean;
onSubmit?: () => Promise<void>;
};
export function ArchiveCycleModal(props: Props) {
const { workspaceSlug, projectId, cycleId, isOpen, handleClose } = props;
// router
const router = useAppRouter();
// states
const [isArchiving, setIsArchiving] = useState(false);
// store hooks
const { getCycleNameById, archiveCycle } = useCycle();
const cycleName = getCycleNameById(cycleId);
const onClose = () => {
setIsArchiving(false);
handleClose();
};
const handleArchiveCycle = async () => {
setIsArchiving(true);
await archiveCycle(workspaceSlug, projectId, cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Archive success",
message: "Your archives can be found in project archives.",
});
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
return;
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be archived. Please try again.",
});
})
.finally(() => setIsArchiving(false));
};
return (
<ModalCore isOpen={isOpen} handleClose={onClose} position={EModalPosition.CENTER} width={EModalWidth.LG}>
<div className="px-5 py-4">
<h3 className="text-18 font-medium 2xl:text-20">Archive cycle {cycleName}</h3>
<p className="mt-3 text-13 text-secondary">
Are you sure you want to archive the cycle? All your archives can be restored later.
</p>
<div className="mt-3 flex justify-end gap-2">
<Button variant="secondary" size="lg" onClick={onClose}>
Cancel
</Button>
<Button variant="primary" size="lg" tabIndex={1} onClick={handleArchiveCycle} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</ModalCore>
);
}

View File

@@ -0,0 +1,89 @@
/**
* 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";
import { useParams } from "next/navigation";
import useSWR from "swr";
// plane imports
import { useTranslation } from "@plane/i18n";
import { EmptyStateDetailed } from "@plane/propel/empty-state";
import type { TCycleFilters } from "@plane/types";
import { calculateTotalFilters } from "@plane/utils";
// components
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
// local imports
import { CycleAppliedFiltersList } from "../applied-filters";
import { ArchivedCyclesView } from "./view";
export const ArchivedCycleLayoutRoot = observer(function ArchivedCycleLayoutRoot() {
// router
const { workspaceSlug, projectId } = useParams();
// plane hooks
const { t } = useTranslation();
// hooks
const { fetchArchivedCycles, currentProjectArchivedCycleIds, loader } = useCycle();
// cycle filters hook
const { clearAllFilters, currentProjectArchivedFilters, updateFilters } = useCycleFilter();
// derived values
const totalArchivedCycles = currentProjectArchivedCycleIds?.length ?? 0;
useSWR(
workspaceSlug && projectId ? `ARCHIVED_CYCLES_${workspaceSlug.toString()}_${projectId.toString()}` : null,
async () => {
if (workspaceSlug && projectId) {
await fetchArchivedCycles(workspaceSlug.toString(), projectId.toString());
}
},
{ revalidateIfStale: false, revalidateOnFocus: false }
);
const handleRemoveFilter = (key: keyof TCycleFilters, value: string | null) => {
if (!projectId) return;
let newValues = currentProjectArchivedFilters?.[key] ?? [];
if (!value) newValues = [];
else newValues = newValues.filter((val) => val !== value);
updateFilters(projectId.toString(), { [key]: newValues }, "archived");
};
if (!workspaceSlug || !projectId) return <></>;
if (loader || !currentProjectArchivedCycleIds) {
return <CycleModuleListLayoutLoader />;
}
return (
<>
{calculateTotalFilters(currentProjectArchivedFilters ?? {}) !== 0 && (
<div className="border-b border-subtle px-5 py-3">
<CycleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
/>
</div>
)}
{totalArchivedCycles === 0 ? (
<div className="h-full place-items-center">
<EmptyStateDetailed
assetKey="archived-cycle"
title={t("workspace_empty_state.archive_cycles.title")}
description={t("workspace_empty_state.archive_cycles.description")}
/>
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedCyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@@ -0,0 +1,62 @@
/**
* 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";
// assets
import AllFiltersImage from "@/app/assets/empty-state/cycle/all-filters.svg?url";
import NameFilterImage from "@/app/assets/empty-state/cycle/name-filter.svg?url";
// components
import { CyclesList } from "@/components/cycles/list";
// ui
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
export interface IArchivedCyclesView {
workspaceSlug: string;
projectId: string;
}
export const ArchivedCyclesView = observer(function ArchivedCyclesView(props: IArchivedCyclesView) {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredArchivedCycleIds, loader } = useCycle();
const { archivedCyclesSearchQuery } = useCycleFilter();
// derived values
const filteredArchivedCycleIds = getFilteredArchivedCycleIds(projectId);
if (loader || !filteredArchivedCycleIds) return <CycleModuleListLayoutLoader />;
if (filteredArchivedCycleIds.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<div className="text-center">
<img
src={archivedCyclesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="mx-auto h-36 w-36 sm:h-48 sm:w-48"
alt="No matching cycles"
/>
<h5 className="mt-7 mb-1 text-18 font-medium">No matching cycles</h5>
<p className="text-14 text-placeholder">
{archivedCyclesSearchQuery.trim() === ""
? "Remove the filters to see all cycles"
: "Remove the search criteria to see all cycles"}
</p>
</div>
</div>
);
return (
<CyclesList
completedCycleIds={[]}
cycleIds={filteredArchivedCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
isArchived
/>
);
});

View File

@@ -0,0 +1,71 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
// hooks
import { generateQueryParams } from "@plane/utils";
import { useCycle } from "@/hooks/store/use-cycle";
import { useAppRouter } from "@/hooks/use-app-router";
// components
import { CycleDetailsSidebar } from "./analytics-sidebar";
type Props = {
projectId?: string;
workspaceSlug: string;
isArchived?: boolean;
};
export const CyclePeekOverview = observer(function CyclePeekOverview(props: Props) {
const { projectId: propsProjectId, workspaceSlug, isArchived } = props;
// router
const router = useAppRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const peekCycle = searchParams.get("peekCycle");
// refs
const ref = React.useRef(null);
// store hooks
const { getCycleById, fetchCycleDetails, fetchArchivedCycleDetails } = useCycle();
// derived values
const cycleDetails = peekCycle ? getCycleById(peekCycle.toString()) : undefined;
const projectId = propsProjectId || cycleDetails?.project_id;
const handleClose = () => {
const query = generateQueryParams(searchParams, ["peekCycle"]);
router.push(`${pathname}?${query}`);
};
useEffect(() => {
if (!peekCycle || !projectId) return;
if (isArchived) fetchArchivedCycleDetails(workspaceSlug, projectId, peekCycle.toString());
else fetchCycleDetails(workspaceSlug, projectId, peekCycle.toString());
}, [fetchArchivedCycleDetails, fetchCycleDetails, isArchived, peekCycle, projectId, workspaceSlug]);
return (
<>
{peekCycle && projectId && (
<div
ref={ref}
className="fixed right-0 z-[9] flex h-full w-full max-w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-subtle bg-surface-1 px-4 duration-300 md:relative"
style={{
boxShadow:
"0px 1px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 4px 0px rgba(16, 24, 40, 0.06), 0px 1px 8px -1px rgba(16, 24, 40, 0.06)",
}}
>
<CycleDetailsSidebar
handleClose={handleClose}
isArchived={isArchived}
projectId={projectId}
workspaceSlug={workspaceSlug}
cycleId={peekCycle}
/>
</div>
)}
</>
);
});

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
import { CloseOutline, FilterOutline, SearchOutline } from "@makeplane/propel/icons";
// plane imports
import { useOutsideClickDetector } from "@plane/hooks";
import { IconButton } from "@plane/propel/icon-button";
import { useTranslation } from "@plane/i18n";
import type { TCycleFilters } from "@plane/types";
import { cn, calculateTotalFilters } from "@plane/utils";
// components
import { FiltersDropdown } from "@/components/issues/issue-layouts/filters";
// hooks
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
// local imports
import { CycleFiltersSelection } from "./dropdowns";
type Props = {
projectId: string;
};
export const CyclesViewHeader = observer(function CyclesViewHeader(props: Props) {
const { projectId } = props;
// refs
const inputRef = useRef<HTMLInputElement>(null);
// hooks
const { currentProjectFilters, searchQuery, updateFilters, updateSearchQuery } = useCycleFilter();
const { t } = useTranslation();
// states
const [isSearchOpen, setIsSearchOpen] = useState(searchQuery !== "" ? true : false);
// outside click detector hook
useOutsideClickDetector(inputRef, () => {
if (isSearchOpen && searchQuery.trim() === "") setIsSearchOpen(false);
});
const handleFilters = useCallback(
(key: keyof TCycleFilters, value: string | string[]) => {
if (!projectId) return;
const newValues = currentProjectFilters?.[key] ?? [];
if (Array.isArray(value))
value.forEach((val) => {
if (!newValues.includes(val)) newValues.push(val);
else newValues.splice(newValues.indexOf(val), 1);
});
else {
if (currentProjectFilters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
else newValues.push(value);
}
updateFilters(projectId, { [key]: newValues });
},
[currentProjectFilters, projectId, updateFilters]
);
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Escape") {
if (searchQuery && searchQuery.trim() !== "") updateSearchQuery("");
else {
setIsSearchOpen(false);
inputRef.current?.blur();
}
}
};
const isFiltersApplied = calculateTotalFilters(currentProjectFilters ?? {}) !== 0;
useEffect(() => {
if (searchQuery.trim() !== "") setIsSearchOpen(true);
}, [searchQuery]);
return (
<div className="flex items-center gap-2">
{!isSearchOpen ? (
<IconButton
variant="ghost"
size="lg"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
icon={SearchOutline}
/>
) : (
<div
className={cn(
"ml-auto flex w-0 items-center justify-start gap-1 overflow-hidden rounded-md border border-transparent bg-surface-1 text-placeholder opacity-0 transition-[width] ease-linear",
{
"w-64 border-subtle px-2.5 py-1.5 opacity-100": isSearchOpen,
}
)}
>
<SearchOutline className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-13 text-primary placeholder:text-placeholder focus:outline-none"
placeholder="Search"
value={searchQuery}
onChange={(e) => updateSearchQuery(e.target.value)}
onKeyDown={handleInputKeyDown}
/>
{isSearchOpen && (
<button
type="button"
className="grid place-items-center"
onClick={() => {
updateSearchQuery("");
setIsSearchOpen(false);
}}
>
<CloseOutline className="h-3 w-3" />
</button>
)}
</div>
)}
<FiltersDropdown
icon={<FilterOutline className="h-3 w-3" />}
title={t("common.filters")}
placement="bottom-end"
isFiltersApplied={isFiltersApplied}
>
<CycleFiltersSelection filters={currentProjectFilters ?? {}} handleFiltersUpdate={handleFilters} />
</FiltersDropdown>
</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 { observer } from "mobx-react";
// components
import { useTranslation } from "@plane/i18n";
// assets
import AllFiltersImage from "@/app/assets/empty-state/cycle/all-filters.svg?url";
import NameFilterImage from "@/app/assets/empty-state/cycle/name-filter.svg?url";
// components
import { CyclesList } from "@/components/cycles/list";
import { CycleModuleListLayoutLoader } from "@/components/ui/loader/cycle-module-list-loader";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useCycleFilter } from "@/hooks/store/use-cycle-filter";
export interface ICyclesView {
workspaceSlug: string;
projectId: string;
}
export const CyclesView = observer(function CyclesView(props: ICyclesView) {
const { workspaceSlug, projectId } = props;
// store hooks
const { getFilteredCycleIds, getFilteredCompletedCycleIds, loader, currentProjectActiveCycleId } = useCycle();
const { searchQuery } = useCycleFilter();
const { t } = useTranslation();
// derived values
const filteredCycleIds = getFilteredCycleIds(projectId, false);
const filteredCompletedCycleIds = getFilteredCompletedCycleIds(projectId);
const filteredUpcomingCycleIds = (filteredCycleIds ?? []).filter(
(cycleId) => cycleId !== currentProjectActiveCycleId
);
if (loader || !filteredCycleIds) return <CycleModuleListLayoutLoader />;
if (filteredCycleIds.length === 0 && filteredCompletedCycleIds?.length === 0)
return (
<div className="grid h-full w-full place-items-center">
<div className="text-center">
<img
src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="mx-auto h-36 w-36 object-contain sm:h-48 sm:w-48"
alt="No matching cycles"
/>
<h5 className="mt-7 mb-1 text-18 font-medium">{t("project_cycles.no_matching_cycles")}</h5>
<p className="text-14 text-placeholder">
{searchQuery.trim() === ""
? t("project_cycles.remove_filters_to_see_all_cycles")
: t("project_cycles.remove_search_criteria_to_see_all_cycles")}
</p>
</div>
</div>
);
return (
<CyclesList
completedCycleIds={filteredCompletedCycleIds ?? []}
upcomingCycleIds={filteredUpcomingCycleIds}
cycleIds={filteredCycleIds}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
);
});

View File

@@ -0,0 +1,95 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
// types
import { PROJECT_ERROR_MESSAGES } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ICycle } from "@plane/types";
// ui
import { AlertModalCore } from "@plane/ui";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useAppRouter } from "@/hooks/use-app-router";
interface ICycleDelete {
cycle: ICycle;
isOpen: boolean;
handleClose: () => void;
workspaceSlug: string;
projectId: string;
}
export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycleDelete) {
const { isOpen, handleClose, cycle, workspaceSlug, projectId } = props;
// states
const [loader, setLoader] = useState(false);
// store hooks
const { deleteCycle } = useCycle();
const { t } = useTranslation();
// router
const router = useAppRouter();
const { cycleId } = useParams();
const searchParams = useSearchParams();
const peekCycle = searchParams.get("peekCycle");
const formSubmit = async () => {
if (!cycle) return;
setLoader(true);
try {
await deleteCycle(workspaceSlug, projectId, cycle.id)
.then(() => {
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle deleted successfully.",
});
})
.catch((errors) => {
const isPermissionError = errors?.error === "You don't have the required permissions.";
const currentError = isPermissionError
? PROJECT_ERROR_MESSAGES.permissionError
: PROJECT_ERROR_MESSAGES.cycleDeleteError;
setToast({
title: t(currentError.i18n_title),
type: TOAST_TYPE.ERROR,
message: currentError.i18n_message && t(currentError.i18n_message),
});
})
.finally(() => handleClose());
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Warning!",
message: "Something went wrong please try again later.",
});
}
setLoader(false);
};
return (
<AlertModalCore
handleClose={handleClose}
handleSubmit={formSubmit}
isSubmitting={loader}
isOpen={isOpen}
title="Delete cycle"
content={
<>
Are you sure you want to delete cycle{' "'}
<span className="font-medium break-words text-primary">{cycle?.name}</span>
{'"'}? All of the data related to the cycle will be permanently removed. This action cannot be undone.
</>
}
/>
);
});

View File

@@ -0,0 +1,50 @@
/**
* 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";
import type { TCycleEstimateType } from "@plane/types";
import { EEstimateSystem } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useCycle } from "@/hooks/store/use-cycle";
// local imports
import { cycleEstimateOptions } from "../analytics-sidebar/issue-progress";
type TProps = {
value: TCycleEstimateType;
onChange: (value: TCycleEstimateType) => Promise<void>;
showDefault?: boolean;
projectId: string;
cycleId: string;
};
export const EstimateTypeDropdown = observer(function EstimateTypeDropdown(props: TProps) {
const { value, onChange, projectId, cycleId, showDefault = false } = props;
const { getIsPointsDataAvailable } = useCycle();
const { areEstimateEnabledByProjectId, currentProjectEstimateType } = useProjectEstimates();
const isCurrentProjectEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId) ? true : false;
return (getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled) &&
currentProjectEstimateType !== EEstimateSystem.CATEGORIES ? (
<div className="relative flex items-center gap-2">
<CustomSelect
value={value}
label={<span>{cycleEstimateOptions.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
buttonClassName="bg-surface-2 border-none rounded-sm text-13 font-medium "
>
{cycleEstimateOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
</div>
) : showDefault ? (
<span className="capitalize">{cycleEstimateOptions.find((v) => v.value === value)?.label ?? value}</span>
) : null;
});

View File

@@ -0,0 +1,82 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
// plane imports
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
import { isInDateFormat } from "@plane/utils";
// components
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterEndDate = observer(function FilterEndDate(props: Props) {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Due date"
/>
)}
<FilterHeader
title={`Due date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

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

View File

@@ -0,0 +1,84 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { CloseOutline, SearchOutline } from "@makeplane/propel/icons";
// plane imports
import type { TCycleFilters, TCycleGroups } from "@plane/types";
// hooks
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { FilterEndDate } from "./end-date";
import { FilterStartDate } from "./start-date";
import { FilterStatus } from "./status";
type Props = {
filters: TCycleFilters;
handleFiltersUpdate: (key: keyof TCycleFilters, value: string | string[]) => void;
isArchived?: boolean;
};
export const CycleFiltersSelection = observer(function CycleFiltersSelection(props: Props) {
const { filters, handleFiltersUpdate, isArchived = false } = props;
// states
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
// hooks
const { isMobile } = usePlatformOS();
return (
<div className="flex h-full w-full flex-col overflow-hidden">
<div className="bg-surface-1 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded-sm border-[0.5px] border-subtle bg-surface-2 px-1.5 py-1 text-11">
<SearchOutline className="text-placeholder" width={12} height={12} />
<input
type="text"
className="w-full bg-surface-2 outline-none placeholder:text-placeholder"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<CloseOutline className="text-tertiary" height={12} width={12} />
</button>
)}
</div>
</div>
<div className="vertical-scrollbar scrollbar-sm h-full w-full divide-y divide-subtle-1 overflow-y-auto px-2.5">
{/* cycle status */}
{!isArchived && (
<div className="py-2">
<FilterStatus
appliedFilters={(filters.status as TCycleGroups[]) ?? null}
handleUpdate={(val) => handleFiltersUpdate("status", val)}
searchQuery={filtersSearchQuery}
/>
</div>
)}
{/* start date */}
<div className="py-2">
<FilterStartDate
appliedFilters={filters.start_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("start_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
{/* end date */}
<div className="py-2">
<FilterEndDate
appliedFilters={filters.end_date ?? null}
handleUpdate={(val) => handleFiltersUpdate("end_date", val)}
searchQuery={filtersSearchQuery}
/>
</div>
</div>
</div>
);
});

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
// constants
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
// components
import { isInDateFormat } from "@plane/utils";
import { DateFilterModal } from "@/components/core/filters/date-filter-modal";
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// helpers
type Props = {
appliedFilters: string[] | null;
handleUpdate: (val: string | string[]) => void;
searchQuery: string;
};
export const FilterStartDate = observer(function FilterStartDate(props: Props) {
const { appliedFilters, handleUpdate, searchQuery } = props;
const [previewEnabled, setPreviewEnabled] = useState(true);
const [isDateFilterModalOpen, setIsDateFilterModalOpen] = useState(false);
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = DATE_AFTER_FILTER_OPTIONS.filter((d) =>
d.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const isCustomDateSelected = () => {
const isValidDateSelected = appliedFilters?.filter((f) => isInDateFormat(f.split(";")[0])) || [];
return isValidDateSelected.length > 0 ? true : false;
};
const handleCustomDate = () => {
if (isCustomDateSelected()) {
const updateAppliedFilters = appliedFilters?.filter((f) => f.includes("-")) || [];
handleUpdate(updateAppliedFilters);
} else setIsDateFilterModalOpen(true);
};
return (
<>
{isDateFilterModalOpen && (
<DateFilterModal
handleClose={() => setIsDateFilterModalOpen(false)}
isOpen={isDateFilterModalOpen}
onSelect={(val) => handleUpdate(val)}
title="Start date"
/>
)}
<FilterHeader
title={`Start date${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
<>
{filteredOptions.map((option) => (
<FilterOption
key={option.value}
isChecked={appliedFilters?.includes(option.value) ? true : false}
onClick={() => handleUpdate(option.value)}
title={option.name}
multiple
/>
))}
<FilterOption isChecked={isCustomDateSelected()} onClick={handleCustomDate} title="Custom" multiple />
</>
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,57 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import React, { useState } from "react";
import { observer } from "mobx-react";
import { CYCLE_STATUS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TCycleGroups } from "@plane/types";
// components
import { FilterHeader, FilterOption } from "@/components/issues/issue-layouts/filters";
// types
// constants
type Props = {
appliedFilters: TCycleGroups[] | null;
handleUpdate: (val: string) => void;
searchQuery: string;
};
export const FilterStatus = observer(function FilterStatus(props: Props) {
const { appliedFilters, handleUpdate, searchQuery } = props;
// states
const [previewEnabled, setPreviewEnabled] = useState(true);
//hooks
const { t } = useTranslation();
const appliedFiltersCount = appliedFilters?.length ?? 0;
const filteredOptions = CYCLE_STATUS.filter((p) => p.value.includes(searchQuery.toLowerCase()));
return (
<>
<FilterHeader
title={`Status of the cycle${appliedFiltersCount > 0 ? ` (${appliedFiltersCount})` : ""}`}
isPreviewEnabled={previewEnabled}
handleIsPreviewEnabled={() => setPreviewEnabled(!previewEnabled)}
/>
{previewEnabled && (
<div>
{filteredOptions.length > 0 ? (
filteredOptions.map((status) => (
<FilterOption
key={status.value}
isChecked={appliedFilters?.includes(status.value) ? true : false}
onClick={() => handleUpdate(status.value)}
title={t(status.i18n_title)}
/>
))
) : (
<p className="text-11 text-placeholder italic">No matches found</p>
)}
</div>
)}
</>
);
});

View File

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

View File

@@ -0,0 +1,212 @@
/**
* 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 { Controller, useForm } from "react-hook-form";
// plane imports
import { Field } from "@makeplane/propel/components/field";
import { Input, InputGroup } from "@makeplane/propel/components/input";
import { TextArea, TextAreaGroup } from "@makeplane/propel/components/text-area";
import { ETabIndices } from "@plane/constants";
// types
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { ICycle } from "@plane/types";
import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
// hooks
import { useUser } from "@/hooks/store/user/user-user";
type Props = {
handleFormSubmit: (values: Partial<ICycle>) => Promise<void>;
handleClose: () => void;
status: boolean;
projectId: string;
setActiveProject: (projectId: string) => void;
data?: ICycle | null;
isMobile?: boolean;
};
const defaultValues: Partial<ICycle> = {
name: "",
description: "",
start_date: null,
end_date: null,
};
export function CycleForm(props: Props) {
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data, isMobile = false } = props;
// plane hooks
const { t } = useTranslation();
// store hooks
const { projectsWithCreatePermissions } = useUser();
// form data
const {
formState: { errors, isSubmitting },
handleSubmit,
control,
reset,
} = useForm<ICycle>({
defaultValues: {
project_id: projectId,
name: data?.name || "",
description: data?.description || "",
start_date: data?.start_date || null,
end_date: data?.end_date || null,
},
});
const { getIndex } = getTabIndex(ETabIndices.PROJECT_CYCLE, isMobile);
useEffect(() => {
reset({
...defaultValues,
...data,
});
}, [data, reset]);
return (
<form onSubmit={handleSubmit((formData) => handleFormSubmit(formData))}>
<div className="space-y-5 p-5">
<div className="flex items-center gap-x-3">
{!status && (
<Controller
control={control}
name="project_id"
render={({ field: { value, onChange } }) => (
<div className="h-7">
<ProjectDropdown
value={value}
onChange={(val) => {
if (!Array.isArray(val)) {
onChange(val);
setActiveProject(val);
}
}}
multiple={false}
buttonVariant="border-with-text"
renderCondition={(projectId) => !!projectsWithCreatePermissions?.[projectId]}
tabIndex={getIndex("cover_image")}
/>
</div>
)}
/>
)}
<h3 className="text-18 font-medium text-secondary">
{status ? t("project_cycles.update_cycle") : t("project_cycles.create_cycle")}
</h3>
</div>
<div className="space-y-3">
<div className="space-y-1">
<Controller
name="name"
control={control}
rules={{
required: t("title_is_required"),
maxLength: {
value: 255,
message: t("title_should_be_less_than_255_characters"),
},
}}
render={({ field: { value, onChange } }) => (
<Field name="name" invalid={Boolean(errors?.name)}>
<InputGroup size="2xl">
<Input
size="2xl"
name="name"
type="text"
placeholder={t("title")}
value={value}
onChange={onChange}
tabIndex={getIndex("description")}
autoFocus
aria-label={t("title")}
/>
</InputGroup>
</Field>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
<div>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<Field name="description" invalid={Boolean(errors?.description)}>
<TextAreaGroup resize="none">
<TextArea
size="lg"
surface="field"
autoResize
maxRows={8}
name="description"
placeholder={t("description")}
value={value}
onChange={onChange}
tabIndex={getIndex("description")}
/>
</TextAreaGroup>
</Field>
)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<Controller
control={control}
name="start_date"
render={({ field: { value: startDateValue, onChange: onChangeStartDate } }) => (
<Controller
control={control}
name="end_date"
render={({ field: { value: endDateValue, onChange: onChangeEndDate } }) => (
<DateRangeDropdown
buttonVariant="border-with-text"
className="h-7"
minDate={new Date()}
value={{
from: getDate(startDateValue),
to: getDate(endDateValue),
}}
onSelect={(val) => {
onChangeStartDate(val?.from ? renderFormattedPayloadDate(val.from) : null);
onChangeEndDate(val?.to ? renderFormattedPayloadDate(val.to) : null);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
hideIcon={{
to: true,
}}
tabIndex={getIndex("date_range")}
/>
)}
/>
)}
/>
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t-[0.5px] border-subtle px-5 py-4">
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="lg" type="submit" loading={isSubmitting} tabIndex={getIndex("submit")}>
{data
? isSubmitting
? t("common.updating")
: t("project_cycles.update_cycle")
: isSubmitting
? t("common.creating")
: t("project_cycles.create_cycle")}
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,46 @@
/**
* 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";
// types
import { CycleGroupIcon } from "@plane/propel/icons";
import { ChevronDownOutline } from "@makeplane/propel/icons";
import type { TCycleGroups } from "@plane/types";
// icons
import { Row } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
type Props = {
type: TCycleGroups;
title: string;
count?: number;
showCount?: boolean;
isExpanded?: boolean;
};
export function CycleListGroupHeader(props: Props) {
const { type, title, count, showCount = false, isExpanded = false } = props;
return (
<Row className="flex items-center justify-between py-2.5">
<div className="flex flex-shrink-0 items-center gap-5">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-xs">
<CycleGroupIcon cycleGroup={type} className="h-5 w-5" />
</div>
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden">
<div className="line-clamp-1 inline-block truncate font-medium text-primary">{title}</div>
{showCount && <div className="pl-2 text-13 font-medium text-tertiary">{`${count ?? "0"}`}</div>}
</div>
</div>
<ChevronDownOutline
className={cn("size-4 shrink-0 text-tertiary transition-transform", {
"rotate-180": isExpanded,
})}
/>
</Row>
);
}

View File

@@ -0,0 +1,316 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { MouseEvent } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams, usePathname, useSearchParams } from "next/navigation";
import { useForm } from "react-hook-form";
import {
CalendarOutline,
MembersOutline,
ShowOutline,
TransferWorkItemOutline,
WorkItemsOutline,
} from "@makeplane/propel/icons";
// plane imports
import { EUserPermissions, EUserPermissionsLevel, IS_FAVORITE_MENU_OPEN } from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { Avatar } from "@makeplane/propel/components/avatar";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@makeplane/propel/components/tooltip";
import type { ICycle, TCycleGroups } from "@plane/types";
import { FavoriteStar } from "@plane/ui";
import { getDate, getFileURL, generateQueryParams } from "@plane/utils";
// components
import { AvatarGroupOverflow } from "@/components/common/avatar-group-overflow";
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useMember } from "@/hooks/store/use-member";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { useTimeZoneConverter } from "@/hooks/use-timezone-converter";
// local imports
import { CycleQuickActions } from "../quick-actions";
import { TransferIssuesModal } from "../transfer-issues-modal";
type Props = {
workspaceSlug: string;
projectId: string;
cycleId: string;
cycleDetails: ICycle;
parentRef: React.RefObject<HTMLDivElement | null>;
isActive?: boolean;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
export const CycleListItemAction = observer(function CycleListItemAction(props: Props) {
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef, isActive = false } = props;
// router
const { projectId: routerProjectId } = useParams();
//states
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
// hooks
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
const { isProjectTimeZoneDifferent, getProjectUTCOffset, renderFormattedDateInUserTimezone } =
useTimeZoneConverter(projectId);
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { allowPermissions } = useUserPermissions();
// local storage
const { setValue: toggleFavoriteMenu, storedValue: isFavoriteMenuOpen } = useLocalStorage<boolean>(
IS_FAVORITE_MENU_OPEN,
false
);
const { getUserDetails } = useMember();
// form
const { reset } = useForm({
defaultValues,
});
// derived values
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const showIssueCount = useMemo(() => cycleStatus === "draft" || cycleStatus === "upcoming", [cycleStatus]);
const transferableIssuesCount = cycleDetails
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
: 0;
const showTransferIssues = routerProjectId && transferableIssuesCount > 0 && cycleStatus === "completed";
const projectUTCOffset = getProjectUTCOffset();
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
// handlers
const handleAddToFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const addToFavoritePromise = addCycleToFavorites(workspaceSlug?.toString(), projectId.toString(), cycleId).then(
// oxlint-disable-next-line promise/always-return
() => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
}
);
setPromiseToast(addToFavoritePromise, {
loading: t("project_cycles.action.favorite.loading"),
success: {
title: t("project_cycles.action.favorite.success.title"),
message: () => t("project_cycles.action.favorite.success.description"),
},
error: {
title: t("project_cycles.action.favorite.failed.title"),
message: () => t("project_cycles.action.favorite.failed.description"),
},
});
};
const handleRemoveFromFavorites = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (!workspaceSlug || !projectId) return;
const removeFromFavoritePromise = removeCycleFromFavorites(
workspaceSlug?.toString(),
projectId.toString(),
cycleId
);
setPromiseToast(removeFromFavoritePromise, {
loading: t("project_cycles.action.unfavorite.loading"),
success: {
title: t("project_cycles.action.unfavorite.success.title"),
message: () => t("project_cycles.action.unfavorite.success.description"),
},
error: {
title: t("project_cycles.action.unfavorite.failed.title"),
message: () => t("project_cycles.action.unfavorite.failed.description"),
},
});
};
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
}
};
return (
<>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
cycleId={cycleId.toString()}
/>
<button
onClick={openCycleOverview}
className={`z-[1] flex flex-shrink-0 gap-1 text-11 text-accent-secondary ${isMobile || (isActive && !searchParams.has("peekCycle")) ? "flex" : "hidden group-hover:flex"}`}
>
<ShowOutline className="my-auto h-4 w-4 text-accent-secondary" />
<span>{t("project_cycles.more_details")}</span>
</button>
{showIssueCount && (
<div className="flex items-center gap-1">
<WorkItemsOutline className="h-4 w-4 text-tertiary" />
<span className="text-11 text-tertiary">{cycleDetails.total_issues}</span>
</div>
)}
{showTransferIssues && (
// oxlint-disable-next-line jsx_a11y/click-events-have-key-events oxlint-disable-next-line jsx_a11y/no-static-element-interactions
<div
className="flex h-6 cursor-pointer items-center gap-1 px-2 text-accent-secondary"
onClick={() => {
setTransferIssuesModal(true);
}}
>
<TransferWorkItemOutline className="w-4 fill-accent-primary" />
<span>{t("project_cycles.transfer_work_items", { count: transferableIssuesCount })}</span>
</div>
)}
{isActive ? (
<>
<div className="flex gap-2">
{/* Duration */}
<Tooltip
label={`${t("project_cycles.in_your_timezone")}: ${renderFormattedDateInUserTimezone(
cycleDetails.start_date ?? ""
)} → ${renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}`}
layout="stacked"
disabled={!isProjectTimeZoneDifferent()}
>
<div className="flex items-center gap-1 text-11 font-medium text-tertiary">
<CalendarOutline className="my-auto h-3 w-3 flex-shrink-0" />
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</div>
</Tooltip>
{projectUTCOffset && (
<span className="cursor-default rounded-md bg-layer-1 px-2 py-1 text-11 text-tertiary">
{projectUTCOffset}
</span>
)}
{/* created by */}
{createdByDetails && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
</div>
</>
) : (
cycleDetails.start_date && (
<>
<DateRangeDropdown
buttonVariant={"transparent-with-text"}
buttonContainerClassName={`h-6 w-full cursor-auto flex items-center gap-1.5 text-tertiary rounded-sm text-11 [&>div]:hover:bg-transparent`}
buttonClassName="p-0"
minDate={new Date()}
value={{
from: getDate(cycleDetails.start_date),
to: getDate(cycleDetails.end_date),
}}
placeholder={{
from: t("project_cycles.start_date"),
to: t("project_cycles.end_date"),
}}
showTooltip={isProjectTimeZoneDifferent()}
customTooltipHeading={t("project_cycles.in_your_timezone")}
customTooltipContent={`${renderFormattedDateInUserTimezone(
cycleDetails.start_date ?? ""
)} → ${renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}`}
mergeDates
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
from: false,
to: false,
}}
/>
</>
)
)}
{/* created by */}
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
{!isActive && (
<Tooltip label={`${cycleDetails.assignee_ids?.length} Members`} layout="stacked" disabled={isMobile}>
<div className="flex w-min cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroupOverflow size="xs">
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return (
<Avatar
key={member?.id}
alt={member?.display_name}
fallback={member?.display_name?.[0]?.toUpperCase()}
src={getFileURL(member?.avatar_url ?? "")}
/>
);
})}
</AvatarGroupOverflow>
) : (
<MembersOutline className="h-4 w-4 text-tertiary" />
)}
</div>
</Tooltip>
)}
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
if (cycleDetails.is_favorite) handleRemoveFromFavorites(e);
else handleAddToFavorites(e);
}}
selected={!!cycleDetails.is_favorite}
/>
)}
<div className="hidden md:block">
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
</>
);
});

View File

@@ -0,0 +1,48 @@
/**
* 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";
import { Logo } from "@plane/propel/emoji-icon-picker";
import { ChevronRightOutline } from "@makeplane/propel/icons";
// icons
import { Row } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
import { useProject } from "@/hooks/store/use-project";
type Props = {
projectId: string;
count?: number;
showCount?: boolean;
isExpanded?: boolean;
};
export const CycleListProjectGroupHeader = observer(function CycleListProjectGroupHeader(props: Props) {
const { projectId, count, showCount = false, isExpanded = false } = props;
// store hooks
const { getProjectById } = useProject();
// derived values
const project = getProjectById(projectId);
if (!project) return null;
return (
<Row className="flex flex-shrink-0 items-center gap-2 py-2.5">
<ChevronRightOutline
className={cn("h-4 w-4 text-tertiary duration-300", {
"rotate-90": isExpanded,
})}
/>
<div className="flex size-4 flex-shrink-0 items-center justify-center overflow-hidden">
<Logo logo={project.logo_props} size={16} />
</div>
<div className="relative flex w-full flex-row items-center gap-1 overflow-hidden">
<div className="line-clamp-1 inline-block truncate font-medium text-primary">{project.name}</div>
{showCount && <div className="pl-2 text-13 font-medium text-tertiary">{`${count ?? "0"}`}</div>}
</div>
</Row>
);
});

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { MouseEvent } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { CircularProgress } from "@makeplane/propel/components/circular-progress";
import { TickOutline } from "@makeplane/propel/icons";
// plane imports
import type { TCycleGroups } from "@plane/types";
// components
import { generateQueryParams, calculateCycleProgress } from "@plane/utils";
import { ListItem } from "@/components/core/list";
// hooks
import { useCycle } from "@/hooks/store/use-cycle";
import { useAppRouter } from "@/hooks/use-app-router";
import { usePlatformOS } from "@/hooks/use-platform-os";
// local imports
import { CycleQuickActions } from "../quick-actions";
import { CycleListItemAction } from "./cycle-list-item-action";
type TCyclesListItem = {
cycleId: string;
handleEditCycle?: () => void;
handleDeleteCycle?: () => void;
handleAddToFavorites?: () => void;
handleRemoveFromFavorites?: () => void;
workspaceSlug: string;
projectId: string;
className?: string;
};
export const CyclesListItem = observer(function CyclesListItem(props: TCyclesListItem) {
const { cycleId, workspaceSlug, projectId, className = "" } = props;
// refs
const parentRef = useRef(null);
// router
const router = useAppRouter();
const searchParams = useSearchParams();
const pathname = usePathname();
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const { getCycleById } = useCycle();
// derived values
const cycleDetails = getCycleById(cycleId);
if (!cycleDetails) return null;
// computed
// TODO: change this logic once backend fix the response
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isActive = cycleStatus === "current";
// handlers
const openCycleOverview = (e: MouseEvent<HTMLButtonElement | HTMLAnchorElement>) => {
e.preventDefault();
e.stopPropagation();
const query = generateQueryParams(searchParams, ["peekCycle"]);
if (searchParams.has("peekCycle") && searchParams.get("peekCycle") === cycleId) {
router.push(`${pathname}?${query}`);
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`);
}
};
// handlers
const handleArchivedCycleClick = (e: MouseEvent<HTMLAnchorElement>) => {
openCycleOverview(e);
};
const handleItemClick = cycleDetails.archived_at ? handleArchivedCycleClick : undefined;
const progress = calculateCycleProgress(cycleDetails);
return (
<ListItem
title={cycleDetails?.name ?? ""}
itemLink={`/${workspaceSlug}/projects/${projectId}/cycles/${cycleDetails.id}`}
onItemClick={handleItemClick}
className={className}
prependTitleElement={
<div className="relative size-[30px]">
<div className="absolute inset-0 flex scale-150 items-center justify-center">
<CircularProgress
value={progress}
size="md"
variant={progress === 100 ? "success" : "brand"}
aria-label="Cycle progress"
/>
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
{progress === 100 ? (
<TickOutline className="h-3 w-3" />
) : (
<span className="text-9 text-primary">{`${progress}%`}</span>
)}
</div>
</div>
}
actionableItems={
<CycleListItemAction
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
cycleDetails={cycleDetails}
parentRef={parentRef}
isActive={isActive}
/>
}
quickActionElement={
<div className="block md:hidden">
<CycleQuickActions
parentRef={parentRef}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
</div>
}
isMobile={isMobile}
parentRef={parentRef}
isSidebarOpen={searchParams.has("peekCycle")}
/>
);
});

View File

@@ -0,0 +1,26 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
// components
import { CyclesListItem } from "./cycles-list-item";
type Props = {
cycleIds: string[];
projectId: string;
workspaceSlug: string;
};
export function CyclesListMap(props: Props) {
const { cycleIds, projectId, workspaceSlug } = props;
return (
<>
{cycleIds.map((cycleId) => (
<CyclesListItem key={cycleId} cycleId={cycleId} workspaceSlug={workspaceSlug} projectId={projectId} />
))}
</>
);
}

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,88 @@
/**
* 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";
import { Disclosure } from "@headlessui/react";
// components
import { useTranslation } from "@plane/i18n";
import { ContentWrapper, ERowVariant } from "@plane/ui";
import { ListLayout } from "@/components/core/list";
// local imports
import { CyclePeekOverview } from "../cycle-peek-overview";
import { CycleListGroupHeader } from "./cycle-list-group-header";
import { CyclesListMap } from "./cycles-list-map";
import { ActiveCycleRoot } from "../active-cycle/root";
export interface ICyclesList {
completedCycleIds: string[];
upcomingCycleIds?: string[] | undefined;
cycleIds: string[];
workspaceSlug: string;
projectId: string;
isArchived?: boolean;
}
export const CyclesList = observer(function CyclesList(props: ICyclesList) {
const { completedCycleIds, upcomingCycleIds, cycleIds, workspaceSlug, projectId, isArchived = false } = props;
const { t } = useTranslation();
return (
<ContentWrapper variant={ERowVariant.HUGGING} className="flex-row">
<ListLayout>
{isArchived ? (
<>
<CyclesListMap cycleIds={cycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</>
) : (
<>
<ActiveCycleRoot workspaceSlug={workspaceSlug} projectId={projectId} />
{upcomingCycleIds && (
<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.upcoming_cycle.label")}
type="upcoming"
count={upcomingCycleIds.length}
showCount
isExpanded={open}
/>
</Disclosure.Button>
<Disclosure.Panel>
<CyclesListMap cycleIds={upcomingCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</Disclosure.Panel>
</>
)}
</Disclosure>
)}
<Disclosure as="div" className="flex flex-shrink-0 flex-col pb-7">
{({ 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.completed_cycle.label")}
type="completed"
count={completedCycleIds.length}
showCount
isExpanded={open}
/>
</Disclosure.Button>
<Disclosure.Panel>
<CyclesListMap cycleIds={completedCycleIds} projectId={projectId} workspaceSlug={workspaceSlug} />
</Disclosure.Panel>
</>
)}
</Disclosure>
</>
)}
</ListLayout>
<CyclePeekOverview projectId={projectId} workspaceSlug={workspaceSlug} isArchived={isArchived} />
</ContentWrapper>
);
});

View File

@@ -0,0 +1,198 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useState } from "react";
import { mutate } from "swr";
// types
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { CycleDateCheckData, ICycle, TCycleTabOptions } from "@plane/types";
// ui
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
// hooks
import { renderFormattedPayloadDate } from "@plane/utils";
import { useCycle } from "@/hooks/store/use-cycle";
import { useProject } from "@/hooks/store/use-project";
import useKeypress from "@/hooks/use-keypress";
import useLocalStorage from "@/hooks/use-local-storage";
import { usePlatformOS } from "@/hooks/use-platform-os";
// services
import { CycleService } from "@/services/cycle.service";
// local imports
import { CycleForm } from "./form";
type CycleModalProps = {
isOpen: boolean;
handleClose: () => void;
data?: ICycle | null;
workspaceSlug: string;
projectId: string;
};
// services
const cycleService = new CycleService();
export function CycleCreateUpdateModal(props: CycleModalProps) {
const { isOpen, handleClose, data, workspaceSlug, projectId } = props;
// states
const [activeProject, setActiveProject] = useState<string | null>(null);
// store hooks
const { workspaceProjectIds } = useProject();
const { createCycle, updateCycleDetails } = useCycle();
const { isMobile } = usePlatformOS();
const { setValue: setCycleTab } = useLocalStorage<TCycleTabOptions>("cycle_tab", "active");
const handleCreateCycle = async (payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project_id ?? projectId.toString();
await createCycle(workspaceSlug, selectedProjectId, payload)
.then((_res) => {
// mutate when the current cycle creation is active
if (payload.start_date && payload.end_date) {
const currentDate = new Date();
const cycleStartDate = new Date(payload.start_date);
const cycleEndDate = new Date(payload.end_date);
if (currentDate >= cycleStartDate && currentDate <= cycleEndDate) {
mutate(`PROJECT_ACTIVE_CYCLE_${selectedProjectId}`);
}
}
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle created successfully.",
});
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.detail ?? "Error in creating cycle. Please try again.",
});
});
};
const handleUpdateCycle = async (cycleId: string, payload: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
const selectedProjectId = payload.project_id ?? projectId.toString();
await updateCycleDetails(workspaceSlug, selectedProjectId, cycleId, payload)
.then((_res) => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle updated successfully.",
});
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.detail ?? "Error in updating cycle. Please try again.",
});
});
};
const dateChecker = async (projectId: string, payload: CycleDateCheckData) => {
let status = false;
await cycleService.cycleDateCheck(workspaceSlug, projectId, payload).then((res) => {
status = res.status;
});
return status;
};
const handleFormSubmit = async (formData: Partial<ICycle>) => {
if (!workspaceSlug || !projectId) return;
const payload: Partial<ICycle> = {
...formData,
start_date: renderFormattedPayloadDate(formData.start_date) ?? null,
end_date: renderFormattedPayloadDate(formData.end_date) ?? null,
};
let isDateValid: boolean = true;
if (payload.start_date && payload.end_date) {
if (data?.id) {
// Update existing cycle - only check dates if they've changed
const originalStartDate = renderFormattedPayloadDate(data.start_date) ?? null;
const originalEndDate = renderFormattedPayloadDate(data.end_date) ?? null;
const hasDateChanged = payload.start_date !== originalStartDate || payload.end_date !== originalEndDate;
if (hasDateChanged) {
isDateValid = await dateChecker(projectId, {
start_date: payload.start_date,
end_date: payload.end_date,
cycle_id: data.id,
});
}
} else {
// Create new cycle - always check dates
isDateValid = await dateChecker(projectId, {
start_date: payload.start_date,
end_date: payload.end_date,
});
}
}
if (isDateValid) {
if (data?.id) await handleUpdateCycle(data.id, payload);
else {
await handleCreateCycle(payload).then(() => {
setCycleTab("all");
});
}
handleClose();
} else
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "You already have a cycle on the given dates, if you want to create a draft cycle, remove the dates.",
});
};
useEffect(() => {
// if modal is closed, reset active project to null
// and return to avoid activeProject being set to some other project
if (!isOpen) {
setActiveProject(null);
return;
}
// if data is present, set active project to the project of the
// issue. This has more priority than the project in the url.
if (data && data.project_id) {
setActiveProject(data.project_id);
return;
}
// if data is not present, set active project to the project
// in the url. This has the least priority.
if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProject)
setActiveProject(projectId ?? workspaceProjectIds?.[0] ?? null);
}, [activeProject, data, projectId, workspaceProjectIds, isOpen]);
useKeypress("Escape", () => {
if (isOpen) handleClose();
});
return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<CycleForm
handleFormSubmit={handleFormSubmit}
handleClose={handleClose}
status={!!data}
projectId={activeProject ?? ""}
setActiveProject={setActiveProject}
data={data}
isMobile={isMobile}
/>
</ModalCore>
);
}

View File

@@ -0,0 +1,185 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { MoreHorizontalOutline } from "@makeplane/propel/icons";
// ui
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TContextMenuItem } from "@plane/ui";
import { ContextMenu, CustomMenu } from "@plane/ui";
import { copyUrlToClipboard, cn } from "@plane/utils";
// hooks
import { useCycleMenuItems } from "@/components/common/quick-actions-helper";
import { useCycle } from "@/hooks/store/use-cycle";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
// local imports
import { ArchiveCycleModal } from "./archived-cycles/modal";
import { CycleDeleteModal } from "./delete-modal";
import { CycleCreateUpdateModal } from "./modal";
type Props = {
parentRef: React.RefObject<HTMLElement | null>;
cycleId: string;
projectId: string;
workspaceSlug: string;
customClassName?: string;
};
export const CycleQuickActions = observer(function CycleQuickActions(props: Props) {
const { parentRef, cycleId, projectId, workspaceSlug, customClassName } = props;
// router
const router = useAppRouter();
// states
const [updateModal, setUpdateModal] = useState(false);
const [archiveCycleModal, setArchiveCycleModal] = useState(false);
const [deleteModal, setDeleteModal] = useState(false);
// store hooks
const { allowPermissions } = useUserPermissions();
const { getCycleById, restoreCycle } = useCycle();
const { t } = useTranslation();
// derived values
const cycleDetails = getCycleById(cycleId);
// auth
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const cycleLink = `${workspaceSlug}/projects/${projectId}/cycles/${cycleId}`;
const handleCopyText = () =>
copyUrlToClipboard(cycleLink).then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"),
message: t("common.link_copied_to_clipboard"),
});
});
const handleOpenInNewTab = () => window.open(`/${cycleLink}`, "_blank");
const handleRestoreCycle = async () =>
await restoreCycle(workspaceSlug, projectId, cycleId)
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: t("project_cycles.action.restore.success.title"),
message: t("project_cycles.action.restore.success.description"),
});
router.push(`/${workspaceSlug}/projects/${projectId}/archives/cycles`);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: t("project_cycles.action.restore.failed.title"),
message: t("project_cycles.action.restore.failed.description"),
});
});
const menuResult = useCycleMenuItems({
cycleDetails: cycleDetails ?? undefined,
workspaceSlug,
projectId,
cycleId,
isEditingAllowed,
handleEdit: () => setUpdateModal(true),
handleArchive: () => setArchiveCycleModal(true),
handleRestore: handleRestoreCycle,
handleDelete: () => setDeleteModal(true),
handleCopyLink: handleCopyText,
handleOpenInNewTab,
});
const MENU_ITEMS: TContextMenuItem[] = Array.isArray(menuResult) ? menuResult : menuResult.items;
const additionalModals = Array.isArray(menuResult) ? null : menuResult.modals;
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map(function CONTEXT_MENU_ITEMS(item) {
return {
...item,
action: () => {
item.action();
},
};
});
return (
<>
{cycleDetails && (
<div className="fixed">
<CycleCreateUpdateModal
data={cycleDetails}
isOpen={updateModal}
handleClose={() => setUpdateModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
<ArchiveCycleModal
workspaceSlug={workspaceSlug}
projectId={projectId}
cycleId={cycleId}
isOpen={archiveCycleModal}
handleClose={() => setArchiveCycleModal(false)}
/>
<CycleDeleteModal
cycle={cycleDetails}
isOpen={deleteModal}
handleClose={() => setDeleteModal(false)}
workspaceSlug={workspaceSlug}
projectId={projectId}
/>
{additionalModals}
</div>
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu
customButton={<IconButton variant="tertiary" size="lg" icon={MoreHorizontalOutline} />}
placement="bottom-end"
closeOnSelect
maxHeight="lg"
buttonClassName={customClassName}
>
{MENU_ITEMS.map((item) => {
if (item.shouldRender === false) return null;
return (
<CustomMenu.MenuItem
key={item.key}
onClick={() => {
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-placeholder": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3 flex-shrink-0", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("whitespace-pre-line text-tertiary", {
"text-placeholder": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,151 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import {
CloseOutline,
CyclesOutline,
SearchOutline,
TransferWorkItemOutline,
WarningCircleOutline,
} from "@makeplane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EIssuesStoreType } from "@plane/types";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
type Props = {
isOpen: boolean;
handleClose: () => void;
cycleId: string;
};
export const TransferIssuesModal = observer(function TransferIssuesModal(props: Props) {
const { isOpen, handleClose, cycleId } = props;
// states
const [query, setQuery] = useState("");
// store hooks
const { currentProjectIncompleteCycleIds, getCycleById, fetchActiveCycleProgress } = useCycle();
const {
issues: { transferIssuesFromCycle },
} = useIssues(EIssuesStoreType.CYCLE);
const { workspaceSlug, projectId } = useParams();
const transferIssue = async (payload: { new_cycle_id: string }) => {
if (!workspaceSlug || !projectId || !cycleId) return;
await transferIssuesFromCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), payload)
.then(async () => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Work items have been transferred successfully",
});
await getCycleDetails(payload.new_cycle_id);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Unable to transfer work items. Please try again.",
});
});
};
/**To update issue counts in target cycle and current cycle */
const getCycleDetails = async (newCycleId: string) => {
const cyclesFetch = [
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), cycleId),
fetchActiveCycleProgress(workspaceSlug.toString(), projectId.toString(), newCycleId),
];
await Promise.all(cyclesFetch).catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error",
message: error.error || "Unable to fetch cycle details",
});
});
};
const filteredOptions = currentProjectIncompleteCycleIds?.filter((optionId) => {
const cycleDetails = getCycleById(optionId);
return cycleDetails?.name?.toLowerCase().includes(query?.toLowerCase());
});
return (
<ModalCore isOpen={isOpen} handleClose={handleClose} position={EModalPosition.TOP} width={EModalWidth.XXL}>
<div className="flex flex-col gap-4 py-5">
<div className="flex items-center justify-between px-5">
<div className="flex items-center gap-1">
<TransferWorkItemOutline className="w-5 fill-primary" />
<h4 className="text-18 font-medium text-primary">Transfer work items</h4>
</div>
<button onClick={handleClose}>
<CloseOutline className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2 border-b border-subtle px-5 pb-3">
<SearchOutline className="h-4 w-4 text-secondary" />
<input
className="text-13 outline-none"
placeholder="Search for a cycle..."
onChange={(e) => setQuery(e.target.value)}
value={query}
/>
</div>
<div className="flex w-full flex-col items-start gap-2 px-5">
{filteredOptions ? (
filteredOptions.length > 0 ? (
filteredOptions.map((optionId) => {
const cycleDetails = getCycleById(optionId);
if (!cycleDetails) return;
return (
<button
key={optionId}
className="flex w-full items-center gap-4 rounded-sm px-4 py-3 text-13 text-secondary hover:bg-surface-2"
onClick={() => {
transferIssue({
new_cycle_id: optionId,
});
handleClose();
}}
>
<CyclesOutline className="h-5 w-5" />
<div className="flex w-full justify-between truncate">
<span className="truncate">{cycleDetails?.name}</span>
{cycleDetails.status && (
<span className="flex flex-shrink-0 items-center rounded-full bg-layer-1 px-2 capitalize">
{cycleDetails.status.toLocaleLowerCase()}
</span>
)}
</div>
</button>
);
})
) : (
<div className="flex w-full items-center justify-center gap-4 p-5 text-13">
<WarningCircleOutline className="h-3.5 w-3.5 text-secondary" />
<span className="text-center text-secondary">
You don’t have any current cycle. Please create one to transfer the work items.
</span>
</div>
)
) : (
<p className="text-center text-secondary">Loading...</p>
)}
</div>
</div>
</ModalCore>
);
});

View File

@@ -0,0 +1,42 @@
/**
* 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 { TransferWorkItemOutline, WarningCircleOutline } from "@makeplane/propel/icons";
// ui
import { Button } from "@plane/propel/button";
type Props = {
handleClick: () => void;
canTransferIssues?: boolean;
disabled?: boolean;
};
export function TransferIssues(props: Props) {
const { handleClick, canTransferIssues = false, disabled = false } = props;
return (
<div className="-mt-2 mb-4 flex items-center justify-between px-4 pt-6">
<div className="flex items-center gap-2 text-13 text-secondary">
<WarningCircleOutline className="h-3.5 w-3.5 text-secondary" />
<span>Completed cycles are not editable.</span>
</div>
{canTransferIssues && (
<div>
<Button
variant="primary"
size="lg"
prependIcon={<TransferWorkItemOutline />}
onClick={handleClick}
disabled={disabled}
>
Transfer work items
</Button>
</div>
)}
</div>
);
}