import React, { useEffect, useRef, useState } from 'react' import moment from 'moment' import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from 'recharts' import Button from '../../Button/Button' import { hasValue, sortByProperty } from '../../../services/HelperServiceTyped' import HeaderMetric from '../../HeaderMetric/HeaderMetric' import ListLoading from '../../Loaders/ListLoading' import MdashCheck from '../../Mdash/MdashCheck' import Popover from '../../Popover/Popover' import { SideDrawer } from '../../SideDrawer/SideDrawer' import SparkLine from './SparkLine' import { useIsMobileView } from '../../../hooks/useIsMobileView/useIsMobileView' import { abbreviateNumber } from './abbreviateNumber' import type { HeaderMetricProps } from '../../HeaderMetric/HeaderMetric' import styles from './_spark-line-with-tooltip.module.scss' import { c } from '../../../translations/LibraryTranslationService' type HeaderMetricType = { /** Display title for popover chart */ title: string /** Main numerical display value at the top of the chart */ value: number /** Is the chart loading */ loading?: boolean /** The change value to be displayed (second displayed metric) */ change?: number /** Currency object that includes currency code & symbol */ currency?: { currencyCode: string currencySymbol: string } /** The change percentage (third displayed metric) */ pctChange?: number /** Value type of display numbers (must be either percentage or number) */ formatType?: 'percentage' | 'number' } & HeaderMetricProps export type SparklineProps = { // REQUIRED PARAMS /** Array of graph data object [{data_key: value, period_key: date }, ...] */ graphData: Array> /** Main graph line color. Accepted values are 'green', 'red', 'blue', or 'purple' (default is 'blue'). The dark variant of the selected color will be applied. */ graphColor: 'green' | 'red' | 'blue' | 'purple' // OPTIONAL PARAMS /** The string format for the display date (default: 'MMM Do'); for hours:minutes use 'h:mm' */ customDateFormat?: string /** The change value to be displayed (second displayed metric) */ changeValue?: number /** connects valid data points across null values (no data point is given to null values) */ connectNulls?: boolean /** The key for the values of the data in the chartData object (default = 'data_point') */ dataKey?: string /** Type of data (default: 'number') */ dataType?: 'number' | 'currency' | 'percentage' | 'sales' /** Max domain value to display (must provide domainMin also) */ domainMax?: number /** Min domain value to display (must provide domainMax also) */ domainMin?: number /** All HeaderMetric props allowed */ headerMetricProps: HeaderMetricType /** Optional HeaderMetric props allowed for secondary metric. The metric is not used for graph plotting but as an additional info metric */ secondaryHeaderMetricProps?: HeaderMetricType /** Hide sparkline preview */ hideSparklinePreview?: boolean /** Y-axis domain inverted */ invertYAxis?: boolean /** Time period object key (default: 'date') */ periodKey?: string /** Secondary display object */ secondaryDisplay?: () => React.JSX.Element /** Show trophy icon in front of display value in header */ showTrophy?: boolean /** Average/Threshold line color (default is the same as labelColor) */ thresholdColor?: 'green' | 'red' | 'blue' /** The key for the data value representing the average/threshold value in the graph (default = 'threshold') */ thresholdKey?: string /* Props to be passed for sparkline container height and width (number or percentage like '100%') */ sparklineContainerDimension?: { height?: number | `${number}%` width?: number | `${number}%` } /** Optional prop to add a test id to the SparklineWithTooltip for QA testing */ qaTestId?: string /** Optional prop to hide decimal values on the Y-axis */ hideDecimalOnYAxis?: boolean } const DEFAULT_BLUE = 'var(--dark-blue)' const DEFAULT_LIGHTBLUE = 'var(--chart-dark-2-blue)' const Sparkline = ({ customDateFormat = 'MMM Do', connectNulls = false, dataType = 'number', dataKey = 'data_point', domainMax, domainMin, graphColor = 'blue', graphData, headerMetricProps, secondaryHeaderMetricProps, hideSparklinePreview = false, invertYAxis, periodKey = 'date', secondaryDisplay, thresholdColor, thresholdKey = 'threshold', sparklineContainerDimension, qaTestId = 'sparkline-with-tooltip', hideDecimalOnYAxis, }: SparklineProps): React.JSX.Element => { const isMobileView = useIsMobileView() const graphLineColor = `var(--dark-${graphColor})` const thresholdStrokeColor = `var(--chart-dark-2-${ thresholdColor || graphColor })` const { title, currency } = headerMetricProps const [isDrawerOpen, setDrawerOpen] = useState(false) ///////////////////////////////////////////////////////////////////////////////////////////////// // DYNAMIC CALCULATIONS FOR TOOLTIP HEIGHT WITH SECONDARY CONTAINER ///////////////////////////////////////////////////////////////////////////////////////////////// const [secondaryContainerHeight, setSecondaryContainerHeight] = useState(0) const [secondaryContainerWidth, setSecondaryContainerWidth] = useState(0) const containerRef = useRef(null) const [isContainerOpened, setIsContainerOpened] = useState(false) const getSecondaryContainerHeight = () => { const elementRect = containerRef.current?.getBoundingClientRect() if (elementRect) { return { height: elementRect.height, width: elementRect.width } } else { return { height: 0, width: 0 } } } useEffect(() => { const { height, width } = getSecondaryContainerHeight() if (height > 0 && width > 0 && isContainerOpened) { setSecondaryContainerWidth(width) setSecondaryContainerHeight(height) setIsContainerOpened(false) } }, [isContainerOpened]) // NOTE: 200 is the height of the main chart container; // 105 includes the chart legend and the top and bottom padding of the tooltip container; // 156 includes the 73 from the main chart plus 83 for the header height; // secondaryContainerHeight is the dynamic height of the secondary container; // if `isMobileView` then the chart and secondary container are stacked vertically and need // the combined height of both containers; otherwise the popover container needs to be // the height of the tallest container const tippyContainerHeight = isMobileView ? `${200 + secondaryContainerHeight + 156}px` : `${Math.max(200, secondaryContainerHeight) + 105}px` ///////////////////////////////////////////////////////////////////////////////////////////////// // NOTE: 400 is the width of the main chart container // secondaryContainerWidth is the dynamic width of the secondary container; const tippyContainerWidth = isMobileView ? '100%' : `${400 + secondaryContainerWidth}px` const TooltipChart = () => (
{ e.stopPropagation() }} > } cursor /> `${ dataType === 'sales' || dataType === 'currency' ? currency?.currencySymbol : '' }${abbreviateNumber(tick, undefined, hideDecimalOnYAxis)}${dataType === 'percentage' ? '%' : ''}` } /> } />
{title}
) const SecondaryDisplay = () => (
{secondaryDisplay && secondaryDisplay()}
) const PopoverOrSidedrawerContent = () => (
{/* TOOLTIP HEADER */}
{secondaryHeaderMetricProps && !isMobileView ? (
) : null} {secondaryHeaderMetricProps ? ( ) : null}
{/* TOOLTIP CHART ONLY */} {!secondaryDisplay && } {/* OR...TOOLTIP CHART && SECONDARY CONTAINER */} {secondaryDisplay && (
)}
) const PopoverOrSideDrawerChildren = ({ openPopoverOrSidedrawer, isOpen, }: { openPopoverOrSidedrawer?: (value: boolean) => void isOpen?: boolean }) => // SMALL SPARKLINE CHART - INITIAL DISPLAY CHART graphData?.length ? (
{ openPopoverOrSidedrawer?.(!isOpen) secondaryDisplay && setTimeout(() => { setIsContainerOpened(true) }, 200) // To ensure Popover is rendered after the animation, so containerRef is set correctly }} className={styles.miniChartContainer} >
) : (
) return isMobileView ? ( <> {PopoverOrSideDrawerChildren({ openPopoverOrSidedrawer: setDrawerOpen, isOpen: isDrawerOpen, })} setDrawerOpen(false)} qaTestId={`${qaTestId}-side-drawer`} > {PopoverOrSidedrawerContent()} ) : ( {({ visible, setVisible }) => PopoverOrSideDrawerChildren({ openPopoverOrSidedrawer: setVisible, isOpen: visible, }) } ) } export default Sparkline type TwoLineDateLabelProps = { x: string | number y: string | number payload: { value: string | number } customDateFormat: string } const TwoLineDateLabel = ({ x, y, payload, customDateFormat, }: TwoLineDateLabelProps): React.JSX.Element => { const day = moment.utc(payload.value) const dateFormat = customDateFormat return ( {day.format(dateFormat)} ) } type LineTooltipTypes = { /** Required but provided through context by Recharts */ payload?: { tooltipId: number dataKey: string value: number color: string }[] active?: boolean customDateFormat?: string decimalScale?: number keysToRemovePrefix?: string[] label?: string prefix?: string suffix?: string tooltipOrder?: string[] tooltipSecondDate?: string } const LineTooltip = ({ payload, active, customDateFormat, decimalScale = 0, keysToRemovePrefix, label, prefix, suffix, tooltipOrder, tooltipSecondDate, }: LineTooltipTypes) => { const locale = undefined // may eventually customize with user's locale const options = { minimumFractionDigits: decimalScale, maximumFractionDigits: decimalScale, } if (tooltipOrder) { payload?.map((e) => { const tooltipId = tooltipOrder.indexOf(e.dataKey) + 1 e.tooltipId = tooltipId return e }) if (payload) { payload = sortByProperty(payload, 'tooltipId') } } const day = moment.utc(label).format(customDateFormat) if (active) { return (
{/* DISPLAY DATE */}
{day}
{tooltipSecondDate && ( {` - ${moment(label).add(6, 'days').format(customDateFormat)}`} )}
{/* DISPLAY DATA POINT DATA */}
{payload?.map((e) => { const check = typeof e.value === 'number' && !isNaN(e.value) ? hasValue(e.value) : !!e.value if (e.dataKey.includes('dontuse')) { return undefined } const usePrefix = prefix && !keysToRemovePrefix?.includes(e.dataKey) const useSuffix = suffix && !keysToRemovePrefix?.includes(e.dataKey) return (
{/* TOOLTIP HOVER TEXT */}
{e.dataKey .toLocaleString() .split('_') .map((word) => { if (word.toLowerCase() === 'vat') { word = 'VAT' } return word.charAt(0).toUpperCase() + word.slice(1) }) .join(' ')}
{/* TOOLTIP HOVER VALUE */}
{usePrefix && prefix} {e.value.toLocaleString(locale, options)} {useSuffix && suffix}
) })}
) } return null }