|
@@ -1,11 +1,11 @@
|
|
|
-import {Fragment} from 'react';
|
|
|
-import {RouteComponentProps, WithRouterProps} from 'react-router';
|
|
|
+import {Fragment, useEffect} from 'react';
|
|
|
import styled from '@emotion/styled';
|
|
|
|
|
|
+import {useFetchIssueTag, useFetchIssueTagValues} from 'sentry/actionCreators/group';
|
|
|
+import {addMessage} from 'sentry/actionCreators/indicator';
|
|
|
import {Button} from 'sentry/components/button';
|
|
|
import ButtonBar from 'sentry/components/buttonBar';
|
|
|
import DataExport, {ExportQueryType} from 'sentry/components/dataExport';
|
|
|
-import DeprecatedAsyncComponent from 'sentry/components/deprecatedAsyncComponent';
|
|
|
import {DeviceName} from 'sentry/components/deviceName';
|
|
|
import {DropdownMenu} from 'sentry/components/dropdownMenu';
|
|
|
import GlobalSelectionLink from 'sentry/components/globalSelectionLink';
|
|
@@ -13,6 +13,7 @@ import UserBadge from 'sentry/components/idBadge/userBadge';
|
|
|
import * as Layout from 'sentry/components/layouts/thirds';
|
|
|
import ExternalLink from 'sentry/components/links/externalLink';
|
|
|
import Link from 'sentry/components/links/link';
|
|
|
+import LoadingError from 'sentry/components/loadingError';
|
|
|
import {extractSelectionParameters} from 'sentry/components/organizations/pageFilters/utils';
|
|
|
import Pagination from 'sentry/components/pagination';
|
|
|
import PanelTable from 'sentry/components/panels/panelTable';
|
|
@@ -20,19 +21,12 @@ import TimeSince from 'sentry/components/timeSince';
|
|
|
import {IconArrow, IconEllipsis, IconMail, IconOpen} from 'sentry/icons';
|
|
|
import {t} from 'sentry/locale';
|
|
|
import {space} from 'sentry/styles/space';
|
|
|
-import {
|
|
|
- Group,
|
|
|
- Organization,
|
|
|
- Project,
|
|
|
- SavedQueryVersions,
|
|
|
- Tag,
|
|
|
- TagValue,
|
|
|
-} from 'sentry/types';
|
|
|
+import {Group, Project, SavedQueryVersions} from 'sentry/types';
|
|
|
import {isUrl, percent} from 'sentry/utils';
|
|
|
import EventView from 'sentry/utils/discover/eventView';
|
|
|
-import withOrganization from 'sentry/utils/withOrganization';
|
|
|
-// eslint-disable-next-line no-restricted-imports
|
|
|
-import withSentryRouter from 'sentry/utils/withSentryRouter';
|
|
|
+import {useLocation} from 'sentry/utils/useLocation';
|
|
|
+import useOrganization from 'sentry/utils/useOrganization';
|
|
|
+import {useParams} from 'sentry/utils/useParams';
|
|
|
|
|
|
type RouteParams = {
|
|
|
groupId: string;
|
|
@@ -43,55 +37,110 @@ type RouteParams = {
|
|
|
type Props = {
|
|
|
baseUrl: string;
|
|
|
group: Group;
|
|
|
- organization: Organization;
|
|
|
environments?: string[];
|
|
|
project?: Project;
|
|
|
-} & RouteComponentProps<RouteParams, {}>;
|
|
|
-
|
|
|
-type State = {
|
|
|
- tag: Tag | null;
|
|
|
- tagValueList: TagValue[] | null;
|
|
|
- tagValueListPageLinks: string;
|
|
|
};
|
|
|
|
|
|
const DEFAULT_SORT = 'count';
|
|
|
|
|
|
-class GroupTagValues extends DeprecatedAsyncComponent<
|
|
|
- Props & DeprecatedAsyncComponent['props'] & WithRouterProps,
|
|
|
- State & DeprecatedAsyncComponent['state']
|
|
|
-> {
|
|
|
- getEndpoints(): ReturnType<DeprecatedAsyncComponent['getEndpoints']> {
|
|
|
- const {environments: environment, organization} = this.props;
|
|
|
- const {groupId, tagKey} = this.props.params;
|
|
|
- return [
|
|
|
- ['tag', `/organizations/${organization.slug}/issues/${groupId}/tags/${tagKey}/`],
|
|
|
- [
|
|
|
- 'tagValueList',
|
|
|
- `/organizations/${organization.slug}/issues/${groupId}/tags/${tagKey}/values/`,
|
|
|
- {query: {environment, sort: this.getSort()}},
|
|
|
- ],
|
|
|
- ];
|
|
|
- }
|
|
|
-
|
|
|
- getSort(): string {
|
|
|
- return this.props.location.query.sort || DEFAULT_SORT;
|
|
|
- }
|
|
|
+function useTagQueries({
|
|
|
+ group,
|
|
|
+ tagKey,
|
|
|
+ environments,
|
|
|
+ sort,
|
|
|
+}: {
|
|
|
+ group: Group;
|
|
|
+ sort: string | string[];
|
|
|
+ tagKey: string;
|
|
|
+ environments?: string[];
|
|
|
+}) {
|
|
|
+ const organization = useOrganization();
|
|
|
+
|
|
|
+ const {
|
|
|
+ data: tagValueList,
|
|
|
+ isLoading: tagValueListIsLoading,
|
|
|
+ isError: tagValueListIsError,
|
|
|
+ getResponseHeader,
|
|
|
+ } = useFetchIssueTagValues({
|
|
|
+ orgSlug: organization.slug,
|
|
|
+ groupId: group.id,
|
|
|
+ tagKey,
|
|
|
+ environment: environments,
|
|
|
+ sort,
|
|
|
+ });
|
|
|
+ const {data: tag, isError: tagIsError} = useFetchIssueTag({
|
|
|
+ orgSlug: organization.slug,
|
|
|
+ groupId: group.id,
|
|
|
+ tagKey,
|
|
|
+ });
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (tagIsError) {
|
|
|
+ addMessage(t('Failed to fetch total tag values'), 'error');
|
|
|
+ }
|
|
|
+ }, [tagIsError]);
|
|
|
+
|
|
|
+ return {
|
|
|
+ tagValueList,
|
|
|
+ tag,
|
|
|
+ isLoading: tagValueListIsLoading,
|
|
|
+ isError: tagValueListIsError,
|
|
|
+ pageLinks: getResponseHeader?.('pageLinks'),
|
|
|
+ };
|
|
|
+}
|
|
|
|
|
|
- renderLoading() {
|
|
|
- return this.renderBody();
|
|
|
- }
|
|
|
+function GroupTagValues({baseUrl, project, group, environments}: Props) {
|
|
|
+ const organization = useOrganization();
|
|
|
+ const location = useLocation();
|
|
|
+ const {orgId, tagKey = ''} = useParams<RouteParams>();
|
|
|
+ const {cursor: _cursor, page: _page, ...currentQuery} = location.query;
|
|
|
+
|
|
|
+ const title = tagKey === 'user' ? t('Affected Users') : tagKey;
|
|
|
+ const sort = location.query.sort || DEFAULT_SORT;
|
|
|
+ const sortArrow = <IconArrow color="gray300" size="xs" direction="down" />;
|
|
|
+
|
|
|
+ const {tagValueList, tag, isLoading, isError, pageLinks} = useTagQueries({
|
|
|
+ group,
|
|
|
+ sort,
|
|
|
+ tagKey,
|
|
|
+ environments,
|
|
|
+ });
|
|
|
+
|
|
|
+ const lastSeenColumnHeader = (
|
|
|
+ <StyledSortLink
|
|
|
+ to={{
|
|
|
+ pathname: location.pathname,
|
|
|
+ query: {
|
|
|
+ ...currentQuery,
|
|
|
+ sort: 'date',
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Last Seen')} {sort === 'date' && sortArrow}
|
|
|
+ </StyledSortLink>
|
|
|
+ );
|
|
|
+ const countColumnHeader = (
|
|
|
+ <StyledSortLink
|
|
|
+ to={{
|
|
|
+ pathname: location.pathname,
|
|
|
+ query: {
|
|
|
+ ...currentQuery,
|
|
|
+ sort: 'count',
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {t('Count')} {sort === 'count' && sortArrow}
|
|
|
+ </StyledSortLink>
|
|
|
+ );
|
|
|
+ const renderResults = () => {
|
|
|
+ if (isError) {
|
|
|
+ return <StyledLoadingError message={t('There was an error loading tag details')} />;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
- renderResults() {
|
|
|
- const {
|
|
|
- baseUrl,
|
|
|
- project,
|
|
|
- environments: environment,
|
|
|
- group,
|
|
|
- location: {query},
|
|
|
- params: {orgId, tagKey},
|
|
|
- organization,
|
|
|
- } = this.props;
|
|
|
- const {tagValueList, tag} = this.state;
|
|
|
const discoverFields = [
|
|
|
'title',
|
|
|
'release',
|
|
@@ -100,8 +149,7 @@ class GroupTagValues extends DeprecatedAsyncComponent<
|
|
|
'timestamp',
|
|
|
];
|
|
|
|
|
|
- const globalSelectionParams = extractSelectionParameters(query);
|
|
|
-
|
|
|
+ const globalSelectionParams = extractSelectionParameters(location.query);
|
|
|
return tagValueList?.map((tagValue, tagValueIdx) => {
|
|
|
const pct = tag?.totalValues
|
|
|
? `${percent(tagValue.count, tag?.totalValues).toFixed(2)}%`
|
|
@@ -118,7 +166,7 @@ class GroupTagValues extends DeprecatedAsyncComponent<
|
|
|
orderby: '-timestamp',
|
|
|
query: `issue:${group.shortId} ${issuesQuery}`,
|
|
|
projects: [Number(project?.id)],
|
|
|
- environment,
|
|
|
+ environment: environments,
|
|
|
version: 2 as SavedQueryVersions,
|
|
|
range: '90d',
|
|
|
});
|
|
@@ -199,101 +247,59 @@ class GroupTagValues extends DeprecatedAsyncComponent<
|
|
|
</Fragment>
|
|
|
);
|
|
|
});
|
|
|
- }
|
|
|
-
|
|
|
- renderBody() {
|
|
|
- const {
|
|
|
- group,
|
|
|
- params: {orgId, tagKey},
|
|
|
- location: {query},
|
|
|
- environments,
|
|
|
- } = this.props;
|
|
|
- const {tagValueList, tag, tagValueListPageLinks, loading} = this.state;
|
|
|
- const {cursor: _cursor, page: _page, ...currentQuery} = query;
|
|
|
-
|
|
|
- const title = tagKey === 'user' ? t('Affected Users') : tagKey;
|
|
|
-
|
|
|
- const sort = this.getSort();
|
|
|
- const sortArrow = <IconArrow color="gray300" size="xs" direction="down" />;
|
|
|
- const lastSeenColumnHeader = (
|
|
|
- <StyledSortLink
|
|
|
- to={{
|
|
|
- pathname: location.pathname,
|
|
|
- query: {
|
|
|
- ...currentQuery,
|
|
|
- sort: 'date',
|
|
|
- },
|
|
|
- }}
|
|
|
- >
|
|
|
- {t('Last Seen')} {sort === 'date' && sortArrow}
|
|
|
- </StyledSortLink>
|
|
|
- );
|
|
|
- const countColumnHeader = (
|
|
|
- <StyledSortLink
|
|
|
- to={{
|
|
|
- pathname: location.pathname,
|
|
|
- query: {
|
|
|
- ...currentQuery,
|
|
|
- sort: 'count',
|
|
|
- },
|
|
|
- }}
|
|
|
- >
|
|
|
- {t('Count')} {sort === 'count' && sortArrow}
|
|
|
- </StyledSortLink>
|
|
|
- );
|
|
|
-
|
|
|
- return (
|
|
|
- <Layout.Body>
|
|
|
- <Layout.Main fullWidth>
|
|
|
- <TitleWrapper>
|
|
|
- <Title>{t('Tag Details')}</Title>
|
|
|
- <ButtonBar gap={1}>
|
|
|
- <Button
|
|
|
- size="sm"
|
|
|
- priority="default"
|
|
|
- href={`/${orgId}/${group.project.slug}/issues/${group.id}/tags/${tagKey}/export/`}
|
|
|
- >
|
|
|
- {t('Export Page to CSV')}
|
|
|
- </Button>
|
|
|
- <DataExport
|
|
|
- payload={{
|
|
|
- queryType: ExportQueryType.ISSUES_BY_TAG,
|
|
|
- queryInfo: {
|
|
|
- project: group.project.id,
|
|
|
- group: group.id,
|
|
|
- key: tagKey,
|
|
|
- },
|
|
|
- }}
|
|
|
- />
|
|
|
- </ButtonBar>
|
|
|
- </TitleWrapper>
|
|
|
- <StyledPanelTable
|
|
|
- isLoading={loading}
|
|
|
- isEmpty={tagValueList?.length === 0}
|
|
|
- headers={[
|
|
|
- title,
|
|
|
- <PercentColumnHeader key="percent">{t('Percent')}</PercentColumnHeader>,
|
|
|
- countColumnHeader,
|
|
|
- lastSeenColumnHeader,
|
|
|
- '',
|
|
|
- ]}
|
|
|
- emptyMessage={t('Sorry, the tags for this issue could not be found.')}
|
|
|
- emptyAction={
|
|
|
- environments?.length
|
|
|
- ? t('No tags were found for the currently selected environments')
|
|
|
- : null
|
|
|
- }
|
|
|
- >
|
|
|
- {tagValueList && tag && this.renderResults()}
|
|
|
- </StyledPanelTable>
|
|
|
- <StyledPagination pageLinks={tagValueListPageLinks} />
|
|
|
- </Layout.Main>
|
|
|
- </Layout.Body>
|
|
|
- );
|
|
|
- }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Layout.Body>
|
|
|
+ <Layout.Main fullWidth>
|
|
|
+ <TitleWrapper>
|
|
|
+ <Title>{t('Tag Details')}</Title>
|
|
|
+ <ButtonBar gap={1}>
|
|
|
+ <Button
|
|
|
+ size="sm"
|
|
|
+ priority="default"
|
|
|
+ href={`/${orgId}/${group.project.slug}/issues/${group.id}/tags/${tagKey}/export/`}
|
|
|
+ >
|
|
|
+ {t('Export Page to CSV')}
|
|
|
+ </Button>
|
|
|
+ <DataExport
|
|
|
+ payload={{
|
|
|
+ queryType: ExportQueryType.ISSUES_BY_TAG,
|
|
|
+ queryInfo: {
|
|
|
+ project: group.project.id,
|
|
|
+ group: group.id,
|
|
|
+ key: tagKey,
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </ButtonBar>
|
|
|
+ </TitleWrapper>
|
|
|
+ <StyledPanelTable
|
|
|
+ isLoading={isLoading}
|
|
|
+ isEmpty={!isError && tagValueList?.length === 0}
|
|
|
+ headers={[
|
|
|
+ title,
|
|
|
+ <PercentColumnHeader key="percent">{t('Percent')}</PercentColumnHeader>,
|
|
|
+ countColumnHeader,
|
|
|
+ lastSeenColumnHeader,
|
|
|
+ '',
|
|
|
+ ]}
|
|
|
+ emptyMessage={t('Sorry, the tags for this issue could not be found.')}
|
|
|
+ emptyAction={
|
|
|
+ environments?.length
|
|
|
+ ? t('No tags were found for the currently selected environments')
|
|
|
+ : null
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {renderResults()}
|
|
|
+ </StyledPanelTable>
|
|
|
+ <StyledPagination pageLinks={pageLinks} />
|
|
|
+ </Layout.Main>
|
|
|
+ </Layout.Body>
|
|
|
+ );
|
|
|
}
|
|
|
|
|
|
-export default withSentryRouter(withOrganization(GroupTagValues));
|
|
|
+export default GroupTagValues;
|
|
|
|
|
|
const TitleWrapper = styled('div')`
|
|
|
display: flex;
|
|
@@ -322,6 +328,13 @@ const StyledPanelTable = styled(PanelTable)`
|
|
|
}
|
|
|
`;
|
|
|
|
|
|
+const StyledLoadingError = styled(LoadingError)`
|
|
|
+ grid-column: 1 / -1;
|
|
|
+ margin-bottom: ${space(4)};
|
|
|
+ border-radius: 0;
|
|
|
+ border-width: 1px 0;
|
|
|
+`;
|
|
|
+
|
|
|
const PercentColumnHeader = styled('div')`
|
|
|
text-align: right;
|
|
|
`;
|