[WEB-1999] dev: interactive active cycle stats (#5280)

* chore: list layout item improvement

* dev: active cycle interactive stats implementation

* dev: in cycle list interactive date picker added
This commit is contained in:
Anmol Singh Bhatia
2024-08-01 12:55:57 +05:30
committed by GitHub
parent daaa04c6ea
commit ee76cb1dc7
5 changed files with 208 additions and 50 deletions

View File

@@ -62,21 +62,21 @@ export const ListItem: FC<IListItemProps> = (props) => {
)}
>
<div className="relative flex w-full items-center justify-between gap-3 overflow-hidden">
<div className="relative flex w-full items-center gap-3 overflow-hidden">
<ControlLink
href={itemLink}
target="_self"
className="flex items-center gap-4 truncate"
onClick={handleControlLinkClick}
disabled={disableLink}
>
<ControlLink
className="relative flex w-full items-center gap-3 overflow-hidden"
href={itemLink}
target="_self"
onClick={handleControlLinkClick}
disabled={disableLink}
>
<div className="flex items-center gap-4 truncate">
{prependTitleElement && <span className="flex items-center flex-shrink-0">{prependTitleElement}</span>}
<Tooltip tooltipContent={title} position="top" isMobile={isMobile}>
<span className="truncate text-sm">{title}</span>
</Tooltip>
</ControlLink>
</div>
{appendTitleElement && <span className="flex items-center flex-shrink-0">{appendTitleElement}</span>}
</div>
</ControlLink>
{quickActionElement && quickActionElement}
</div>
{actionableItems && (

View File

@@ -2,13 +2,12 @@
import { FC, Fragment, useCallback, useRef, useState } from "react";
import { observer } from "mobx-react";
import Link from "next/link";
import useSWR from "swr";
import { CalendarCheck } from "lucide-react";
// headless ui
import { Tab } from "@headlessui/react";
// types
import { ICycle } from "@plane/types";
import { ICycle, IIssueFilterOptions } from "@plane/types";
// ui
import { Tooltip, Loader, PriorityIcon, Avatar } from "@plane/ui";
// components
@@ -32,10 +31,11 @@ export type ActiveCycleStatsProps = {
projectId: string;
cycle: ICycle | null;
cycleId?: string | null;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
};
export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
const { workspaceSlug, projectId, cycle, cycleId } = props;
const { workspaceSlug, projectId, cycle, cycleId, handleFiltersUpdate } = props;
const { storedValue: tab, setValue: setTab } = useLocalStorage("activeCycleTab", "Assignees");
@@ -59,6 +59,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
} = useIssues(EIssuesStoreType.CYCLE);
const {
issue: { getIssueById },
setPeekIssue,
} = useIssueDetail();
const { currentProjectDetails } = useProject();
@@ -171,10 +172,15 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
if (!issue) return null;
return (
<Link
<div
key={issue.id}
href={`/${workspaceSlug}/projects/${projectId}/issues/${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 });
handleFiltersUpdate("priority", ["urgent", "high"], true);
}
}}
>
<div className="flex items-center gap-1.5 flex-grow w-full min-w-24 truncate">
<PriorityIcon priority={issue.priority} withContainer size={12} />
@@ -215,7 +221,7 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
</Tooltip>
)}
</div>
</Link>
</div>
);
})}
{(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && (
@@ -262,6 +268,11 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
}
completed={assignee.completed_issues}
total={assignee.total_issues}
onClick={() => {
if (assignee.assignee_id) {
handleFiltersUpdate("assignees", [assignee.assignee_id], true);
}
}}
/>
);
else
@@ -317,6 +328,11 @@ export const ActiveCycleStats: FC<ActiveCycleStatsProps> = observer((props) => {
}
completed={label.completed_issues}
total={label.total_issues}
onClick={() => {
if (label.label_id) {
handleFiltersUpdate("labels", [label.label_id], true);
}
}}
/>
))
) : (

View File

@@ -1,9 +1,9 @@
"use client";
import { FC } from "react";
import Link from "next/link";
import { observer } from "mobx-react";
// types
import { ICycle } from "@plane/types";
import { ICycle, IIssueFilterOptions } from "@plane/types";
// ui
import { LinearProgressIndicator, Loader } from "@plane/ui";
// components
@@ -11,15 +11,18 @@ import { EmptyState } from "@/components/empty-state";
// constants
import { PROGRESS_STATE_GROUPS_DETAILS } from "@/constants/common";
import { EmptyStateType } from "@/constants/empty-state";
// hooks
import { useProjectState } from "@/hooks/store";
export type ActiveCycleProgressProps = {
workspaceSlug: string;
projectId: string;
cycle: ICycle | null;
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => void;
};
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
const { workspaceSlug, projectId, cycle } = props;
export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = observer((props) => {
const { cycle, handleFiltersUpdate } = props;
// store hooks
const { groupedProjectStates } = useProjectState();
const progressIndicatorData = PROGRESS_STATE_GROUPS_DETAILS.map((group, index) => ({
id: index,
@@ -38,10 +41,7 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
: {};
return cycle ? (
<Link
href={`/${workspaceSlug}/projects/${projectId}/cycles/${cycle?.id}`}
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 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">Progress</h3>
@@ -62,7 +62,15 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
<>
{groupedIssues[group] > 0 && (
<div key={index}>
<div className="flex items-center justify-between gap-2 text-sm">
<div
className="flex items-center justify-between gap-2 text-sm cursor-pointer"
onClick={() => {
if (groupedProjectStates) {
const states = groupedProjectStates[group].map((state) => state.id);
handleFiltersUpdate("state", states, true);
}
}}
>
<div className="flex items-center gap-1.5">
<span
className="block h-3 w-3 rounded-full"
@@ -95,10 +103,10 @@ export const ActiveCycleProgress: FC<ActiveCycleProgressProps> = (props) => {
<EmptyState type={EmptyStateType.ACTIVE_CYCLE_PROGRESS_EMPTY_STATE} layout="screen-simple" size="sm" />
</div>
)}
</Link>
</div>
) : (
<Loader className="flex flex-col min-h-[17rem] gap-5 bg-custom-background-100 border border-custom-border-200 rounded-lg">
<Loader.Item width="100%" height="100%" />
</Loader>
);
};
});

View File

@@ -1,9 +1,14 @@
"use client";
import { useCallback } from "react";
import isEqual from "lodash/isEqual";
import { observer } from "mobx-react";
import { useRouter } from "next/navigation";
import useSWR from "swr";
// ui
import { Disclosure } from "@headlessui/react";
// types
import { IIssueFilterOptions } from "@plane/types";
// ui
import { Loader } from "@plane/ui";
// components
import {
@@ -16,8 +21,9 @@ import {
import { EmptyState } from "@/components/empty-state";
// constants
import { EmptyStateType } from "@/constants/empty-state";
import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue";
// hooks
import { useCycle } from "@/hooks/store";
import { useCycle, useIssues } from "@/hooks/store";
interface IActiveCycleDetails {
workspaceSlug: string;
@@ -27,7 +33,12 @@ interface IActiveCycleDetails {
export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) => {
// props
const { workspaceSlug, projectId } = props;
// router
const router = useRouter();
// store hooks
const {
issuesFilter: { issueFilters, updateFilters },
} = useIssues(EIssuesStoreType.CYCLE);
const { currentProjectActiveCycle, fetchActiveCycle, currentProjectActiveCycleId, getActiveCycleById } = useCycle();
// derived values
const activeCycle = currentProjectActiveCycleId ? getActiveCycleById(currentProjectActiveCycleId) : null;
@@ -37,6 +48,32 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
workspaceSlug && projectId ? () => fetchActiveCycle(workspaceSlug, projectId) : null
);
const handleFiltersUpdate = useCallback(
(key: keyof IIssueFilterOptions, value: string[], redirect?: boolean) => {
if (!workspaceSlug || !projectId || !currentProjectActiveCycleId) return;
const newFilters: IIssueFilterOptions = {};
Object.keys(issueFilters?.filters ?? {}).forEach((key) => {
newFilters[key as keyof IIssueFilterOptions] = [];
});
let newValues: string[] = [];
if (isEqual(newValues, value)) newValues = [];
else newValues = value;
updateFilters(
workspaceSlug.toString(),
projectId.toString(),
EIssueFilterType.FILTERS,
{ ...newFilters, [key]: newValues },
currentProjectActiveCycleId.toString()
);
if (redirect) router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${currentProjectActiveCycleId}`);
},
[workspaceSlug, projectId, currentProjectActiveCycleId, issueFilters, updateFilters, router]
);
// show loader if active cycle is loading
if (!currentProjectActiveCycle && isLoading)
return (
@@ -69,7 +106,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
)}
<div className="bg-custom-background-100 pt-3 pb-6 px-6">
<div className="grid grid-cols-1 bg-custom-background-100 gap-3 lg:grid-cols-2 xl:grid-cols-3">
<ActiveCycleProgress workspaceSlug={workspaceSlug} projectId={projectId} cycle={activeCycle} />
<ActiveCycleProgress cycle={activeCycle} handleFiltersUpdate={handleFiltersUpdate} />
<ActiveCycleProductivity
workspaceSlug={workspaceSlug}
projectId={projectId}
@@ -80,6 +117,7 @@ export const ActiveCycleRoot: React.FC<IActiveCycleDetails> = observer((props) =
projectId={projectId}
cycle={activeCycle}
cycleId={currentProjectActiveCycleId}
handleFiltersUpdate={handleFiltersUpdate}
/>
</div>
</div>

View File

@@ -1,24 +1,28 @@
"use client";
import React, { FC, MouseEvent } from "react";
import React, { FC, MouseEvent, useEffect } from "react";
import { observer } from "mobx-react";
import { CalendarCheck2, CalendarClock, MoveRight, Users } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { Users } from "lucide-react";
// types
import { ICycle, TCycleGroups } from "@plane/types";
// ui
import { Avatar, AvatarGroup, FavoriteStar, Tooltip, setPromiseToast } from "@plane/ui";
import { Avatar, AvatarGroup, FavoriteStar, TOAST_TYPE, Tooltip, setPromiseToast, setToast } from "@plane/ui";
// components
import { CycleQuickActions } from "@/components/cycles";
import { DateRangeDropdown } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
// constants
import { CYCLE_STATUS } from "@/constants/cycle";
import { CYCLE_FAVORITED, CYCLE_UNFAVORITED } from "@/constants/event-tracker";
import { EUserProjectRoles } from "@/constants/project";
// helpers
import { findHowManyDaysLeft, getDate, renderFormattedDate } from "@/helpers/date-time.helper";
import { findHowManyDaysLeft, getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper";
// hooks
import { useCycle, useEventTracker, useMember, useUser } from "@/hooks/store";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { CycleService } from "@/services/cycle.service";
const cycleService = new CycleService();
type Props = {
workspaceSlug: string;
@@ -28,24 +32,32 @@ type Props = {
parentRef: React.RefObject<HTMLDivElement>;
};
const defaultValues: Partial<ICycle> = {
start_date: null,
end_date: null,
};
export const CycleListItemAction: FC<Props> = observer((props) => {
const { workspaceSlug, projectId, cycleId, cycleDetails, parentRef } = props;
// hooks
const { isMobile } = usePlatformOS();
// store hooks
const { addCycleToFavorites, removeCycleFromFavorites } = useCycle();
const { addCycleToFavorites, removeCycleFromFavorites, updateCycleDetails } = useCycle();
const { captureEvent } = useEventTracker();
const {
membership: { currentProjectRole },
} = useUser();
const { getUserDetails } = useMember();
// form
const { control, reset } = useForm({
defaultValues,
});
// derived values
const endDate = getDate(cycleDetails.end_date);
const startDate = getDate(cycleDetails.start_date);
const cycleStatus = cycleDetails.status ? (cycleDetails.status.toLocaleLowerCase() as TCycleGroups) : "draft";
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER;
const renderDate = cycleDetails.start_date || cycleDetails.end_date;
const renderIcon = Boolean(cycleDetails.start_date) || Boolean(cycleDetails.end_date);
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
const daysLeft = findHowManyDaysLeft(cycleDetails.end_date) ?? 0;
@@ -106,20 +118,104 @@ export const CycleListItemAction: FC<Props> = observer((props) => {
});
};
const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return;
updateCycleDetails(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
};
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) => {
if (!startDate || !endDate) return;
let isDateValid = false;
const payload = {
start_date: renderFormattedPayloadDate(startDate),
end_date: renderFormattedPayloadDate(endDate),
};
if (cycleDetails && cycleDetails.start_date && cycleDetails.end_date)
isDateValid = await dateChecker({
...payload,
cycle_id: cycleDetails.id,
});
else isDateValid = await dateChecker(payload);
if (isDateValid) {
submitChanges(payload);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Cycle updated successfully.",
});
} 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, you can do that by removing both the dates.",
});
reset({ ...cycleDetails });
}
};
const createdByDetails = cycleDetails.created_by ? getUserDetails(cycleDetails.created_by) : undefined;
useEffect(() => {
if (cycleDetails)
reset({
...cycleDetails,
});
}, [cycleDetails, reset]);
const isArchived = Boolean(cycleDetails.archived_at);
const isCompleted = cycleStatus === "completed";
const isDisabled = !isEditingAllowed || isArchived || isCompleted;
return (
<>
{renderDate && (
<div className="h-6 flex items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs px-2 cursor-default">
<CalendarClock className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{renderFormattedDate(startDate)}</span>
<MoveRight className="h-3 w-3 flex-shrink-0" />
<CalendarCheck2 className="h-3 w-3 flex-shrink-0" />
<span className="flex-grow truncate">{renderFormattedDate(endDate)}</span>
</div>
)}
<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
buttonContainerClassName={`h-6 w-full flex ${isDisabled ? "cursor-not-allowed" : "cursor-pointer"} items-center gap-1.5 text-custom-text-300 border-[0.5px] border-custom-border-300 rounded text-xs`}
buttonVariant="transparent-with-text"
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);
handleDateChange(val?.from, val?.to);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
required={cycleDetails.status !== "draft"}
disabled={isDisabled}
hideIcon={{ from: renderIcon ?? true, to: renderIcon }}
/>
)}
/>
)}
/>
{currentCycle && (
<div
className="relative flex h-6 w-20 flex-shrink-0 items-center justify-center rounded-sm text-center text-xs"