feat: init
Some checks failed
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
chuan
2025-11-11 01:56:44 +08:00
commit bba4bb40c8
4638 changed files with 447437 additions and 0 deletions

View File

@@ -0,0 +1,370 @@
"use client";
import type { FC } from "react";
import { Fragment, useCallback, useRef, useState } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
import { PriorityIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { ICycle } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
// ui
import { Loader, Avatar } from "@plane/ui";
import { cn, renderFormattedDate, renderFormattedDateWithoutYear, getFileURL } from "@plane/utils";
// 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";
// helpers
// 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";
// plane web components
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
// 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;
};
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
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);
// plane hooks
const { t } = useTranslation();
// derived values
const priorityResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/priority" });
const assigneesResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/assignee" });
const labelsResolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/label" });
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="flex flex-col gap-4 p-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1 border border-custom-border-200 rounded-lg">
<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 border-[0.5px] border-custom-border-200 rounded bg-custom-background-80 p-[1px] grid"
style={{
gridTemplateColumns: `repeat(3, 1fr)`,
}}
>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
{t("project_cycles.active_cycle.priority_issue")}
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
{t("project_cycles.active_cycle.assignees")}
</Tab>
<Tab
className={({ selected }) =>
cn(
"relative z-[1] font-semibold text-xs rounded-[3px] py-1.5 text-custom-text-400 focus:outline-none transition duration-500",
{
"text-custom-text-300 bg-custom-background-100": selected,
"hover:text-custom-text-300": !selected,
}
)
}
>
{t("project_cycles.active_cycle.labels")}
</Tab>
</Tab.List>
<Tab.Panels as={Fragment}>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
<div
ref={issuesContainerRef}
className="flex flex-col gap-1 h-full w-full overflow-y-auto vertical-scrollbar scrollbar-sm"
>
{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 hover:bg-custom-background-90 p-1"
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 items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<IssueIdentifier
issueId={issue.id}
projectId={projectId}
textContainerClassName="text-xs text-custom-text-200"
/>
<Tooltip position="top-start" tooltipHeading="Title" tooltipContent={issue.name}>
<span className="text-[0.825rem] text-custom-text-100 truncate">{issue.name}</span>
</Tooltip>
</div>
<PriorityIcon priority={issue.priority} withContainer size={12} />
<div className="flex items-center gap-1.5 flex-shrink-0">
<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
tooltipHeading="Target Date"
tooltipContent={renderFormattedDate(issue.target_date)}
>
<div className="h-full flex truncate items-center gap-1.5 rounded text-xs px-2 py-0.5 bg-custom-background-80 group-hover:bg-custom-background-100 cursor-pointer">
<CalendarCheck className="h-3 w-3 flex-shrink-0" />
<span className="text-xs truncate">
{renderFormattedDateWithoutYear(issue.target_date)}
</span>
</div>
</Tooltip>
)}
</div>
</div>
);
})}
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
<div
ref={setIssueLoaderElement}
className={
"h-11 relative flex items-center gap-3 bg-custom-background-80 p-3 text-sm cursor-pointer animate-pulse"
}
/>
)}
</>
) : (
<div className="flex items-center justify-center h-full w-full">
<SimpleEmptyState
title={t("active_cycle.empty_state.priority_issue.title")}
assetPath={priorityResolvedPath}
/>
</div>
)
) : (
loaders
)}
</div>
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{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
name={assignee?.display_name ?? undefined}
src={getFileURL(assignee?.avatar_url ?? "")}
/>
<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-custom-border-200 bg-custom-background-80">
<img src="/user.png" 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 items-center justify-center h-full w-full">
<SimpleEmptyState
title={t("active_cycle.empty_state.assignee.title")}
assetPath={assigneesResolvedPath}
/>
</div>
)
) : (
loaders
)}
</Tab.Panel>
<Tab.Panel
as="div"
className="flex h-52 w-full flex-col gap-1 overflow-y-auto text-custom-text-200 vertical-scrollbar scrollbar-sm"
>
{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 rounded-full flex-shrink-0"
style={{
backgroundColor: label.color ?? "#000000",
}}
/>
<span className="text-xs text-ellipsis truncate">{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 items-center justify-center h-full w-full">
<SimpleEmptyState title={t("active_cycle.empty_state.label.title")} assetPath={labelsResolvedPath} />
</div>
)
) : (
loaders
)}
</Tab.Panel>
</Tab.Panels>
</Tab.Group>
</div>
) : (
<Loader className="flex flex-col gap-4 min-h-[17rem] overflow-hidden bg-custom-background-100 col-span-1 lg:col-span-2 xl:col-span-1">
<Loader.Item width="100%" height="17rem" />
</Loader>
);
});

View File

@@ -0,0 +1,101 @@
import type { FC } from "react";
import { Fragment } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { ICycle, TCycleEstimateType } from "@plane/types";
import { Loader } from "@plane/ui";
// components
import ProgressChart from "@/components/core/sidebar/progress-chart";
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// constants
import { useCycle } from "@/hooks/store/use-cycle";
// plane web constants
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
import { EstimateTypeDropdown } from "../dropdowns/estimate-type-dropdown";
export type ActiveCycleProductivityProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle | null;
};
export const ActiveCycleProductivity: FC<ActiveCycleProductivityProps> = observer((props) => {
const { workspaceSlug, projectId, cycle } = props;
// plane hooks
const { t } = useTranslation();
// hooks
const { getEstimateTypeByCycleId, setEstimateType } = useCycle();
// derived values
const estimateType: TCycleEstimateType = (cycle && getEstimateTypeByCycleId(cycle.id)) || "issues";
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/chart" });
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 flex-col min-h-[17rem] gap-5 px-3.5 py-4 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="relative flex items-center justify-between gap-4">
<Link href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}>
<h3 className="text-base text-custom-text-300 font-semibold">
{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-xs text-custom-text-300">
{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 items-center justify-center h-full w-full">
<SimpleEmptyState title={t("active_cycle.empty_state.chart.title")} assetPath={resolvedPath} />
</div>
</>
)}
</Link>
</div>
) : (
<Loader className="flex flex-col min-h-[17rem] gap-5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<Loader.Item width="100%" height="100%" />
</Loader>
);
});

