Logs.DatePickers.tsx474 lines · main
| 1 | import { Label } from '@ui/components/shadcn/ui/label' |
| 2 | import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group' |
| 3 | import dayjs from 'dayjs' |
| 4 | import { Clock, HistoryIcon, Lock } from 'lucide-react' |
| 5 | import type { PropsWithChildren } from 'react' |
| 6 | import { useCallback, useEffect, useMemo, useState } from 'react' |
| 7 | import { |
| 8 | Button, |
| 9 | ButtonProps, |
| 10 | Calendar, |
| 11 | cn, |
| 12 | copyToClipboard, |
| 13 | Input, |
| 14 | Popover, |
| 15 | PopoverContent, |
| 16 | PopoverTrigger, |
| 17 | } from 'ui' |
| 18 | |
| 19 | import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants' |
| 20 | import type { DatetimeHelper } from './Logs.types' |
| 21 | import { ButtonTooltip } from '@/components/ui/ButtonTooltip' |
| 22 | import { TimeSplitInput } from '@/components/ui/DatePicker/TimeSplitInput' |
| 23 | import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' |
| 24 | |
| 25 | type Unit = 'minute' | 'hour' | 'day' |
| 26 | |
| 27 | export type ParsedCustomInput = |
| 28 | | { type: 'number'; value: number } |
| 29 | | { type: 'unit'; value: number; unit: Unit } |
| 30 | | { type: 'invalid' } |
| 31 | |
| 32 | export const parseCustomInput = (input: string): ParsedCustomInput => { |
| 33 | const trimmed = input.trim().toLowerCase() |
| 34 | if (!trimmed) return { type: 'invalid' } |
| 35 | |
| 36 | // Try to match "number + optional space + unit prefix" |
| 37 | const match = trimmed.match(/^(\d+)\s*([a-z]*)$/) |
| 38 | if (!match) return { type: 'invalid' } |
| 39 | |
| 40 | const [, numStr, unitStr] = match |
| 41 | const value = parseInt(numStr, 10) |
| 42 | |
| 43 | if (isNaN(value) || value <= 0) return { type: 'invalid' } |
| 44 | |
| 45 | if (!unitStr) { |
| 46 | return { type: 'number', value } |
| 47 | } |
| 48 | |
| 49 | // Match if unitStr is a prefix of any unit name or its first letter |
| 50 | const units: Unit[] = ['minute', 'hour', 'day'] |
| 51 | const matchedUnit = units.find((u) => u.startsWith(unitStr) || u[0] === unitStr) |
| 52 | |
| 53 | if (!matchedUnit) return { type: 'invalid' } |
| 54 | |
| 55 | return { type: 'unit', value, unit: matchedUnit } |
| 56 | } |
| 57 | |
| 58 | export const generateDynamicHelper = (value: number, unit: Unit): DatetimeHelper => { |
| 59 | return { |
| 60 | text: `Last ${value} ${unit}${value === 1 ? '' : 's'}`, |
| 61 | calcFrom: () => dayjs().subtract(value, unit).toISOString(), |
| 62 | calcTo: () => dayjs().toISOString(), |
| 63 | } |
| 64 | } |
| 65 | |
| 66 | export const generateDynamicHelpers = (value: number): DatetimeHelper[] => { |
| 67 | const units: Unit[] = ['minute', 'hour', 'day'] |
| 68 | return units.map((unit) => generateDynamicHelper(value, unit)) |
| 69 | } |
| 70 | |
| 71 | export const generateHelpersFromInput = (input: string): DatetimeHelper[] | null => { |
| 72 | const parsed = parseCustomInput(input) |
| 73 | |
| 74 | switch (parsed.type) { |
| 75 | case 'number': |
| 76 | return generateDynamicHelpers(parsed.value) |
| 77 | case 'unit': |
| 78 | return [generateDynamicHelper(parsed.value, parsed.unit)] |
| 79 | case 'invalid': |
| 80 | return null |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | export type DatePickerValue = { |
| 85 | to: string |
| 86 | from: string |
| 87 | isHelper?: boolean |
| 88 | text?: string |
| 89 | } |
| 90 | |
| 91 | interface LogsDatePickerProps { |
| 92 | value: DatePickerValue |
| 93 | helpers: DatetimeHelper[] |
| 94 | onSubmit: (value: DatePickerValue) => void |
| 95 | buttonTriggerProps?: ButtonProps |
| 96 | popoverContentProps?: typeof PopoverContent |
| 97 | hideWarnings?: boolean |
| 98 | align?: 'start' | 'end' | 'center' |
| 99 | open?: boolean |
| 100 | onOpenChange?: (open: boolean) => void |
| 101 | } |
| 102 | |
| 103 | export const LogsDatePicker = ({ |
| 104 | onSubmit, |
| 105 | helpers, |
| 106 | value, |
| 107 | buttonTriggerProps, |
| 108 | popoverContentProps, |
| 109 | hideWarnings, |
| 110 | align = 'end', |
| 111 | open: openProp, |
| 112 | onOpenChange, |
| 113 | }: PropsWithChildren<LogsDatePickerProps>) => { |
| 114 | const [internalOpen, setInternalOpen] = useState(false) |
| 115 | const isControlled = openProp !== undefined |
| 116 | const open = isControlled ? openProp : internalOpen |
| 117 | const setOpen = (next: boolean) => { |
| 118 | if (!isControlled) setInternalOpen(next) |
| 119 | onOpenChange?.(next) |
| 120 | } |
| 121 | const [customValue, setCustomValue] = useState('') |
| 122 | |
| 123 | const displayedHelpers = useMemo(() => { |
| 124 | if (!customValue.trim()) return helpers |
| 125 | const generated = generateHelpersFromInput(customValue) |
| 126 | return generated ?? [] |
| 127 | }, [customValue, helpers]) |
| 128 | |
| 129 | // Reset the state when the popover closes |
| 130 | useEffect(() => { |
| 131 | if (!open) { |
| 132 | setCustomValue('') |
| 133 | setStartDate(value.from ? new Date(value.from) : null) |
| 134 | const defaultEndDate = value.to ? new Date(value.to) : new Date() |
| 135 | setEndDate(defaultEndDate) |
| 136 | setCurrentMonth(new Date(defaultEndDate)) |
| 137 | |
| 138 | const fromDate = value.from ? new Date(value.from) : null |
| 139 | const toDate = value.to ? new Date(value.to) : null |
| 140 | |
| 141 | setStartTime({ |
| 142 | HH: fromDate?.getHours().toString().padStart(2, '0') || '00', |
| 143 | mm: fromDate?.getMinutes().toString().padStart(2, '0') || '00', |
| 144 | ss: fromDate?.getSeconds().toString().padStart(2, '0') || '00', |
| 145 | }) |
| 146 | |
| 147 | const now = new Date() |
| 148 | const nowHH = now.getHours().toString().padStart(2, '0') |
| 149 | const nowMM = now.getMinutes().toString().padStart(2, '0') |
| 150 | const nowSS = now.getSeconds().toString().padStart(2, '0') |
| 151 | |
| 152 | setEndTime({ |
| 153 | HH: toDate?.getHours().toString().padStart(2, '0') || nowHH, |
| 154 | mm: toDate?.getMinutes().toString().padStart(2, '0') || nowMM, |
| 155 | ss: toDate?.getSeconds().toString().padStart(2, '0') || nowSS, |
| 156 | }) |
| 157 | } |
| 158 | }, [open, value]) |
| 159 | |
| 160 | const handleHelperChange = (newValue: string) => { |
| 161 | const selectedHelper = displayedHelpers.find((h) => h.text === newValue) |
| 162 | if (onSubmit && selectedHelper) { |
| 163 | onSubmit({ |
| 164 | to: selectedHelper.calcTo(), |
| 165 | from: selectedHelper.calcFrom(), |
| 166 | isHelper: true, |
| 167 | text: selectedHelper.text, |
| 168 | }) |
| 169 | } |
| 170 | |
| 171 | setOpen(false) |
| 172 | } |
| 173 | |
| 174 | const [startDate, setStartDate] = useState<Date | null>(value.from ? new Date(value.from) : null) |
| 175 | const [endDate, setEndDate] = useState<Date | null>(value.to ? new Date(value.to) : new Date()) |
| 176 | const [currentMonth, setCurrentMonth] = useState<Date>(() => |
| 177 | value.to ? new Date(value.to) : new Date() |
| 178 | ) |
| 179 | |
| 180 | const [startTime, setStartTime] = useState({ |
| 181 | HH: startDate?.getHours().toString() || '00', |
| 182 | mm: startDate?.getMinutes().toString() || '00', |
| 183 | ss: startDate?.getSeconds().toString() || '00', |
| 184 | }) |
| 185 | const [endTime, setEndTime] = useState({ |
| 186 | HH: endDate?.getHours().toString() || '23', |
| 187 | mm: endDate?.getMinutes().toString() || '59', |
| 188 | ss: endDate?.getSeconds().toString() || '59', |
| 189 | }) |
| 190 | |
| 191 | function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) { |
| 192 | const [from, to] = dates |
| 193 | |
| 194 | setStartDate(from) |
| 195 | setEndDate(to) |
| 196 | } |
| 197 | |
| 198 | function handleApply() { |
| 199 | const from = startDate || new Date() |
| 200 | const to = endDate || new Date() |
| 201 | |
| 202 | // Add Time to the dates |
| 203 | const finalFrom = new Date(from.setHours(+startTime.HH, +startTime.mm, +startTime.ss)) |
| 204 | const finalTo = new Date(to.setHours(+endTime.HH, +endTime.mm, +endTime.ss)) |
| 205 | |
| 206 | onSubmit({ |
| 207 | from: finalFrom.toISOString(), |
| 208 | to: finalTo.toISOString(), |
| 209 | isHelper: false, |
| 210 | }) |
| 211 | |
| 212 | setOpen(false) |
| 213 | } |
| 214 | |
| 215 | const [copied, setCopied] = useState(false) |
| 216 | const [pasted, setPasted] = useState(false) |
| 217 | |
| 218 | useEffect(() => { |
| 219 | if (copied) { |
| 220 | setTimeout(() => { |
| 221 | setCopied(false) |
| 222 | }, 2000) |
| 223 | } |
| 224 | }, [copied]) |
| 225 | |
| 226 | function handlePaste() { |
| 227 | navigator.clipboard |
| 228 | .readText() |
| 229 | .then((text) => { |
| 230 | try { |
| 231 | const json = JSON.parse(text) |
| 232 | |
| 233 | if (!json.from || !json.to) { |
| 234 | console.warn('Invalid date range format in clipboard') |
| 235 | return |
| 236 | } |
| 237 | |
| 238 | const fromDate = new Date(json.from) |
| 239 | const toDate = new Date(json.to) |
| 240 | |
| 241 | // Check if dates are valid |
| 242 | if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { |
| 243 | console.warn('Invalid date values in clipboard') |
| 244 | return |
| 245 | } |
| 246 | |
| 247 | setStartDate(fromDate) |
| 248 | setEndDate(toDate) |
| 249 | setCurrentMonth(new Date(toDate)) |
| 250 | |
| 251 | // Update time states |
| 252 | setStartTime({ |
| 253 | HH: fromDate.getHours().toString(), |
| 254 | mm: fromDate.getMinutes().toString(), |
| 255 | ss: fromDate.getSeconds().toString(), |
| 256 | }) |
| 257 | |
| 258 | setEndTime({ |
| 259 | HH: toDate.getHours().toString(), |
| 260 | mm: toDate.getMinutes().toString(), |
| 261 | ss: toDate.getSeconds().toString(), |
| 262 | }) |
| 263 | |
| 264 | setPasted(true) |
| 265 | } catch (error) { |
| 266 | console.warn('Failed to parse clipboard content as date range:', error) |
| 267 | } |
| 268 | }) |
| 269 | .catch((error) => { |
| 270 | console.warn('Failed to read clipboard:', error) |
| 271 | }) |
| 272 | } |
| 273 | |
| 274 | const handleCopy = useCallback(() => { |
| 275 | if (!startDate || !endDate) return |
| 276 | |
| 277 | const fromDate = new Date(startDate) |
| 278 | const toDate = new Date(endDate) |
| 279 | |
| 280 | // Add time from time states |
| 281 | fromDate.setHours(+startTime.HH, +startTime.mm, +startTime.ss) |
| 282 | toDate.setHours(+endTime.HH, +endTime.mm, +endTime.ss) |
| 283 | |
| 284 | copyToClipboard( |
| 285 | JSON.stringify({ |
| 286 | from: fromDate.toISOString(), |
| 287 | to: toDate.toISOString(), |
| 288 | }) |
| 289 | ) |
| 290 | |
| 291 | setCopied(true) |
| 292 | }, [startDate, endDate, startTime, endTime]) |
| 293 | |
| 294 | useEffect(() => { |
| 295 | if (pasted) { |
| 296 | setTimeout(() => { |
| 297 | setPasted(false) |
| 298 | }, 2000) |
| 299 | } |
| 300 | }, [pasted]) |
| 301 | |
| 302 | useEffect(() => { |
| 303 | if (open) { |
| 304 | document.addEventListener('paste', handlePaste) |
| 305 | document.addEventListener('copy', handleCopy) |
| 306 | } |
| 307 | return () => { |
| 308 | document.removeEventListener('paste', handlePaste) |
| 309 | document.removeEventListener('copy', handleCopy) |
| 310 | } |
| 311 | }, [open, startDate, endDate, handleCopy]) |
| 312 | |
| 313 | const isLargeRange = |
| 314 | Math.abs(dayjs(startDate).diff(dayjs(endDate), 'days')) > |
| 315 | LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD - 1 |
| 316 | |
| 317 | const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days') |
| 318 | const entitledToAuditLogDays = getEntitlementNumericValue() |
| 319 | |
| 320 | const showHelperBadge = (helper?: DatetimeHelper) => { |
| 321 | if (!helper) return false |
| 322 | if (!entitledToAuditLogDays) return false |
| 323 | |
| 324 | const day = Math.abs(dayjs().diff(dayjs(helper.calcFrom()), 'day')) |
| 325 | if (day <= entitledToAuditLogDays) return false |
| 326 | return true |
| 327 | } |
| 328 | |
| 329 | return ( |
| 330 | <Popover open={open} onOpenChange={setOpen}> |
| 331 | <PopoverTrigger asChild> |
| 332 | <Button type="default" icon={<Clock size={12} />} {...buttonTriggerProps}> |
| 333 | {value.isHelper |
| 334 | ? value.text |
| 335 | : `${dayjs(value.from).format('DD MMM, HH:mm')} - ${dayjs(value.to || new Date()).format('DD MMM, HH:mm')}`} |
| 336 | </Button> |
| 337 | </PopoverTrigger> |
| 338 | <PopoverContent |
| 339 | className="flex w-full p-0" |
| 340 | side="bottom" |
| 341 | align={align} |
| 342 | {...popoverContentProps} |
| 343 | > |
| 344 | <div className="border-r p-2 flex flex-col gap-px"> |
| 345 | <Input |
| 346 | type="text" |
| 347 | placeholder="e.g. 2h, 30m, 7d" |
| 348 | value={customValue} |
| 349 | onChange={(e) => setCustomValue(e.target.value)} |
| 350 | className="mb-2 text-xs h-7 rounded-xs" |
| 351 | /> |
| 352 | <RadioGroup |
| 353 | onValueChange={handleHelperChange} |
| 354 | value={value.isHelper ? value.text : ''} |
| 355 | className="flex flex-col gap-px" |
| 356 | > |
| 357 | {displayedHelpers.map((helper) => ( |
| 358 | <Label |
| 359 | key={helper.text} |
| 360 | className={cn( |
| 361 | '[&:has([data-state=checked])]:bg-background-overlay-hover [&:has([data-state=checked])]:text-foreground px-4 py-1.5 text-foreground-light flex items-center gap-2 hover:bg-background-overlay-hover hover:text-foreground transition-all rounded-xs text-xs w-full', |
| 362 | { |
| 363 | 'cursor-not-allowed pointer-events-none opacity-50': helper.disabled, |
| 364 | } |
| 365 | )} |
| 366 | > |
| 367 | <RadioGroupItem |
| 368 | hidden |
| 369 | key={helper.text} |
| 370 | value={helper.text} |
| 371 | disabled={helper.disabled} |
| 372 | aria-disabled={helper.disabled} |
| 373 | ></RadioGroupItem> |
| 374 | {helper.text} |
| 375 | {showHelperBadge(helper) ? ( |
| 376 | <Lock size={12} className="text-foreground-muted" /> |
| 377 | ) : null} |
| 378 | </Label> |
| 379 | ))} |
| 380 | </RadioGroup> |
| 381 | </div> |
| 382 | |
| 383 | <div> |
| 384 | <div className="flex p-2 gap-2 items-center"> |
| 385 | <div className="flex grow *:grow gap-2 font-mono"> |
| 386 | <TimeSplitInput |
| 387 | type="start" |
| 388 | startTime={startTime} |
| 389 | endTime={endTime} |
| 390 | time={startTime} |
| 391 | setTime={setStartTime} |
| 392 | setStartTime={setStartTime} |
| 393 | setEndTime={setEndTime} |
| 394 | startDate={startDate} |
| 395 | endDate={endDate} |
| 396 | /> |
| 397 | <TimeSplitInput |
| 398 | type="end" |
| 399 | startTime={startTime} |
| 400 | endTime={endTime} |
| 401 | time={endTime} |
| 402 | setTime={setEndTime} |
| 403 | setStartTime={setStartTime} |
| 404 | setEndTime={setEndTime} |
| 405 | startDate={startDate} |
| 406 | endDate={endDate} |
| 407 | /> |
| 408 | </div> |
| 409 | <div className="shrink"> |
| 410 | <ButtonTooltip |
| 411 | tooltip={{ |
| 412 | content: { |
| 413 | text: 'Clear time range', |
| 414 | }, |
| 415 | }} |
| 416 | icon={<HistoryIcon size={14} />} |
| 417 | type="text" |
| 418 | size="tiny" |
| 419 | className="px-1.5" |
| 420 | onClick={() => { |
| 421 | setStartTime({ HH: '00', mm: '00', ss: '00' }) |
| 422 | setEndTime({ HH: '00', mm: '00', ss: '00' }) |
| 423 | }} |
| 424 | ></ButtonTooltip> |
| 425 | </div> |
| 426 | </div> |
| 427 | <div className="p-2 border-t"> |
| 428 | <Calendar |
| 429 | mode="range" |
| 430 | month={currentMonth} |
| 431 | onMonthChange={(month) => setCurrentMonth(new Date(month))} |
| 432 | selected={{ from: startDate ?? undefined, to: endDate ?? undefined }} |
| 433 | onSelect={(range) => { |
| 434 | handleDatePickerChange([range?.from ?? null, range?.to ?? null]) |
| 435 | }} |
| 436 | /> |
| 437 | </div> |
| 438 | {isLargeRange && !hideWarnings && ( |
| 439 | <div className="text-xs px-3 py-1.5 border-y bg-warning-300 text-warning-foreground border-warning-500 text-warning"> |
| 440 | Large ranges may result in memory errors for <br /> big projects. |
| 441 | </div> |
| 442 | )} |
| 443 | <div className="flex items-center justify-end gap-2 p-2 border-t"> |
| 444 | {startDate && endDate ? ( |
| 445 | <Button |
| 446 | type="text" |
| 447 | size="tiny" |
| 448 | onClick={handleCopy} |
| 449 | className={cn({ |
| 450 | 'text-brand-600': copied || pasted, |
| 451 | })} |
| 452 | > |
| 453 | {copied ? 'Copied!' : pasted ? 'Pasted!' : 'Copy range'} |
| 454 | </Button> |
| 455 | ) : null} |
| 456 | |
| 457 | <Button |
| 458 | type="default" |
| 459 | onClick={() => { |
| 460 | const today = new Date() |
| 461 | setCurrentMonth(today) |
| 462 | setStartDate(new Date(today)) |
| 463 | setEndDate(new Date(today)) |
| 464 | }} |
| 465 | > |
| 466 | Today |
| 467 | </Button> |
| 468 | <Button onClick={handleApply}>Apply</Button> |
| 469 | </div> |
| 470 | </div> |
| 471 | </PopoverContent> |
| 472 | </Popover> |
| 473 | ) |
| 474 | } |