ChartHighlightActions.tsx 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. import dayjs from 'dayjs'
  2. import { ArrowRight, SearchIcon } from 'lucide-react'
  3. import { ReactNode, useEffect, useMemo, useState } from 'react'
  4. import {
  5. cn,
  6. DropdownMenu,
  7. DropdownMenuContent,
  8. DropdownMenuItem,
  9. DropdownMenuLabel,
  10. DropdownMenuSeparator,
  11. DropdownMenuTrigger,
  12. } from 'ui'
  13. import { ChartHighlight } from './useChartHighlight'
  14. import { useFormatDateTime } from '@/lib/datetime'
  15. export type UpdateDateRange = (from: string, to: string) => void
  16. export type ChartHighlightActionContext = {
  17. start: string
  18. end: string
  19. clear: () => void
  20. chartId?: string
  21. }
  22. export type ChartHighlightAction = {
  23. id: string
  24. label: string | ((ctx: ChartHighlightActionContext) => string)
  25. icon?: ReactNode
  26. isDisabled?: (ctx: ChartHighlightActionContext) => boolean
  27. rightSlot?: ReactNode | ((ctx: ChartHighlightActionContext) => ReactNode)
  28. onSelect: (ctx: ChartHighlightActionContext) => void
  29. }
  30. export const ChartHighlightActions = ({
  31. chartHighlight,
  32. updateDateRange,
  33. actions,
  34. chartId,
  35. }: {
  36. chartHighlight?: ChartHighlight
  37. updateDateRange?: UpdateDateRange
  38. actions?: ChartHighlightAction[]
  39. chartId?: string
  40. }) => {
  41. const { left: selectedRangeStart, right: selectedRangeEnd, clearHighlight } = chartHighlight ?? {}
  42. const [isOpen, setIsOpen] = useState(!!chartHighlight?.popoverPosition)
  43. const formatChartDate = useFormatDateTime()
  44. useEffect(() => {
  45. setIsOpen(!!chartHighlight?.popoverPosition && selectedRangeStart !== selectedRangeEnd)
  46. }, [chartHighlight?.popoverPosition])
  47. const ctx: ChartHighlightActionContext | undefined =
  48. selectedRangeStart && selectedRangeEnd && clearHighlight
  49. ? { start: selectedRangeStart, end: selectedRangeEnd, clear: clearHighlight, chartId }
  50. : undefined
  51. const defaultActions: ChartHighlightAction[] = useMemo(() => {
  52. if (!updateDateRange || !ctx) return []
  53. const isDisabled = dayjs(ctx.end).diff(dayjs(ctx.start), 'minutes') < 10
  54. return [
  55. {
  56. id: 'zoom-in',
  57. label: 'Zoom in',
  58. icon: <SearchIcon className="text-foreground-lighter" size={12} />,
  59. rightSlot: isDisabled ? <span className="text-xs">Min. 10 minutes</span> : null,
  60. isDisabled: () => isDisabled,
  61. onSelect: ({ start, end, clear }) => {
  62. if (isDisabled) return
  63. updateDateRange(start, end)
  64. clear()
  65. },
  66. },
  67. ]
  68. }, [ctx, updateDateRange])
  69. const allActions: ChartHighlightAction[] = useMemo(() => {
  70. const provided = actions ?? []
  71. return [...defaultActions, ...provided]
  72. }, [defaultActions, actions])
  73. return (
  74. <DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
  75. <DropdownMenuTrigger
  76. className="w-auto p-0"
  77. style={{
  78. position: 'absolute',
  79. left: chartHighlight?.popoverPosition?.x + 'px' || 0,
  80. top: chartHighlight?.popoverPosition?.y + 'px' || 0,
  81. }}
  82. />
  83. <DropdownMenuContent
  84. className="flex flex-col gap-1 p-1 w-fit text-left"
  85. onEscapeKeyDown={() => clearHighlight?.()}
  86. onInteractOutside={(e) => {
  87. const target = e.target as Element | null
  88. // If the user clicked on a chart, handleMouseDown will manage the new selection.
  89. // Calling clearHighlight here would race with it and clobber the new state.
  90. if (target?.closest('.recharts-wrapper')) return
  91. clearHighlight?.()
  92. }}
  93. >
  94. <DropdownMenuLabel className="flex items-center justify-center text-foreground-light font-mono gap-x-2 text-xs">
  95. <span>{formatChartDate(selectedRangeStart!, 'MMM D, H:mm')}</span>
  96. <ArrowRight size={10} />
  97. <span>{formatChartDate(selectedRangeEnd!, 'MMM D, H:mm')}</span>
  98. </DropdownMenuLabel>
  99. <DropdownMenuSeparator className="my-0" />
  100. {allActions.map((action) => {
  101. const disabled = ctx && action.isDisabled ? action.isDisabled(ctx) : false
  102. let labelNode: ReactNode = null
  103. if (typeof action.label === 'function') {
  104. labelNode = ctx ? action.label(ctx) : null
  105. } else {
  106. labelNode = action.label
  107. }
  108. let rightNode: ReactNode = null
  109. if (typeof action.rightSlot === 'function') {
  110. rightNode = ctx ? action.rightSlot(ctx) : null
  111. } else {
  112. rightNode = action.rightSlot ?? null
  113. }
  114. return (
  115. <DropdownMenuItem asChild key={action.id} disabled={disabled} className={cn('group')}>
  116. <button
  117. disabled={disabled}
  118. onClick={() => ctx && action.onSelect({ ...ctx })}
  119. className="w-full flex items-center gap-1.5"
  120. >
  121. {action.icon}
  122. <span className="grow text-left">{labelNode}</span>
  123. {rightNode}
  124. </button>
  125. </DropdownMenuItem>
  126. )
  127. })}
  128. </DropdownMenuContent>
  129. </DropdownMenu>
  130. )
  131. }