diff --git a/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/getParams.tsx b/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/getParams.tsx new file mode 100644 index 00000000000000..022a8dc0b36ff5 --- /dev/null +++ b/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/getParams.tsx @@ -0,0 +1,142 @@ +import {DEFAULT_STATS_PERIOD} from 'app/constants'; +import {defined} from 'app/utils'; +import moment from 'moment'; + +const STATS_PERIOD_PATTERN = '^\\d+[hdmsw]?$'; + +function validStatsPeriod(input: string) { + return !!input.match(STATS_PERIOD_PATTERN); +} + +const getStatsPeriodValue = ( + maybe: string | string[] | undefined | null +): string | undefined => { + if (Array.isArray(maybe)) { + if (maybe.length <= 0) { + return undefined; + } + + return maybe.find(validStatsPeriod); + } + + if (typeof maybe === 'string' && validStatsPeriod(maybe)) { + return maybe; + } + + return undefined; +}; + +// We normalize potential datetime strings into the form that would be valid +// if it were to be parsed by datetime.strptime using the format %Y-%m-%dT%H:%M:%S.%f +// This format was transformed to the form that moment.js understands using +// https://gist.github.com/asafge/0b13c5066d06ae9a4446 +const normalizeDateTimeString = ( + input: string | undefined | null +): string | undefined => { + if (!input) { + return undefined; + } + + const parsed = moment.utc(input); + + if (!parsed.isValid()) { + return undefined; + } + + return parsed.format('YYYY-MM-DDTHH:mm:ss.SSS'); +}; + +const getDateTimeString = ( + maybe: string | string[] | undefined | null +): string | undefined => { + if (Array.isArray(maybe)) { + if (maybe.length <= 0) { + return undefined; + } + + const result = maybe.find(needle => { + return moment.utc(needle).isValid(); + }); + + return normalizeDateTimeString(result); + } + + return normalizeDateTimeString(maybe); +}; + +const parseUtcValue = (utc: any) => { + if (typeof utc !== 'undefined') { + return utc === true || utc === 'true' ? 'true' : 'false'; + } + return undefined; +}; + +const getUtcValue = (maybe: string | string[] | undefined | null): string | undefined => { + if (Array.isArray(maybe)) { + if (maybe.length <= 0) { + return undefined; + } + + return maybe.find(needle => { + return !!parseUtcValue(needle); + }); + } + + maybe = parseUtcValue(maybe); + + if (typeof maybe === 'string') { + return maybe; + } + + return undefined; +}; + +interface Params { + start?: string | string[] | undefined | null; + end?: string | string[] | undefined | null; + period?: string | string[] | undefined | null; + statsPeriod?: string | string[] | undefined | null; + utc?: string | string[] | undefined | null; + [others: string]: string | string[] | undefined | null; +} + +// Filters out params with null values and returns a default +// `statsPeriod` when necessary. +// +// Accepts `period` and `statsPeriod` but will only return `statsPeriod` +// +// TODO(billy): Make period parameter name consistent +export function getParams(params: Params): {[key: string]: string | string[]} { + const {start, end, period, statsPeriod, utc, ...otherParams} = params; + + // `statsPeriod` takes precendence for now + let coercedPeriod = getStatsPeriodValue(statsPeriod) || getStatsPeriodValue(period); + + const dateTimeStart = getDateTimeString(start); + const dateTimeEnd = getDateTimeString(end); + + if (!(dateTimeStart && dateTimeEnd)) { + if (!coercedPeriod) { + coercedPeriod = DEFAULT_STATS_PERIOD; + } + } + + // Filter null values + return Object.entries({ + statsPeriod: coercedPeriod, + start: coercedPeriod ? null : dateTimeStart, + end: coercedPeriod ? null : dateTimeEnd, + // coerce utc into a string (it can be both: a string representation from router, + // or a boolean from time range picker) + utc: getUtcValue(utc), + ...otherParams, + }) + .filter(([_key, value]) => defined(value)) + .reduce( + (acc, [key, value]) => ({ + ...acc, + [key]: value, + }), + {} + ); +} diff --git a/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/utils.jsx b/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/utils.jsx index afc48039fa0a9b..b7dbd8926dfbe0 100644 --- a/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/utils.jsx +++ b/src/sentry/static/sentry/app/components/organizations/globalSelectionHeader/utils.jsx @@ -2,17 +2,20 @@ import {pick, pickBy, identity} from 'lodash'; import {defined} from 'app/utils'; import {getUtcToLocalDateObject} from 'app/utils/dates'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import {URL_PARAM} from 'app/constants/globalSelectionHeader'; // Parses URL query parameters for values relevant to global selection header export function getStateFromQuery(query) { - let start = query[URL_PARAM.START] !== 'null' && query[URL_PARAM.START]; - let end = query[URL_PARAM.END] !== 'null' && query[URL_PARAM.END]; + const parsedParams = getParams(query); + + let start = parsedParams.start; + let end = parsedParams.end; let project = query[URL_PARAM.PROJECT]; let environment = query[URL_PARAM.ENVIRONMENT]; - const period = query[URL_PARAM.PERIOD]; - const utc = query[URL_PARAM.UTC]; + const period = parsedParams.statsPeriod; + const utc = parsedParams.utc; const hasAbsolute = !!start && !!end; diff --git a/src/sentry/static/sentry/app/views/events/events.jsx b/src/sentry/static/sentry/app/views/events/events.jsx index 44fb3eed2fc44f..2e3d689459578d 100644 --- a/src/sentry/static/sentry/app/views/events/events.jsx +++ b/src/sentry/static/sentry/app/views/events/events.jsx @@ -16,7 +16,7 @@ import SentryTypes from 'app/sentryTypes'; import parseLinkHeader from 'app/utils/parseLinkHeader'; import withOrganization from 'app/utils/withOrganization'; -import {getParams} from './utils/getParams'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import EventsChart from './eventsChart'; import EventsTable from './eventsTable'; diff --git a/src/sentry/static/sentry/app/views/events/index.jsx b/src/sentry/static/sentry/app/views/events/index.jsx index b62bd8c28a5f02..285f26611c1365 100644 --- a/src/sentry/static/sentry/app/views/events/index.jsx +++ b/src/sentry/static/sentry/app/views/events/index.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import styled from 'react-emotion'; -import {getParams} from 'app/views/events/utils/getParams'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import {t} from 'app/locale'; import BetaTag from 'app/components/betaTag'; import Feature from 'app/components/acl/feature'; diff --git a/src/sentry/static/sentry/app/views/events/utils/getParams.tsx b/src/sentry/static/sentry/app/views/events/utils/getParams.tsx deleted file mode 100644 index 9fa0b86c15eaec..00000000000000 --- a/src/sentry/static/sentry/app/views/events/utils/getParams.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import {DEFAULT_STATS_PERIOD} from 'app/constants'; -import {defined} from 'app/utils'; - -const getUtcValue = utc => { - if (typeof utc !== 'undefined') { - return utc === true || utc === 'true' ? 'true' : 'false'; - } - - return utc; -}; - -interface Params { - start?: string; - end?: string; - period?: string; - statsPeriod?: string; - utc?: string; - [others: string]: string | string[] | undefined | null; -} - -// Filters out params with null values and returns a default -// `statsPeriod` when necessary. -// -// Accepts `period` and `statsPeriod` but will only return `statsPeriod` -// -// TODO(billy): Make period parameter name consistent -export function getParams(params: Params): {[key: string]: string | string[]} { - const {start, end, period, statsPeriod, utc, ...otherParams} = params; - - // `statsPeriod` takes precendence for now - let coercedPeriod = statsPeriod || period; - - if (!start && !end && !coercedPeriod) { - coercedPeriod = DEFAULT_STATS_PERIOD; - } - - // Filter null values - return Object.entries({ - statsPeriod: coercedPeriod, - start: coercedPeriod ? null : start, - end: coercedPeriod ? null : end, - // coerce utc into a string (it can be both: a string representation from router, - // or a boolean from time range picker) - utc: getUtcValue(utc), - ...otherParams, - }) - .filter(([_key, value]) => defined(value)) - .reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value, - }), - {} - ); -} diff --git a/src/sentry/static/sentry/app/views/eventsV2/eventView.tsx b/src/sentry/static/sentry/app/views/eventsV2/eventView.tsx index a825aa6096efe0..784226b050a727 100644 --- a/src/sentry/static/sentry/app/views/eventsV2/eventView.tsx +++ b/src/sentry/static/sentry/app/views/eventsV2/eventView.tsx @@ -6,6 +6,7 @@ import {DEFAULT_PER_PAGE} from 'app/constants'; import {EventViewv1} from 'app/types'; import {SavedQuery as LegacySavedQuery} from 'app/views/discover/types'; import {SavedQuery, NewQuery} from 'app/stores/discoverSavedQueriesStore'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import {AUTOLINK_FIELDS, SPECIAL_FIELDS, FIELD_FORMATTERS} from './data'; import { @@ -350,6 +351,8 @@ class EventView { } static fromLocation(location: Location): EventView { + const {start, end, statsPeriod} = getParams(location.query); + return new EventView({ id: decodeScalar(location.query.id), name: decodeScalar(location.query.name), @@ -358,9 +361,9 @@ class EventView { tags: collectQueryStringByKey(location.query, 'tag'), query: decodeQuery(location) || '', project: decodeProjects(location), - start: decodeScalar(location.query.start), - end: decodeScalar(location.query.end), - statsPeriod: decodeScalar(location.query.statsPeriod), + start: decodeScalar(start), + end: decodeScalar(end), + statsPeriod: decodeScalar(statsPeriod), environment: collectQueryStringByKey(location.query, 'environment'), }); } @@ -402,14 +405,23 @@ class EventView { }); } + // normalize datetime selection + + const {start, end, statsPeriod} = getParams({ + start: saved.start, + end: saved.end, + statsPeriod: saved.range, + }); + return new EventView({ fields, id: saved.id, name: saved.name, query: queryStringFromSavedQuery(saved), project: saved.projects, - start: saved.start, - end: saved.end, + start: decodeScalar(start), + end: decodeScalar(end), + statsPeriod: decodeScalar(statsPeriod), sorts: fromSorts(saved.orderby), tags: collectQueryStringByKey( { @@ -417,7 +429,6 @@ class EventView { }, 'tags' ), - statsPeriod: saved.range, environment: collectQueryStringByKey( { environment: (saved as SavedQuery).environment as string[], @@ -845,17 +856,31 @@ class EventView { const picked = pickRelevantLocationQueryStrings(location); + // normalize datetime selection + + const normalizedTimeWindowParams = getParams({ + start: this.start, + end: this.end, + period: decodeScalar(query.period), + statsPeriod: this.statsPeriod, + utc: decodeScalar(query.utc), + }); + const sort = this.sorts.length > 0 ? encodeSort(this.sorts[0]) : undefined; const fields = this.getFields(); // generate event query - const eventQuery: EventQuery & LocationQuery = Object.assign(picked, { - field: [...new Set(fields)], - sort, - per_page: DEFAULT_PER_PAGE, - query: this.getQuery(query.query), - }); + const eventQuery: EventQuery & LocationQuery = Object.assign( + picked, + normalizedTimeWindowParams, + { + field: [...new Set(fields)], + sort, + per_page: DEFAULT_PER_PAGE, + query: this.getQuery(query.query), + } + ); if (!eventQuery.sort) { delete eventQuery.sort; diff --git a/src/sentry/static/sentry/app/views/eventsV2/events.tsx b/src/sentry/static/sentry/app/views/eventsV2/events.tsx index 8e829673030d8f..b5a92440c93f4f 100644 --- a/src/sentry/static/sentry/app/views/eventsV2/events.tsx +++ b/src/sentry/static/sentry/app/views/eventsV2/events.tsx @@ -11,7 +11,7 @@ import {Panel} from 'app/components/panels'; import EventsChart from 'app/views/events/eventsChart'; import getDynamicText from 'app/utils/getDynamicText'; -import {getParams} from 'app/views/events/utils/getParams'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import Table from './table'; import Tags from './tags'; diff --git a/src/sentry/static/sentry/app/views/eventsV2/table/tableView.tsx b/src/sentry/static/sentry/app/views/eventsV2/table/tableView.tsx index 6eeb6067b0e4a4..715dddbedc16b6 100644 --- a/src/sentry/static/sentry/app/views/eventsV2/table/tableView.tsx +++ b/src/sentry/static/sentry/app/views/eventsV2/table/tableView.tsx @@ -85,7 +85,7 @@ class TableView extends React.Component { _updateColumn = (columnIndex: number, nextColumn: TableColumn) => { const {location, eventView, tableData} = this.props; - if (!tableData) { + if (!tableData || !tableData.meta) { return; } @@ -112,7 +112,7 @@ class TableView extends React.Component { _deleteColumn = (columnIndex: number) => { const {location, eventView, tableData} = this.props; - if (!tableData) { + if (!tableData || !tableData.meta) { return; } @@ -143,7 +143,7 @@ class TableView extends React.Component { _renderGridHeaderCell = (column: TableColumn): React.ReactNode => { const {eventView, location, tableData} = this.props; - if (!tableData) { + if (!tableData || !tableData.meta) { return column.name; } @@ -178,9 +178,11 @@ class TableView extends React.Component { dataRow: TableDataRow ): React.ReactNode => { const {location, organization, tableData, eventView} = this.props; - if (!tableData) { + + if (!tableData || !tableData.meta) { return dataRow[column.key]; } + const hasLinkField = eventView.hasAutolinkField(); const forceLink = !hasLinkField && eventView.getFields().indexOf(String(column.field)) === 0; diff --git a/src/sentry/static/sentry/app/views/eventsV2/table/types.tsx b/src/sentry/static/sentry/app/views/eventsV2/table/types.tsx index 18cb2f1671c86d..073e9b7fb8128e 100644 --- a/src/sentry/static/sentry/app/views/eventsV2/table/types.tsx +++ b/src/sentry/static/sentry/app/views/eventsV2/table/types.tsx @@ -31,6 +31,6 @@ export type TableDataRow = { }; export type TableData = { - meta: MetaType; + meta?: MetaType; data: Array; }; diff --git a/src/sentry/static/sentry/app/views/monitors/monitors.jsx b/src/sentry/static/sentry/app/views/monitors/monitors.jsx index b26fa9b34e23f8..4693758c6688fa 100644 --- a/src/sentry/static/sentry/app/views/monitors/monitors.jsx +++ b/src/sentry/static/sentry/app/views/monitors/monitors.jsx @@ -5,7 +5,7 @@ import styled from 'react-emotion'; import {PageHeader} from 'app/styles/organization'; import {Panel, PanelBody, PanelItem} from 'app/components/panels'; -import {getParams} from 'app/views/events/utils/getParams'; +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; import {t} from 'app/locale'; import AsyncView from 'app/views/asyncView'; import BetaTag from 'app/components/betaTag'; diff --git a/tests/js/spec/components/organizations/getParams.spec.jsx b/tests/js/spec/components/organizations/getParams.spec.jsx new file mode 100644 index 00000000000000..8fc1a7b50bd411 --- /dev/null +++ b/tests/js/spec/components/organizations/getParams.spec.jsx @@ -0,0 +1,79 @@ +import {getParams} from 'app/components/organizations/globalSelectionHeader/getParams'; + +describe('getParams', function() { + it('should return default statsPeriod if it is not provided or is invalid', function() { + expect(getParams({})).toEqual({statsPeriod: '14d'}); + expect(getParams({statsPeriod: 'invalid'})).toEqual({statsPeriod: '14d'}); + expect(getParams({statsPeriod: '24f'})).toEqual({statsPeriod: '14d'}); + }); + + it('should parse statsPeriod', function() { + expect(getParams({statsPeriod: '5s'})).toEqual({statsPeriod: '5s'}); + expect(getParams({statsPeriod: '11h'})).toEqual({statsPeriod: '11h'}); + expect(getParams({statsPeriod: '14d'})).toEqual({statsPeriod: '14d'}); + expect(getParams({statsPeriod: '24w'})).toEqual({statsPeriod: '24w'}); + expect(getParams({statsPeriod: '42m'})).toEqual({statsPeriod: '42m'}); + }); + + it('should parse first valid statsPeriod', function() { + expect(getParams({statsPeriod: ['invalid', '24d', '5s']})).toEqual({ + statsPeriod: '24d', + }); + }); + + it('should parse start and end', function() { + expect(getParams({start: '2019-10-01T00:00:00', end: '2019-10-02T00:00:00'})).toEqual( + {start: '2019-10-01T00:00:00.000', end: '2019-10-02T00:00:00.000'} + ); + + expect( + getParams({start: '2019-10-23T04:28:49+0000', end: '2019-10-26T02:56:17+0000'}) + ).toEqual({start: '2019-10-23T04:28:49.000', end: '2019-10-26T02:56:17.000'}); + }); + + it('should parse first valid start and end', function() { + expect( + getParams({ + start: ['invalid', '2019-10-01T00:00:00', '2020-10-01T00:00:00'], + end: ['invalid', '2019-10-02T00:00:00', '2020-10-02T00:00:00'], + }) + ).toEqual({start: '2019-10-01T00:00:00.000', end: '2019-10-02T00:00:00.000'}); + }); + + it('should return default statsPeriod if both start and end are not provided, or either are invalid', function() { + expect(getParams({start: '2019-10-01T00:00:00'})).toEqual({ + statsPeriod: '14d', + }); + + expect(getParams({end: '2019-10-01T00:00:00'})).toEqual({ + statsPeriod: '14d', + }); + + expect( + getParams({ + start: ['invalid'], + end: ['invalid'], + }) + ).toEqual({statsPeriod: '14d'}); + + expect( + getParams({ + start: ['invalid'], + end: ['invalid', '2019-10-02T00:00:00', '2020-10-02T00:00:00'], + }) + ).toEqual({statsPeriod: '14d'}); + + expect( + getParams({ + start: ['invalid', '2019-10-01T00:00:00', '2020-10-01T00:00:00'], + end: ['invalid'], + }) + ).toEqual({statsPeriod: '14d'}); + }); + + it('should parse utc', function() { + expect(getParams({utc: 'true'})).toEqual({utc: 'true', statsPeriod: '14d'}); + expect(getParams({utc: 'false'})).toEqual({utc: 'false', statsPeriod: '14d'}); + expect(getParams({utc: 'invalid'})).toEqual({utc: 'false', statsPeriod: '14d'}); + }); +}); diff --git a/tests/js/spec/components/organizations/globalSelectionHeader.spec.jsx b/tests/js/spec/components/organizations/globalSelectionHeader.spec.jsx index 8b1b5d26d2e7b5..d029925f6ad9b9 100644 --- a/tests/js/spec/components/organizations/globalSelectionHeader.spec.jsx +++ b/tests/js/spec/components/organizations/globalSelectionHeader.spec.jsx @@ -210,7 +210,7 @@ describe('GlobalSelectionHeader', function() { expect(GlobalSelectionStore.get()).toEqual({ datetime: { - period: null, + period: '14d', utc: null, start: null, end: null, @@ -239,7 +239,7 @@ describe('GlobalSelectionHeader', function() { // Store should not have any environments selected expect(GlobalSelectionStore.get()).toEqual({ datetime: { - period: null, + period: '14d', utc: null, start: null, end: null, @@ -327,7 +327,7 @@ describe('GlobalSelectionHeader', function() { wrapper.update(); expect(globalActions.updateDateTime).toHaveBeenCalledWith({ - period: null, + period: '7d', utc: null, start: null, end: null, @@ -337,7 +337,7 @@ describe('GlobalSelectionHeader', function() { expect(GlobalSelectionStore.get()).toEqual({ datetime: { - period: null, + period: '14d', utc: null, start: null, end: null, @@ -386,7 +386,7 @@ describe('GlobalSelectionHeader', function() { }); }); - it('updates store when there are no query params in URL and `disableLoadFromStore` is false', function() { + it('updates store when there are query params in URL', function() { const initializationObj = initializeOrg({ organization: { features: ['global-views'], @@ -407,7 +407,7 @@ describe('GlobalSelectionHeader', function() { expect(globalActions.updateDateTime).toHaveBeenCalled(); }); - it('does not update store when there are no query params in URL and `disableLoadFromStore` is true', function() { + it('updates store with default values when there are no query params in URL', function() { const initializationObj = initializeOrg({ organization: { features: ['global-views'], @@ -419,16 +419,18 @@ describe('GlobalSelectionHeader', function() { }); mountWithTheme( - , + , initializationObj.routerContext ); - expect(globalActions.updateProjects).not.toHaveBeenCalled(); - expect(globalActions.updateEnvironments).not.toHaveBeenCalled(); - expect(globalActions.updateDateTime).not.toHaveBeenCalled(); + expect(globalActions.updateProjects).toHaveBeenCalledWith([]); + expect(globalActions.updateEnvironments).toHaveBeenCalledWith([]); + expect(globalActions.updateDateTime).toHaveBeenCalledWith({ + end: null, + period: '14d', + start: null, + utc: null, + }); }); describe('Single project selection mode', function() { @@ -549,7 +551,7 @@ describe('GlobalSelectionHeader', function() { expect(initialData.router.replace).toHaveBeenLastCalledWith({ pathname: undefined, - query: {project: [0], environment: []}, + query: {project: [0]}, }); }); @@ -604,7 +606,7 @@ describe('GlobalSelectionHeader', function() { expect(initialData.router.replace).toHaveBeenLastCalledWith({ pathname: undefined, - query: {project: [1], environment: []}, + query: {project: [1]}, }); }); }); diff --git a/tests/js/spec/views/events/events.spec.jsx b/tests/js/spec/views/events/events.spec.jsx index e7bfa52e0703fc..5aa99b7a76dd6d 100644 --- a/tests/js/spec/views/events/events.spec.jsx +++ b/tests/js/spec/views/events/events.spec.jsx @@ -182,8 +182,8 @@ describe('EventsErrors', function() { expect.any(String), expect.objectContaining({ query: { - start: '2017-10-01T04:00:00', - end: '2017-10-02T03:59:59', + start: '2017-10-01T04:00:00.000', + end: '2017-10-02T03:59:59.000', }, }) ); diff --git a/tests/js/spec/views/eventsV2/eventView.spec.jsx b/tests/js/spec/views/eventsV2/eventView.spec.jsx index ae4ee8bc7275e1..52f8651e8dd52b 100644 --- a/tests/js/spec/views/eventsV2/eventView.spec.jsx +++ b/tests/js/spec/views/eventsV2/eventView.spec.jsx @@ -51,13 +51,81 @@ describe('EventView.fromLocation()', function() { tags: ['foo', 'bar'], query: 'event.type:transaction', project: [123], - start: '2019-10-01T00:00:00', - end: '2019-10-02T00:00:00', + start: undefined, + end: undefined, statsPeriod: '14d', environment: ['staging'], }); }); + it('includes first valid statsPeriod', function() { + const location = { + query: { + id: '42', + name: 'best query', + field: ['count()', 'id'], + fieldnames: ['events', 'projects'], + sort: ['title', '-count'], + tag: ['foo', 'bar'], + query: 'event.type:transaction', + project: [123], + start: '2019-10-01T00:00:00', + end: '2019-10-02T00:00:00', + statsPeriod: ['invalid', '28d'], + environment: ['staging'], + }, + }; + + const eventView = EventView.fromLocation(location); + + expect(eventView).toMatchObject({ + id: '42', + name: 'best query', + fields: [{field: 'count()', title: 'events'}, {field: 'id', title: 'projects'}], + sorts: generateSorts(['count']), + tags: ['foo', 'bar'], + query: 'event.type:transaction', + project: [123], + start: undefined, + end: undefined, + statsPeriod: '28d', + environment: ['staging'], + }); + }); + + it('includes start and end', function() { + const location = { + query: { + id: '42', + name: 'best query', + field: ['count()', 'id'], + fieldnames: ['events', 'projects'], + sort: ['title', '-count'], + tag: ['foo', 'bar'], + query: 'event.type:transaction', + project: [123], + start: '2019-10-01T00:00:00', + end: '2019-10-02T00:00:00', + environment: ['staging'], + }, + }; + + const eventView = EventView.fromLocation(location); + + expect(eventView).toMatchObject({ + id: '42', + name: 'best query', + fields: [{field: 'count()', title: 'events'}, {field: 'id', title: 'projects'}], + sorts: generateSorts(['count']), + tags: ['foo', 'bar'], + query: 'event.type:transaction', + project: [123], + start: '2019-10-01T00:00:00.000', + end: '2019-10-02T00:00:00.000', + environment: ['staging'], + }); + }); + it('generates event view when there are no query strings', function() { const location = { query: {}, @@ -75,7 +143,7 @@ describe('EventView.fromLocation()', function() { project: [], start: void 0, end: void 0, - statsPeriod: void 0, + statsPeriod: '14d', environment: [], }); }); @@ -105,11 +173,30 @@ describe('EventView.fromSavedQuery()', function() { tags: [], query: 'event.type:transaction', project: [123], - start: '2019-10-01T00:00:00', - end: '2019-10-02T00:00:00', + start: undefined, + end: undefined, + // statsPeriod has precedence statsPeriod: '14d', environment: ['staging'], }); + + const eventView2 = EventView.fromSavedQuery({ + ...saved, + range: undefined, + }); + expect(eventView2).toMatchObject({ + id: saved.id, + name: saved.name, + fields: [{field: 'count()', title: 'count()'}, {field: 'id', title: 'id'}], + sorts: [{field: 'id', kind: 'desc'}], + tags: [], + query: 'event.type:transaction', + project: [123], + start: '2019-10-01T00:00:00.000', + end: '2019-10-02T00:00:00.000', + statsPeriod: undefined, + environment: ['staging'], + }); }); it('maps saved query with no conditions', function() { @@ -170,8 +257,8 @@ describe('EventView.fromSavedQuery()', function() { ]); expect(eventView.name).toEqual(saved.name); expect(eventView.statsPeriod).toEqual('14d'); - expect(eventView.start).toEqual(''); - expect(eventView.end).toEqual(''); + expect(eventView.start).toEqual(undefined); + expect(eventView.end).toEqual(undefined); }); it('saved queries are equal when start and end datetime differ in format', function() { @@ -248,7 +335,9 @@ describe('EventView.fromSavedQuery()', function() { }); expect(eventView.isEqualTo(eventView3)).toBe(false); - expect(eventView2.isEqualTo(eventView3)).toBe(false); + + // this is expected since datetime (start and end) are normalized + expect(eventView2.isEqualTo(eventView3)).toBe(true); }); }); @@ -432,8 +521,8 @@ describe('EventView.getEventsAPIPayload()', function() { environment: ['staging'], start: 'start', end: 'end', - utc: 'utc', - statsPeriod: 'statsPeriod', + utc: 'true', + statsPeriod: '14d', cursor: 'some cursor', // non-relevant query strings @@ -446,8 +535,8 @@ describe('EventView.getEventsAPIPayload()', function() { environment: ['staging'], start: 'start', end: 'end', - utc: 'utc', - statsPeriod: 'statsPeriod', + utc: 'true', + statsPeriod: '14d', field: ['title', 'count()'], per_page: 50, @@ -456,6 +545,153 @@ describe('EventView.getEventsAPIPayload()', function() { cursor: 'some cursor', }); }); + + it('includes default coerced statsPeriod when omitted or is invalid', function() { + const eventView = new EventView({ + fields: generateFields(['title', 'count()']), + sorts: generateSorts(['project', 'count']), + tags: [], + query: 'event.type:csp', + }); + + const location = { + query: { + project: '1234', + environment: ['staging'], + start: 'start', + end: 'end', + utc: 'true', + // invalid statsPeriod string + statsPeriod: 'invalid', + cursor: 'some cursor', + }, + }; + + expect(eventView.getEventsAPIPayload(location)).toEqual({ + project: '1234', + environment: ['staging'], + start: 'start', + end: 'end', + utc: 'true', + statsPeriod: '14d', + + field: ['title', 'count()'], + per_page: 50, + query: 'event.type:csp', + sort: '-count', + cursor: 'some cursor', + }); + + const location2 = { + query: { + project: '1234', + environment: ['staging'], + start: 'start', + end: 'end', + utc: 'true', + // statsPeriod is omitted here + cursor: 'some cursor', + }, + }; + + expect(eventView.getEventsAPIPayload(location2)).toEqual({ + project: '1234', + environment: ['staging'], + start: 'start', + end: 'end', + utc: 'true', + statsPeriod: '14d', + + field: ['title', 'count()'], + per_page: 50, + query: 'event.type:csp', + sort: '-count', + cursor: 'some cursor', + }); + }); + + it('includes default coerced statsPeriod when either start or end is only provided', function() { + const eventView = new EventView({ + fields: generateFields(['title', 'count()']), + sorts: generateSorts(['project', 'count']), + tags: [], + query: 'event.type:csp', + }); + + const location = { + query: { + project: '1234', + environment: ['staging'], + start: 'start', + utc: 'true', + statsPeriod: 'invalid', + cursor: 'some cursor', + }, + }; + + expect(eventView.getEventsAPIPayload(location)).toEqual({ + project: '1234', + environment: ['staging'], + utc: 'true', + start: 'start', + statsPeriod: '14d', + + field: ['title', 'count()'], + per_page: 50, + query: 'event.type:csp', + sort: '-count', + cursor: 'some cursor', + }); + + const location2 = { + query: { + project: '1234', + environment: ['staging'], + end: 'end', + utc: 'true', + statsPeriod: 'invalid', + cursor: 'some cursor', + }, + }; + + expect(eventView.getEventsAPIPayload(location2)).toEqual({ + project: '1234', + environment: ['staging'], + utc: 'true', + end: 'end', + statsPeriod: '14d', + + field: ['title', 'count()'], + per_page: 50, + query: 'event.type:csp', + sort: '-count', + cursor: 'some cursor', + }); + }); + + it('includes start and end', function() { + const eventView = new EventView({ + fields: generateFields(['title', 'count()']), + sorts: generateSorts(['count']), + tags: [], + query: 'event.type:csp', + start: '2019-10-01T00:00:00', + end: '2019-10-02T00:00:00', + }); + + const location = { + query: {}, + }; + + expect(eventView.getEventsAPIPayload(location)).toEqual({ + field: ['title', 'count()'], + sort: '-count', + query: 'event.type:csp', + start: '2019-10-01T00:00:00.000', + end: '2019-10-02T00:00:00.000', + per_page: 50, + }); + }); }); describe('EventView.getTagsAPIPayload()', function() { @@ -473,8 +709,8 @@ describe('EventView.getTagsAPIPayload()', function() { environment: ['staging'], start: 'start', end: 'end', - utc: 'utc', - statsPeriod: 'statsPeriod', + utc: 'true', + statsPeriod: '14d', // non-relevant query strings bestCountry: 'canada', @@ -488,8 +724,8 @@ describe('EventView.getTagsAPIPayload()', function() { environment: ['staging'], start: 'start', end: 'end', - utc: 'utc', - statsPeriod: 'statsPeriod', + utc: 'true', + statsPeriod: '14d', field: ['title', 'count()'], per_page: 50, diff --git a/tests/js/spec/views/eventsV2/saveQueryButton.spec.jsx b/tests/js/spec/views/eventsV2/saveQueryButton.spec.jsx index 84757f9db772e4..53907af45a8fcc 100644 --- a/tests/js/spec/views/eventsV2/saveQueryButton.spec.jsx +++ b/tests/js/spec/views/eventsV2/saveQueryButton.spec.jsx @@ -130,6 +130,7 @@ describe('EventsV2 > SaveQueryButton', function() { name: 'my query', query: '', sort: [], + statsPeriod: '14d', tag: [], }, });