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