-
-
Notifications
You must be signed in to change notification settings - Fork 952
feat(dashboard): Upgrade to the dateTime filter UI #2864
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
samejr
wants to merge
33
commits into
main
Choose a base branch
from
claude/slack-add-custom-time-interval-2Tv4K
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 30 commits
Commits
Show all changes
33 commits
Select commit
Hold shift + click to select a range
d54909e
feat(webapp): add custom time interval input for run filters
claude 74a4ca3
fix(webapp): sync custom duration state when period prop changes
github-actions[bot] 97ecf38
style(webapp): use existing styled components for custom duration input
github-actions[bot] b7a7c17
Improve the UX and style of the custom duration option
samejr 076436c
Improve the UX of the selected time range to be applied
samejr c0bfc95
Adds a new shadcn component for picking the date and time
samejr ff3eb2b
Don’t populate the custom duration field when timePeriods are selected
samejr db8e57d
Small layout improvements
samejr 02a9c16
Show a tooltip when clearing
samejr 2b36e26
Replace 1year with 90 days
samejr b54847b
Selecting timePeriods populates the custom duration field
samejr a00e9b7
Move custom duration field to top
samejr 623d0bb
Fix type imports
samejr e83ec07
Layout tweaks
samejr 1a988c4
Use the Button component
samejr c4e4882
Remove div wrapper
samejr 99b830d
Show an optional inline label
samejr 62d3f29
Selected time durations have an active state when clicked
samejr 579493e
Nicer styling for the dateTimePicker
samejr 489b9e1
Add “Last weekday” option
samejr a7cae5b
Style improvements
samejr e2e6b42
Fix ilegal DOM html
samejr 2b89a4e
style improvement
samejr 23f9f81
Better layout and adds shorthand month names
samejr f31e897
Default period exposed as a prop
samejr 36e9987
style fix
samejr 1340518
Calendar restructured to include nav buttons inline
samejr 0cbed51
autofocus the input field
samejr c9a8d10
Merge remote-tracking branch 'origin/main' into claude/slack-add-cust…
samejr 5d9abbb
pnpm lock file update
samejr 2cf6a98
Adds defaultPeriod to the dep array
samejr 68cd270
If the From date is after the To date, show an error
samejr 1dd70d1
Reorder the quick date links
samejr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| "use client"; | ||
|
|
||
| import * as React from "react"; | ||
| import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; | ||
| import { format } from "date-fns"; | ||
| import { DayPicker, useDayPicker } from "react-day-picker"; | ||
| import { cn } from "~/utils/cn"; | ||
|
|
||
| export type CalendarProps = React.ComponentProps<typeof DayPicker>; | ||
|
|
||
| const navButtonClass = | ||
| "size-7 rounded-[3px] bg-secondary border border-charcoal-600 text-text-bright hover:bg-charcoal-600 hover:border-charcoal-550 transition inline-flex items-center justify-center"; | ||
|
|
||
| function CustomMonthCaption({ calendarMonth }: { calendarMonth: { date: Date } }) { | ||
| const { goToMonth, nextMonth, previousMonth } = useDayPicker(); | ||
|
|
||
| return ( | ||
| <div className="flex w-full items-center justify-between px-1"> | ||
| <button | ||
| type="button" | ||
| className={navButtonClass} | ||
| disabled={!previousMonth} | ||
| onClick={() => previousMonth && goToMonth(previousMonth)} | ||
| aria-label="Go to previous month" | ||
| > | ||
| <ChevronLeftIcon className="size-4" /> | ||
| </button> | ||
| <div className="flex items-center gap-2"> | ||
| <select | ||
| className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none" | ||
| value={calendarMonth.date.getMonth()} | ||
| onChange={(e) => { | ||
| const newDate = new Date(calendarMonth.date); | ||
| newDate.setMonth(parseInt(e.target.value)); | ||
| goToMonth(newDate); | ||
| }} | ||
| > | ||
| {Array.from({ length: 12 }, (_, i) => ( | ||
| <option key={i} value={i}> | ||
| {format(new Date(2000, i), "MMM")} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| <select | ||
| className="rounded border border-charcoal-600 bg-charcoal-750 px-2 py-1 text-sm text-text-bright focus:border-charcoal-500 focus:outline-none" | ||
| value={calendarMonth.date.getFullYear()} | ||
| onChange={(e) => { | ||
| const newDate = new Date(calendarMonth.date); | ||
| newDate.setFullYear(parseInt(e.target.value)); | ||
| goToMonth(newDate); | ||
| }} | ||
| > | ||
| {Array.from({ length: 100 }, (_, i) => { | ||
| const year = new Date().getFullYear() - 50 + i; | ||
| return ( | ||
| <option key={year} value={year}> | ||
| {year} | ||
| </option> | ||
| ); | ||
| })} | ||
| </select> | ||
| </div> | ||
| <button | ||
| type="button" | ||
| className={navButtonClass} | ||
| disabled={!nextMonth} | ||
| onClick={() => nextMonth && goToMonth(nextMonth)} | ||
| aria-label="Go to next month" | ||
| > | ||
| <ChevronRightIcon className="size-4" /> | ||
| </button> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export function Calendar({ | ||
| className, | ||
| classNames, | ||
| showOutsideDays = true, | ||
| ...props | ||
| }: CalendarProps) { | ||
| return ( | ||
| <DayPicker | ||
| showOutsideDays={showOutsideDays} | ||
| weekStartsOn={1} | ||
| className={cn("p-3", className)} | ||
| classNames={{ | ||
| months: "flex flex-col sm:flex-row gap-2", | ||
| month: "flex flex-col gap-4", | ||
| month_caption: "flex justify-center pt-1 relative items-center w-full", | ||
| caption_label: "sr-only", | ||
| nav: "hidden", | ||
| month_grid: "w-full border-collapse", | ||
| weekdays: "flex", | ||
| weekday: "text-text-dimmed rounded-md w-8 font-normal text-[0.8rem]", | ||
| week: "flex w-full mt-2", | ||
| day: "relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-charcoal-700 [&:has([aria-selected].day-outside)]:bg-charcoal-700/50 [&:has([aria-selected].day-range-end)]:rounded-r-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md", | ||
| day_button: cn( | ||
| "size-8 p-0 font-normal text-text-bright rounded-md", | ||
| "hover:bg-charcoal-700 hover:text-text-bright", | ||
| "focus:bg-charcoal-700 focus:text-text-bright focus:outline-none", | ||
| "aria-selected:opacity-100" | ||
| ), | ||
| range_start: "day-range-start rounded-l-md", | ||
| range_end: "day-range-end rounded-r-md", | ||
| selected: | ||
| "bg-indigo-600 text-text-bright hover:bg-indigo-600 hover:text-text-bright focus:bg-indigo-600 focus:text-text-bright rounded-md", | ||
| today: "bg-charcoal-700 text-text-bright rounded-md", | ||
| outside: | ||
| "day-outside text-text-dimmed opacity-50 aria-selected:bg-charcoal-700/50 aria-selected:text-text-dimmed aria-selected:opacity-30", | ||
| disabled: "text-text-dimmed opacity-50", | ||
| range_middle: "aria-selected:bg-charcoal-700 aria-selected:text-text-bright", | ||
| hidden: "invisible", | ||
| dropdowns: "flex gap-2 items-center justify-center", | ||
| dropdown: | ||
| "bg-charcoal-750 border border-charcoal-600 rounded px-2 py-1 text-sm text-text-bright focus:outline-none focus:border-charcoal-500", | ||
| ...classNames, | ||
| }} | ||
| components={{ | ||
| MonthCaption: CustomMonthCaption, | ||
| }} | ||
| {...props} | ||
| /> | ||
| ); | ||
| } | ||
| Calendar.displayName = "Calendar"; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
145 changes: 145 additions & 0 deletions
145
apps/webapp/app/components/primitives/DateTimePicker.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| "use client"; | ||
|
|
||
| import * as React from "react"; | ||
| import { ChevronUpDownIcon } from "@heroicons/react/20/solid"; | ||
| import { format } from "date-fns"; | ||
| import { Calendar } from "./Calendar"; | ||
| import { Popover, PopoverContent, PopoverTrigger } from "./Popover"; | ||
| import { Button } from "./Buttons"; | ||
| import { cn } from "~/utils/cn"; | ||
| import { SimpleTooltip } from "./Tooltip"; | ||
| import { XIcon } from "lucide-react"; | ||
|
|
||
| type DateTimePickerProps = { | ||
| label: string; | ||
| value?: Date; | ||
| onChange?: (date: Date | undefined) => void; | ||
| showSeconds?: boolean; | ||
| showNowButton?: boolean; | ||
| showClearButton?: boolean; | ||
| showInlineLabel?: boolean; | ||
| className?: string; | ||
| }; | ||
|
|
||
| export function DateTimePicker({ | ||
| label, | ||
| value, | ||
| onChange, | ||
| showSeconds = true, | ||
| showNowButton = false, | ||
| showClearButton = false, | ||
| showInlineLabel = false, | ||
| className, | ||
| }: DateTimePickerProps) { | ||
| const [open, setOpen] = React.useState(false); | ||
|
|
||
| // Extract time parts from value | ||
| const hours = value ? value.getHours().toString().padStart(2, "0") : ""; | ||
| const minutes = value ? value.getMinutes().toString().padStart(2, "0") : ""; | ||
| const seconds = value ? value.getSeconds().toString().padStart(2, "0") : ""; | ||
| const timeValue = showSeconds ? `${hours}:${minutes}:${seconds}` : `${hours}:${minutes}`; | ||
|
|
||
| const handleDateSelect = (date: Date | undefined) => { | ||
| if (date) { | ||
| // Preserve the time from the current value if it exists | ||
| if (value) { | ||
| date.setHours(value.getHours()); | ||
| date.setMinutes(value.getMinutes()); | ||
| date.setSeconds(value.getSeconds()); | ||
| } | ||
| onChange?.(date); | ||
| } else { | ||
| onChange?.(undefined); | ||
| } | ||
| setOpen(false); | ||
| }; | ||
|
|
||
| const handleTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| const timeString = e.target.value; | ||
| if (!timeString) return; | ||
|
|
||
| const [h, m, s] = timeString.split(":").map(Number); | ||
| const newDate = value ? new Date(value) : new Date(); | ||
| newDate.setHours(h || 0); | ||
| newDate.setMinutes(m || 0); | ||
| newDate.setSeconds(s || 0); | ||
| onChange?.(newDate); | ||
| }; | ||
|
|
||
| const handleNowClick = () => { | ||
| onChange?.(new Date()); | ||
| }; | ||
|
|
||
| const handleClearClick = () => { | ||
| onChange?.(undefined); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className={cn("flex items-center gap-2", className)}> | ||
| {showInlineLabel && ( | ||
| <span className="w-6 shrink-0 text-right text-xxs text-charcoal-500">{label}</span> | ||
| )} | ||
| <Popover open={open} onOpenChange={setOpen}> | ||
| <PopoverTrigger asChild> | ||
| <button | ||
| type="button" | ||
| className={cn( | ||
| "flex h-[1.8rem] w-full items-center justify-between gap-2 whitespace-nowrap rounded border border-charcoal-650 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-600", | ||
| value ? "text-text-bright" : "text-text-dimmed" | ||
| )} | ||
| > | ||
| {value ? format(value, "yyyy/MM/dd") : "Select date"} | ||
| <ChevronUpDownIcon className="size-3.5 text-text-dimmed" /> | ||
| </button> | ||
| </PopoverTrigger> | ||
| <PopoverContent className="w-auto p-0" align="start"> | ||
| <Calendar | ||
| mode="single" | ||
| selected={value} | ||
| onSelect={handleDateSelect} | ||
| captionLayout="dropdown" | ||
| /> | ||
| </PopoverContent> | ||
| </Popover> | ||
| <input | ||
| type="time" | ||
| step={showSeconds ? "1" : "60"} | ||
| value={value ? timeValue : ""} | ||
| onChange={handleTimeChange} | ||
| className={cn( | ||
| "h-[1.8rem] rounded border border-charcoal-650 bg-charcoal-750 px-2 text-xs tabular-nums transition hover:border-charcoal-600", | ||
| value ? "text-text-bright" : "text-text-dimmed", | ||
| "focus:border-charcoal-500 focus:outline-none", | ||
| "[&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" | ||
| )} | ||
| aria-label={`${label} time`} | ||
| /> | ||
| {showNowButton && ( | ||
| <Button | ||
| type="button" | ||
| variant="secondary/small" | ||
| className="h-[1.8rem]" | ||
| onClick={handleNowClick} | ||
| > | ||
| Now | ||
| </Button> | ||
| )} | ||
| {showClearButton && ( | ||
| <SimpleTooltip | ||
| button={ | ||
| <button | ||
| type="button" | ||
| className="flex h-[1.8rem] items-center justify-center px-1 text-text-dimmed transition hover:text-text-bright" | ||
| onClick={handleClearClick} | ||
| > | ||
| <XIcon className="size-3.5" /> | ||
| </button> | ||
| } | ||
| content="Clear" | ||
| disableHoverableContent | ||
| asChild | ||
| /> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid mutating the incoming
dateparameter.The
handleDateSelectfunction mutates thedateobject directly viasetHours,setMinutes, andsetSeconds. This can cause unexpected side effects if the caller retains a reference to the original date object.🔧 Proposed fix: Create a new Date object
const handleDateSelect = (date: Date | undefined) => { if (date) { // Preserve the time from the current value if it exists if (value) { - date.setHours(value.getHours()); - date.setMinutes(value.getMinutes()); - date.setSeconds(value.getSeconds()); + const newDate = new Date(date); + newDate.setHours(value.getHours()); + newDate.setMinutes(value.getMinutes()); + newDate.setSeconds(value.getSeconds()); + onChange?.(newDate); + setOpen(false); + return; } onChange?.(date); } else { onChange?.(undefined); } setOpen(false); };