From 0530fa496802d4ae95d60c6c0dafc3c47b70dafc Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Wed, 31 Jul 2024 18:25:55 +0530 Subject: [PATCH 1/3] chore: list layout item improvement --- web/core/components/core/list/list-item.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/web/core/components/core/list/list-item.tsx b/web/core/components/core/list/list-item.tsx index a64ad910dda..b225131b68e 100644 --- a/web/core/components/core/list/list-item.tsx +++ b/web/core/components/core/list/list-item.tsx @@ -62,21 +62,21 @@ export const ListItem: FC = (props) => { )} >
-
- + +
{prependTitleElement && {prependTitleElement}} {title} - +
{appendTitleElement && {appendTitleElement}} -
+ {quickActionElement && quickActionElement}
{actionableItems && ( From 721fd81cfffbfc973e0a295bb411862d84b6f79c Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Wed, 31 Jul 2024 18:26:48 +0530 Subject: [PATCH 2/3] dev: active cycle interactive stats implementation --- .../cycles/active-cycle/cycle-stats.tsx | 28 +++++++++--- .../cycles/active-cycle/progress.tsx | 34 ++++++++------ .../components/cycles/active-cycle/root.tsx | 44 +++++++++++++++++-- 3 files changed, 84 insertions(+), 22 deletions(-) diff --git a/web/core/components/cycles/active-cycle/cycle-stats.tsx b/web/core/components/cycles/active-cycle/cycle-stats.tsx index e93658de264..164020d764e 100644 --- a/web/core/components/cycles/active-cycle/cycle-stats.tsx +++ b/web/core/components/cycles/active-cycle/cycle-stats.tsx @@ -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 = 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 = observer((props) => { } = useIssues(EIssuesStoreType.CYCLE); const { issue: { getIssueById }, + setPeekIssue, } = useIssueDetail(); const { currentProjectDetails } = useProject(); @@ -171,10 +172,15 @@ export const ActiveCycleStats: FC = observer((props) => { if (!issue) return null; return ( - { + if (issue.id) { + setPeekIssue({ workspaceSlug, projectId, issueId: issue.id }); + handleFiltersUpdate("priority", ["urgent", "high"], true); + } + }} >
@@ -215,7 +221,7 @@ export const ActiveCycleStats: FC = observer((props) => { )}
- + ); })} {(cycleIssueDetails.nextPageResults === undefined || cycleIssueDetails.nextPageResults) && ( @@ -262,6 +268,11 @@ export const ActiveCycleStats: FC = 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 = observer((props) => { } completed={label.completed_issues} total={label.total_issues} + onClick={() => { + if (label.label_id) { + handleFiltersUpdate("labels", [label.label_id], true); + } + }} /> )) ) : ( diff --git a/web/core/components/cycles/active-cycle/progress.tsx b/web/core/components/cycles/active-cycle/progress.tsx index 815aeedfbb7..fc6e86561a0 100644 --- a/web/core/components/cycles/active-cycle/progress.tsx +++ b/web/core/components/cycles/active-cycle/progress.tsx @@ -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 = (props) => { - const { workspaceSlug, projectId, cycle } = props; +export const ActiveCycleProgress: FC = 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 = (props) => { : {}; return cycle ? ( - +

Progress

@@ -62,7 +62,15 @@ export const ActiveCycleProgress: FC = (props) => { <> {groupedIssues[group] > 0 && (
-
+
{ + if (groupedProjectStates) { + const states = groupedProjectStates[group].map((state) => state.id); + handleFiltersUpdate("state", states, true); + } + }} + >
= (props) => {
)} - +
) : ( ); -}; +}); diff --git a/web/core/components/cycles/active-cycle/root.tsx b/web/core/components/cycles/active-cycle/root.tsx index 8f398b7b3d2..610de473c0f 100644 --- a/web/core/components/cycles/active-cycle/root.tsx +++ b/web/core/components/cycles/active-cycle/root.tsx @@ -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 = 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 = 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 = observer((props) = )}
- + = observer((props) = projectId={projectId} cycle={activeCycle} cycleId={currentProjectActiveCycleId} + handleFiltersUpdate={handleFiltersUpdate} />
From 9813ce57bf57c260c0ca3bc1f0e83891401d82e3 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Wed, 31 Jul 2024 18:27:44 +0530 Subject: [PATCH 3/3] dev: in cycle list interactive date picker added --- .../cycles/list/cycle-list-item-action.tsx | 132 +++++++++++++++--- 1 file changed, 114 insertions(+), 18 deletions(-) diff --git a/web/core/components/cycles/list/cycle-list-item-action.tsx b/web/core/components/cycles/list/cycle-list-item-action.tsx index 124fe45d5a4..89ac1bdc779 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -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; }; +const defaultValues: Partial = { + start_date: null, + end_date: null, +}; + export const CycleListItemAction: FC = 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 = observer((props) => { }); }; + const submitChanges = (data: Partial) => { + 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 && ( -
- - {renderFormattedDate(startDate)} - - - {renderFormattedDate(endDate)} -
- )} - + ( + ( + { + 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 && (