From 1e43af55471d699b955ac29eaa2f6819a26aff4c Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Fri, 30 May 2025 17:37:28 +0530 Subject: [PATCH 1/3] feat: added merge date display --- .../analytics-sidebar/sidebar-header.tsx | 14 +-- .../cycles/list/cycle-list-item-action.tsx | 7 +- web/core/components/dropdowns/date-range.tsx | 110 +++++++++++++----- web/core/components/dropdowns/index.ts | 1 + web/core/components/dropdowns/merged-date.tsx | 81 +++++++++++++ .../sub-issues/issues-list/properties.tsx | 66 +++++++---- .../properties/all-properties.tsx | 62 ++++------ .../modules/module-list-item-action.tsx | 1 + 8 files changed, 239 insertions(+), 103 deletions(-) create mode 100644 web/core/components/dropdowns/merged-date.tsx diff --git a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx index f441b907bb1..01c69283735 100644 --- a/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx +++ b/web/core/components/cycles/analytics-sidebar/sidebar-header.tsx @@ -3,21 +3,12 @@ import React, { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; import { Controller, useForm } from "react-hook-form"; -import { - ArchiveIcon, - ArchiveRestoreIcon, - ArrowRight, - ChevronRight, - EllipsisIcon, - LinkIcon, - Trash2, -} from "lucide-react"; +import { ArrowRight, ChevronRight } from "lucide-react"; // Plane Imports import { CYCLE_STATUS, CYCLE_UPDATED, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; import { ICycle } from "@plane/types"; -import { CustomMenu, setToast, TOAST_TYPE } from "@plane/ui"; -import { copyUrlToClipboard } from "@plane/utils"; +import { setToast, TOAST_TYPE } from "@plane/ui"; // components import { DateRangeDropdown } from "@/components/dropdowns"; // helpers @@ -239,6 +230,7 @@ export const CycleSidebarHeader: FC = observer((props) => { {renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")} } + mergeDates showTooltip={!!cycleDetails.start_date && !!cycleDetails.end_date} // show tooltip only if both start and end date are present required={cycleDetails.status !== "draft"} disabled={!isEditingAllowed || isArchived || isCompleted} 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 c90ff653ea3..b124ca565c9 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -1,7 +1,6 @@ "use client"; import React, { FC, MouseEvent, useEffect, useMemo, useState } from "react"; -import { format, parseISO } from "date-fns"; import { observer } from "mobx-react"; import { useParams, usePathname, useSearchParams } from "next/navigation"; import { useForm } from "react-hook-form"; @@ -23,6 +22,7 @@ import { Avatar, AvatarGroup, FavoriteStar, LayersIcon, Tooltip, TransferIcon, s import { CycleQuickActions, TransferIssuesModal } from "@/components/cycles"; import { DateRangeDropdown } from "@/components/dropdowns"; import { ButtonAvatars } from "@/components/dropdowns/member/avatar"; +import { MergedDateDisplay } from "@/components/dropdowns/merged-date"; import { getDate } from "@/helpers/date-time.helper"; import { getFileURL } from "@/helpers/file.helper"; // hooks @@ -230,9 +230,7 @@ export const CycleListItemAction: FC = observer((props) => { >
- {cycleDetails.start_date && {format(parseISO(cycleDetails.start_date), "MMM dd, yyyy")}} - - {cycleDetails.end_date && {format(parseISO(cycleDetails.end_date), "MMM dd, yyyy")}} +
{projectUTCOffset && ( @@ -269,6 +267,7 @@ export const CycleListItemAction: FC = observer((props) => { {renderFormattedDateInUserTimezone(cycleDetails.end_date ?? "")} } + mergeDates required={cycleDetails.status !== "draft"} disabled hideIcon={{ diff --git a/web/core/components/dropdowns/date-range.tsx b/web/core/components/dropdowns/date-range.tsx index 0ab9fd44be3..62c7d9409b1 100644 --- a/web/core/components/dropdowns/date-range.tsx +++ b/web/core/components/dropdowns/date-range.tsx @@ -4,7 +4,7 @@ import React, { useEffect, useRef, useState } from "react"; import { Placement } from "@popperjs/core"; import { DateRange, Matcher } from "react-day-picker"; import { usePopper } from "react-popper"; -import { ArrowRight, CalendarCheck2, CalendarDays } from "lucide-react"; +import { ArrowRight, CalendarCheck2, CalendarDays, X } from "lucide-react"; import { Combobox } from "@headlessui/react"; // plane imports import { useTranslation } from "@plane/i18n"; @@ -17,6 +17,7 @@ import { renderFormattedDate } from "@/helpers/date-time.helper"; import { useDropdown } from "@/hooks/use-dropdown"; // components import { DropdownButton } from "./buttons"; +import { MergedDateDisplay } from "./merged-date"; // types import { TButtonVariants } from "./types"; @@ -30,11 +31,14 @@ type Props = { buttonVariant: TButtonVariants; cancelButtonText?: string; className?: string; + clearIconClassName?: string; disabled?: boolean; hideIcon?: { from?: boolean; to?: boolean; }; + isClearable?: boolean; + mergeDates?: boolean; minDate?: Date; maxDate?: Date; onSelect?: (range: DateRange | undefined) => void; @@ -65,11 +69,14 @@ export const DateRangeDropdown: React.FC = (props) => { buttonToDateClassName, buttonVariant, className, + clearIconClassName = "", disabled = false, hideIcon = { from: true, to: true, }, + isClearable = false, + mergeDates, minDate, maxDate, onSelect, @@ -118,20 +125,18 @@ export const DateRangeDropdown: React.FC = (props) => { setIsOpen, }); - const handleClose = () => { - if (!isOpen) return; - setIsOpen(false); - setDateRange({ - from: value.from, - to: value.to, - }); - if (referenceElement) referenceElement.blur(); - }; - const disabledDays: Matcher[] = []; if (minDate) disabledDays.push({ before: minDate }); if (maxDate) disabledDays.push({ after: maxDate }); + const clearDates = () => { + const clearedRange = { from: undefined, to: undefined }; + setDateRange(clearedRange); + onSelect?.(clearedRange); + }; + + const hasDisplayedDates = dateRange.from || dateRange.to; + useEffect(() => { setDateRange(value); }, [value]); @@ -158,9 +163,9 @@ export const DateRangeDropdown: React.FC = (props) => { tooltipContent={ customTooltipContent ?? ( <> - {dateRange.from ? renderFormattedDate(dateRange.from) : "N/A"} - {" - "} - {dateRange.to ? renderFormattedDate(dateRange.to) : "N/A"} + {dateRange.from ? renderFormattedDate(dateRange.from) : ""} + {dateRange.from && dateRange.to ? " - " : ""} + {dateRange.to ? renderFormattedDate(dateRange.to) : ""} ) } @@ -168,19 +173,70 @@ export const DateRangeDropdown: React.FC = (props) => { variant={buttonVariant} renderToolTipByDefault={renderByDefault} > - - {!hideIcon.from && } - {dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""} - - - - {!hideIcon.to && } - {dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""} - + {mergeDates ? ( + // Merged date display +
+ {!hideIcon.from && } + {dateRange.from || dateRange.to ? ( + + ) : ( + renderPlaceholder && ( + <> + {placeholder.from} + + {placeholder.to} + + ) + )} + {isClearable && !disabled && hasDisplayedDates && ( + { + e.stopPropagation(); + e.preventDefault(); + clearDates(); + }} + /> + )} +
+ ) : ( + // Original separate date display + <> + + {!hideIcon.from && } + {dateRange.from ? renderFormattedDate(dateRange.from) : renderPlaceholder ? placeholder.from : ""} + + + + {!hideIcon.to && } + {dateRange.to ? renderFormattedDate(dateRange.to) : renderPlaceholder ? placeholder.to : ""} + + {isClearable && !disabled && hasDisplayedDates && ( + { + e.stopPropagation(); + e.preventDefault(); + clearDates(); + }} + /> + )} + + )} ); diff --git a/web/core/components/dropdowns/index.ts b/web/core/components/dropdowns/index.ts index 64b7efe808d..0948f5e75e8 100644 --- a/web/core/components/dropdowns/index.ts +++ b/web/core/components/dropdowns/index.ts @@ -3,6 +3,7 @@ export * from "./cycle"; export * from "./date-range"; export * from "./date"; export * from "./estimate"; +export * from "./merged-date"; export * from "./module"; export * from "./priority"; export * from "./project"; diff --git a/web/core/components/dropdowns/merged-date.tsx b/web/core/components/dropdowns/merged-date.tsx new file mode 100644 index 00000000000..975f38e8b56 --- /dev/null +++ b/web/core/components/dropdowns/merged-date.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { format } from "date-fns"; +import { observer } from "mobx-react"; +// helpers +import { getDate } from "@/helpers/date-time.helper"; + +type Props = { + startDate: Date | string | null | undefined; + endDate: Date | string | null | undefined; + className?: string; +}; + +/** + * Formats merged date range display with smart formatting + * - Single date: "Jan 24, 2025" + * - Same year, same month: "Jan 24 - 28, 2025" + * - Same year, different month: "Jan 24 - Feb 6, 2025" + * - Different year: "Dec 28, 2024 - Jan 4, 2025" + */ +export const MergedDateDisplay: React.FC = observer((props) => { + const { startDate, endDate, className = "" } = props; + + // Parse dates + const parsedStartDate = getDate(startDate); + const parsedEndDate = getDate(endDate); + + // Helper function to format date range + const formatDateRange = (): string => { + // If no dates are provided + if (!parsedStartDate && !parsedEndDate) { + return ""; + } + + // If only start date is provided + if (parsedStartDate && !parsedEndDate) { + return format(parsedStartDate, "MMM dd, yyyy"); + } + + // If only end date is provided + if (!parsedStartDate && parsedEndDate) { + return format(parsedEndDate, "MMM dd, yyyy"); + } + + // If both dates are provided + if (parsedStartDate && parsedEndDate) { + const startYear = parsedStartDate.getFullYear(); + const startMonth = parsedStartDate.getMonth(); + const endYear = parsedEndDate.getFullYear(); + const endMonth = parsedEndDate.getMonth(); + + // Same year, same month + if (startYear === endYear && startMonth === endMonth) { + const startDay = format(parsedStartDate, "dd"); + const endDay = format(parsedEndDate, "dd"); + return `${format(parsedStartDate, "MMM")} ${startDay} - ${endDay}, ${startYear}`; + } + + // Same year, different month + if (startYear === endYear) { + const startFormatted = format(parsedStartDate, "MMM dd"); + const endFormatted = format(parsedEndDate, "MMM dd"); + return `${startFormatted} - ${endFormatted}, ${startYear}`; + } + + // Different year + const startFormatted = format(parsedStartDate, "MMM dd, yyyy"); + const endFormatted = format(parsedEndDate, "MMM dd, yyyy"); + return `${startFormatted} - ${endFormatted}`; + } + + return ""; + }; + + const displayText = formatDateRange(); + + if (!displayText) { + return null; + } + + return {displayText}; +}); diff --git a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx index a6a8fae3d79..29b6e27706c 100644 --- a/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx +++ b/web/core/components/issues/issue-detail-widgets/sub-issues/issues-list/properties.tsx @@ -1,11 +1,10 @@ // plane imports import { SyntheticEvent } from "react"; import { observer } from "mobx-react"; -import { CalendarClock } from "lucide-react"; import { useTranslation } from "@plane/i18n"; import { IIssueDisplayProperties, TIssue } from "@plane/types"; // components -import { PriorityDropdown, MemberDropdown, StateDropdown, DateDropdown } from "@/components/dropdowns"; +import { PriorityDropdown, MemberDropdown, StateDropdown, DateRangeDropdown } from "@/components/dropdowns"; // hooks import { WithDisplayPropertiesHOC } from "@/components/issues/issue-layouts/properties/with-display-properties-HOC"; import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; @@ -37,6 +36,22 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => e.preventDefault(); }; + const handleStartDate = (date: Date | null) => { + if (issue.project_id) { + updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { + start_date: date ? renderFormattedPayloadDate(date) : null, + }); + } + }; + + const handleTargetDate = (date: Date | null) => { + if (issue.project_id) { + updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { + target_date: date ? renderFormattedPayloadDate(date) : null, + }); + } + }; + if (!displayProperties) return <>; const maxDate = getDate(issue.target_date); @@ -88,29 +103,32 @@ export const SubIssuesListItemProperties: React.FC = observer((props) => - -
- - issue.project_id && - updateSubIssue( - workspaceSlug, - issue.project_id, - parentIssueId, - issueId, - { - target_date: val ? renderFormattedPayloadDate(val) : null, - }, - { ...issue } - ) - } - maxDate={maxDate} - placeholder={t("common.order_by.due_date")} - icon={} - buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} - optionsClassName="z-30" + {/* merged dates */} + !!(properties.start_date || properties.due_date)} + > +
+ { + handleStartDate(range?.from ?? null); + handleTargetDate(range?.to ?? null); + }} + hideIcon={{ + from: false, + }} + isClearable + mergeDates + buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"} disabled={!disabled} + showTooltip + customTooltipHeading="Date Range" + renderPlaceholder={false} />
diff --git a/web/core/components/issues/issue-layouts/properties/all-properties.tsx b/web/core/components/issues/issue-layouts/properties/all-properties.tsx index c14922b047a..aacabc448fa 100644 --- a/web/core/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/core/components/issues/issue-layouts/properties/all-properties.tsx @@ -5,7 +5,7 @@ import xor from "lodash/xor"; import { observer } from "mobx-react"; import { useParams, usePathname } from "next/navigation"; // icons -import { CalendarCheck2, CalendarClock, Layers, Link, Paperclip } from "lucide-react"; +import { Layers, Link, Paperclip } from "lucide-react"; // types import { ISSUE_UPDATED } from "@plane/constants"; // i18n @@ -15,13 +15,13 @@ import { TIssue, IIssueDisplayProperties, TIssuePriorities } from "@plane/types" import { Tooltip } from "@plane/ui"; // components import { - DateDropdown, EstimateDropdown, PriorityDropdown, MemberDropdown, ModuleDropdown, CycleDropdown, StateDropdown, + DateRangeDropdown, } from "@/components/dropdowns"; // constants // helpers @@ -265,12 +265,6 @@ export const IssueProperties: React.FC = observer((props) => { const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; - const minDate = getDate(issue.start_date); - minDate?.setDate(minDate.getDate()); - - const maxDate = getDate(issue.target_date); - maxDate?.setDate(maxDate.getDate()); - const handleEventPropagation = (e: SyntheticEvent) => { e.stopPropagation(); e.preventDefault(); @@ -310,40 +304,34 @@ export const IssueProperties: React.FC = observer((props) => {
- {/* start date */} - -
- } - buttonVariant={issue.start_date ? "border-with-text" : "border-without-text"} - optionsClassName="z-10" - disabled={isReadOnly} - renderByDefault={isMobile} - showTooltip - /> -
-
- - {/* target/due date */} - + {/* merged dates */} + !!(properties.start_date || properties.due_date)} + >
- } - buttonVariant={issue.target_date ? "border-with-text" : "border-without-text"} + { + handleStartDate(range?.from ?? null); + handleTargetDate(range?.to ?? null); + }} + hideIcon={{ + from: false, + }} + isClearable + mergeDates + buttonVariant={issue.start_date || issue.target_date ? "border-with-text" : "border-without-text"} buttonClassName={shouldHighlightIssueDueDate(issue.target_date, stateDetails?.group) ? "text-red-500" : ""} - clearIconClassName="!text-custom-text-100" - optionsClassName="z-10" disabled={isReadOnly} renderByDefault={isMobile} showTooltip + renderPlaceholder={false} + customTooltipHeading="Date Range" />
diff --git a/web/core/components/modules/module-list-item-action.tsx b/web/core/components/modules/module-list-item-action.tsx index 7a1b37a65ce..8235ad8b21b 100644 --- a/web/core/components/modules/module-list-item-action.tsx +++ b/web/core/components/modules/module-list-item-action.tsx @@ -158,6 +158,7 @@ export const ModuleListItemAction: FC = observer((props) => { target_date: val?.to ? renderFormattedPayloadDate(val.to) : null, }); }} + mergeDates placeholder={{ from: t("start_date"), to: t("end_date"), From 4836bc2c3a2fcaf1eb9d45cbfcdf0b7f84722902 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 3 Jun 2025 14:14:36 +0530 Subject: [PATCH 2/3] chore: moved formatter ti utils --- packages/utils/src/datetime.ts | 56 +++++++++++++++++++ web/core/components/dropdowns/merged-date.tsx | 51 +---------------- 2 files changed, 58 insertions(+), 49 deletions(-) diff --git a/packages/utils/src/datetime.ts b/packages/utils/src/datetime.ts index 0a12a227084..8cec48f5f27 100644 --- a/packages/utils/src/datetime.ts +++ b/packages/utils/src/datetime.ts @@ -333,3 +333,59 @@ export const generateDateArray = (startDate: string | Date, endDate: string | Da } return dateArray; }; + +/** + * Formats merged date range display with smart formatting + * - Single date: "Jan 24, 2025" + * - Same year, same month: "Jan 24 - 28, 2025" + * - Same year, different month: "Jan 24 - Feb 6, 2025" + * - Different year: "Dec 28, 2024 - Jan 4, 2025" + */ +export const formatDateRange = ( + parsedStartDate: Date | null | undefined, + parsedEndDate: Date | null | undefined +): string => { + // If no dates are provided + if (!parsedStartDate && !parsedEndDate) { + return ""; + } + + // If only start date is provided + if (parsedStartDate && !parsedEndDate) { + return format(parsedStartDate, "MMM dd, yyyy"); + } + + // If only end date is provided + if (!parsedStartDate && parsedEndDate) { + return format(parsedEndDate, "MMM dd, yyyy"); + } + + // If both dates are provided + if (parsedStartDate && parsedEndDate) { + const startYear = parsedStartDate.getFullYear(); + const startMonth = parsedStartDate.getMonth(); + const endYear = parsedEndDate.getFullYear(); + const endMonth = parsedEndDate.getMonth(); + + // Same year, same month + if (startYear === endYear && startMonth === endMonth) { + const startDay = format(parsedStartDate, "dd"); + const endDay = format(parsedEndDate, "dd"); + return `${format(parsedStartDate, "MMM")} ${startDay} - ${endDay}, ${startYear}`; + } + + // Same year, different month + if (startYear === endYear) { + const startFormatted = format(parsedStartDate, "MMM dd"); + const endFormatted = format(parsedEndDate, "MMM dd"); + return `${startFormatted} - ${endFormatted}, ${startYear}`; + } + + // Different year + const startFormatted = format(parsedStartDate, "MMM dd, yyyy"); + const endFormatted = format(parsedEndDate, "MMM dd, yyyy"); + return `${startFormatted} - ${endFormatted}`; + } + + return ""; +}; \ No newline at end of file diff --git a/web/core/components/dropdowns/merged-date.tsx b/web/core/components/dropdowns/merged-date.tsx index 975f38e8b56..ca2adc46e5a 100644 --- a/web/core/components/dropdowns/merged-date.tsx +++ b/web/core/components/dropdowns/merged-date.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { format } from "date-fns"; import { observer } from "mobx-react"; // helpers +import { formatDateRange } from "@plane/utils"; import { getDate } from "@/helpers/date-time.helper"; type Props = { @@ -24,54 +24,7 @@ export const MergedDateDisplay: React.FC = observer((props) => { const parsedStartDate = getDate(startDate); const parsedEndDate = getDate(endDate); - // Helper function to format date range - const formatDateRange = (): string => { - // If no dates are provided - if (!parsedStartDate && !parsedEndDate) { - return ""; - } - - // If only start date is provided - if (parsedStartDate && !parsedEndDate) { - return format(parsedStartDate, "MMM dd, yyyy"); - } - - // If only end date is provided - if (!parsedStartDate && parsedEndDate) { - return format(parsedEndDate, "MMM dd, yyyy"); - } - - // If both dates are provided - if (parsedStartDate && parsedEndDate) { - const startYear = parsedStartDate.getFullYear(); - const startMonth = parsedStartDate.getMonth(); - const endYear = parsedEndDate.getFullYear(); - const endMonth = parsedEndDate.getMonth(); - - // Same year, same month - if (startYear === endYear && startMonth === endMonth) { - const startDay = format(parsedStartDate, "dd"); - const endDay = format(parsedEndDate, "dd"); - return `${format(parsedStartDate, "MMM")} ${startDay} - ${endDay}, ${startYear}`; - } - - // Same year, different month - if (startYear === endYear) { - const startFormatted = format(parsedStartDate, "MMM dd"); - const endFormatted = format(parsedEndDate, "MMM dd"); - return `${startFormatted} - ${endFormatted}, ${startYear}`; - } - - // Different year - const startFormatted = format(parsedStartDate, "MMM dd, yyyy"); - const endFormatted = format(parsedEndDate, "MMM dd, yyyy"); - return `${startFormatted} - ${endFormatted}`; - } - - return ""; - }; - - const displayText = formatDateRange(); + const displayText = formatDateRange(parsedStartDate, parsedEndDate); if (!displayText) { return null; From e97edc1bd90b86afad64c9cdd332ef67e3bf1294 Mon Sep 17 00:00:00 2001 From: vamsikrishnamathala Date: Tue, 3 Jun 2025 14:16:57 +0530 Subject: [PATCH 3/3] chore: removed unwanted props --- web/core/components/cycles/list/cycle-list-item-action.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b124ca565c9..bc355307871 100644 --- a/web/core/components/cycles/list/cycle-list-item-action.tsx +++ b/web/core/components/cycles/list/cycle-list-item-action.tsx @@ -230,7 +230,7 @@ export const CycleListItemAction: FC = observer((props) => { >
- +
{projectUTCOffset && (