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

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
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
95 changes: 95 additions & 0 deletions static/app/views/issueList/issueViews/issueViewQueryCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import styled from '@emotion/styled';
import {motion} from 'framer-motion';

import type {PageFilters} from 'sentry/types/core';
import {getUtcDateString} from 'sentry/utils/dates';
import theme from 'sentry/utils/theme';
import useOrganization from 'sentry/utils/useOrganization';
import usePageFilters from 'sentry/utils/usePageFilters';
import type {IssueView} from 'sentry/views/issueList/issueViews/issueViews';
import {useFetchIssueCounts} from 'sentry/views/issueList/queries/useFetchIssueCounts';

const TAB_MAX_COUNT = 99;

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)} : {}),
};
};

interface IssueViewQueryCountProps {
view: IssueView;
}

export function IssueViewQueryCount({view}: IssueViewQueryCountProps) {
const organization = useOrganization();
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 exist.
const {data: queryCount, isFetching: queryCountFetching} = useFetchIssueCounts({
orgSlug: organization.slug,
query: [view.unsavedChanges ? view.unsavedChanges[0] : view.query],
project: pageFilters.selection.projects,
environment: pageFilters.selection.environments,
...constructCountTimeFrame(pageFilters.selection.datetime),
});

const displayedCount =
queryCount?.[view.unsavedChanges ? view.unsavedChanges[0] : view.query] ?? 0;

return (
<QueryCountBubble
layout="size"
animate={{
backgroundColor: queryCountFetching
? [theme.gray100, theme.translucentSurface100, theme.gray100]
: 'transparent',
}}
transition={{
layout: {
duration: 0.1,
},
default: {
duration: 2.5,
repeat: queryCountFetching ? Infinity : 0,
ease: 'easeInOut',
},
}}
>
<motion.span
layout="position"
initial={{opacity: 0}}
animate={{opacity: queryCountFetching ? 0 : 1}}
transition={{duration: 0.1}}
>
{displayedCount > TAB_MAX_COUNT ? `${TAB_MAX_COUNT}+` : displayedCount}
</motion.span>
</QueryCountBubble>
);
}

const QueryCountBubble = styled(motion.span)`
line-height: 20px;
font-size: 75%;
MichaelSun48 marked this conversation as resolved.
Show resolved Hide resolved
padding: 0 5px;
MichaelSun48 marked this conversation as resolved.
Show resolved Hide resolved
min-width: 20px;
display: flex;
height: 16px;
align-items: center;
justify-content: center;
border-radius: 10px;
border: 1px solid ${p => p.theme.gray200};
color: ${p => p.theme.gray300};
margin-left: 0;
`;
2 changes: 2 additions & 0 deletions static/app/views/issueList/issueViews/issueViewTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {InjectedRouter} from 'sentry/types/legacyReactRouter';
import {useNavigate} from 'sentry/utils/useNavigate';
import EditableTabTitle from 'sentry/views/issueList/issueViews/editableTabTitle';
import {IssueViewEllipsisMenu} from 'sentry/views/issueList/issueViews/issueViewEllipsisMenu';
import {IssueViewQueryCount} from 'sentry/views/issueList/issueViews/issueViewQueryCount';
import {
generateTempViewId,
type IssueView,
Expand Down Expand Up @@ -117,6 +118,7 @@ export function IssueViewTab({
(!tabListState && view.key === initialTabKey)
}
/>
<IssueViewQueryCount view={view} />
{/* 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
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();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {ApiQueryKey, UseApiQueryOptions} from 'sentry/utils/queryClient';
import {useApiQuery} from 'sentry/utils/queryClient';
import {keepPreviousData, useApiQuery} from 'sentry/utils/queryClient';
import type {QueryCount, QueryCounts} from 'sentry/views/issueList/utils';

interface FetchIssueCountsParameters {
Expand Down Expand Up @@ -31,6 +31,7 @@ export const useFetchIssueCounts = (
) => {
return useApiQuery<QueryCounts>(makeFetchIssueCounts(params), {
staleTime: 0,
placeholderData: keepPreviousData,
Copy link
Member Author

Choose a reason for hiding this comment

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

We want to keep previous data while new data loads so that the query count bubble doesn't change size while the new count loads. If this were not here, the previous data would be scrubbed, and the query count bubble would lose its content and shrink to its min width before growing again.

...options,
});
};
Loading