Upstream getsentry/sentry state of the touched files before 8c570445b0
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user