View File

@@ -0,0 +1,110 @@
"use client";
import type { FC } from "react";
import { observer } from "mobx-react";
// plane imports
import { PROGRESS_STATE_GROUPS_DETAILS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TWorkItemFilterCondition } from "@plane/shared-state";
import type { ICycle } from "@plane/types";
import { LinearProgressIndicator, Loader } from "@plane/ui";
// components
import { SimpleEmptyState } from "@/components/empty-state/simple-empty-state-root";
// hooks
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
export type ActiveCycleProgressProps = {
cycle: ICycle | null;
workspaceSlug: string;
projectId: string;
handleFiltersUpdate: (conditions: TWorkItemFilterCondition[]) => void;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props) => {
const { handleFiltersUpdate, cycle } = props;
// plane hooks
const { t } = useTranslation();
// derived values
const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
name: group.title,
value: cycle && cycle.total_issues > 0 ? (cycle[group.key as keyof ICycle] as number) : 0,
color: group.color,
}));
const groupedIssues: any = cycle
? {
completed: cycle?.completed_issues,
started: cycle?.started_issues,
unstarted: cycle?.unstarted_issues,
backlog: cycle?.backlog_issues,
}
: {};
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/active-cycle/progress" });
return cycle && cycle.hasOwnProperty("started_issues") ? (
<div className="flex flex-col min-h-[17rem] gap-5 py-4 px-3.5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-4">
<h3 className="text-base text-custom-text-300 font-semibold">{t("project_cycles.active_cycle.progress")}</h3>
{cycle.total_issues > 0 && (
<span className="flex gap-1 text-sm text-custom-text-400 font-medium whitespace-nowrap rounded-sm px-3 py-1 ">
{`${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 && <LinearProgressIndicator size="lg" data={progressIndicatorData} />}
</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 items-center justify-between gap-2 text-sm cursor-pointer"
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="text-custom-text-300 capitalize font-medium w-16">{group}</span>
</div>
<span className="text-custom-text-300">{`${groupedIssues[group]} ${
groupedIssues[group] > 1 ? "Work items" : "Work item"
}`}</span>
</div>
</div>
)}
</>
))}
{cycle.cancelled_issues > 0 && (
<span className="flex items-center gap-2 text-sm text-custom-text-300">
<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 items-center justify-center h-full w-full">
<SimpleEmptyState title={t("active_cycle.empty_state.progress.title")} assetPath={resolvedPath} />
</div>
)}
</div>
) : (
<Loader className="flex flex-col min-h-[17rem] gap-5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<Loader.Item width="100%" height="100%" />
</Loader>
);
});

View File

@@ -0,0 +1,91 @@
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 "@/constants/fetch-keys";
// 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 @@
export * from "./root";

View File

@@ -0,0 +1,176 @@
"use client";
import type { FC } from "react";
import { useMemo } from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { useSearchParams } from "next/navigation";
import { ChevronUp, ChevronDown } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
// plane imports
import { useTranslation } from "@plane/i18n";
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";
import { SidebarChartRoot } from "@/plane-web/components/cycles";
// local imports
import { CycleProgressStats } from "./progress-stats";
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: FC<TCycleAnalyticsProgress> = observer((props) => {
// 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="border-t border-custom-border-200 space-y-4 py-5">
<Disclosure defaultOpen>
{({ open }) => (
<div className="flex flex-col">
{/* progress bar header */}
{isCycleDateValid ? (
<div className="relative w-full flex justify-between items-center gap-2">
<Disclosure.Button className="relative flex items-center gap-2 w-full">
<div className="font-medium text-custom-text-200 text-sm">
{t("project_cycles.active_cycle.progress")}
</div>
</Disclosure.Button>
<Disclosure.Button className="ml-auto">
{open ? (
<ChevronUp className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
) : (
<div className="relative w-full flex justify-between items-center gap-2">
<div className="font-medium text-custom-text-200 text-sm">
{t("project_cycles.active_cycle.progress")}
</div>
</div>
)}
<Transition show={open}>
<Disclosure.Panel className="flex flex-col divide-y divide-custom-border-200">
{cycleStartDate && cycleEndDate ? (
<>
{isCycleDateValid && (
<SidebarChartRoot 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 py-2 text-sm text-custom-text-350 bg-custom-background-90 rounded-md px-2 w-full">
{t("no_data_yet")}
</div>
)}
</Disclosure.Panel>
</Transition>
</div>
)}
</Disclosure>
</div>
);
});

View File

@@ -0,0 +1,177 @@
"use client";
import type { FC } from "react";
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: FC<TCycleProgressStats> = observer((props) => {
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 defaultIndex={currentTabIndex(currentTab ? currentTab : "stat-assignees")}>
<Tab.List
as="div"
className={cn(
`flex w-full items-center justify-between gap-2 rounded-md p-1`,
roundedTab ? `rounded-3xl` : `rounded-md`,
noBackground ? `` : `bg-custom-background-90`,
size === "xs" ? `text-xs` : `text-sm`
)}
>
{PROGRESS_STATS.map((stat) => (
<Tab
className={cn(
`p-1 w-full text-custom-text-100 outline-none focus:outline-none cursor-pointer transition-all`,
roundedTab ? `rounded-3xl border border-custom-border-200` : `rounded`,
stat.key === currentTab
? "bg-custom-background-100 text-custom-text-300"
: "text-custom-text-400 hover:text-custom-text-300"
)}
key={stat.key}
onClick={() => setCycleTab(stat.key)}
>
{t(stat.i18n_title)}
</Tab>
))}
</Tab.List>
<Tab.Panels className="py-3 text-custom-text-200">
<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,64 @@
"use client";
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: React.FC<Props> = observer((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 flex-col gap-5 w-full">
<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,145 @@
"use client";
import type { FC } from "react";
import React from "react";
import { isEmpty } from "lodash-es";
import { observer } from "mobx-react";
import { SquareUser, Users } from "lucide-react";
// plane types
import { EEstimateSystem } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { WorkItemsIcon } from "@plane/propel/icons";
import type { ICycle } from "@plane/types";
// plane ui
import { Avatar, AvatarGroup, TextArea } from "@plane/ui";
// helpers
import { getFileURL } from "@plane/utils";
// hooks
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: FC<Props> = observer((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 flex-col gap-5 w-full">
{cycleDetails?.description && (
<TextArea
className="outline-none ring-none w-full max-h-max bg-transparent !p-0 !m-0 !border-0 resize-none text-sm leading-5 text-custom-text-200"
value={cycleDetails.description}
disabled
/>
)}
<div className="flex flex-col gap-5 pb-6 pt-2.5">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<SquareUser className="h-4 w-4" />
<span className="text-base">{t("lead")}</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
<Avatar name={cycleOwnerDetails?.display_name} src={getFileURL(cycleOwnerDetails?.avatar_url ?? "")} />
<span className="text-sm text-custom-text-200">{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-custom-text-300">
<Users className="h-4 w-4" />
<span className="text-base">{t("members")}</span>
</div>
<div className="flex w-3/5 items-center rounded-sm">
<div className="flex items-center gap-2.5">
{cycleDetails?.assignee_ids && cycleDetails.assignee_ids.length > 0 ? (
<>
<AvatarGroup showTooltip>
{cycleDetails.assignee_ids.map((member) => {
const memberDetails = getUserDetails(member);
return (
<Avatar
key={memberDetails?.id}
name={memberDetails?.display_name ?? ""}
src={getFileURL(memberDetails?.avatar_url ?? "")}
showTooltip={false}
/>
);
})}
</AvatarGroup>
</>
) : (
<span className="px-1.5 text-sm text-custom-text-300">{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-custom-text-300">
<WorkItemsIcon className="h-4 w-4" />
<span className="text-base">{t("work_items")}</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueCount}</span>
</div>
</div>
{/**
* NOTE: Render this section when estimate points of he projects is enabled and the estimate system is points
*/}
{isEstimatePointValid && !isCompleted && (
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-custom-text-300">
<WorkItemsIcon className="h-4 w-4" />
<span className="text-base">{t("points")}</span>
</div>
<div className="flex w-3/5 items-center">
<span className="px-1.5 text-sm text-custom-text-300">{issueEstimatePointCount}</span>
</div>
</div>
)}
</div>
</div>
);
});

View File

@@ -0,0 +1,237 @@
"use client";
import type { FC } from "react";
import React, { useEffect } from "react";
import { observer } from "mobx-react";
import { Controller, useForm } from "react-hook-form";
import { ArrowRight, ChevronRight } from "lucide-react";
// Plane Imports
import {
CYCLE_TRACKER_EVENTS,
CYCLE_STATUS,
EUserPermissions,
EUserPermissionsLevel,
CYCLE_TRACKER_ELEMENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
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 { captureElementAndEvent } from "@/helpers/event-tracker.helper";
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: FC<Props> = observer((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)
.then(() => {
captureElementAndEvent({
element: {
elementName: CYCLE_TRACKER_ELEMENTS.RIGHT_SIDEBAR,
},
event: {
eventName: CYCLE_TRACKER_EVENTS.update,
state: "SUCCESS",
payload: {
id: cycleDetails.id,
},
},
});
})
.catch(() => {
captureElementAndEvent({
element: {
elementName: CYCLE_TRACKER_ELEMENTS.RIGHT_SIDEBAR,
},
event: {
eventName: CYCLE_TRACKER_EVENTS.update,
state: "ERROR",
payload: {
id: cycleDetails.id,
},
},
});
});
};
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const dateChecker = async (payload: any) => {
try {
const res = await cycleService.cycleDateCheck(workspaceSlug as string, projectId as string, 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 z-10 top-0 pt-2 flex items-center justify-between bg-custom-sidebar-background-100">
<div className="flex items-center justify-center size-5">
<button
className="flex size-4 items-center justify-center rounded-full bg-custom-border-200"
onClick={() => handleClose()}
>
<ChevronRight className="h-3 w-3 stroke-2 text-white" />
</button>
</div>
</div>
<div className="flex flex-col gap-2 w-full">
<div className="flex items-start justify-between gap-3 pt-2">
<h4 className="w-full break-words text-xl font-semibold text-custom-text-100">{cycleDetails.name}</h4>
{currentCycle && (
<span
className="flex h-6 min-w-20 px-3 items-center justify-center rounded text-center text-xs font-medium"
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 gap-2 items-center">
<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={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
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="rounded-md text-xs px-2 cursor-default py-1 bg-custom-background-80 text-custom-text-300">
{projectUTCOffset}
</span>
)}
</div>
)}
/>
</div>
</>
);
});

View File

@@ -0,0 +1,54 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// helpers
import { DATE_AFTER_FILTER_OPTIONS } from "@plane/constants";
import { renderFormattedDate, capitalizeFirstLetter } from "@plane/utils";
// constants
type Props = {
editable: boolean | undefined;
handleRemove: (val: string) => void;
values: string[];
};
export const AppliedDateFilters: React.FC<Props> = observer((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 bg-custom-background-80 py-1 px-1.5 text-xs">
<span className="normal-case">{getDateLabel(date)}</span>
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(date)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
))}
</>
);
});

View File

@@ -0,0 +1 @@
export * from "./root";

View File

@@ -0,0 +1,87 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import type { TCycleFilters } from "@plane/types";
import { Tag } from "@plane/ui";
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: React.FC<Props> = observer((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-custom-background-100">
{Object.entries(appliedFilters).map(([key, value]) => {
const filterKey = key as keyof TCycleFilters;
if (!value) return;
if (Array.isArray(value) && value.length === 0) return;
return (
<Tag key={filterKey}>
<span className="text-xs text-custom-text-300">{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-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemoveFilter(filterKey, null)}
>
<X size={12} strokeWidth={2} />
</button>
)}
</div>
</Tag>
);
})}
{isEditingAllowed && (
<button type="button" onClick={handleClearAllFilters}>
<Tag>
{t("common.clear_all")}
<X size={12} strokeWidth={2} />
</Tag>
</button>
)}
</div>
);
});

View File

@@ -0,0 +1,45 @@
import { observer } from "mobx-react";
import { X } from "lucide-react";
import { CYCLE_STATUS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { cn } from "@plane/utils";
type Props = {
handleRemove: (val: string) => void;
values: string[];
editable: boolean | undefined;
};
export const AppliedStatusFilters: React.FC<Props> = observer((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 py-1 px-1.5 text-xs",
statusDetails?.bgColor,
statusDetails?.textColor
)}
>
{statusDetails && t(statusDetails?.i18n_title)}
{editable && (
<button
type="button"
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
onClick={() => handleRemove(status)}
>
<X size={10} strokeWidth={2} />
</button>
)}
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,131 @@
import type { FC } from "react";
import { useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
// icons
import { ListFilter, Search, X } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// 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: FC = observer(() => {
// 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-custom-border-200">
<div className="flex w-full items-center overflow-x-auto px-4 gap-2 horizontal-scrollbar scrollbar-sm">
<ArchiveTabsList />
</div>
{/* filter options */}
<div className="h-full flex items-center gap-3 self-end px-8">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 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);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown
icon={<ListFilter 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 @@
export * from "./root";

View File

@@ -0,0 +1,121 @@
"use client";
import { useState, Fragment } from "react";
import { Dialog, Transition } from "@headlessui/react";
// ui
import { CYCLE_TRACKER_EVENTS } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
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 const ArchiveCycleModal: React.FC<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.",
});
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.archive,
payload: {
id: cycleId,
},
});
onClose();
router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
})
.catch(() => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Cycle could not be archived. Please try again.",
});
captureError({
eventName: CYCLE_TRACKER_EVENTS.archive,
payload: {
id: cycleId,
},
});
})
.finally(() => setIsArchiving(false));
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-20" onClose={onClose}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-custom-background-100 text-left shadow-custom-shadow-md transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div className="px-5 py-4">
<h3 className="text-xl font-medium 2xl:text-2xl">Archive cycle {cycleName}</h3>
<p className="mt-3 text-sm text-custom-text-200">
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="neutral-primary" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" tabIndex={1} onClick={handleArchiveCycle} loading={isArchiving}>
{isArchiving ? "Archiving" : "Archive"}
</Button>
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};

View File

@@ -0,0 +1,85 @@
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 type { TCycleFilters } from "@plane/types";
import { calculateTotalFilters } from "@plane/utils";
// components
import { DetailedEmptyState } from "@/components/empty-state/detailed-empty-state-root";
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";
import { useResolvedAssetPath } from "@/hooks/use-resolved-asset-path";
// local imports
import { CycleAppliedFiltersList } from "../applied-filters";
import { ArchivedCyclesView } from "./view";
export const ArchivedCycleLayoutRoot: React.FC = observer(() => {
// 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;
const resolvedPath = useResolvedAssetPath({ basePath: "/empty-state/archived/empty-cycles" });
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-custom-border-200 px-5 py-3">
<CycleAppliedFiltersList
appliedFilters={currentProjectArchivedFilters ?? {}}
handleClearAllFilters={() => clearAllFilters(projectId.toString(), "archived")}
handleRemoveFilter={handleRemoveFilter}
/>
</div>
)}
{totalArchivedCycles === 0 ? (
<div className="h-full place-items-center">
<DetailedEmptyState
title={t("project_cycles.empty_state.archived.title")}
description={t("project_cycles.empty_state.archived.description")}
assetPath={resolvedPath}
/>
</div>
) : (
<div className="relative h-full w-full overflow-auto">
<ArchivedCyclesView workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
</div>
)}
</>
);
});

View File

@@ -0,0 +1,58 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// 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";
// assets
import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg";
import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg";
export interface IArchivedCyclesView {
workspaceSlug: string;
projectId: string;
}
export const ArchivedCyclesView: FC<IArchivedCyclesView> = observer((props) => {
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="h-full w-full grid place-items-center">
<div className="text-center">
<Image
src={archivedCyclesSearchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="h-36 sm:h-48 w-36 sm:w-48 mx-auto"
alt="No matching cycles"
/>
<h5 className="text-xl font-medium mt-7 mb-1">No matching cycles</h5>
<p className="text-custom-text-400 text-base">
{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,65 @@
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: React.FC<Props> = observer((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}`, { showProgress: false });
};
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="flex h-full w-full max-w-[21.5rem] flex-shrink-0 flex-col gap-3.5 overflow-y-auto border-l border-custom-border-100 bg-custom-sidebar-background-100 px-4 duration-300 fixed md:relative right-0 z-[9]"
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,126 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { observer } from "mobx-react";
// icons
import { ListFilter, Search, X } from "lucide-react";
// plane helpers
import { useOutsideClickDetector } from "@plane/hooks";
// types
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: React.FC<Props> = observer((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-3">
{!isSearchOpen && (
<button
type="button"
className="-mr-5 p-2 hover:bg-custom-background-80 rounded text-custom-text-400 grid place-items-center"
onClick={() => {
setIsSearchOpen(true);
inputRef.current?.focus();
}}
>
<Search className="h-3.5 w-3.5" />
</button>
)}
<div
className={cn(
"ml-auto flex items-center justify-start gap-1 rounded-md border border-transparent bg-custom-background-100 text-custom-text-400 w-0 transition-[width] ease-linear overflow-hidden opacity-0",
{
"w-64 px-2.5 py-1.5 border-custom-border-200 opacity-100": isSearchOpen,
}
)}
>
<Search className="h-3.5 w-3.5" />
<input
ref={inputRef}
className="w-full max-w-[234px] border-none bg-transparent text-sm text-custom-text-100 placeholder:text-custom-text-400 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);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
<FiltersDropdown
icon={<ListFilter 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,64 @@
import type { FC } from "react";
import { observer } from "mobx-react";
import Image from "next/image";
// components
import { useTranslation } from "@plane/i18n";
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";
// assets
import AllFiltersImage from "@/public/empty-state/cycle/all-filters.svg";
import NameFilterImage from "@/public/empty-state/cycle/name-filter.svg";
export interface ICyclesView {
workspaceSlug: string;
projectId: string;
}
export const CyclesView: FC<ICyclesView> = observer((props) => {
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">
<Image
src={searchQuery.trim() === "" ? AllFiltersImage : NameFilterImage}
className="mx-auto h-36 w-36 sm:h-48 sm:w-48"
alt="No matching cycles"
/>
<h5 className="mb-1 mt-7 text-xl font-medium">{t("project_cycles.no_matching_cycles")}</h5>
<p className="text-base text-custom-text-400">
{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,106 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
import { useParams, useSearchParams } from "next/navigation";
// types
import { PROJECT_ERROR_MESSAGES, CYCLE_TRACKER_EVENTS } 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";
// helpers
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
// 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: React.FC<ICycleDelete> = observer((props) => {
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.",
});
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.delete,
payload: {
id: cycle.id,
},
});
})
.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),
});
captureError({
eventName: CYCLE_TRACKER_EVENTS.delete,
payload: {
id: cycle.id,
},
error: errors,
});
})
.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="break-words font-medium text-custom-text-100">{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,44 @@
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((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-custom-background-90 border-none rounded text-sm 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,76 @@
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: React.FC<Props> = observer((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-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,4 @@
export * from "./end-date";
export * from "./root";
export * from "./start-date";
export * from "./status";

View File

@@ -0,0 +1,78 @@
import { useState } from "react";
import { observer } from "mobx-react";
import { Search, X } from "lucide-react";
// 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: React.FC<Props> = observer((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-custom-background-100 p-2.5 pb-0">
<div className="flex items-center gap-1.5 rounded border-[0.5px] border-custom-border-200 bg-custom-background-90 px-1.5 py-1 text-xs">
<Search className="text-custom-text-400" size={12} strokeWidth={2} />
<input
type="text"
className="w-full bg-custom-background-90 outline-none placeholder:text-custom-text-400"
placeholder="Search"
value={filtersSearchQuery}
onChange={(e) => setFiltersSearchQuery(e.target.value)}
autoFocus={!isMobile}
/>
{filtersSearchQuery !== "" && (
<button type="button" className="grid place-items-center" onClick={() => setFiltersSearchQuery("")}>
<X className="text-custom-text-300" size={12} strokeWidth={2} />
</button>
)}
</div>
</div>
<div className="h-full w-full divide-y divide-custom-border-200 overflow-y-auto px-2.5 vertical-scrollbar scrollbar-sm">
{/* 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,77 @@
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: React.FC<Props> = observer((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-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,51 @@
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: React.FC<Props> = observer((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-xs italic text-custom-text-400">No matches found</p>
)}
</div>
)}
</>
);
});

View File

@@ -0,0 +1,2 @@
export * from "./filters";
export * from "./estimate-type-dropdown";

View File

@@ -0,0 +1,198 @@
"use client";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
// types
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { ICycle } from "@plane/types";
// ui
import { Input, TextArea } from "@plane/ui";
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 const CycleForm: React.FC<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-xl font-medium text-custom-text-200">
{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 } }) => (
<Input
name="name"
type="text"
placeholder={t("title")}
className="w-full text-base"
value={value}
inputSize="md"
onChange={onChange}
hasError={Boolean(errors?.name)}
tabIndex={getIndex("description")}
autoFocus
/>
)}
/>
<span className="text-xs text-red-500">{errors?.name?.message}</span>
</div>
<div>
<Controller
name="description"
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
name="description"
placeholder={t("description")}
className="w-full text-base resize-none min-h-24"
hasError={Boolean(errors?.description)}
value={value}
onChange={onChange}
tabIndex={getIndex("description")}
/>
)}
/>
</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="px-5 py-4 flex items-center justify-end gap-2 border-t-[0.5px] border-custom-border-200">
<Button variant="neutral-primary" size="sm" onClick={handleClose} tabIndex={getIndex("cancel")}>
{t("common.cancel")}
</Button>
<Button variant="primary" size="sm" 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,43 @@
"use client";
import type { FC } from "react";
import React from "react";
import { ChevronDown } from "lucide-react";
// types
import { CycleGroupIcon } from "@plane/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 const CycleListGroupHeader: FC<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 items-center gap-5 flex-shrink-0">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center overflow-hidden rounded-sm">
<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="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{title}</div>
{showCount && <div className="pl-2 text-sm font-medium text-custom-text-300">{`${count ?? "0"}`}</div>}
</div>
</div>
<ChevronDown
className={cn("h-4 w-4 text-custom-sidebar-text-300 duration-300 ", {
"rotate-180": isExpanded,
})}
/>
</Row>
);
};

View File

@@ -0,0 +1,344 @@
"use client";
import type { FC, 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 { Eye, Users, ArrowRight, CalendarDays } from "lucide-react";
// plane imports
import {
CYCLE_TRACKER_EVENTS,
EUserPermissions,
EUserPermissionsLevel,
IS_FAVORITE_MENU_OPEN,
CYCLE_TRACKER_ELEMENTS,
} from "@plane/constants";
import { useLocalStorage } from "@plane/hooks";
import { useTranslation } from "@plane/i18n";
import { TransferIcon, WorkItemsIcon } from "@plane/propel/icons";
import { setPromiseToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { ICycle, TCycleGroups } from "@plane/types";
import { Avatar, AvatarGroup, FavoriteStar } from "@plane/ui";
import { getDate, getFileURL, generateQueryParams } from "@plane/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
import { MergedDateDisplay } from "@/components/dropdowns/merged-date";
// hooks
import { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
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";
// plane web components
import { CycleAdditionalActions } from "@/plane-web/components/cycles";
// 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>;
isActive?: boolean;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
export const CycleListItemAction: FC<Props> = observer((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(() => {
if (!isFavoriteMenuOpen) toggleFavoriteMenu(true);
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.favorite,
payload: {
id: cycleId,
},
});
})
.catch((error) => {
captureError({
eventName: CYCLE_TRACKER_EVENTS.favorite,
payload: {
id: cycleId,
},
error,
});
});
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)
.then(() => {
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.unfavorite,
payload: {
id: cycleId,
},
});
})
.catch((error) => {
captureError({
eventName: CYCLE_TRACKER_EVENTS.unfavorite,
payload: {
id: cycleId,
},
error,
});
});
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}`, { showProgress: false });
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`, { showProgress: false });
}
};
return (
<>
<TransferIssuesModal
handleClose={() => setTransferIssuesModal(false)}
isOpen={transferIssuesModal}
cycleId={cycleId.toString()}
/>
<button
onClick={openCycleOverview}
className={`z-[1] flex text-custom-primary-200 text-xs gap-1 flex-shrink-0 ${isMobile || (isActive && !searchParams.has("peekCycle")) ? "flex" : "hidden group-hover:flex"}`}
>
<Eye className="h-4 w-4 my-auto text-custom-primary-200" />
<span>{t("project_cycles.more_details")}</span>
</button>
{showIssueCount && (
<div className="flex items-center gap-1">
<WorkItemsIcon className="h-4 w-4 text-custom-text-300" />
<span className="text-xs text-custom-text-300">{cycleDetails.total_issues}</span>
</div>
)}
<CycleAdditionalActions cycleId={cycleId} projectId={projectId} />
{showTransferIssues && (
<div
className="px-2 h-6 text-custom-primary-200 flex items-center gap-1 cursor-pointer"
onClick={() => {
setTransferIssuesModal(true);
}}
>
<TransferIcon className="fill-custom-primary-200 w-4" />
<span>{t("project_cycles.transfer_work_items", { count: transferableIssuesCount })}</span>
</div>
)}
{isActive ? (
<>
<div className="flex gap-2">
{/* Duration */}
<Tooltip
tooltipContent={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
disabled={!isProjectTimeZoneDifferent()}
tooltipHeading={t("project_cycles.in_your_timezone")}
>
<div className="flex gap-1 text-xs text-custom-text-300 font-medium items-center">
<CalendarDays className="h-3 w-3 flex-shrink-0 my-auto" />
<MergedDateDisplay startDate={cycleDetails.start_date} endDate={cycleDetails.end_date} />
</div>
</Tooltip>
{projectUTCOffset && (
<span className="rounded-md text-xs px-2 cursor-default py-1 bg-custom-background-80 text-custom-text-300">
{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-custom-text-300 rounded text-xs [&>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={
<span className="flex gap-1">
{renderFormattedDateInUserTimezone(cycleDetails.start_date ?? "")}
<ArrowRight className="h-3 w-3 flex-shrink-0 my-auto" />
{renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")}
</span>
}
mergeDates
required={cycleDetails.status !== "draft"}
disabled
hideIcon={{
from: false,
to: false,
}}
/>
</>
)
)}
{/* created by */}
{createdByDetails && !isActive && <ButtonAvatars showTooltip={false} userIds={createdByDetails?.id} />}
{!isActive && (
<Tooltip tooltipContent={`${cycleDetails.assignee_ids?.length} Members`} isMobile={isMobile}>
<div className="flex w-10 cursor-default items-center justify-center">
{cycleDetails.assignee_ids && cycleDetails.assignee_ids?.length > 0 ? (
<AvatarGroup showTooltip={false}>
{cycleDetails.assignee_ids?.map((assignee_id) => {
const member = getUserDetails(assignee_id);
return (
<Avatar key={member?.id} name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} />
);
})}
</AvatarGroup>
) : (
<Users className="h-4 w-4 text-custom-text-300" />
)}
</div>
</Tooltip>
)}
{isEditingAllowed && !cycleDetails.archived_at && (
<FavoriteStar
data-ph-element={CYCLE_TRACKER_ELEMENTS.LIST_ITEM}
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,47 @@
"use client";
import type { FC } from "react";
import React from "react";
import { observer } from "mobx-react";
import { ChevronRight } from "lucide-react";
// icons
import { Row } from "@plane/ui";
// helpers
import { cn } from "@plane/utils";
// components
import { Logo } from "@/components/common/logo";
import { useProject } from "@/hooks/store/use-project";
type Props = {
projectId: string;
count?: number;
showCount?: boolean;
isExpanded?: boolean;
};
export const CycleListProjectGroupHeader: FC<Props> = observer((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 items-center gap-2 flex-shrink-0 py-2.5">
<ChevronRight
className={cn("h-4 w-4 text-custom-sidebar-text-300 duration-300 ", {
"rotate-90": isExpanded,
})}
strokeWidth={2}
/>
<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="inline-block line-clamp-1 truncate font-medium text-custom-text-100">{project.name}</div>
{showCount && <div className="pl-2 text-sm font-medium text-custom-text-300">{`${count ?? "0"}`}</div>}
</div>
</Row>
);
});

View File

@@ -0,0 +1,117 @@
"use client";
import type { FC, MouseEvent } from "react";
import { useRef } from "react";
import { observer } from "mobx-react";
import { usePathname, useSearchParams } from "next/navigation";
import { Check } from "lucide-react";
// plane imports
import type { TCycleGroups } from "@plane/types";
import { CircularProgressIndicator } from "@plane/ui";
// 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: FC<TCyclesListItem> = observer((props) => {
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}`, { showProgress: false });
} else {
router.push(`${pathname}?${query && `${query}&`}peekCycle=${cycleId}`, { showProgress: false });
}
};
// 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={
<CircularProgressIndicator size={30} percentage={progress} strokeWidth={3}>
{progress === 100 ? (
<Check className="h-3 w-3 stroke-[2] text-custom-primary-100" />
) : (
<span className="text-[9px] text-custom-text-100">{`${progress}%`}</span>
)}
</CircularProgressIndicator>
}
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,20 @@
// components
import { CyclesListItem } from "./cycles-list-item";
type Props = {
cycleIds: string[];
projectId: string;
workspaceSlug: string;
};
export const CyclesListMap: React.FC<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 @@
export * from "./root";

View File

@@ -0,0 +1,83 @@
import type { FC } from "react";
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";
import { ActiveCycleRoot } from "@/plane-web/components/cycles";
// local imports
import { CyclePeekOverview } from "../cycle-peek-overview";
import { CycleListGroupHeader } from "./cycle-list-group-header";
import { CyclesListMap } from "./cycles-list-map";
export interface ICyclesList {
completedCycleIds: string[];
upcomingCycleIds?: string[] | undefined;
cycleIds: string[];
workspaceSlug: string;
projectId: string;
isArchived?: boolean;
}
export const CyclesList: FC<ICyclesList> = observer((props) => {
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 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
<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 border-b border-custom-border-200 bg-custom-background-90 cursor-pointer">
<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,209 @@
"use client";
import React, { useEffect, useState } from "react";
import { mutate } from "swr";
// types
import { CYCLE_TRACKER_EVENTS } from "@plane/constants";
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 { captureError, captureSuccess } from "@/helpers/event-tracker.helper";
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 const CycleCreateUpdateModal: React.FC<CycleModalProps> = (props) => {
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.",
});
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.create,
payload: {
id: res.id,
},
});
})
.catch((err) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: err?.detail ?? "Error in creating cycle. Please try again.",
});
captureError({
eventName: CYCLE_TRACKER_EVENTS.create,
error: err,
});
});
};
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) => {
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.update,
payload: {
id: res.id,
},
});
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.",
});
captureError({
eventName: CYCLE_TRACKER_EVENTS.update,
error: err,
});
});
};
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 - always include cycle_id for validation
isDateValid = await dateChecker(projectId, {
start_date: payload.start_date,
end_date: payload.end_date,
cycle_id: data.id,
});
} else {
// Create new cycle - no cycle_id needed
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,262 @@
"use client";
import { useState } from "react";
import { observer } from "mobx-react";
// icons
import { ArchiveRestoreIcon, ExternalLink, LinkIcon, Pencil, Trash2 } from "lucide-react";
// ui
import {
CYCLE_TRACKER_EVENTS,
EUserPermissions,
EUserPermissionsLevel,
CYCLE_TRACKER_ELEMENTS,
} from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ArchiveIcon } from "@plane/propel/icons";
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";
// helpers
// hooks
import { captureClick, captureError, captureSuccess } from "@/helpers/event-tracker.helper";
import { useCycle } from "@/hooks/store/use-cycle";
import { useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router";
import { useEndCycle, EndCycleModal } from "@/plane-web/components/cycles";
// local imports
import { ArchiveCycleModal } from "./archived-cycles/modal";
import { CycleDeleteModal } from "./delete-modal";
import { CycleCreateUpdateModal } from "./modal";
type Props = {
parentRef: React.RefObject<HTMLElement>;
cycleId: string;
projectId: string;
workspaceSlug: string;
customClassName?: string;
};
export const CycleQuickActions: React.FC<Props> = observer((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);
const isArchived = !!cycleDetails?.archived_at;
const isCompleted = cycleDetails?.status?.toLowerCase() === "completed";
const isCurrentCycle = cycleDetails?.status?.toLowerCase() === "current";
const transferrableIssuesCount = cycleDetails
? cycleDetails.total_issues - (cycleDetails.cancelled_issues + cycleDetails.completed_issues)
: 0;
// auth
const isEditingAllowed = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
EUserPermissionsLevel.PROJECT,
workspaceSlug,
projectId
);
const { isEndCycleModalOpen, setEndCycleModalOpen, endCycleContextMenu } = useEndCycle(isCurrentCycle);
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 handleEditCycle = () => {
setUpdateModal(true);
};
const handleArchiveCycle = () => setArchiveCycleModal(true);
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"),
});
captureSuccess({
eventName: CYCLE_TRACKER_EVENTS.restore,
payload: {
id: cycleId,
},
});
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"),
});
captureError({
eventName: CYCLE_TRACKER_EVENTS.restore,
payload: {
id: cycleId,
},
});
});
const handleDeleteCycle = () => {
setDeleteModal(true);
};
const MENU_ITEMS: TContextMenuItem[] = [
{
key: "edit",
title: t("edit"),
icon: Pencil,
action: handleEditCycle,
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
},
{
key: "open-new-tab",
action: handleOpenInNewTab,
title: t("open_in_new_tab"),
icon: ExternalLink,
shouldRender: !isArchived,
},
{
key: "copy-link",
action: handleCopyText,
title: t("copy_link"),
icon: LinkIcon,
shouldRender: !isArchived,
},
{
key: "archive",
action: handleArchiveCycle,
title: t("archive"),
description: isCompleted ? undefined : t("project_cycles.only_completed_cycles_can_be_archived"),
icon: ArchiveIcon,
className: "items-start",
iconClassName: "mt-1",
shouldRender: isEditingAllowed && !isArchived,
disabled: !isCompleted,
},
{
key: "restore",
action: handleRestoreCycle,
title: t("restore"),
icon: ArchiveRestoreIcon,
shouldRender: isEditingAllowed && isArchived,
},
{
key: "delete",
action: handleDeleteCycle,
title: t("delete"),
icon: Trash2,
shouldRender: isEditingAllowed && !isCompleted && !isArchived,
},
];
if (endCycleContextMenu) MENU_ITEMS.splice(3, 0, endCycleContextMenu);
const CONTEXT_MENU_ITEMS = MENU_ITEMS.map((item) => ({
...item,
action: () => {
captureClick({
elementName: CYCLE_TRACKER_ELEMENTS.CONTEXT_MENU,
});
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}
/>
{isCurrentCycle && (
<EndCycleModal
isOpen={isEndCycleModalOpen}
handleClose={() => setEndCycleModalOpen(false)}
cycleId={cycleId}
projectId={projectId}
workspaceSlug={workspaceSlug}
transferrableIssuesCount={transferrableIssuesCount}
cycleName={cycleDetails.name}
/>
)}
</div>
)}
<ContextMenu parentRef={parentRef} items={CONTEXT_MENU_ITEMS} />
<CustomMenu ellipsis 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={() => {
captureClick({
elementName: CYCLE_TRACKER_ELEMENTS.QUICK_ACTIONS,
});
item.action();
}}
className={cn(
"flex items-center gap-2",
{
"text-custom-text-400": item.disabled,
},
item.className
)}
disabled={item.disabled}
>
{item.icon && <item.icon className={cn("h-3 w-3", item.iconClassName)} />}
<div>
<h5>{item.title}</h5>
{item.description && (
<p
className={cn("text-custom-text-300 whitespace-pre-line", {
"text-custom-text-400": item.disabled,
})}
>
{item.description}
</p>
)}
</div>
</CustomMenu.MenuItem>
);
})}
</CustomMenu>
</>
);
});

View File

@@ -0,0 +1,182 @@
"use client";
import React, { useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { AlertCircle, Search, X } from "lucide-react";
import { Dialog, Transition } from "@headlessui/react";
import { CycleIcon, TransferIcon } from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EIssuesStoreType } from "@plane/types";
import { useCycle } from "@/hooks/store/use-cycle";
import { useIssues } from "@/hooks/store/use-issues";
//icons
// constants
type Props = {
isOpen: boolean;
handleClose: () => void;
cycleId: string;
};
export const TransferIssuesModal: React.FC<Props> = observer((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());
});
// useEffect(() => {
// const handleKeyDown = (e: KeyboardEvent) => {
// if (e.key === "Escape") {
// handleClose();
// }
// };
// }, [handleClose]);
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-20" onClose={handleClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-custom-backdrop transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10">
<div className="mt-10 flex min-h-full items-start justify-center p-4 text-center sm:p-0 md:mt-20">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform rounded-lg bg-custom-background-100 py-5 text-left shadow-custom-shadow-md transition-all sm:w-full sm:max-w-2xl">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between px-5">
<div className="flex items-center gap-1">
<TransferIcon className="w-5 fill-custom-text-100" />
<h4 className="text-xl font-medium text-custom-text-100">Transfer work items</h4>
</div>
<button onClick={handleClose}>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex items-center gap-2 border-b border-custom-border-200 px-5 pb-3">
<Search className="h-4 w-4 text-custom-text-200" />
<input
className="outline-none text-sm"
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 px-4 py-3 text-sm text-custom-text-200 hover:bg-custom-background-90"
onClick={() => {
transferIssue({
new_cycle_id: optionId,
});
handleClose();
}}
>
<CycleIcon className="h-5 w-5" />
<div className="flex w-full justify-between truncate">
<span className="truncate">{cycleDetails?.name}</span>
{cycleDetails.status && (
<span className="flex-shrink-0 flex items-center rounded-full bg-custom-background-80 px-2 capitalize">
{cycleDetails.status.toLocaleLowerCase()}
</span>
)}
</div>
</button>
);
})
) : (
<div className="flex w-full items-center justify-center gap-4 p-5 text-sm">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span className="text-center text-custom-text-200">
You dont have any current cycle. Please create one to transfer the work items.
</span>
</div>
)
) : (
<p className="text-center text-custom-text-200">Loading...</p>
)}
</div>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
});

View File

@@ -0,0 +1,37 @@
"use client";
import React from "react";
import { AlertCircle } from "lucide-react";
// ui
import { Button } from "@plane/propel/button";
import { TransferIcon } from "@plane/propel/icons";
type Props = {
handleClick: () => void;
canTransferIssues?: boolean;
disabled?: boolean;
};
export const TransferIssues: React.FC<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-sm text-custom-text-200">
<AlertCircle className="h-3.5 w-3.5 text-custom-text-200" />
<span>Completed cycles are not editable.</span>
</div>
{canTransferIssues && (
<div>
<Button
variant="primary"
prependIcon={<TransferIcon color="white" />}
onClick={handleClick}
disabled={disabled}
>
Transfer work items
</Button>
</div>
)}
</div>
);
};