From d15705e488aae5c3e722e73134a0b8ca3f16984b Mon Sep 17 00:00:00 2001 From: Gideon Mendels Date: Thu, 27 Feb 2025 18:11:40 -0500 Subject: [PATCH 1/3] added support to change date time in text field --- .../shared/FiltersButton/rows/TimeRow.tsx | 153 ++++++++++++++---- .../src/hooks/useProjectWithStatisticsList.ts | 48 +++--- apps/opik-frontend/src/lib/filters.ts | 80 ++++----- 3 files changed, 188 insertions(+), 93 deletions(-) diff --git a/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx b/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx index 4bde4a6444..48af12c05b 100644 --- a/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx +++ b/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx @@ -1,5 +1,6 @@ -import React, { useMemo, useState } from "react"; +import React, { useMemo, useState, useRef, useCallback } from "react"; import { Calendar as CalendarIcon } from "lucide-react"; +import debounce from "lodash/debounce"; import { cn } from "@/lib/utils"; import { formatDate } from "@/lib/date"; @@ -10,12 +11,12 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { Input } from "@/components/ui/input"; import { Filter } from "@/types/filters"; import OperatorSelector from "@/components/shared/FiltersButton/OperatorSelector"; import { DEFAULT_OPERATORS, OPERATORS_MAP } from "@/constants/filters"; import { COLUMN_TYPE } from "@/types/shared"; import dayjs from "dayjs"; -import { SelectSingleEventHandler } from "react-day-picker"; type TimeRowProps = { filter: Filter; @@ -27,19 +28,102 @@ export const TimeRow: React.FunctionComponent = ({ onChange, }) => { const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(() => + filter.value ? formatDate(filter.value as string) : "" + ); + const lastFilterValue = useRef(filter.value); + const date = useMemo( - () => (dayjs(filter.value).isValid() ? new Date(filter.value) : undefined), + () => { + if (!filter.value) return undefined; + const parsed = dayjs(filter.value); + return parsed.isValid() ? parsed.toDate() : undefined; + }, [filter.value], ); - const onSelectDate: SelectSingleEventHandler = (value) => { - onChange({ - ...filter, - value: value ? value.toISOString() : "", - }); + // Only update input value when filter value changes from external sources + React.useEffect(() => { + if (filter.value !== lastFilterValue.current) { + setInputValue(filter.value ? formatDate(filter.value as string) : ""); + lastFilterValue.current = filter.value; + } + }, [filter.value]); + + const debouncedUpdateFilter = useCallback( + debounce((newValue: string | "") => { + // Don't update if the value hasn't changed + if (newValue === filter.value) return; + + // Validate the date before updating + if (newValue && !dayjs(newValue).isValid()) return; + + lastFilterValue.current = newValue; + onChange({ + ...filter, + value: newValue, + }); + }, 500), + [filter, onChange] + ); + + const onSelectDate = (value: Date | undefined) => { + if (!value) { + setInputValue(""); + debouncedUpdateFilter(""); + return; + } + + try { + // Preserve the current time when changing date + if (date) { + value.setHours(date.getHours()); + value.setMinutes(date.getMinutes()); + } else { + // Set default time to start of day if no previous time + value.setHours(0); + value.setMinutes(0); + value.setSeconds(0); + value.setMilliseconds(0); + } + + const newValue = value.toISOString(); + setInputValue(formatDate(newValue)); + debouncedUpdateFilter(newValue); + } catch (error) { + console.error("Invalid date:", error); + } setOpen(false); }; + const handleInputChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + setInputValue(newValue); + + if (!newValue) { + debouncedUpdateFilter(""); + return; + } + + const parsedDate = dayjs(newValue, ["MM/DD/YY HH:mm A", "MM/DD/YYYY HH:mm A"], true); + + if (parsedDate.isValid()) { + try { + const isoString = parsedDate.toISOString(); + debouncedUpdateFilter(isoString); + } catch (error) { + console.error("Invalid date:", error); + } + } + }; + + // Cleanup debounce on unmount + React.useEffect(() => { + return () => { + debouncedUpdateFilter.cancel(); + }; + }, [debouncedUpdateFilter]); + return ( <> @@ -52,32 +136,35 @@ export const TimeRow: React.FunctionComponent = ({ /> - - - - - - +
+ - - + + + + + + + + +
+ ); diff --git a/apps/opik-frontend/src/hooks/useProjectWithStatisticsList.ts b/apps/opik-frontend/src/hooks/useProjectWithStatisticsList.ts index 7ffc1073db..d074035af3 100644 --- a/apps/opik-frontend/src/hooks/useProjectWithStatisticsList.ts +++ b/apps/opik-frontend/src/hooks/useProjectWithStatisticsList.ts @@ -41,32 +41,36 @@ export default function useProjectWithStatisticsList( ); const data = useMemo(() => { - if (projectsData) { - let statisticMap: Record = {}; + const defaultResponse = { content: [], total: 0 }; - if (projectsStatisticData && projectsStatisticData.content?.length > 0) { - statisticMap = projectsStatisticData.content.reduce< - Record - >((acc, statistic) => { - acc[statistic.project_id!] = statistic; - return acc; - }, {}); - } + if (!projectsData?.content) { + return defaultResponse; + } + + let statisticMap: Record = {}; - return { - ...projectsData, - content: projectsData.content.map((project) => { - return statisticMap - ? { - ...project, - ...statisticMap[project.id], - } - : project; - }), - }; + if (projectsStatisticData?.content?.length > 0) { + statisticMap = projectsStatisticData.content.reduce< + Record + >((acc, statistic) => { + if (statistic?.project_id) { + acc[statistic.project_id] = statistic; + } + return acc; + }, {}); } - return { content: [], total: 0 }; + return { + ...projectsData, + content: projectsData.content.map((project) => { + return project?.id && statisticMap[project.id] + ? { + ...project, + ...statisticMap[project.id], + } + : project; + }), + }; }, [projectsData, projectsStatisticData]); return { diff --git a/apps/opik-frontend/src/lib/filters.ts b/apps/opik-frontend/src/lib/filters.ts index 3a036a6bf9..1ba8ba3e18 100644 --- a/apps/opik-frontend/src/lib/filters.ts +++ b/apps/opik-frontend/src/lib/filters.ts @@ -44,38 +44,36 @@ export const generateSearchByIDFilters = (search?: string) => { }; const processTimeFilter: (filter: Filter) => Filter | Filter[] = (filter) => { - switch (filter.operator) { - case "=": - return [ - { - ...filter, - operator: ">", - value: makeStartOfDay(filter.value as string), - }, - { - ...filter, - operator: "<", - value: makeEndOfDay(filter.value as string), - }, - ]; - case ">": - case "<=": - return [ - { - ...filter, - value: makeEndOfDay(filter.value as string), - }, - ]; - case "<": - case ">=": - return [ - { - ...filter, - value: makeStartOfDay(filter.value as string), - }, - ]; - default: - return filter; + if (!filter.value) { + return filter; + } + + try { + switch (filter.operator) { + case "=": + return filter; + case ">": + case "<=": + return [ + { + ...filter, + value: filter.value, + }, + ]; + case "<": + case ">=": + return [ + { + ...filter, + value: filter.value, + }, + ]; + default: + return filter; + } + } catch (error) { + console.error("Error processing time filter:", error); + return filter; } }; @@ -85,8 +83,12 @@ const processDurationFilter: (filter: Filter) => Filter = (filter) => ({ }); const processFiltersArray = (filters: Filter[]) => { + if (!Array.isArray(filters)) return []; + return flatten( filters.map((filter) => { + if (!filter) return filter; + switch (filter.type) { case COLUMN_TYPE.time: return processTimeFilter(filter); @@ -108,14 +110,16 @@ export const processFilters = ( } = {}; const processedFilters: Filter[] = []; - if (filters && filters.length > 0) { - processFiltersArray(filters).forEach((f) => processedFilters.push(f)); + if (filters && Array.isArray(filters) && filters.length > 0) { + processFiltersArray(filters).forEach((f) => { + if (f) processedFilters.push(f); + }); } - if (additionalFilters && additionalFilters.length > 0) { - processFiltersArray(additionalFilters).forEach((f) => - processedFilters.push(f), - ); + if (additionalFilters && Array.isArray(additionalFilters) && additionalFilters.length > 0) { + processFiltersArray(additionalFilters).forEach((f) => { + if (f) processedFilters.push(f); + }); } if (processedFilters.length > 0) { From 84951f9e7c1d1e05bcdc7739a7f34cb5692d991b Mon Sep 17 00:00:00 2001 From: Gideon Mendels Date: Thu, 27 Feb 2025 18:48:11 -0500 Subject: [PATCH 2/3] added hour fields in date picker --- .../shared/FiltersButton/rows/TimeRow.tsx | 73 +++++-- .../src/components/ui/calendar.tsx | 190 +++++++++++++----- 2 files changed, 204 insertions(+), 59 deletions(-) diff --git a/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx b/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx index 48af12c05b..59d81a17cb 100644 --- a/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx +++ b/apps/opik-frontend/src/components/shared/FiltersButton/rows/TimeRow.tsx @@ -31,6 +31,8 @@ export const TimeRow: React.FunctionComponent = ({ const [inputValue, setInputValue] = useState(() => filter.value ? formatDate(filter.value as string) : "" ); + const [pendingDate, setPendingDate] = useState(undefined); + const [pendingTime, setPendingTime] = useState("00:00"); const lastFilterValue = useRef(filter.value); const date = useMemo( @@ -52,10 +54,7 @@ export const TimeRow: React.FunctionComponent = ({ const debouncedUpdateFilter = useCallback( debounce((newValue: string | "") => { - // Don't update if the value hasn't changed if (newValue === filter.value) return; - - // Validate the date before updating if (newValue && !dayjs(newValue).isValid()) return; lastFilterValue.current = newValue; @@ -67,33 +66,38 @@ export const TimeRow: React.FunctionComponent = ({ [filter, onChange] ); + const getTimeFromDate = (date: Date | undefined) => { + if (!date) return "00:00"; + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + }; + const onSelectDate = (value: Date | undefined) => { if (!value) { - setInputValue(""); - debouncedUpdateFilter(""); + setPendingDate(undefined); return; } try { - // Preserve the current time when changing date - if (date) { + // If there's a pending date, preserve its time + if (pendingDate) { + value.setHours(pendingDate.getHours()); + value.setMinutes(pendingDate.getMinutes()); + } else if (date) { + // If no pending date but we have a current date, use its time value.setHours(date.getHours()); value.setMinutes(date.getMinutes()); } else { - // Set default time to start of day if no previous time + // Default to start of day value.setHours(0); value.setMinutes(0); value.setSeconds(0); value.setMilliseconds(0); } - const newValue = value.toISOString(); - setInputValue(formatDate(newValue)); - debouncedUpdateFilter(newValue); + setPendingDate(value); } catch (error) { console.error("Invalid date:", error); } - setOpen(false); }; const handleInputChange = (e: React.ChangeEvent) => { @@ -117,6 +121,45 @@ export const TimeRow: React.FunctionComponent = ({ } }; + const handleTimeChange = (timeString: string) => { + setPendingTime(timeString); + if (!pendingDate) return; + + try { + const [hours, minutes] = timeString.split(':').map(Number); + const newDate = new Date(pendingDate); + newDate.setHours(hours); + newDate.setMinutes(minutes); + setPendingDate(newDate); + } catch (error) { + console.error("Invalid time:", error); + } + }; + + // Handle popover close + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + + if (!isOpen && pendingDate) { + // When closing, apply the pending changes + const newValue = pendingDate.toISOString(); + setInputValue(formatDate(newValue)); + debouncedUpdateFilter(newValue); + + // Reset pending states + setPendingDate(undefined); + setPendingTime("00:00"); + } + }; + + // Initialize pending values when opening the popover + React.useEffect(() => { + if (open && date) { + setPendingDate(new Date(date)); + setPendingTime(getTimeFromDate(date)); + } + }, [open, date]); + // Cleanup debounce on unmount React.useEffect(() => { return () => { @@ -144,7 +187,7 @@ export const TimeRow: React.FunctionComponent = ({ placeholder="MM/DD/YY HH:mm A" className="w-full pr-10" /> - +