Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(issue-views): add query counts back to tabs #82990

Merged
merged 13 commits into from
Jan 10, 2025
2 changes: 2 additions & 0 deletions static/app/views/issueList/issueViews/editableTabTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ const UnselectedTabTitle = styled('div')<{isSelected: boolean}>`
text-overflow: ellipsis;
padding-right: 1px;
cursor: pointer;
line-height: 1.45;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very minor change to align the view name better with the count:

Before:

image

After:

image

`;

const StyledGrowingInput = styled(GrowingInput)<{
Expand All @@ -160,6 +161,7 @@ const StyledGrowingInput = styled(GrowingInput)<{
text-overflow: ellipsis;
cursor: text;
max-width: 325px;
line-height: 1.45;

&,
&:focus,
Expand Down
61 changes: 61 additions & 0 deletions static/app/views/issueList/issueViews/issueViewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,27 @@ import {useContext} from 'react';
import styled from '@emotion/styled';
import {motion} from 'framer-motion';

import Badge from 'sentry/components/badge/badge';
import {TEMPORARY_TAB_KEY} from 'sentry/components/draggableTabs/draggableTabList';
import type {MenuItemProps} from 'sentry/components/dropdownMenu';
import QueryCount from 'sentry/components/queryCount';
import {t} from 'sentry/locale';
import type {PageFilters} from 'sentry/types/core';
import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
import {getUtcDateString} from 'sentry/utils/dates';
import {useNavigate} from 'sentry/utils/useNavigate';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import EditableTabTitle from 'sentry/views/issueList/issueViews/editableTabTitle';
import {IssueViewEllipsisMenu} from 'sentry/views/issueList/issueViews/issueViewEllipsisMenu';
import {
generateTempViewId,
type IssueView,
IssueViewsContext,
} from 'sentry/views/issueList/issueViews/issueViews';
import {useFetchIssueCounts} from 'sentry/views/issueList/queries/useFetchIssueCounts';

const TAB_MAX_COUNT = 99;
interface IssueViewTabProps {
editingTabKey: string | null;
initialTabKey: string;
Expand All @@ -23,6 +31,22 @@ interface IssueViewTabProps {
view: IssueView;
}

const constructCountTimeFrame = (
pageFilters: PageFilters['datetime']
): {
end?: string;
start?: string;
statsPeriod?: string;
} => {
if (pageFilters.period) {
return {statsPeriod: pageFilters.period};
}
return {
...(pageFilters.start ? {start: getUtcDateString(pageFilters.start)} : {}),
...(pageFilters.end ? {end: getUtcDateString(pageFilters.end)} : {}),
};
};

export function IssueViewTab({
editingTabKey,
initialTabKey,
Expand All @@ -31,11 +55,24 @@ export function IssueViewTab({
view,
}: IssueViewTabProps) {
const navigate = useNavigate();
const organization = useOrganization();

const {cursor: _cursor, page: _page, ...queryParams} = router?.location?.query ?? {};
const {tabListState, state, dispatch} = useContext(IssueViewsContext);
const {views} = state;

const pageFilters = usePageFilters();

// TODO(msun): Once page filters are saved to views, remember to use the view's specific
// page filters here instead of the global pageFilters, if they exists.
const {data: queryCount, isLoading: queryCountLoading} = useFetchIssueCounts({
orgSlug: organization.slug,
query: [view.query],
project: pageFilters.selection.projects,
environment: pageFilters.selection.environments,
...constructCountTimeFrame(pageFilters.selection.datetime),
});

const handleDuplicateView = () => {
const newViewId = generateTempViewId();
const duplicatedTab = views.find(tab => tab.key === tabListState?.selectedKey);
Expand Down Expand Up @@ -117,6 +154,18 @@ export function IssueViewTab({
(!tabListState && view.key === initialTabKey)
}
/>
{!queryCountLoading && queryCount && (
<motion.div>
<QueryCountBadge>
<QueryCount
count={queryCount?.[view.query]}
max={TAB_MAX_COUNT}
hideIfEmpty={false}
hideParens
/>
</QueryCountBadge>
</motion.div>
)}
{/* If tablistState isn't initialized, we want to load the elipsis menu
for the initial tab, that way it won't load in a second later
and cause the tabs to shift and animate on load. */}
Expand Down Expand Up @@ -242,3 +291,15 @@ const TabContentWrap = styled('span')`
padding: 0;
gap: 6px;
`;

const QueryCountBadge = styled(Badge)`
display: flex;
height: 16px;
align-items: center;
justify-content: center;
border-radius: 10px;
background: transparent;
border: 1px solid ${p => p.theme.gray200};
color: ${p => p.theme.gray300};
margin-left: 0;
`;
121 changes: 121 additions & 0 deletions static/app/views/issueList/issueViewsHeader.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ describe('IssueViewsHeader', () => {
method: 'GET',
body: getRequestViews,
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {},
});
});

it('renders all tabs, selects the first one by default, and replaces the query params accordingly', async () => {
Expand Down Expand Up @@ -120,6 +125,11 @@ describe('IssueViewsHeader', () => {
},
],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {},
});

render(<IssueViewsIssueListHeader {...defaultProps} />, {router: defaultRouter});

Expand Down Expand Up @@ -153,6 +163,11 @@ describe('IssueViewsHeader', () => {
},
],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {},
});

render(<IssueViewsIssueListHeader {...defaultProps} router={queryOnlyRouter} />, {
router: queryOnlyRouter,
Expand Down Expand Up @@ -277,6 +292,11 @@ describe('IssueViewsHeader', () => {
},
],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {},
});

const defaultTabDifferentQueryRouter = RouterFixture({
location: LocationFixture({
Expand Down Expand Up @@ -320,6 +340,11 @@ describe('IssueViewsHeader', () => {
method: 'GET',
body: getRequestViews,
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {},
});
});

it('switches tabs when clicked, and updates the query params accordingly', async () => {
Expand Down Expand Up @@ -453,6 +478,11 @@ describe('IssueViewsHeader', () => {
method: 'GET',
body: getRequestViews,
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {},
});
});

it('should render the correct set of actions for an unchanged tab', async () => {
Expand Down Expand Up @@ -792,4 +822,95 @@ describe('IssueViewsHeader', () => {
});
});
});

describe('Issue views query counts', () => {
it('should render the correct count for a single view', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/group-search-views/`,
method: 'GET',
body: [getRequestViews[0]],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
query: {
query: getRequestViews[0]!.query,
},
body: {
[getRequestViews[0]!.query]: 42,
},
});

render(<IssueViewsIssueListHeader {...defaultProps} />, {router: defaultRouter});

expect(await screen.findByText('42')).toBeInTheDocument();
});

it('should render the correct count for multiple views', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/group-search-views/`,
method: 'GET',
body: getRequestViews,
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
body: {
[getRequestViews[0]!.query]: 42,
[getRequestViews[1]!.query]: 6,
[getRequestViews[2]!.query]: 98,
},
});

render(<IssueViewsIssueListHeader {...defaultProps} />, {router: defaultRouter});

expect(await screen.findByText('42')).toBeInTheDocument();
expect(screen.getByText('6')).toBeInTheDocument();
expect(screen.getByText('98')).toBeInTheDocument();
});

it('should show a max count of 99+ if the count is greater than 99', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/group-search-views/`,
method: 'GET',
body: [getRequestViews[0]],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
query: {
query: getRequestViews[0]!.query,
},
body: {
[getRequestViews[0]!.query]: 101,
},
});

render(<IssueViewsIssueListHeader {...defaultProps} />, {router: defaultRouter});

expect(await screen.findByText('99+')).toBeInTheDocument();
});

it('should show stil show a 0 query count if the count is 0', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/group-search-views/`,
method: 'GET',
body: [getRequestViews[0]],
});
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/issues-count/`,
method: 'GET',
query: {
query: getRequestViews[0]!.query,
},
body: {
[getRequestViews[0]!.query]: 0,
},
});

render(<IssueViewsIssueListHeader {...defaultProps} />, {router: defaultRouter});

expect(await screen.findByText('0')).toBeInTheDocument();
});
});
});
Loading