From 916dae80616df61576fca3f503ab6a8ba1853d1d Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 17 Oct 2026 04:21:20 +0200 Subject: [PATCH] Upstream getsentry/sentry state of the touched files before 8c570445b0 --- .../tagDetailsDrawerContent.spec.tsx | 550 ++++++++++++++++++ .../groupTags/tagDetailsDrawerContent.tsx | 398 +++++++++++++ 2 files changed, 948 insertions(+) create mode 100644 static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx create mode 100644 static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx diff --git a/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx new file mode 100644 index 0000000..2193f10 --- /dev/null +++ b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.spec.tsx @@ -0,0 +1,550 @@ +import * as qs from 'query-string'; +import {GroupFixture} from 'sentry-fixture/group'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {TagsFixture} from 'sentry-fixture/tags'; +import {TagValuesFixture} from 'sentry-fixture/tagvalues'; + +import { + act, + render, + screen, + userEvent, + waitFor, + waitForElementToBeRemoved, +} from 'sentry-test/reactTestingLibrary'; +import {getEmotionRules} from 'sentry-test/utils'; + +import {Container} from '@sentry/scraps/layout'; + +import type {TagValue, TagWithTopValues} from 'sentry/types/group'; + +import {TagDetailsDrawerContent} from './tagDetailsDrawerContent'; + +const group = GroupFixture(); +const tags = TagsFixture(); +const pageLinks = + '; rel="previous"; results="false"; cursor="0:0:1", ' + + '; rel="next"; results="true"; cursor="0:100:0"'; + +const makeInitialRouterConfig = (tagKey: string) => ({ + location: { + pathname: `/organizations/org-slug/issues/1/tags/${tagKey}/`, + query: {}, + }, + route: '/organizations/:orgId/issues/:groupId/tags/:tagKey/', +}); + +describe('TagDetailsDrawerContent', () => { + beforeEach(() => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + body: tags.find(({key}) => key === 'user'), + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/', + body: tags, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/', + body: GroupFixture(), + }); + }); + + afterEach(() => { + MockApiClient.clearMockResponses(); + jest.restoreAllMocks(); + }); + + it('renders a list of tag values', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + }); + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + + expect(screen.getByText('Value')).toBeInTheDocument(); + expect(screen.getByText('Last Seen')).toBeInTheDocument(); + expect(screen.getByText('Count')).toBeInTheDocument(); + expect(screen.getByText('Share')).toBeInTheDocument(); + + // Affected user column + expect(screen.getByText('David Cramer')).toBeInTheDocument(); + expect(screen.getByText('17%')).toBeInTheDocument(); + // Count column + expect(screen.getByText('3')).toBeInTheDocument(); + + // Displays dropdown menu + await userEvent.hover(screen.getByText('David Cramer')); + expect( + screen.getByRole('button', {name: 'Tag Value Actions Menu'}) + ).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Tag Value Actions Menu'})); + expect( + await screen.findByRole('menuitemradio', {name: 'Copy tag value to clipboard'}) + ).toBeInTheDocument(); + }); + + it('can page through tag values', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + headers: {Link: pageLinks}, + }); + const {router} = render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + + expect(screen.getByRole('button', {name: 'Previous'})).toBeDisabled(); + expect(screen.getByRole('button', {name: 'Next'})).toBeEnabled(); + + await userEvent.click(screen.getByRole('button', {name: 'Next'})); + await waitFor(() => { + expect(router.location.query.tagDrawerCursor).toBe('0:100:0'); + }); + }); + + it('sorts tag values by the clicked column header', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + }); + const dateSortRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + match: [MockApiClient.matchQuery({sort: 'date'})], + }); + const {router} = render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + const countHeaderSort = screen + .getByRole('columnheader', {name: 'Count'}) + .getAttribute('aria-sort'); + + await userEvent.click(screen.getByRole('link', {name: 'Last Seen'})); + + expect(countHeaderSort).toBe('descending'); + await waitFor(() => { + expect(router.location.query.tagDrawerSort).toBe('date'); + }); + expect(screen.getByRole('columnheader', {name: 'Last Seen'})).toHaveAttribute( + 'aria-sort', + 'descending' + ); + expect(screen.getByRole('columnheader', {name: 'Count'})).not.toHaveAttribute( + 'aria-sort' + ); + expect(dateSortRequest).toHaveBeenCalled(); + }); + + it('renders an empty state when the tag has no values', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: [], + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + expect(await screen.findByText('No tag values found')).toBeInTheDocument(); + }); + + it('navigates to issue details events tab with correct query params', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + }); + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + await userEvent.click( + await screen.findByRole('button', {name: 'Tag Value Actions Menu'}) + ); + expect( + screen.getByRole('menuitemradio', { + name: 'View other events with this tag value', + }) + ).toHaveAttribute( + 'href', + '/organizations/org-slug/issues/1/events/?query=user.username%3Adavid' + ); + }); + + it('navigates to explore with issue + tag query', async () => { + const discoverOrganization = OrganizationFixture({ + features: ['discover-basic'], + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + }); + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + organization: discoverOrganization, + }); + + await userEvent.click( + await screen.findByRole('button', {name: 'Tag Value Actions Menu'}) + ); + + const discoverMenuItem = screen.getByRole('menuitemradio', { + name: 'Open in Explore', + }); + expect(discoverMenuItem).toBeInTheDocument(); + + const link = new URL(discoverMenuItem.getAttribute('href') ?? '', 'http://localhost'); + expect(link.pathname).toBe('/organizations/org-slug/explore/errors/results/'); + const discoverQueryParams = qs.parse(link.search); + + expect(discoverQueryParams).toEqual({ + dataset: 'errors', + field: ['title', 'release', 'environment', 'user.display', 'timestamp'], + interval: '1m', + name: 'RequestError: GET /issues/ 404', + project: '2', + query: 'issue:JAVASCRIPT-6QS (user.username:david)', + queryDataset: 'error-events', + statsPeriod: '14d', + yAxis: ['count()', 'count_unique(user)'], + }); + }); + + it('renders an error message if tag values request fails', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + statusCode: 500, + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + expect( + await screen.findByText('There was an error loading tag details') + ).toBeInTheDocument(); + }); + + it('renders an error without pagination when the tag request fails', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + statusCode: 500, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + headers: {Link: pageLinks}, + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + expect( + await screen.findByText('There was an error loading tag details') + ).toBeInTheDocument(); + expect(screen.queryByRole('button', {name: 'Next'})).not.toBeInTheDocument(); + }); + + it('hides pagination while the tag request is pending', async () => { + let resolveTagRequest = () => {}; + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + body: tags.find(({key}) => key === 'user'), + asyncDelay: new Promise(resolve => { + resolveTagRequest = resolve; + }), + }); + const valuesRequest = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + headers: {Link: pageLinks}, + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + await waitFor(() => expect(valuesRequest).toHaveBeenCalled()); + await act(() => new Promise(resolve => setTimeout(resolve, 0))); + const nextButtonWhilePending = screen.queryByRole('button', {name: 'Next'}); + resolveTagRequest(); + + expect(nextButtonWhilePending).not.toBeInTheDocument(); + expect(await screen.findByRole('button', {name: 'Next'})).toBeInTheDocument(); + }); + + it('hides the Last Seen and Share columns when the container is narrow', async () => { + jest.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: TagValuesFixture(), + }); + + render( + + + , + {initialRouterConfig: makeInitialRouterConfig('user')} + ); + + const table = await screen.findByRole('table', {name: 'Tag values'}); + const rules = getEmotionRules(table).join(''); + + expect(rules).toContain("nth-child(2 of [role='cell'], [role='columnheader'])"); + expect(rules).toContain("nth-child(4 of [role='cell'], [role='columnheader'])"); + expect(rules).not.toContain("nth-child(3 of [role='cell'], [role='columnheader'])"); + }); + + it('renders rounded percentages counts [996, 4], total 1000', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + body: VariableTagFixture([996, 4], 1000), + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: VariableTagValueFixture([996, 4]), + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + + // Value and percent columns + expect(screen.getByText('David Cramer 0')).toBeInTheDocument(); + expect(screen.getByText('>99%')).toBeInTheDocument(); // 996/1000 = 99.6% rounds to 100%, should show >99% + expect(screen.getByText('David Cramer 1')).toBeInTheDocument(); + expect(screen.getByText('<1%')).toBeInTheDocument(); + + // Count column + expect(screen.getByText('996')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + }); + + it('renders rounded percentages counts [992, 7], total 1000', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + body: VariableTagFixture([992, 7], 1000), + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: VariableTagValueFixture([992, 7]), + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + + // Value and percent columns + expect(screen.getByText('David Cramer 0')).toBeInTheDocument(); + expect(screen.getByText('99%')).toBeInTheDocument(); + expect(screen.getByText('David Cramer 1')).toBeInTheDocument(); + expect(screen.getByText('1%')).toBeInTheDocument(); + + // Count column + expect(screen.getByText('992')).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + }); + + it('renders rounded percentages counts [995, 5], total 1000', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + body: VariableTagFixture([995, 5], 1000), + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: VariableTagValueFixture([995, 5]), + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + + // Value and percent columns + expect(screen.getByText('David Cramer 0')).toBeInTheDocument(); + expect(screen.getByText('>99%')).toBeInTheDocument(); + expect(screen.getByText('David Cramer 1')).toBeInTheDocument(); + expect(screen.getByText('1%')).toBeInTheDocument(); + + // Count column + expect(screen.getByText('995')).toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('never displays 100% when there are multiple tag values', async () => { + // Create a case where first item would round to 100% (997/1000 = 99.7%) + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/', + body: VariableTagFixture([997, 3], 1000), + }); + + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: VariableTagValueFixture([997, 3]), + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + await waitForElementToBeRemoved(() => screen.queryByTestId('loading-indicator')); + + // Should show >99% instead of 100% + expect(screen.getByText('David Cramer 0')).toBeInTheDocument(); + expect(screen.getByText('>99%')).toBeInTheDocument(); + expect(screen.getByText('David Cramer 1')).toBeInTheDocument(); + expect(screen.getByText('<1%')).toBeInTheDocument(); + expect(screen.queryByText('100%')).not.toBeInTheDocument(); // Should never show 100% + + // Count column + expect(screen.getByText('997')).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('falls back to tagValue.value when user has no identifiable fields', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/user/values/', + body: [ + { + count: 5, + firstSeen: '2024-01-01T00:00:00Z', + lastSeen: '2024-01-01T00:00:00Z', + name: '', + value: 'id:123', + key: 'user', + }, + ], + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('user'), + }); + + expect(await screen.findByText('id:123')).toBeInTheDocument(); + }); + + it('renders empty tag value label and hides copy action', async () => { + const makeTopValue = (value: string, count: number) => ({ + count, + name: value, + value, + key: 'device', + lastSeen: '2024-01-01T00:00:00Z', + firstSeen: '2024-01-01T00:00:00Z', + }); + const makeTagValue = (value: string, count: number, id: string): TagValue => ({ + count, + firstSeen: '2024-01-01T00:00:00Z', + lastSeen: '2024-01-01T00:00:00Z', + name: value, + value, + key: 'device', + id, + email: '', + username: '', + ip_address: '', + }); + + const deviceTag: TagWithTopValues = { + key: 'device', + name: 'Device', + totalValues: 8, + uniqueValues: 2, + topValues: [makeTopValue('', 5), makeTopValue('iPhone10', 3)], + }; + const deviceValues: TagValue[] = [ + makeTagValue('', 5, '1'), + makeTagValue('iPhone10', 3, '2'), + ]; + + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/', + body: [deviceTag], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/device/', + body: deviceTag, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/tags/device/values/', + body: deviceValues, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1/', + body: group, + }); + + render(, { + initialRouterConfig: makeInitialRouterConfig('device'), + }); + + expect(await screen.findByText('(empty)')).toBeInTheDocument(); + + await userEvent.hover(screen.getByText('(empty)')); + const actionButtons = await screen.findAllByRole('button', { + name: 'Tag Value Actions Menu', + }); + await userEvent.click(actionButtons[0]!); + + const viewEventsMenuItem = screen.getByRole('menuitemradio', { + name: 'View other events with this tag value', + }); + const viewEventsUrl = new URL( + viewEventsMenuItem.getAttribute('href') ?? '', + 'http://localhost' + ); + expect(viewEventsUrl.pathname).toBe('/organizations/org-slug/issues/1/events/'); + expect(viewEventsUrl.searchParams.get('query')).toBe('!has:device'); + expect(viewEventsUrl.searchParams.get('referrer')).toBe('tag-details-drawer'); + expect( + screen.queryByRole('menuitemradio', {name: 'Copy tag value to clipboard'}) + ).not.toBeInTheDocument(); + }); +}); + +function VariableTagFixture(topValues: number[], totalValues: number): TagWithTopValues { + return { + topValues: topValues.map((count, index) => ({ + count, + name: `david${index}`, + value: `username:david${index}`, + lastSeen: '2018-12-20T23:32:25Z', + key: 'user', + query: 'user.username:david', + firstSeen: '2018-10-03T03:40:05.627Z', + })), + uniqueValues: topValues.length + 1, + name: 'User', + key: 'user', + totalValues, + }; +} + +function VariableTagValueFixture(counts: number[]): TagValue[] { + return counts.map((count, index) => ({ + count, + username: `david${index}`, + name: `David Cramer ${index}`, + value: `username:david${index}`, + id: '10799', + lastSeen: '2018-12-20T23:32:25Z', + firstSeen: '2018-10-03T03:40:05.627Z', + email: 'david@example.com', + ip_address: '0.0.0.0', + })); +} diff --git a/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx new file mode 100644 index 0000000..04770ad --- /dev/null +++ b/static/app/views/issueDetails/groupTags/tagDetailsDrawerContent.tsx @@ -0,0 +1,398 @@ +import {Fragment, useState} from 'react'; +import {useTheme} from '@emotion/react'; +import {IconEllipsis} from '@sentry/icons/ellipsis'; +import {IconOpen} from '@sentry/icons/open'; +import {useQuery} from '@tanstack/react-query'; +import type {LocationDescriptor} from 'history'; + +import {Button} from '@sentry/scraps/button'; +import {DropdownMenu} from '@sentry/scraps/dropdownMenu'; +import {Container, Flex} from '@sentry/scraps/layout'; +import {Link} from '@sentry/scraps/link'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Pagination} from '@sentry/scraps/pagination'; +import {RevealOnHover} from '@sentry/scraps/revealOnHover'; +import type {TableColumnConfig} from '@sentry/scraps/table'; +import {Text} from '@sentry/scraps/text'; + +import { + issueTagValuesApiOptions, + fetchIssueTagApiOptions, +} from 'sentry/actionCreators/group'; +import {openNavigateToExternalLinkModal} from 'sentry/actionCreators/modal'; +import {DeviceName} from 'sentry/components/deviceName'; +import {getContextIcon} from 'sentry/components/events/contexts/utils'; +import {extractSelectionParameters} from 'sentry/components/pageFilters/parse'; +import {SimpleTable} from 'sentry/components/tables/simpleTable'; +import {TimeSince} from 'sentry/components/timeSince'; +import {t, tct} from 'sentry/locale'; +import type {Group, Tag, TagValue} from 'sentry/types/group'; +import {percent} from 'sentry/utils'; +import {selectJsonWithHeaders} from 'sentry/utils/api/apiOptions'; +import {SavedQueryDatasets} from 'sentry/utils/discover/types'; +import {escapeIssueTagKey, generateQueryWithTag} from 'sentry/utils/queryString'; +import {isValidUrl} from 'sentry/utils/string/isValidUrl'; +import {useCopyToClipboard} from 'sentry/utils/useCopyToClipboard'; +import {useLocation} from 'sentry/utils/useLocation'; +import {useNavigate} from 'sentry/utils/useNavigate'; +import {useOrganization} from 'sentry/utils/useOrganization'; +import {useParams} from 'sentry/utils/useParams'; +import {hasDatasetSelector} from 'sentry/views/dashboards/utils'; +import {TagBar} from 'sentry/views/issueDetails/groupTags/tagDistribution'; +import {useIssueDetailsEventView} from 'sentry/views/issueDetails/hooks/useIssueDetailsDiscoverQuery'; +import {getUserTagValue} from 'sentry/views/issueDetails/utils'; + +type TagSort = 'date' | 'count'; +const DEFAULT_SORT: TagSort = 'count'; + +const COLUMNS: TableColumnConfig[] = [ + {key: 'value', width: '1fr'}, + {key: 'lastSeen', width: 'min-content', visible: {xs: true}}, + {key: 'count', width: 'min-content'}, + {key: 'share', width: 'min-content', visible: {sm: true}}, + {key: 'actions', width: 'min-content'}, +]; + +export function TagDetailsDrawerContent({group}: {group: Group}) { + const location = useLocation(); + const navigate = useNavigate(); + const organization = useOrganization(); + const {tagKey} = useParams<{tagKey: string}>(); + + const sort = (location.query.tagDrawerSort as TagSort | undefined) ?? DEFAULT_SORT; + + const { + data: tagValuesResponse, + isError: tagValuesIsError, + isPending: tagValuesIsPending, + } = useQuery({ + ...issueTagValuesApiOptions({ + organization, + groupId: group.id, + tagKey, + sort, + cursor: location.query.tagDrawerCursor as string | undefined, + }), + select: selectJsonWithHeaders, + }); + const tagValues = tagValuesResponse?.json; + + const { + data: tag, + isError: tagIsError, + isPending: tagIsPending, + } = useQuery(fetchIssueTagApiOptions({organization, groupId: group.id, tagKey})); + + const isError = tagValuesIsError || tagIsError; + const isPending = tagValuesIsPending || tagIsPending; + + const {cursor: _cursor, page: _page, ...currentQuery} = location.query; + const paginationCaption = tct('[count] results', { + count: (tag?.uniqueValues ?? 0).toLocaleString(), + }); + + const getSortLocation = (newSort: TagSort) => ({ + pathname: location.pathname, + query: { + ...currentQuery, + tagDrawerCursor: undefined, + tagDrawerSort: newSort, + }, + }); + + return ( + + + + {t('Value')} + + {t('Last Seen')} + + + {t('Count')} + + {t('Share')} + + + } + > + {isPending ? ( + + ) : isError ? ( + + ) : tag && tagValues?.length ? ( + tagValues.map((tv, i) => ( + + )) + ) : ( + {t('No tag values found')} + )} + + + {isPending || isError ? null : ( + + navigate({ + pathname: path, + query: { + ...query, + tagDrawerCursor: cursor, + }, + }) + } + size="xs" + pageLinks={tagValuesResponse?.headers.Link} + /> + )} + + ); +} + +function TagDetailsRow({ + group, + tag, + tagValue, +}: { + group: Group; + tag: Tag; + tagValue: TagValue; +}) { + const organization = useOrganization(); + const location = useLocation(); + + const key = tagValue.key ?? tag.key; + const query = + key === 'environment' + ? { + environment: tagValue.value, + query: undefined, + } + : { + query: tagValue.query || `${key}:"${tagValue.value}"`, + }; + + const allEventsLocation = { + pathname: `/organizations/${organization.slug}/issues/${group.id}/events/recommended/`, + query: { + ...location.query, + ...query, + }, + }; + const percentage = Math.round(percent(tagValue.count ?? 0, tag.totalValues ?? 0)); + // Ensure no item shows 100% when there are multiple tag values + const hasMultipleItems = (tag.uniqueValues ?? 0) > 1; + const cappedPercentage = hasMultipleItems && percentage >= 100 ? 99 : percentage; + const displayPercentage = + cappedPercentage < 1 + ? '<1%' + : hasMultipleItems && percentage >= 100 + ? '>99%' + : `${cappedPercentage.toFixed(0)}%`; + + return ( + + {props => ( + + + + + + + {textProps => } + + + + {tagValue.count.toLocaleString()} + + + {displayPercentage} + {tag.totalValues ? ( + + + + ) : ( + '--' + )} + + + + + + )} + + ); +} + +function TagDetailsValue({ + valueLocation, + tagKey, + tagValue, +}: { + tagKey: string; + tagValue: TagValue; + valueLocation: LocationDescriptor; +}) { + const theme = useTheme(); + const userValues = getUserTagValue(tagValue); + const value = + tagValue.value === '' ? {t('(empty)')} : tagValue.value; + let valueComponent: React.ReactNode = value; + if (tagValue.value !== '') { + if (tagKey === 'user') { + valueComponent = ( + + {getContextIcon({ + alias: 'user', + type: 'user', + value: tagValue, + contextIconProps: { + size: 'md', + }, + theme, + })} + + {userValues.title} + {userValues.subtitle && {userValues.subtitle}} + + + ); + } else if (tagKey === 'device') { + valueComponent = ; + } + } + + return ( + + + {layoutProps => ( + + {textProps => ( + + {valueComponent} + + )} + + )} + + {isValidUrl(tagValue.value) && ( + + {props => ( +