feat: init
This commit is contained in:
370
apps/web/core/components/cycles/active-cycle/cycle-stats.tsx
Normal file
370
apps/web/core/components/cycles/active-cycle/cycle-stats.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
101
apps/web/core/components/cycles/active-cycle/productivity.tsx
Normal file
101
apps/web/core/components/cycles/active-cycle/productivity.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
110
apps/web/core/components/cycles/active-cycle/progress.tsx
Normal file
110
apps/web/core/components/cycles/active-cycle/progress.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
64
apps/web/core/components/cycles/analytics-sidebar/root.tsx
Normal file
64
apps/web/core/components/cycles/analytics-sidebar/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
54
apps/web/core/components/cycles/applied-filters/date.tsx
Normal file
54
apps/web/core/components/cycles/applied-filters/date.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/cycles/applied-filters/index.ts
Normal file
1
apps/web/core/components/cycles/applied-filters/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
87
apps/web/core/components/cycles/applied-filters/root.tsx
Normal file
87
apps/web/core/components/cycles/applied-filters/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
45
apps/web/core/components/cycles/applied-filters/status.tsx
Normal file
45
apps/web/core/components/cycles/applied-filters/status.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
131
apps/web/core/components/cycles/archived-cycles/header.tsx
Normal file
131
apps/web/core/components/cycles/archived-cycles/header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
1
apps/web/core/components/cycles/archived-cycles/index.ts
Normal file
1
apps/web/core/components/cycles/archived-cycles/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
121
apps/web/core/components/cycles/archived-cycles/modal.tsx
Normal file
121
apps/web/core/components/cycles/archived-cycles/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
85
apps/web/core/components/cycles/archived-cycles/root.tsx
Normal file
85
apps/web/core/components/cycles/archived-cycles/root.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
58
apps/web/core/components/cycles/archived-cycles/view.tsx
Normal file
58
apps/web/core/components/cycles/archived-cycles/view.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
});
|
||||
65
apps/web/core/components/cycles/cycle-peek-overview.tsx
Normal file
65
apps/web/core/components/cycles/cycle-peek-overview.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
126
apps/web/core/components/cycles/cycles-view-header.tsx
Normal file
126
apps/web/core/components/cycles/cycles-view-header.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
64
apps/web/core/components/cycles/cycles-view.tsx
Normal file
64
apps/web/core/components/cycles/cycles-view.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
106
apps/web/core/components/cycles/delete-modal.tsx
Normal file
106
apps/web/core/components/cycles/delete-modal.tsx
Normal 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.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./end-date";
|
||||
export * from "./root";
|
||||
export * from "./start-date";
|
||||
export * from "./status";
|
||||
78
apps/web/core/components/cycles/dropdowns/filters/root.tsx
Normal file
78
apps/web/core/components/cycles/dropdowns/filters/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
51
apps/web/core/components/cycles/dropdowns/filters/status.tsx
Normal file
51
apps/web/core/components/cycles/dropdowns/filters/status.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
2
apps/web/core/components/cycles/dropdowns/index.ts
Normal file
2
apps/web/core/components/cycles/dropdowns/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./filters";
|
||||
export * from "./estimate-type-dropdown";
|
||||
198
apps/web/core/components/cycles/form.tsx
Normal file
198
apps/web/core/components/cycles/form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
344
apps/web/core/components/cycles/list/cycle-list-item-action.tsx
Normal file
344
apps/web/core/components/cycles/list/cycle-list-item-action.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
117
apps/web/core/components/cycles/list/cycles-list-item.tsx
Normal file
117
apps/web/core/components/cycles/list/cycles-list-item.tsx
Normal 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")}
|
||||
/>
|
||||
);
|
||||
});
|
||||
20
apps/web/core/components/cycles/list/cycles-list-map.tsx
Normal file
20
apps/web/core/components/cycles/list/cycles-list-map.tsx
Normal 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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
apps/web/core/components/cycles/list/index.ts
Normal file
1
apps/web/core/components/cycles/list/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./root";
|
||||
83
apps/web/core/components/cycles/list/root.tsx
Normal file
83
apps/web/core/components/cycles/list/root.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
209
apps/web/core/components/cycles/modal.tsx
Normal file
209
apps/web/core/components/cycles/modal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
262
apps/web/core/components/cycles/quick-actions.tsx
Normal file
262
apps/web/core/components/cycles/quick-actions.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
182
apps/web/core/components/cycles/transfer-issues-modal.tsx
Normal file
182
apps/web/core/components/cycles/transfer-issues-modal.tsx
Normal 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 don’t 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>
|
||||
);
|
||||
});
|
||||
37
apps/web/core/components/cycles/transfer-issues.tsx
Normal file
37
apps/web/core/components/cycles/transfer-issues.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user