Upstream getsentry/sentry state of the touched files before 8c570445b0

This commit is contained in:
upstream committed 2026-10-17 04:21:20 +02:00
commit 916dae8061
2 files changed
+948

No files matched your search

@@ -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 =
'<https://sentry.io/api/0/organizations/sentry/user-feedback/?statsPeriod=14d&cursor=0:0:1>; rel="previous"; results="false"; cursor="0:0:1", ' +
'<https://sentry.io/api/0/organizations/sentry/user-feedback/?statsPeriod=14d&cursor=0:100:0>; 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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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<void>(resolve => {
resolveTagRequest = resolve;
}),
});
const valuesRequest = MockApiClient.addMockResponse({
url: '/organizations/org-slug/issues/1/tags/user/values/',
body: TagValuesFixture(),
headers: {Link: pageLinks},
});
render(<TagDetailsDrawerContent group={group} />, {
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(
<Container containerType="inline-size">
<TagDetailsDrawerContent group={group} />
</Container>,
{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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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(<TagDetailsDrawerContent group={group} />, {
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',
}));
}
@@ -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 (
<Fragment>
<Container flexShrink={0}>
<SimpleTable
aria-label={t('Tag values')}
columns={COLUMNS}
density="compressed"
header={
<SimpleTable.HeaderRow>
<SimpleTable.HeaderCell>{t('Value')}</SimpleTable.HeaderCell>
<SimpleTable.HeaderCell
sort={sort === 'date' ? 'desc' : undefined}
to={getSortLocation('date')}
>
{t('Last Seen')}
</SimpleTable.HeaderCell>
<SimpleTable.HeaderCell
align="right"
sort={sort === 'count' ? 'desc' : undefined}
to={getSortLocation('count')}
>
{t('Count')}
</SimpleTable.HeaderCell>
<SimpleTable.HeaderCell align="right">{t('Share')}</SimpleTable.HeaderCell>
<SimpleTable.HeaderCell aria-label={t('Actions')} />
</SimpleTable.HeaderRow>
}
>
{isPending ? (
<SimpleTable.Loading />
) : isError ? (
<SimpleTable.Error message={t('There was an error loading tag details')} />
) : tag && tagValues?.length ? (
tagValues.map((tv, i) => (
<TagDetailsRow
key={`${tv.value}-${i}`}
group={group}
tag={tag}
tagValue={tv}
/>
))
) : (
<SimpleTable.Empty>{t('No tag values found')}</SimpleTable.Empty>
)}
</SimpleTable>
</Container>
{isPending || isError ? null : (
<Pagination
caption={paginationCaption}
onCursor={(cursor, path, query) =>
navigate({
pathname: path,
query: {
...query,
tagDrawerCursor: cursor,
},
})
}
size="xs"
pageLinks={tagValuesResponse?.headers.Link}
/>
)}
</Fragment>
);
}
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 (
<RevealOnHover>
{props => (
<SimpleTable.Row {...props} data-test-id="group-tag-value">
<SimpleTable.RowCell>
<TagDetailsValue
valueLocation={allEventsLocation}
tagKey={key}
tagValue={tagValue}
/>
</SimpleTable.RowCell>
<SimpleTable.RowCell>
<Text ellipsis variant="inherit">
{textProps => <TimeSince {...textProps} date={tagValue.lastSeen} />}
</Text>
</SimpleTable.RowCell>
<SimpleTable.RowCell justify="end">
{tagValue.count.toLocaleString()}
</SimpleTable.RowCell>
<SimpleTable.RowCell justify="end" gap="md">
{displayPercentage}
{tag.totalValues ? (
<Container width="45px">
<TagBar percentage={percentage} />
</Container>
) : (
'--'
)}
</SimpleTable.RowCell>
<SimpleTable.RowCell>
<TagValueActionsMenu group={group} tag={tag} tagValue={tagValue} />
</SimpleTable.RowCell>
</SimpleTable.Row>
)}
</RevealOnHover>
);
}
function TagDetailsValue({
valueLocation,
tagKey,
tagValue,
}: {
tagKey: string;
tagValue: TagValue;
valueLocation: LocationDescriptor;
}) {
const theme = useTheme();
const userValues = getUserTagValue(tagValue);
const value =
tagValue.value === '' ? <Text variant="muted">{t('(empty)')}</Text> : tagValue.value;
let valueComponent: React.ReactNode = value;
if (tagValue.value !== '') {
if (tagKey === 'user') {
valueComponent = (
<Flex align="center" gap="sm" minWidth={0} overflow="hidden">
{getContextIcon({
alias: 'user',
type: 'user',
value: tagValue,
contextIconProps: {
size: 'md',
},
theme,
})}
<Flex wrap="wrap" gap="xs" minWidth={0}>
<Text>{userValues.title}</Text>
{userValues.subtitle && <Text variant="muted">{userValues.subtitle}</Text>}
</Flex>
</Flex>
);
} else if (tagKey === 'device') {
valueComponent = <DeviceName value={tagValue.value} />;
}
}
return (
<Flex gap="xs" align="center" minWidth={0} overflow="hidden">
<Container minWidth={0} overflow="hidden">
{layoutProps => (
<Text variant="primary">
{textProps => (
<Link
className={`${layoutProps.className} ${textProps.className}`}
to={valueLocation}
>
{valueComponent}
</Link>
)}
</Text>
)}
</Container>
{isValidUrl(tagValue.value) && (
<Text variant="muted">
{props => (
<Button
{...props}
variant="link"
icon={<IconOpen />}
aria-label="Open link"
data-test-id="group-tag-url"
size="xs"
onClick={() => openNavigateToExternalLinkModal({linkText: tagValue.value})}
/>
)}
</Text>
)}
</Flex>
);
}
function TagValueActionsMenu({
group,
tag,
tagValue,
}: {
group: Group;
tag: Tag;
tagValue: TagValue;
}) {
const organization = useOrganization();
const location = useLocation();
const {copy} = useCopyToClipboard();
const referrer = 'tag-details-drawer';
const key = escapeIssueTagKey(tagValue.key ?? tag.key);
const query = tagValue.query
? {
query: tagValue.query,
}
: generateQueryWithTag({referrer}, {key, value: tagValue.value});
const globalSelectionParams = extractSelectionParameters(location.query);
const eventView = useIssueDetailsEventView({group, queryProps: query});
const [isVisible, setIsVisible] = useState(false);
return (
<RevealOnHover.Action visible={isVisible}>
<DropdownMenu
size="xs"
strategy="fixed"
onOpenChange={isOpen => setIsVisible(isOpen)}
trigger={triggerProps => (
<OverlayTrigger.IconButton
{...triggerProps}
aria-label={t('Tag Value Actions Menu')}
icon={<IconEllipsis />}
/>
)}
items={[
{
key: 'open-in-discover',
label: t('Open in Explore'),
to: eventView.getResultsViewUrlTarget(
organization,
false,
hasDatasetSelector(organization) ? SavedQueryDatasets.ERRORS : undefined
),
hidden: !group || !organization.features.includes('discover-basic'),
},
{
key: 'view-events',
label: t('View other events with this tag value'),
to: {
pathname: `/organizations/${organization.slug}/issues/${group.id}/events/`,
query: {...globalSelectionParams, ...query},
},
},
{
key: 'view-issues',
label: t('Search issues with this tag value'),
to: {
pathname: `/organizations/${organization.slug}/issues/`,
query: {...globalSelectionParams, ...query},
},
},
{
key: 'copy-value',
label: t('Copy tag value to clipboard'),
onAction: () =>
copy(tagValue.value, {
successMessage: t('Copied tag value to clipboard'),
}),
hidden: tagValue.value === '',
},
]}
/>
</RevealOnHover.Action>
);
}