123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379 |
- import {Component, Fragment} from 'react';
- import {browserHistory, InjectedRouter} from 'react-router';
- import styled from '@emotion/styled';
- import {Location, Query} from 'history';
- import moment from 'moment';
- import {resetPageFilters} from 'sentry/actionCreators/pageFilters';
- import {Client} from 'sentry/api';
- import Feature from 'sentry/components/acl/feature';
- import {Button} from 'sentry/components/button';
- import {DropdownMenu, MenuItemProps} from 'sentry/components/dropdownMenu';
- import EmptyStateWarning from 'sentry/components/emptyStateWarning';
- import Pagination from 'sentry/components/pagination';
- import TimeSince from 'sentry/components/timeSince';
- import {IconEllipsis} from 'sentry/icons';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {Organization, SavedQuery} from 'sentry/types';
- import {trackAnalytics} from 'sentry/utils/analytics';
- import EventView from 'sentry/utils/discover/eventView';
- import parseLinkHeader from 'sentry/utils/parseLinkHeader';
- import {decodeList} from 'sentry/utils/queryString';
- import withApi from 'sentry/utils/withApi';
- import {
- handleCreateQuery,
- handleDeleteQuery,
- handleUpdateHomepageQuery,
- } from './savedQuery/utils';
- import MiniGraph from './miniGraph';
- import QueryCard from './querycard';
- import {getPrebuiltQueries, handleAddQueryToDashboard} from './utils';
- type Props = {
- api: Client;
- location: Location;
- onQueryChange: () => void;
- organization: Organization;
- pageLinks: string;
- renderPrebuilt: boolean;
- router: InjectedRouter;
- savedQueries: SavedQuery[];
- savedQuerySearchQuery: string;
- };
- class QueryList extends Component<Props> {
- componentDidMount() {
- /**
- * We need to reset global selection here because the saved queries can define their own projects
- * in the query. This can lead to mismatched queries for the project
- */
- resetPageFilters();
- }
- handleDeleteQuery = (eventView: EventView) => {
- const {api, organization, onQueryChange, location, savedQueries} = this.props;
- handleDeleteQuery(api, organization, eventView).then(() => {
- if (savedQueries.length === 1 && location.query.cursor) {
- browserHistory.push({
- pathname: location.pathname,
- query: {...location.query, cursor: undefined},
- });
- } else {
- onQueryChange();
- }
- });
- };
- handleDuplicateQuery = (eventView: EventView, yAxis: string[]) => {
- const {api, location, organization, onQueryChange} = this.props;
- eventView = eventView.clone();
- eventView.name = `${eventView.name} copy`;
- handleCreateQuery(api, organization, eventView, yAxis).then(() => {
- onQueryChange();
- browserHistory.push({
- pathname: location.pathname,
- query: {},
- });
- });
- };
- renderQueries() {
- const {pageLinks, renderPrebuilt} = this.props;
- const links = parseLinkHeader(pageLinks || '');
- let cards: React.ReactNode[] = [];
- // If we're on the first page (no-previous page exists)
- // include the pre-built queries.
- if (renderPrebuilt && (!links.previous || links.previous.results === false)) {
- cards = cards.concat(this.renderPrebuiltQueries());
- }
- cards = cards.concat(this.renderSavedQueries());
- if (cards.filter(x => x).length === 0) {
- return (
- <StyledEmptyStateWarning>
- <p>{t('No saved queries match that filter')}</p>
- </StyledEmptyStateWarning>
- );
- }
- return cards;
- }
- renderDropdownMenu(items: MenuItemProps[]) {
- return (
- <DropdownMenu
- items={items}
- trigger={triggerProps => (
- <DropdownTrigger
- {...triggerProps}
- aria-label={t('Query actions')}
- size="xs"
- borderless
- onClick={e => {
- e.stopPropagation();
- e.preventDefault();
- triggerProps.onClick?.(e);
- }}
- icon={<IconEllipsis direction="down" size="sm" />}
- data-test-id="menu-trigger"
- />
- )}
- position="bottom-end"
- offset={4}
- />
- );
- }
- renderPrebuiltQueries() {
- const {api, location, organization, savedQuerySearchQuery, router} = this.props;
- const views = getPrebuiltQueries(organization);
- const hasSearchQuery =
- typeof savedQuerySearchQuery === 'string' && savedQuerySearchQuery.length > 0;
- const needleSearch = hasSearchQuery ? savedQuerySearchQuery.toLowerCase() : '';
- const list = views.map((view, index) => {
- const eventView = EventView.fromNewQueryWithLocation(view, location);
- // if a search is performed on the list of queries, we filter
- // on the pre-built queries
- if (
- hasSearchQuery &&
- eventView.name &&
- !eventView.name.toLowerCase().includes(needleSearch)
- ) {
- return null;
- }
- const recentTimeline = t('Last ') + eventView.statsPeriod;
- const customTimeline =
- moment(eventView.start).format('MMM D, YYYY h:mm A') +
- ' - ' +
- moment(eventView.end).format('MMM D, YYYY h:mm A');
- const to = eventView.getResultsViewUrlTarget(organization.slug);
- const menuItems = [
- {
- key: 'add-to-dashboard',
- label: t('Add to Dashboard'),
- onAction: () =>
- handleAddQueryToDashboard({
- eventView,
- location,
- query: view,
- organization,
- yAxis: view?.yAxis,
- router,
- }),
- },
- {
- key: 'set-as-default',
- label: t('Set as Default'),
- onAction: () => {
- handleUpdateHomepageQuery(api, organization, eventView.toNewQuery());
- trackAnalytics('discover_v2.set_as_default', {
- organization,
- source: 'context-menu',
- type: 'prebuilt-query',
- });
- },
- },
- ];
- return (
- <QueryCard
- key={`${index}-${eventView.name}`}
- to={to}
- title={eventView.name}
- subtitle={eventView.statsPeriod ? recentTimeline : customTimeline}
- queryDetail={eventView.query}
- createdBy={eventView.createdBy}
- renderGraph={() => (
- <MiniGraph
- location={location}
- eventView={eventView}
- organization={organization}
- referrer="api.discover.homepage.prebuilt"
- />
- )}
- onEventClick={() => {
- trackAnalytics('discover_v2.prebuilt_query_click', {
- organization,
- query_name: eventView.name,
- });
- }}
- renderContextMenu={() => (
- <Feature organization={organization} features={['dashboards-edit']}>
- {({hasFeature}) => {
- return hasFeature && this.renderDropdownMenu(menuItems);
- }}
- </Feature>
- )}
- />
- );
- });
- return list;
- }
- renderSavedQueries() {
- const {api, savedQueries, location, organization, router} = this.props;
- if (!savedQueries || !Array.isArray(savedQueries) || savedQueries.length === 0) {
- return [];
- }
- return savedQueries.map((savedQuery, index) => {
- const eventView = EventView.fromSavedQuery(savedQuery);
- const recentTimeline = t('Last ') + eventView.statsPeriod;
- const customTimeline =
- moment(eventView.start).format('MMM D, YYYY h:mm A') +
- ' - ' +
- moment(eventView.end).format('MMM D, YYYY h:mm A');
- const to = eventView.getResultsViewShortUrlTarget(organization.slug);
- const dateStatus = <TimeSince date={savedQuery.dateUpdated} />;
- const referrer = `api.discover.${eventView.getDisplayMode()}-chart`;
- const menuItems = (canAddToDashboard: boolean): MenuItemProps[] => [
- ...(canAddToDashboard
- ? [
- {
- key: 'add-to-dashboard',
- label: t('Add to Dashboard'),
- onAction: () =>
- handleAddQueryToDashboard({
- eventView,
- location,
- query: savedQuery,
- organization,
- yAxis: savedQuery?.yAxis ?? eventView.yAxis,
- router,
- }),
- },
- ]
- : []),
- {
- key: 'set-as-default',
- label: t('Set as Default'),
- onAction: () => {
- handleUpdateHomepageQuery(api, organization, eventView.toNewQuery());
- trackAnalytics('discover_v2.set_as_default', {
- organization,
- source: 'context-menu',
- type: 'saved-query',
- });
- },
- },
- {
- key: 'duplicate',
- label: t('Duplicate Query'),
- onAction: () =>
- this.handleDuplicateQuery(eventView, decodeList(savedQuery.yAxis)),
- },
- {
- key: 'delete',
- label: t('Delete Query'),
- priority: 'danger',
- onAction: () => this.handleDeleteQuery(eventView),
- },
- ];
- return (
- <QueryCard
- key={`${index}-${eventView.id}`}
- to={to}
- title={eventView.name}
- subtitle={eventView.statsPeriod ? recentTimeline : customTimeline}
- queryDetail={eventView.query}
- createdBy={eventView.createdBy}
- dateStatus={dateStatus}
- onEventClick={() => {
- trackAnalytics('discover_v2.saved_query_click', {organization});
- }}
- renderGraph={() => (
- <MiniGraph
- location={location}
- eventView={eventView}
- organization={organization}
- referrer={referrer}
- yAxis={
- savedQuery.yAxis && savedQuery.yAxis.length
- ? savedQuery.yAxis
- : ['count()']
- }
- />
- )}
- renderContextMenu={() => (
- <Feature organization={organization} features={['dashboards-edit']}>
- {({hasFeature}) => this.renderDropdownMenu(menuItems(hasFeature))}
- </Feature>
- )}
- />
- );
- });
- }
- render() {
- const {pageLinks} = this.props;
- return (
- <Fragment>
- <QueryGrid>{this.renderQueries()}</QueryGrid>
- <PaginationRow
- pageLinks={pageLinks}
- onCursor={(cursor, path, query, direction) => {
- const offset = Number(cursor?.split(':')?.[1] ?? 0);
- const newQuery: Query & {cursor?: string} = {...query, cursor};
- const isPrevious = direction === -1;
- if (offset <= 0 && isPrevious) {
- delete newQuery.cursor;
- }
- browserHistory.push({
- pathname: path,
- query: newQuery,
- });
- }}
- />
- </Fragment>
- );
- }
- }
- const PaginationRow = styled(Pagination)`
- margin-bottom: 20px;
- `;
- const QueryGrid = styled('div')`
- display: grid;
- grid-template-columns: minmax(100px, 1fr);
- gap: ${space(2)};
- @media (min-width: ${p => p.theme.breakpoints.medium}) {
- grid-template-columns: repeat(2, minmax(100px, 1fr));
- }
- @media (min-width: ${p => p.theme.breakpoints.large}) {
- grid-template-columns: repeat(3, minmax(100px, 1fr));
- }
- `;
- const DropdownTrigger = styled(Button)`
- transform: translateX(${space(1)});
- `;
- const StyledEmptyStateWarning = styled(EmptyStateWarning)`
- grid-column: 1 / 4;
- `;
- export default withApi(QueryList);
|