import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import {browserHistory} from 'react-router'; import styled from '@emotion/styled'; import * as Sentry from '@sentry/react'; import {Location} from 'history'; import {Client} from 'sentry/api'; import {Button} from 'sentry/components/button'; import ErrorPanel from 'sentry/components/charts/errorPanel'; import {ChartContainer} from 'sentry/components/charts/styles'; import Count from 'sentry/components/count'; import ErrorBoundary from 'sentry/components/errorBoundary'; import GlobalSelectionLink from 'sentry/components/globalSelectionLink'; import NotAvailable from 'sentry/components/notAvailable'; import Panel from 'sentry/components/panels/panel'; import PanelTable from 'sentry/components/panels/panelTable'; import {Tooltip} from 'sentry/components/tooltip'; import {PlatformKey} from 'sentry/data/platformCategories'; import {IconArrow, IconChevron, IconList, IconWarning} from 'sentry/icons'; import {t, tct, tn} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import { Organization, ReleaseComparisonChartType, ReleaseProject, ReleaseWithHealth, SessionApiResponse, SessionFieldWithOperation, SessionStatus, } from 'sentry/types'; import {defined} from 'sentry/utils'; import {trackAnalytics} from 'sentry/utils/analytics'; import {formatPercentage} from 'sentry/utils/formatters'; import getDynamicText from 'sentry/utils/getDynamicText'; import {decodeList, decodeScalar} from 'sentry/utils/queryString'; import {getCount, getCrashFreeRate, getSessionStatusRate} from 'sentry/utils/sessions'; import {Color} from 'sentry/utils/theme'; import {MutableSearch} from 'sentry/utils/tokenizeSearch'; import { displaySessionStatusPercent, getReleaseBounds, getReleaseHandledIssuesUrl, getReleaseParams, getReleaseUnhandledIssuesUrl, } from 'sentry/views/releases/utils'; import ReleaseComparisonChartRow from './releaseComparisonChartRow'; import ReleaseEventsChart from './releaseEventsChart'; import ReleaseSessionsChart from './releaseSessionsChart'; export type ReleaseComparisonRow = { allReleases: React.ReactNode; diff: React.ReactNode; diffColor: Color | null; diffDirection: 'up' | 'down' | null; drilldown: React.ReactNode; role: 'parent' | 'children' | 'default'; thisRelease: React.ReactNode; type: ReleaseComparisonChartType; }; type Props = { allSessions: SessionApiResponse | null; api: Client; errored: boolean; hasHealthData: boolean; loading: boolean; location: Location; organization: Organization; platform: PlatformKey; project: ReleaseProject; release: ReleaseWithHealth; releaseSessions: SessionApiResponse | null; reloading: boolean; }; type EventsTotals = { allErrorCount: number; allFailureRate: number; allTransactionCount: number; releaseErrorCount: number; releaseFailureRate: number; releaseTransactionCount: number; } | null; type IssuesTotals = { handled: number; unhandled: number; } | null; function ReleaseComparisonChart({ release, project, releaseSessions, allSessions, platform, location, loading, reloading, errored, api, organization, hasHealthData, }: Props) { const [issuesTotals, setIssuesTotals] = useState(null); const [eventsTotals, setEventsTotals] = useState(null); const [eventsLoading, setEventsLoading] = useState(false); const [expanded, setExpanded] = useState(new Set()); const [isOtherExpanded, setIsOtherExpanded] = useState(false); const charts: ReleaseComparisonRow[] = []; const additionalCharts: ReleaseComparisonRow[] = []; const hasDiscover = organization.features.includes('discover-basic') || organization.features.includes('performance-view'); const hasPerformance = organization.features.includes('performance-view'); const { statsPeriod: period, start, end, utc, } = useMemo( () => // Memoizing this so that it does not calculate different `end` for releases without events+sessions each rerender getReleaseParams({ location, releaseBounds: getReleaseBounds(release), }), [release, location] ); useEffect(() => { const chartInUrl = decodeScalar(location.query.chart) as ReleaseComparisonChartType; if ( [ ReleaseComparisonChartType.HEALTHY_SESSIONS, ReleaseComparisonChartType.ABNORMAL_SESSIONS, ReleaseComparisonChartType.ERRORED_SESSIONS, ReleaseComparisonChartType.CRASHED_SESSIONS, ].includes(chartInUrl) ) { setExpanded(e => new Set(e.add(ReleaseComparisonChartType.CRASH_FREE_SESSIONS))); } if ( [ ReleaseComparisonChartType.HEALTHY_USERS, ReleaseComparisonChartType.ABNORMAL_USERS, ReleaseComparisonChartType.ERRORED_USERS, ReleaseComparisonChartType.CRASHED_USERS, ].includes(chartInUrl) ) { setExpanded(e => new Set(e.add(ReleaseComparisonChartType.CRASH_FREE_USERS))); } if ( [ ReleaseComparisonChartType.SESSION_COUNT, ReleaseComparisonChartType.USER_COUNT, ReleaseComparisonChartType.ERROR_COUNT, ReleaseComparisonChartType.TRANSACTION_COUNT, ].includes(chartInUrl) ) { setIsOtherExpanded(true); } }, [location.query.chart]); const fetchEventsTotals = useCallback(async () => { const url = `/organizations/${organization.slug}/events/`; const commonQuery = { environment: decodeList(location.query.environment), project: decodeList(location.query.project), start, end, ...(period ? {statsPeriod: period} : {}), }; setEventsLoading(true); try { const [ releaseTransactionTotals, allTransactionTotals, releaseErrorTotals, allErrorTotals, ] = await Promise.all([ api.requestPromise(url, { query: { field: ['failure_rate()', 'count()'], query: new MutableSearch([ 'event.type:transaction', `release:${release.version}`, ]).formatString(), ...commonQuery, }, }), api.requestPromise(url, { query: { field: ['failure_rate()', 'count()'], query: new MutableSearch(['event.type:transaction']).formatString(), ...commonQuery, }, }), api.requestPromise(url, { query: { field: ['count()'], query: new MutableSearch([ 'event.type:error', `release:${release.version}`, ]).formatString(), ...commonQuery, }, }), api.requestPromise(url, { query: { field: ['count()'], query: new MutableSearch(['event.type:error']).formatString(), ...commonQuery, }, }), ]); setEventsTotals({ allErrorCount: allErrorTotals.data[0]['count()'], releaseErrorCount: releaseErrorTotals.data[0]['count()'], allTransactionCount: allTransactionTotals.data[0]['count()'], releaseTransactionCount: releaseTransactionTotals.data[0]['count()'], releaseFailureRate: releaseTransactionTotals.data[0]['failure_rate()'], allFailureRate: allTransactionTotals.data[0]['failure_rate()'], }); setEventsLoading(false); } catch (err) { setEventsTotals(null); setEventsLoading(false); Sentry.captureException(err); } }, [ api, end, location.query.environment, location.query.project, organization.slug, period, release.version, start, ]); const fetchIssuesTotals = useCallback(async () => { const UNHANDLED_QUERY = `release:"${release.version}" error.handled:0`; const HANDLED_QUERY = `release:"${release.version}" error.handled:1`; try { const response = await api.requestPromise( `/organizations/${organization.slug}/issues-count/`, { query: { project: project.id, environment: decodeList(location.query.environment), start, end, ...(period ? {statsPeriod: period} : {}), query: [UNHANDLED_QUERY, HANDLED_QUERY], }, } ); setIssuesTotals({ handled: response[HANDLED_QUERY] ?? 0, unhandled: response[UNHANDLED_QUERY] ?? 0, }); } catch (err) { setIssuesTotals(null); Sentry.captureException(err); } }, [ api, end, location.query.environment, organization.slug, period, project.id, release.version, start, ]); useEffect(() => { if (hasDiscover || hasPerformance) { fetchEventsTotals(); fetchIssuesTotals(); } }, [fetchEventsTotals, fetchIssuesTotals, hasDiscover, hasPerformance]); const releaseCrashFreeSessions = getCrashFreeRate( releaseSessions?.groups, SessionFieldWithOperation.SESSIONS ); const allCrashFreeSessions = getCrashFreeRate( allSessions?.groups, SessionFieldWithOperation.SESSIONS ); const diffCrashFreeSessions = defined(releaseCrashFreeSessions) && defined(allCrashFreeSessions) ? releaseCrashFreeSessions - allCrashFreeSessions : null; const releaseHealthySessions = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.HEALTHY ); const allHealthySessions = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.HEALTHY ); const diffHealthySessions = defined(releaseHealthySessions) && defined(allHealthySessions) ? releaseHealthySessions - allHealthySessions : null; const releaseAbnormalSessions = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.ABNORMAL ); const allAbnormalSessions = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.ABNORMAL ); const diffAbnormalSessions = defined(releaseAbnormalSessions) && defined(allAbnormalSessions) ? releaseAbnormalSessions - allAbnormalSessions : null; const releaseErroredSessions = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.ERRORED ); const allErroredSessions = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.ERRORED ); const diffErroredSessions = defined(releaseErroredSessions) && defined(allErroredSessions) ? releaseErroredSessions - allErroredSessions : null; const releaseCrashedSessions = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.CRASHED ); const allCrashedSessions = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.SESSIONS, SessionStatus.CRASHED ); const diffCrashedSessions = defined(releaseCrashedSessions) && defined(allCrashedSessions) ? releaseCrashedSessions - allCrashedSessions : null; const releaseCrashFreeUsers = getCrashFreeRate( releaseSessions?.groups, SessionFieldWithOperation.USERS ); const allCrashFreeUsers = getCrashFreeRate( allSessions?.groups, SessionFieldWithOperation.USERS ); const diffCrashFreeUsers = defined(releaseCrashFreeUsers) && defined(allCrashFreeUsers) ? releaseCrashFreeUsers - allCrashFreeUsers : null; const releaseHealthyUsers = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.HEALTHY ); const allHealthyUsers = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.HEALTHY ); const diffHealthyUsers = defined(releaseHealthyUsers) && defined(allHealthyUsers) ? releaseHealthyUsers - allHealthyUsers : null; const releaseAbnormalUsers = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.ABNORMAL ); const allAbnormalUsers = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.ABNORMAL ); const diffAbnormalUsers = defined(releaseAbnormalUsers) && defined(allAbnormalUsers) ? releaseAbnormalUsers - allAbnormalUsers : null; const releaseErroredUsers = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.ERRORED ); const allErroredUsers = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.ERRORED ); const diffErroredUsers = defined(releaseErroredUsers) && defined(allErroredUsers) ? releaseErroredUsers - allErroredUsers : null; const releaseCrashedUsers = getSessionStatusRate( releaseSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.CRASHED ); const allCrashedUsers = getSessionStatusRate( allSessions?.groups, SessionFieldWithOperation.USERS, SessionStatus.CRASHED ); const diffCrashedUsers = defined(releaseCrashedUsers) && defined(allCrashedUsers) ? releaseCrashedUsers - allCrashedUsers : null; const releaseSessionsCount = getCount( releaseSessions?.groups, SessionFieldWithOperation.SESSIONS ); const allSessionsCount = getCount( allSessions?.groups, SessionFieldWithOperation.SESSIONS ); const releaseUsersCount = getCount( releaseSessions?.groups, SessionFieldWithOperation.USERS ); const allUsersCount = getCount(allSessions?.groups, SessionFieldWithOperation.USERS); const diffFailure = eventsTotals?.releaseFailureRate && eventsTotals?.allFailureRate ? eventsTotals.releaseFailureRate - eventsTotals.allFailureRate : null; if (hasHealthData) { charts.push({ type: ReleaseComparisonChartType.CRASH_FREE_SESSIONS, role: 'parent', drilldown: null, thisRelease: defined(releaseCrashFreeSessions) ? displaySessionStatusPercent(releaseCrashFreeSessions) : null, allReleases: defined(allCrashFreeSessions) ? displaySessionStatusPercent(allCrashFreeSessions) : null, diff: defined(diffCrashFreeSessions) ? displaySessionStatusPercent(diffCrashFreeSessions) : null, diffDirection: diffCrashFreeSessions ? diffCrashFreeSessions > 0 ? 'up' : 'down' : null, diffColor: diffCrashFreeSessions ? diffCrashFreeSessions > 0 ? 'green300' : 'red300' : null, }); if (expanded.has(ReleaseComparisonChartType.CRASH_FREE_SESSIONS)) { charts.push( { type: ReleaseComparisonChartType.HEALTHY_SESSIONS, role: 'children', drilldown: null, thisRelease: defined(releaseHealthySessions) ? displaySessionStatusPercent(releaseHealthySessions) : null, allReleases: defined(allHealthySessions) ? displaySessionStatusPercent(allHealthySessions) : null, diff: defined(diffHealthySessions) ? displaySessionStatusPercent(diffHealthySessions) : null, diffDirection: diffHealthySessions ? diffHealthySessions > 0 ? 'up' : 'down' : null, diffColor: diffHealthySessions ? diffHealthySessions > 0 ? 'green300' : 'red300' : null, }, { type: ReleaseComparisonChartType.ABNORMAL_SESSIONS, role: 'children', drilldown: null, thisRelease: defined(releaseAbnormalSessions) ? displaySessionStatusPercent(releaseAbnormalSessions) : null, allReleases: defined(allAbnormalSessions) ? displaySessionStatusPercent(allAbnormalSessions) : null, diff: defined(diffAbnormalSessions) ? displaySessionStatusPercent(diffAbnormalSessions) : null, diffDirection: diffAbnormalSessions ? diffAbnormalSessions > 0 ? 'up' : 'down' : null, diffColor: diffAbnormalSessions ? diffAbnormalSessions > 0 ? 'red300' : 'green300' : null, }, { type: ReleaseComparisonChartType.ERRORED_SESSIONS, role: 'children', drilldown: defined(issuesTotals?.handled) ? ( {tct('([count] handled [issues])', { count: issuesTotals?.handled ? issuesTotals.handled >= 100 ? '99+' : issuesTotals.handled : 0, issues: tn('issue', 'issues', issuesTotals?.handled), })} ) : null, thisRelease: defined(releaseErroredSessions) ? displaySessionStatusPercent(releaseErroredSessions) : null, allReleases: defined(allErroredSessions) ? displaySessionStatusPercent(allErroredSessions) : null, diff: defined(diffErroredSessions) ? displaySessionStatusPercent(diffErroredSessions) : null, diffDirection: diffErroredSessions ? diffErroredSessions > 0 ? 'up' : 'down' : null, diffColor: diffErroredSessions ? diffErroredSessions > 0 ? 'red300' : 'green300' : null, }, { type: ReleaseComparisonChartType.CRASHED_SESSIONS, role: 'default', drilldown: defined(issuesTotals?.unhandled) ? ( {tct('([count] unhandled [issues])', { count: issuesTotals?.unhandled ? issuesTotals.unhandled >= 100 ? '99+' : issuesTotals.unhandled : 0, issues: tn('issue', 'issues', issuesTotals?.unhandled), })} ) : null, thisRelease: defined(releaseCrashedSessions) ? displaySessionStatusPercent(releaseCrashedSessions) : null, allReleases: defined(allCrashedSessions) ? displaySessionStatusPercent(allCrashedSessions) : null, diff: defined(diffCrashedSessions) ? displaySessionStatusPercent(diffCrashedSessions) : null, diffDirection: diffCrashedSessions ? diffCrashedSessions > 0 ? 'up' : 'down' : null, diffColor: diffCrashedSessions ? diffCrashedSessions > 0 ? 'red300' : 'green300' : null, } ); } } const hasUsers = !!getCount(releaseSessions?.groups, SessionFieldWithOperation.USERS); if (hasHealthData && (hasUsers || loading)) { charts.push({ type: ReleaseComparisonChartType.CRASH_FREE_USERS, role: 'parent', drilldown: null, thisRelease: defined(releaseCrashFreeUsers) ? displaySessionStatusPercent(releaseCrashFreeUsers) : null, allReleases: defined(allCrashFreeUsers) ? displaySessionStatusPercent(allCrashFreeUsers) : null, diff: defined(diffCrashFreeUsers) ? displaySessionStatusPercent(diffCrashFreeUsers) : null, diffDirection: diffCrashFreeUsers ? (diffCrashFreeUsers > 0 ? 'up' : 'down') : null, diffColor: diffCrashFreeUsers ? diffCrashFreeUsers > 0 ? 'green300' : 'red300' : null, }); if (expanded.has(ReleaseComparisonChartType.CRASH_FREE_USERS)) { charts.push( { type: ReleaseComparisonChartType.HEALTHY_USERS, role: 'children', drilldown: null, thisRelease: defined(releaseHealthyUsers) ? displaySessionStatusPercent(releaseHealthyUsers) : null, allReleases: defined(allHealthyUsers) ? displaySessionStatusPercent(allHealthyUsers) : null, diff: defined(diffHealthyUsers) ? displaySessionStatusPercent(diffHealthyUsers) : null, diffDirection: diffHealthyUsers ? (diffHealthyUsers > 0 ? 'up' : 'down') : null, diffColor: diffHealthyUsers ? diffHealthyUsers > 0 ? 'green300' : 'red300' : null, }, { type: ReleaseComparisonChartType.ABNORMAL_USERS, role: 'children', drilldown: null, thisRelease: defined(releaseAbnormalUsers) ? displaySessionStatusPercent(releaseAbnormalUsers) : null, allReleases: defined(allAbnormalUsers) ? displaySessionStatusPercent(allAbnormalUsers) : null, diff: defined(diffAbnormalUsers) ? displaySessionStatusPercent(diffAbnormalUsers) : null, diffDirection: diffAbnormalUsers ? diffAbnormalUsers > 0 ? 'up' : 'down' : null, diffColor: diffAbnormalUsers ? diffAbnormalUsers > 0 ? 'red300' : 'green300' : null, }, { type: ReleaseComparisonChartType.ERRORED_USERS, role: 'children', drilldown: null, thisRelease: defined(releaseErroredUsers) ? displaySessionStatusPercent(releaseErroredUsers) : null, allReleases: defined(allErroredUsers) ? displaySessionStatusPercent(allErroredUsers) : null, diff: defined(diffErroredUsers) ? displaySessionStatusPercent(diffErroredUsers) : null, diffDirection: diffErroredUsers ? (diffErroredUsers > 0 ? 'up' : 'down') : null, diffColor: diffErroredUsers ? diffErroredUsers > 0 ? 'red300' : 'green300' : null, }, { type: ReleaseComparisonChartType.CRASHED_USERS, role: 'default', drilldown: null, thisRelease: defined(releaseCrashedUsers) ? displaySessionStatusPercent(releaseCrashedUsers) : null, allReleases: defined(allCrashedUsers) ? displaySessionStatusPercent(allCrashedUsers) : null, diff: defined(diffCrashedUsers) ? displaySessionStatusPercent(diffCrashedUsers) : null, diffDirection: diffCrashedUsers ? (diffCrashedUsers > 0 ? 'up' : 'down') : null, diffColor: diffCrashedUsers ? diffCrashedUsers > 0 ? 'red300' : 'green300' : null, } ); } } if (hasPerformance) { charts.push({ type: ReleaseComparisonChartType.FAILURE_RATE, role: 'default', drilldown: null, thisRelease: eventsTotals?.releaseFailureRate ? formatPercentage(eventsTotals?.releaseFailureRate) : null, allReleases: eventsTotals?.allFailureRate ? formatPercentage(eventsTotals?.allFailureRate) : null, diff: diffFailure ? formatPercentage(Math.abs(diffFailure)) : null, diffDirection: diffFailure ? (diffFailure > 0 ? 'up' : 'down') : null, diffColor: diffFailure ? (diffFailure > 0 ? 'red300' : 'green300') : null, }); } if (hasHealthData) { additionalCharts.push({ type: ReleaseComparisonChartType.SESSION_COUNT, role: 'default', drilldown: null, thisRelease: defined(releaseSessionsCount) ? ( ) : null, allReleases: defined(allSessionsCount) ? : null, diff: null, diffDirection: null, diffColor: null, }); if (hasUsers || loading) { additionalCharts.push({ type: ReleaseComparisonChartType.USER_COUNT, role: 'default', drilldown: null, thisRelease: defined(releaseUsersCount) ? ( ) : null, allReleases: defined(allUsersCount) ? : null, diff: null, diffDirection: null, diffColor: null, }); } } if (hasDiscover) { additionalCharts.push({ type: ReleaseComparisonChartType.ERROR_COUNT, role: 'default', drilldown: null, thisRelease: defined(eventsTotals?.releaseErrorCount) ? ( ) : null, allReleases: defined(eventsTotals?.allErrorCount) ? ( ) : null, diff: null, diffDirection: null, diffColor: null, }); } if (hasPerformance) { additionalCharts.push({ type: ReleaseComparisonChartType.TRANSACTION_COUNT, role: 'default', drilldown: null, thisRelease: defined(eventsTotals?.releaseTransactionCount) ? ( ) : null, allReleases: defined(eventsTotals?.allTransactionCount) ? ( ) : null, diff: null, diffDirection: null, diffColor: null, }); } function handleChartChange(chartType: ReleaseComparisonChartType) { trackAnalytics('releases.change_chart_type', { organization, chartType, }); browserHistory.push({ ...location, query: { ...location.query, chart: chartType, }, }); } function handleExpanderToggle(chartType: ReleaseComparisonChartType) { if (expanded.has(chartType)) { expanded.delete(chartType); setExpanded(new Set(expanded)); } else { setExpanded(new Set(expanded.add(chartType))); } } function getTableHeaders(withExpanders: boolean) { const headers = [ {t('Description')}, {t('All Releases')}, {t('This Release')}, {t('Change')}, ]; if (withExpanders) { headers.push(); } return headers; } function getChartDiff( diff: ReleaseComparisonRow['diff'], diffColor: ReleaseComparisonRow['diffColor'], diffDirection: ReleaseComparisonRow['diffDirection'] ) { return diff ? ( {diff}{' '} {defined(diffDirection) ? ( ) : diff === '0%' ? null : ( )} ) : null; } // if there are no sessions, we do not need to do row toggling because there won't be as many rows if (!hasHealthData) { charts.push(...additionalCharts); additionalCharts.splice(0, additionalCharts.length); } let activeChart = decodeScalar( location.query.chart, hasHealthData ? ReleaseComparisonChartType.CRASH_FREE_SESSIONS : hasPerformance ? ReleaseComparisonChartType.FAILURE_RATE : ReleaseComparisonChartType.ERROR_COUNT ) as ReleaseComparisonChartType; let chart = [...charts, ...additionalCharts].find(ch => ch.type === activeChart); if (!chart) { chart = charts[0]; activeChart = charts[0].type; } const showPlaceholders = loading || eventsLoading; const withExpanders = hasHealthData || additionalCharts.length > 0; if (errored || !chart) { return ( ); } const titleChartDiff = chart.diff !== '0%' && chart.thisRelease !== '0%' ? getChartDiff(chart.diff, chart.diffColor, chart.diffDirection) : null; function renderChartRow({ diff, diffColor, diffDirection, ...rest }: ReleaseComparisonRow) { return ( ); } return ( {[ ReleaseComparisonChartType.ERROR_COUNT, ReleaseComparisonChartType.TRANSACTION_COUNT, ReleaseComparisonChartType.FAILURE_RATE, ].includes(activeChart) ? getDynamicText({ value: ( ), fixed: 'Events Chart', }) : getDynamicText({ value: ( ), fixed: 'Sessions Chart', })} {charts.map(chartRow => renderChartRow(chartRow))} {isOtherExpanded && additionalCharts.map(chartRow => renderChartRow(chartRow))} {additionalCharts.length > 0 && ( setIsOtherExpanded(!isOtherExpanded)}> {isOtherExpanded ? tn('Hide %s Other', 'Hide %s Others', additionalCharts.length) : tn('Show %s Other', 'Show %s Others', additionalCharts.length)}