diff --git a/src/sentry/static/sentry/app/types/index.tsx b/src/sentry/static/sentry/app/types/index.tsx index 220d12238df661..8b0e69756ab02f 100644 --- a/src/sentry/static/sentry/app/types/index.tsx +++ b/src/sentry/static/sentry/app/types/index.tsx @@ -275,7 +275,7 @@ export type Group = { seenBy: User[]; }; -export type EventView = { +export type EventViewv1 = { id: string; name: string; data: { @@ -283,10 +283,6 @@ export type EventView = { columnNames: string[]; sort: string[]; query?: string; - - // TODO: removed as of https://github.com/getsentry/sentry/pull/14321 - // groupby: string[]; - // orderby: string[]; }; tags: string[]; columnWidths: string[]; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/data.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/data.tsx index 4fd861e18e34cb..5457b011c2b8f5 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/data.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/data.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled from 'react-emotion'; +import {Location} from 'history'; -import {deepFreeze} from 'app/utils'; import {t} from 'app/locale'; import Count from 'app/components/count'; import DateTime from 'app/components/dateTime'; @@ -12,37 +12,28 @@ import getDynamicText from 'app/utils/getDynamicText'; import overflowEllipsis from 'app/styles/overflowEllipsis'; import pinIcon from 'app/../images/location-pin.png'; import space from 'app/styles/space'; -import {EventView} from 'app/types'; +import {EventViewv1, Organization} from 'app/types'; import {QueryLink} from './styles'; -// TODO(ts): add as const after babel upgrade -export const MODAL_QUERY_KEYS = ['eventSlug']; +export const MODAL_QUERY_KEYS = ['eventSlug'] as const; export const PIN_ICON = `image://${pinIcon}`; -// TODO(ts): add as const after babel upgrade -export const AGGREGATE_ALIASES = ['last_seen', 'latest_event']; +export const AGGREGATE_ALIASES = ['last_seen', 'latest_event'] as const; -// TODO(ts): eventually defer to TS compile-time check to ensure this is readonly instead -// of deepfreezing it in runtime -export const ALL_VIEWS: Readonly> = deepFreeze([ - { - id: 'all', - name: t('All Events'), - data: { - fields: ['title', 'event.type', 'project', 'user', 'timestamp'], - columnNames: ['title', 'type', 'project', 'user', 'time'], - sort: ['-timestamp'], - }, - tags: [ - 'event.type', - 'release', - 'project.name', - 'user.email', - 'user.ip', - 'environment', - ], - columnWidths: ['3fr', '80px', '1fr', '1fr', '1.5fr'], +export const DEFAULT_EVENT_VIEW_V1: Readonly = { + id: 'all', + name: t('All Events'), + data: { + fields: ['title', 'event.type', 'project', 'user', 'timestamp'], + columnNames: ['title', 'type', 'project', 'user', 'time'], + sort: ['-timestamp'], }, + tags: ['event.type', 'release', 'project.name', 'user.email', 'user.ip', 'environment'], + columnWidths: ['3fr', '80px', '1fr', '1fr', '1.5fr'], +}; + +export const ALL_VIEWS: Readonly> = [ + DEFAULT_EVENT_VIEW_V1, { id: 'errors', name: t('Errors'), @@ -92,14 +83,47 @@ export const ALL_VIEWS: Readonly> = deepFreeze([ ], columnWidths: ['3fr', '1fr', '70px'], }, -]); +]; + +type EventData = {[key: string]: any}; + +type RenderFunctionBaggage = { + organization: Organization; + location: Location; +}; + +type FieldFormatterRenderFunction = ( + field: string, + data: EventData, + baggage: RenderFunctionBaggage +) => React.ReactNode; + +export type FieldFormatterRenderFunctionPartial = ( + data: EventData, + baggage: RenderFunctionBaggage +) => React.ReactNode; + +type FieldFormatter = { + sortField: boolean; + renderFunc: FieldFormatterRenderFunction; +}; + +type FieldFormatters = { + boolean: FieldFormatter; + integer: FieldFormatter; + number: FieldFormatter; + date: FieldFormatter; + string: FieldFormatter; +}; + +export type FieldTypes = keyof FieldFormatters; /** * A mapping of field types to their rendering function. * This mapping is used when a field is not defined in SPECIAL_FIELDS * This mapping should match the output sentry.utils.snuba:get_json_type */ -export const FIELD_FORMATTERS = { +export const FIELD_FORMATTERS: FieldFormatters = { boolean: { sortField: true, renderFunc: (field, data, {organization, location}) => { @@ -160,12 +184,31 @@ export const FIELD_FORMATTERS = { }, }; +type SpecialFieldRenderFunc = ( + data: EventData, + baggage: RenderFunctionBaggage +) => React.ReactNode; + +type SpecialField = { + sortField: string | null; + renderFunc: SpecialFieldRenderFunc; +}; + +type SpecialFields = { + transaction: SpecialField; + title: SpecialField; + 'event.type': SpecialField; + project: SpecialField; + user: SpecialField; + last_seen: SpecialField; +}; + /** * "Special fields" do not map 1:1 to an single column in the event database, * they are a UI concept that combines the results of multiple fields and * displays with a custom render function. */ -export const SPECIAL_FIELDS = { +export const SPECIAL_FIELDS: SpecialFields = { transaction: { sortField: 'transaction', renderFunc: (data, {location}) => { @@ -203,7 +246,7 @@ export const SPECIAL_FIELDS = { ); }, }, - type: { + 'event.type': { sortField: 'event.type', renderFunc: (data, {location}) => { const target = { @@ -218,7 +261,7 @@ export const SPECIAL_FIELDS = { }, }, project: { - sortField: false, + sortField: null, renderFunc: (data, {organization}) => { const project = organization.projects.find(p => p.slug === data['project.name']); return ( diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/discover2table.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/discover2table.tsx new file mode 100644 index 00000000000000..02621cadd0b2c6 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/discover2table.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import {Location} from 'history'; +import {omit} from 'lodash'; +import {browserHistory} from 'react-router'; +import styled from 'react-emotion'; + +import space from 'app/styles/space'; +import withApi from 'app/utils/withApi'; +import {Client} from 'app/api'; +import {Organization} from 'app/types'; +import Pagination from 'app/components/pagination'; +import Panel from 'app/components/panels/panel'; +import {PanelBody} from 'app/components/panels'; +import LoadingContainer from 'app/components/loading/loadingContainer'; +import EmptyStateWarning from 'app/components/emptyStateWarning'; +import {t} from 'app/locale'; + +import {DEFAULT_EVENT_VIEW_V1} from './data'; +import {MetaType, getFieldRenderer} from './utils'; +import EventView from './eventView'; +import SortLink from './sortLink'; + +type DataRow = { + [key: string]: string; +}; + +// TODO: move this +type DataPayload = { + data: Array; + meta: MetaType; +}; + +type Props = { + api: Client; + location: Location; + organization: Organization; +}; + +type State = { + eventView: EventView; + loading: boolean; + hasError: boolean; + pageLinks: null | string; + dataPayload: DataPayload | null | undefined; +}; + +class Discover2Table extends React.PureComponent { + state: State = { + eventView: EventView.fromLocation(this.props.location), + loading: true, + hasError: false, + pageLinks: null, + dataPayload: null, + }; + + static getDerivedStateFromProps(props: Props, state: State): State { + return { + ...state, + eventView: EventView.fromLocation(props.location), + }; + } + + componentDidMount() { + const {location} = this.props; + + if (!this.state.eventView.isValid()) { + const nextEventView = EventView.fromEventViewv1(DEFAULT_EVENT_VIEW_V1); + + browserHistory.replace({ + pathname: location.pathname, + query: { + ...location.query, + ...nextEventView.generateQueryStringObject(), + }, + }); + return; + } + + this.fetchData(); + } + + componentDidUpdate(prevProps) { + if (this.props.location !== prevProps.location) { + this.fetchData(); + } + } + + fetchData = () => { + const {organization, location} = this.props; + + const url = `/organizations/${organization.slug}/eventsv2/`; + + this.props.api.request(url, { + query: this.state.eventView.getEventsAPIPayload(location), + success: (dataPayload, __textStatus, jqxhr) => { + this.setState(prevState => { + return { + loading: false, + hasError: false, + pageLinks: jqxhr ? jqxhr.getResponseHeader('Link') : prevState.pageLinks, + dataPayload, + }; + }); + }, + error: _err => { + this.setState({ + hasError: true, + }); + }, + }); + }; + + render() { + const {organization, location} = this.props; + const {pageLinks, eventView, loading, dataPayload} = this.state; + + return ( + + + + + ); + } +} + +type TableProps = { + organization: Organization; + eventView: EventView; + isLoading: boolean; + dataPayload: DataPayload | null | undefined; + location: Location; +}; + +class Table extends React.Component { + renderLoading = () => { + return ( + + + + + + ); + }; + + renderHeader = () => { + const {eventView, location, dataPayload} = this.props; + + if (eventView.fields.length <= 0) { + return null; + } + + const defaultSort = eventView.getDefaultSort() || eventView.fields[0].snuba_column; + + return eventView.fields.map((field, index) => { + if (!dataPayload) { + return {field.title}; + } + + const {meta} = dataPayload; + const sortKey = eventView.getSortKey(field.snuba_column, meta); + + if (sortKey === null) { + return {field.title}; + } + + return ( + + + + ); + }); + }; + + renderContent = (): React.ReactNode => { + const {dataPayload, eventView, organization, location} = this.props; + + if (!(dataPayload && dataPayload.data && dataPayload.data.length > 0)) { + return ( + + +

{t('No results found')}

+
+
+ ); + } + + const {meta} = dataPayload; + const fields = eventView.getFieldSnubaCols(); + + // TODO: deal with this + // if (fields.length <= 0) { + // return ( + // + // + //

{t('No field column selected')}

+ //
+ //
+ // ); + // } + + const lastRowIndex = dataPayload.data.length - 1; + + const firstCellIndex = 0; + const lastCellIndex = fields.length - 1; + + return dataPayload.data.map((row, rowIndex) => { + return ( + + {fields.map((field, columnIndex) => { + const key = `${field}.${columnIndex}`; + + const fieldRenderer = getFieldRenderer(field, meta); + return ( + + {fieldRenderer(row, {organization, location})} + + ); + })} + + ); + }); + }; + + renderTable = () => { + return ( + + {this.renderHeader()} + {this.renderContent()} + + ); + }; + + render() { + const {isLoading, eventView} = this.props; + + if (isLoading) { + return this.renderLoading(); + } + + return ( + {this.renderTable()} + ); + } +} + +type PanelGridProps = { + numOfCols: number; +}; + +const PanelGrid = styled((props: PanelGridProps) => { + const otherProps = omit(props, 'numOfCols'); + return ; +})` + display: grid; + + overflow-x: auto; + + ${(props: PanelGridProps) => { + const firstColumn = '3fr'; + + function generateRestColumns(): string { + if (props.numOfCols <= 1) { + return ''; + } + + return `repeat(${props.numOfCols - 1}, auto)`; + } + + return ` + grid-template-columns: ${firstColumn} ${generateRestColumns()}; + `; + }}; +`; + +const PanelHeaderCell = styled('div')` + color: ${p => p.theme.gray3}; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + border-bottom: 1px solid ${p => p.theme.borderDark}; + border-radius: ${p => p.theme.borderRadius} ${p => p.theme.borderRadius} 0 0; + background: ${p => p.theme.offWhite}; + line-height: 1; + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + padding: ${space(2)}; + + /* + By default, a grid item cannot be smaller than the size of its content. + We override this by setting it to be 0. + */ + min-width: 0; +`; + +type PanelGridInfoProps = { + numOfCols: number; +}; + +const PanelGridInfo = styled('div')` + ${(props: PanelGridInfoProps) => { + return ` + grid-column: 1 / span ${props.numOfCols}; + `; + }}; +`; + +const PanelItemCell = styled('div')<{hideBottomBorder: boolean}>` + border-bottom: ${p => + p.hideBottomBorder ? 'none' : `1px solid ${p.theme.borderLight}`}; + + font-size: ${p => p.theme.fontSizeMedium}; + + padding-top: ${space(1)}; + padding-bottom: ${space(1)}; + + /* + By default, a grid item cannot be smaller than the size of its content. + We override this by setting it to be 0. + */ + min-width: 0; +`; + +const Container = styled('div')` + min-width: 0; + overflow: hidden; +`; + +export default withApi(Discover2Table); diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.tsx index 37aedff98ea871..9f44cf5db9531d 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/eventDetails.tsx @@ -13,10 +13,11 @@ import NotFound from 'app/components/errors/notFound'; import withApi from 'app/utils/withApi'; import theme from 'app/utils/theme'; import space from 'app/styles/space'; -import {Organization, EventView, Event} from 'app/types'; +import {Organization, Event} from 'app/types'; import EventModalContent from './eventModalContent'; -import {EventQuery, getQuery} from './utils'; +import {EventQuery} from './utils'; +import EventView from './eventView'; const slugValidator = function( props: {[key: string]: any}, @@ -49,8 +50,8 @@ type Props = { organization: Organization; location: Location; eventSlug: string; - view: EventView; params: Params; + eventView: EventView; }; type State = { @@ -62,12 +63,11 @@ class EventDetails extends AsyncComponent { - const {organization, eventSlug, view, location} = this.props; - const query = getQuery(view, location); + const {organization, eventSlug, eventView, location} = this.props; + const query = eventView.getEventsAPIPayload(location); const url = `/organizations/${organization.slug}/events/${eventSlug}/`; // Get a specific event. This could be coming from @@ -91,7 +91,7 @@ class EventDetails extends AsyncComponent diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.tsx index c5635e784db9ed..7a39fc5197d398 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/eventModalContent.tsx @@ -10,7 +10,7 @@ import overflowEllipsis from 'app/styles/overflowEllipsis'; import space from 'app/styles/space'; import getDynamicText from 'app/utils/getDynamicText'; import {getMessage, getTitle} from 'app/utils/events'; -import {Event, Organization, EventView} from 'app/types'; +import {Event, Organization} from 'app/types'; import {Location} from 'history'; import EventInterfaces from './eventInterfaces'; @@ -20,13 +20,14 @@ import ModalLineGraph from './modalLineGraph'; import RelatedEvents from './relatedEvents'; import TagsTable from './tagsTable'; import {hasAggregateField} from './utils'; +import EventView from './eventView'; type EventModalContentProps = { event: Event; projectId: string; organization: Organization; location: Location; - view: EventView; + eventView: EventView; }; /** @@ -34,10 +35,10 @@ type EventModalContentProps = { * Controlled by the EventDetails View. */ const EventModalContent = (props: EventModalContentProps) => { - const {event, projectId, organization, location, view} = props; + const {event, projectId, organization, location, eventView} = props; // Having an aggregate field means we want to show pagination/graphs - const isGroupedView = hasAggregateField(view); + const isGroupedView = hasAggregateField(eventView); const eventJsonUrl = `/api/0/projects/${organization.slug}/${projectId}/events/${ event.eventID }/json/`; @@ -54,7 +55,7 @@ const EventModalContent = (props: EventModalContentProps) => { organization={organization} currentEvent={event} location={location} - view={view} + eventView={eventView} /> ), fixed: 'events chart', @@ -82,7 +83,6 @@ EventModalContent.propTypes = { event: SentryTypes.Event.isRequired, projectId: PropTypes.string.isRequired, organization: SentryTypes.Organization.isRequired, - view: PropTypes.object.isRequired, location: PropTypes.object.isRequired, }; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/eventView.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/eventView.tsx new file mode 100644 index 00000000000000..512d0a2c800b40 --- /dev/null +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/eventView.tsx @@ -0,0 +1,356 @@ +import {Location, Query} from 'history'; +import {isString, pick} from 'lodash'; + +import {EventViewv1} from 'app/types'; +import {DEFAULT_PER_PAGE} from 'app/constants'; + +import {SPECIAL_FIELDS, FIELD_FORMATTERS} from './data'; +import {MetaType, EventQuery} from './utils'; + +type Descending = { + kind: 'desc'; + snuba_col: string; +}; + +type Ascending = { + kind: 'asc'; + snuba_col: string; +}; + +type Sort = Descending | Ascending; + +type QueryStringField = [ + /* snuba_column */ string, + /* title */ string + // TODO: implement later + // /* width */ number +]; + +type Field = { + snuba_column: string; + title: string; + // TODO: implement later + // width: number; +}; + +const isValidQueryStringField = (maybe: any): maybe is QueryStringField => { + if (!Array.isArray(maybe)) { + return false; + } + + if (maybe.length !== 2) { + return false; + } + + const hasSnubaCol = isString(maybe[0]); + const hasTitle = isString(maybe[1]); + + // TODO: implement later + // const hasWidth = typeof maybe[2] === 'number' && isFinite(maybe[2]); + // TODO: implement later + // const validTypes = hasSnubaCol && hasTitle && hasWidth; + + const validTypes = hasSnubaCol && hasTitle; + + return validTypes; +}; + +const decodeFields = (location: Location): Array => { + const {query} = location; + + if (!query || !query.field) { + return []; + } + + const fields: Array = isString(query.field) ? [query.field] : query.field; + + return fields.reduce((acc: Array, field: string) => { + try { + const result = JSON.parse(field); + + if (isValidQueryStringField(result)) { + const snuba_column = result[0].trim(); + + if (snuba_column.length > 0) { + acc.push({ + snuba_column, + title: result[1], + }); + } + + return acc; + } + } catch (_err) { + // no-op + } + + return acc; + }, []); +}; + +export const encodeFields = (fields: Array): Array => { + return fields.map(field => { + return JSON.stringify([field.snuba_column, field.title]); + }); +}; + +const fromSorts = (sorts: Array): Array => { + return sorts.reduce((acc: Array, sort: string) => { + sort = sort.trim(); + + if (sort.startsWith('-')) { + acc.push({ + kind: 'desc', + snuba_col: sort.substring(1), + }); + return acc; + } + + acc.push({ + kind: 'asc', + snuba_col: sort, + }); + + return acc; + }, []); +}; + +const decodeSorts = (location: Location): Array => { + const {query} = location; + + if (!query || !query.sort) { + return []; + } + + const sorts: Array = isString(query.sort) ? [query.sort] : query.sort; + + return fromSorts(sorts); +}; + +const encodeSort = (sort: Sort): string => { + switch (sort.kind) { + case 'desc': { + return `-${sort.snuba_col}`; + } + case 'asc': { + return String(sort.snuba_col); + } + default: { + throw new Error('unexpected sort type'); + } + } +}; + +const encodeSorts = (sorts: Array): Array => { + return sorts.map(encodeSort); +}; + +const decodeTags = (location: Location): Array => { + const {query} = location; + + if (!query || !query.tag) { + return []; + } + + const tags: Array = isString(query.tag) ? [query.tag] : query.tag; + + return tags.reduce((acc: Array, tag: string) => { + tag = tag.trim(); + + if (tag.length > 0) { + acc.push(tag); + } + + return acc; + }, []); +}; + +const decodeQuery = (location: Location): string | undefined => { + if (!location.query || !location.query.query) { + return void 0; + } + + const queryParameter = location.query.query; + + const query = + Array.isArray(queryParameter) && queryParameter.length > 0 + ? queryParameter[0] + : isString(queryParameter) + ? queryParameter + : void 0; + + return isString(query) ? query.trim() : undefined; +}; + +const AGGREGATE_PATTERN = /^([a-z0-9_]+)\(([a-z\._]*)\)$/i; + +class EventView { + fields: Field[]; + sorts: Sort[]; + tags: string[]; + query: string | undefined; + + constructor(props: { + fields: Field[]; + sorts: Sort[]; + tags: string[]; + query?: string | undefined; + }) { + this.fields = props.fields; + this.sorts = props.sorts; + this.tags = props.tags; + this.query = props.query; + } + + static fromLocation(location: Location): EventView { + return new EventView({ + fields: decodeFields(location), + sorts: decodeSorts(location), + tags: decodeTags(location), + query: decodeQuery(location), + }); + } + + static fromEventViewv1(eventViewV1: EventViewv1): EventView { + const fields = eventViewV1.data.fields.map((snubaColName: string, index: number) => { + return { + snuba_column: snubaColName, + title: eventViewV1.data.columnNames[index], + }; + }); + + return new EventView({ + fields, + sorts: fromSorts(eventViewV1.data.sort), + tags: eventViewV1.tags, + query: eventViewV1.data.query, + }); + } + + generateQueryStringObject = (): Query => { + return { + field: encodeFields(this.fields), + sort: encodeSorts(this.sorts), + tag: this.tags, + query: this.query, + }; + }; + + isValid = (): boolean => { + return this.fields.length > 0; + }; + + getFieldTitles = () => { + return this.fields.map(field => { + return field.title; + }); + }; + + getFieldSnubaCols = () => { + return this.fields.map(field => { + return field.snuba_column; + }); + }; + + numOfColumns = (): number => { + return this.fields.length; + }; + + getQuery = (inputQuery: string | string[] | null | undefined): string => { + const queryParts: Array = []; + + if (this.query) { + queryParts.push(this.query); + } + + if (inputQuery) { + // there may be duplicate query in the query string + // e.g. query=hello&query=world + if (Array.isArray(inputQuery)) { + inputQuery.forEach(query => { + if (typeof query === 'string') { + queryParts.push(query); + } + }); + } + + if (typeof inputQuery === 'string') { + queryParts.push(inputQuery); + } + } + + return queryParts.join(' '); + }; + + // Takes an EventView instance and converts it into the format required for the events API + getEventsAPIPayload = (location: Location): EventQuery => { + const query = location.query || {}; + + type LocationQuery = { + project?: string; + environment?: string; + start?: string; + end?: string; + utc?: string; + statsPeriod?: string; + cursor?: string; + sort?: string; + }; + + const picked = pick(query || {}, [ + 'project', + 'environment', + 'start', + 'end', + 'utc', + 'statsPeriod', + 'cursor', + 'sort', + ]); + + const fieldNames = this.getFieldSnubaCols(); + + const defaultSort = fieldNames.length > 0 ? [fieldNames[0]] : undefined; + + const eventQuery: EventQuery = Object.assign(picked, { + field: [...new Set(fieldNames)], + sort: picked.sort ? picked.sort : defaultSort, + per_page: DEFAULT_PER_PAGE, + query: this.getQuery(query.query), + }); + + if (!eventQuery.sort) { + delete eventQuery.sort; + } + + return eventQuery; + }; + + getDefaultSort = (): string | undefined => { + if (this.sorts.length <= 0) { + return void 0; + } + + return encodeSort(this.sorts[0]); + }; + + getSortKey = (snubaColumn: string, meta: MetaType): string | null => { + let column = snubaColumn; + if (snubaColumn.match(AGGREGATE_PATTERN)) { + column = snubaColumn.replace(AGGREGATE_PATTERN, '$1_$2').toLowerCase(); + } + if (SPECIAL_FIELDS.hasOwnProperty(column)) { + return SPECIAL_FIELDS[column as keyof typeof SPECIAL_FIELDS].sortField; + } + + if (FIELD_FORMATTERS.hasOwnProperty(meta[column])) { + return FIELD_FORMATTERS[meta[column] as keyof typeof FIELD_FORMATTERS].sortField + ? column + : null; + } + + return null; + }; +} + +export default EventView; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/events.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/events.tsx index 76e077ddbd71f1..be02d2814780c5 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/events.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/events.tsx @@ -1,26 +1,20 @@ import React from 'react'; -import PropTypes from 'prop-types'; import styled from 'react-emotion'; -import {omit, isEqual} from 'lodash'; import * as ReactRouter from 'react-router'; import {Location} from 'history'; -import {Organization, EventView} from 'app/types'; -import SentryTypes from 'app/sentryTypes'; +import {Organization} from 'app/types'; import space from 'app/styles/space'; import SearchBar from 'app/views/events/searchBar'; -import AsyncComponent from 'app/components/asyncComponent'; -import Pagination from 'app/components/pagination'; 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 Table from './table'; +import Discover2Table from './discover2table'; import Tags from './tags'; -import {getQuery, EventQuery} from './utils'; -import {MODAL_QUERY_KEYS} from './data'; +import EventView from './eventView'; const CHART_AXIS_OPTIONS = [ {label: 'Count', value: 'event_count'}, @@ -31,17 +25,10 @@ type EventsProps = { router: ReactRouter.InjectedRouter; location: Location; organization: Organization; - view: EventView; + eventView: EventView; }; export default class Events extends React.Component { - static propTypes = { - router: PropTypes.object.isRequired, - location: PropTypes.object.isRequired, - organization: SentryTypes.Organization.isRequired, - view: SentryTypes.EventView.isRequired, - }; - handleSearch = query => { const {router, location} = this.props; router.push({ @@ -54,7 +41,7 @@ export default class Events extends React.Component { }; render() { - const {organization, view, location, router} = this.props; + const {organization, eventView, location, router} = this.props; const query = location.query.query || ''; return ( @@ -64,7 +51,7 @@ export default class Events extends React.Component { value: ( { onSearch={this.handleSearch} /> - - + + ); } } -type EventsTableProps = { - location: Location; - organization: Organization; - view: EventView; -}; - -class EventsTable extends AsyncComponent { - static propTypes = { - location: PropTypes.object.isRequired, - organization: SentryTypes.Organization.isRequired, - view: SentryTypes.EventView.isRequired, - }; - - shouldReload = false; - - componentDidUpdate(prevProps: EventsTableProps, prevContext) { - // Do not update if we are just opening/closing the modal - const locationHasChanged = !isEqual( - omit(prevProps.location.query, MODAL_QUERY_KEYS), - omit(this.props.location.query, MODAL_QUERY_KEYS) - ); - - if (locationHasChanged) { - super.componentDidUpdate(prevProps, prevContext); - } - } - - getEndpoints(): Array<[string, string, {query: EventQuery}]> { - const {location, organization, view} = this.props; - return [ - [ - 'data', - `/organizations/${organization.slug}/eventsv2/`, - { - query: getQuery(view, location), - }, - ], - ]; - } - - renderLoading() { - return this.renderBody(); - } - - renderBody() { - const {organization, view, location} = this.props; - const {data, dataPageLinks, loading} = this.state; - - return ( -
-
- - - ); - } -} - const Container = styled('div')` display: grid; grid-template-columns: auto 300px; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/index.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/index.tsx index f5d2a929d764a3..cd974f054ddd09 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/index.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/index.tsx @@ -21,7 +21,9 @@ import withOrganization from 'app/utils/withOrganization'; import Events from './events'; import EventDetails from './eventDetails'; -import {getCurrentView, getFirstQueryString} from './utils'; +import {getFirstQueryString} from './utils'; +import {ALL_VIEWS} from './data'; +import EventView from './eventView'; type Props = { organization: Organization; @@ -39,86 +41,77 @@ class OrganizationEventsV2 extends React.Component { renderQueryList() { const {location} = this.props; - const allEvents = { - pathname: location.pathname, - query: { - ...location.query, - view: 'all', - }, - }; - const errors = { - pathname: location.pathname, - query: { - ...location.query, - view: 'errors', - cursor: undefined, - sort: undefined, - }, - }; - const csp = { - pathname: location.pathname, - query: { - ...location.query, - view: 'csp', - cursor: undefined, - sort: undefined, - }, - }; - const transactions = { - pathname: location.pathname, - query: { - ...location.query, - view: 'transactions', - cursor: undefined, - sort: undefined, - }, - }; - return ( - - - All Events - - - Errors - - - CSP - - - Transactions + + const list = ALL_VIEWS.map((eventViewv1, index) => { + const eventView = EventView.fromEventViewv1(eventViewv1); + + const name = eventViewv1.name; + + const to = { + pathname: location.pathname, + query: { + ...location.query, + name, + ...eventView.generateQueryStringObject(), + }, + }; + + return ( + + {name} - - ); + ); + }); + + return {list}; } + getEventViewName = (): Array => { + const {location} = this.props; + + const name = getFirstQueryString(location.query, 'name'); + + if (typeof name === 'string' && String(name).trim().length > 0) { + return [t('Events'), String(name).trim()]; + // return `${} \u2014 ${}`; + } + + return [t('Events')]; + }; + render() { const {organization, location, router} = this.props; const eventSlug = getFirstQueryString(location.query, 'eventSlug'); - const view = getFirstQueryString(location.query, 'view'); - const currentView = getCurrentView(view); + const eventView = EventView.fromLocation(location); + const hasQuery = location.query.field || location.query.eventSlug || location.query.view; + const documentTitle = this.getEventViewName() + .reverse() + .join(' - '); + const pageTitle = this.getEventViewName().join(' \u2014 '); + return ( - + - {t('Events')} + {pageTitle} {!hasQuery && this.renderQueryList()} {hasQuery && ( )} {hasQuery && eventSlug && ( @@ -126,7 +119,7 @@ class OrganizationEventsV2 extends React.Component { organization={organization} params={this.props.params} eventSlug={eventSlug} - view={currentView} + eventView={eventView} location={location} /> )} diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.tsx index 6f19dfb6a815da..feb59eb1980e71 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/modalLineGraph.tsx @@ -20,10 +20,10 @@ import {Panel} from 'app/components/panels'; import withApi from 'app/utils/withApi'; import withGlobalSelection from 'app/utils/withGlobalSelection'; import theme from 'app/utils/theme'; -import {Event, Organization, EventView} from 'app/types'; +import {Event, Organization} from 'app/types'; import {MODAL_QUERY_KEYS, PIN_ICON} from './data'; -import {getQueryString} from './utils'; +import EventView from './eventView'; /** * Generate the data to display a vertical line for the current @@ -135,7 +135,7 @@ type ModalLineGraphProps = { organization: Organization; location: Location; currentEvent: Event; - view: EventView; + eventView: EventView; // TODO(ts): adjust selection: any; }; @@ -144,7 +144,7 @@ type ModalLineGraphProps = { * Render a graph of event volumes for the current group + event. */ const ModalLineGraph = (props: ModalLineGraphProps) => { - const {api, organization, location, selection, currentEvent, view} = props; + const {api, organization, location, selection, currentEvent, eventView} = props; const isUtc = selection.datetime.utc; const dateFormat = 'lll'; @@ -169,7 +169,7 @@ const ModalLineGraph = (props: ModalLineGraphProps) => { }, }; - const queryString = getQueryString(view, location); + const queryString = eventView.getQuery(location.query.query); const referenceEvent = `${currentEvent.projectSlug}:${currentEvent.eventID}`; return ( @@ -185,7 +185,7 @@ const ModalLineGraph = (props: ModalLineGraphProps) => { interval={interval} showLoading={true} query={queryString} - field={view.data.fields} + field={eventView.getFieldSnubaCols()} referenceEvent={referenceEvent} includePrevious={false} > @@ -199,7 +199,7 @@ const ModalLineGraph = (props: ModalLineGraphProps) => { }} onClick={series => handleClick(series, { - field: view.data.fields, + field: eventView.getFieldSnubaCols(), api, organization, currentEvent, @@ -227,7 +227,6 @@ ModalLineGraph.propTypes = { location: PropTypes.object.isRequired, organization: SentryTypes.Organization.isRequired, selection: PropTypes.object.isRequired, - view: PropTypes.object.isRequired, } as any; export default withGlobalSelection(withApi(ModalLineGraph)); diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/relatedEvents.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/relatedEvents.tsx index 5174efbbed48b6..9fc7f38602a323 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/relatedEvents.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/relatedEvents.tsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import {omit} from 'lodash'; import {Location} from 'history'; -import {Organization, EventView, Event, Project} from 'app/types'; +import {Organization, EventViewv1, Event, Project} from 'app/types'; import {t} from 'app/locale'; import SentryTypes from 'app/sentryTypes'; import AsyncComponent from 'app/components/asyncComponent'; @@ -23,7 +23,7 @@ import {EventQuery} from './utils'; type Props = { location: Location; organization: Organization; - view: EventView; + view: EventViewv1; event: Event; projects: Array; }; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/sortLink.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/sortLink.tsx index 6ad1c15d1acd03..8c8e63b326acc0 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/sortLink.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/sortLink.tsx @@ -9,7 +9,7 @@ import Link from 'app/components/links/link'; type Props = { title: string; sortKey: string; - defaultSort: string | null; + defaultSort: string; location: Location; }; @@ -21,9 +21,9 @@ class SortLink extends React.Component { location: PropTypes.object.isRequired, }; - getCurrentSort() { + getCurrentSort(): string { const {defaultSort, location} = this.props; - return location.query.sort ? location.query.sort : defaultSort; + return typeof location.query.sort === 'string' ? location.query.sort : defaultSort; } getSort() { diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/table.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/table.tsx deleted file mode 100644 index b05b59819f3242..00000000000000 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/table.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled, {css} from 'react-emotion'; -import {omit} from 'lodash'; -import {Location} from 'history'; - -import SentryTypes from 'app/sentryTypes'; -import {Panel, PanelHeader, PanelBody, PanelItem} from 'app/components/panels'; -import EmptyStateWarning from 'app/components/emptyStateWarning'; -import LoadingContainer from 'app/components/loading/loadingContainer'; -import {t} from 'app/locale'; -import space from 'app/styles/space'; -import {EventView, Organization} from 'app/types'; - -import {FIELD_FORMATTERS, SPECIAL_FIELDS} from './data'; -import {getFieldRenderer} from './utils'; -import SortLink from './sortLink'; - -type Props = { - view: EventView; - isLoading: boolean; - location: Location; - organization: Organization; - // TODO(ts): adjust this type - data: any; -}; - -export default class Table extends React.Component { - static propTypes = { - view: SentryTypes.EventView.isRequired, - data: PropTypes.object, - isLoading: PropTypes.bool.isRequired, - organization: SentryTypes.Organization.isRequired, - location: PropTypes.object, - }; - - renderBody() { - const {view, organization, location, isLoading} = this.props; - - if (!this.props.data || isLoading) { - return null; - } - const {fields} = view.data; - const {data, meta} = this.props.data; - - if (data.length === 0) { - return ( - -

{t('No results found')}

-
- ); - } - - return data.map((row, idx) => ( - - {fields.map(field => { - const fieldRenderer = getFieldRenderer(field, meta); - return {fieldRenderer(row, {organization, location})}; - })} - - )); - } - - render() { - const {isLoading, location, view} = this.props; - const {fields, columnNames, sort} = view.data; - const defaultSort = sort.length ? sort[0] : null; - - return ( - - - {fields.map((field, i) => { - const title = columnNames[i] || field; - - let sortKey: string | null = field; - if (SPECIAL_FIELDS.hasOwnProperty(field)) { - sortKey = SPECIAL_FIELDS[field].sortField || null; - } else if (FIELD_FORMATTERS.hasOwnProperty(field)) { - sortKey = FIELD_FORMATTERS[field].sortField ? field : null; - } - - if (sortKey === null) { - return {title}; - } - - return ( - - - - ); - })} - - - {this.renderBody()} - - - ); - } -} - -function getGridStyle(view) { - const cols = Array.isArray(view.columnWidths) - ? view.columnWidths.join(' ') - : `3fr repeat(${view.data.fields.length - 1}, 1fr)`; - - return css` - display: grid; - grid-template-columns: ${cols}; - grid-gap: ${space(1)}; - `; -} - -const TableHeader = styled(PanelHeader)` - padding: ${space(2)} ${space(1)}; -`; - -const HeaderItem = styled('div')` - padding: 0 ${space(1)}; -`; - -const Row = styled(PanelItem)` - font-size: ${p => p.theme.fontSizeMedium}; - padding: ${space(1)}; -`; - -const Cell = styled('div')` - display: flex; - align-items: center; - overflow: hidden; -`; - -// TODO(ts): adjust types -const StyledPanelBody = styled(props => { - const otherProps = omit(props, 'isLoading'); - return ; -})` - ${(p: {isLoading: boolean}) => p.isLoading && 'min-height: 240px;'}; -`; diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/tags.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/tags.tsx index d96c9b5b24fa97..c52db58c7facea 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/tags.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/tags.tsx @@ -10,22 +10,22 @@ import SentryTypes from 'app/sentryTypes'; import Placeholder from 'app/components/placeholder'; import TagDistributionMeter from 'app/components/tagDistributionMeter'; import withApi from 'app/utils/withApi'; -import {Organization, EventView} from 'app/types'; +import {Organization} from 'app/types'; import { fetchTagDistribution, fetchTotalCount, getEventTagSearchUrl, - getQuery, Tag, TagTopValue, } from './utils'; import {MODAL_QUERY_KEYS} from './data'; +import EventView from './eventView'; type Props = { api: Client; organization: Organization; - view: EventView; + eventView: EventView; location: Location; }; @@ -38,8 +38,8 @@ class Tags extends React.Component { static propTypes: any = { api: PropTypes.object.isRequired, organization: SentryTypes.Organization.isRequired, - view: SentryTypes.EventView.isRequired, location: PropTypes.object.isRequired, + eventView: PropTypes.object.isRequired, }; state: State = { @@ -58,23 +58,23 @@ class Tags extends React.Component { omit(this.props.location.query, MODAL_QUERY_KEYS) ); - if (this.props.view.id !== prevProps.view.id || locationHasChanged) { + if (locationHasChanged) { this.fetchData(); } } fetchData = async () => { - const {api, organization, view, location} = this.props; + const {api, organization, eventView, location} = this.props; this.setState({tags: {}, totalValues: null}); - view.tags.forEach(async tag => { + eventView.tags.forEach(async tag => { try { const val = await fetchTagDistribution( api, organization.slug, tag, - getQuery(view, location) + eventView.getEventsAPIPayload(location) ); this.setState(state => ({tags: {...state.tags, [tag]: val}})); @@ -87,7 +87,7 @@ class Tags extends React.Component { const totalValues = await fetchTotalCount( api, organization.slug, - getQuery(view, location) + eventView.getEventsAPIPayload(location) ); this.setState({totalValues}); } catch (err) { @@ -123,7 +123,7 @@ class Tags extends React.Component { } render() { - return
{this.props.view.tags.map(tag => this.renderTag(tag))}
; + return
{this.props.eventView.tags.map(tag => this.renderTag(tag))}
; } } diff --git a/src/sentry/static/sentry/app/views/organizationEventsV2/utils.tsx b/src/sentry/static/sentry/app/views/organizationEventsV2/utils.tsx index f688c977dd068c..d79a5e88892758 100644 --- a/src/sentry/static/sentry/app/views/organizationEventsV2/utils.tsx +++ b/src/sentry/static/sentry/app/views/organizationEventsV2/utils.tsx @@ -1,22 +1,16 @@ -import {partial, pick, get} from 'lodash'; +import {partial, pick} from 'lodash'; import {Location} from 'history'; import {Client} from 'app/api'; -import {EventView} from 'app/types'; -import {DEFAULT_PER_PAGE} from 'app/constants'; import {URL_PARAM} from 'app/constants/globalSelectionHeader'; -import {ALL_VIEWS, AGGREGATE_ALIASES, SPECIAL_FIELDS, FIELD_FORMATTERS} from './data'; - -/** - * Given a view id, return the corresponding view object - * - * @param {String} requestedView - * @returns {Object} - * - */ -export function getCurrentView(requestedView?: string): EventView { - return ALL_VIEWS.find(view => view.id === requestedView) || ALL_VIEWS[0]; -} +import { + AGGREGATE_ALIASES, + SPECIAL_FIELDS, + FIELD_FORMATTERS, + FieldTypes, + FieldFormatterRenderFunctionPartial, +} from './data'; +import EventView from './eventView'; export type EventQuery = { field: Array; @@ -29,89 +23,17 @@ export type EventQuery = { /** * Takes a view and determines if there are any aggregate fields in it. * - * TODO(mark) This function should be part of an EventView abstraction * * @param {Object} view * @returns {Boolean} */ -export function hasAggregateField(view) { - return view.data.fields.some( - field => AGGREGATE_ALIASES.includes(field) || field.match(/[a-z_]+\([a-z_\.]+\)/) - ); -} - -/** - * Takes a view and converts it into the format required for the events API - * - * TODO(mark) This function should be part of an EventView abstraction - * - * @param {Object} view - * @returns {Object} - */ -export function getQuery(view: EventView, location: Location) { - const fields: Array = get(view, 'data.fields', []); - - type LocationQuery = { - project?: string; - environment?: string; - start?: string; - end?: string; - utc?: string; - statsPeriod?: string; - cursor?: string; - sort?: string; - }; - - const picked = pick(location.query, [ - 'project', - 'environment', - 'start', - 'end', - 'utc', - 'statsPeriod', - 'cursor', - 'sort', - ]); - - const data: EventQuery = Object.assign(picked, { - field: [...new Set(fields)], - sort: picked.sort ? picked.sort : view.data.sort, - per_page: DEFAULT_PER_PAGE, - query: getQueryString(view, location), - }); - - return data; -} - -/** - * Generate a querystring based on the view defaults, current - * location and any additional parameters - * - * TODO(mark) This function should be part of an EventView abstraction - * - * @param {Object} view defaults containing `.data.query` - * @param {Location} browser location - */ -export function getQueryString(view: EventView, location: Location): string { - const queryParts: Array = []; - if (view.data.query) { - queryParts.push(view.data.query); - } - if (location.query && location.query.query) { - // there may be duplicate query in the query string - // e.g. query=hello&query=world - if (Array.isArray(location.query.query)) { - location.query.query.forEach(query => { - queryParts.push(query); - }); - } - - if (typeof location.query.query === 'string') { - queryParts.push(location.query.query); - } - } - - return queryParts.join(' '); +export function hasAggregateField(eventView: EventView): boolean { + return eventView + .getFieldSnubaCols() + .some( + field => + AGGREGATE_ALIASES.includes(field as any) || field.match(/[a-z_]+\([a-z_\.]+\)/) + ); } /** @@ -208,6 +130,10 @@ export function fetchTotalCount( .then((res: Response) => res.count); } +export type MetaType = { + [key: string]: FieldTypes; +}; + /** * Get the field renderer for the named field and metadata * @@ -215,7 +141,10 @@ export function fetchTotalCount( * @param {object} metadata mapping. * @returns {Function} */ -export function getFieldRenderer(field: string, meta) { +export function getFieldRenderer( + field: string, + meta: MetaType +): FieldFormatterRenderFunctionPartial { if (SPECIAL_FIELDS.hasOwnProperty(field)) { return SPECIAL_FIELDS[field].renderFunc; } diff --git a/tests/acceptance/test_organization_events_v2.py b/tests/acceptance/test_organization_events_v2.py index 6abbe552af0761..b9b376c7a1a0ea 100644 --- a/tests/acceptance/test_organization_events_v2.py +++ b/tests/acceptance/test_organization_events_v2.py @@ -11,6 +11,9 @@ FEATURE_NAME = "organizations:events-v2" +all_view = 'field=%5B"title"%2C"title"%5D&field=%5B"event.type"%2C"type"%5D&field=%5B"project"%2C"project"%5D&field=%5B"user"%2C"user"%5D&field=%5B"timestamp"%2C"time"%5D&name=All+Events&sort=-timestamp&tag=event.type&tag=release&tag=project.name&tag=user.email&tag=user.ip&tag=environment' +error_view = 'field=%5B"title"%2C"error"%5D&field=%5B"count%28id%29"%2C"events"%5D&field=%5B"count_unique%28user%29"%2C"users"%5D&field=%5B"project"%2C"project"%5D&field=%5B"last_seen"%2C"last+seen"%5D&name=Errors&query=event.type%3Aerror&sort=-last_seen&sort=-title&tag=error.type&tag=project.name' + class OrganizationEventsV2Test(AcceptanceTestCase, SnubaTestCase): def setUp(self): @@ -30,7 +33,7 @@ def wait_until_loaded(self): def test_all_events_empty(self): with self.feature(FEATURE_NAME): - self.browser.get(self.path + "?view=all") + self.browser.get(self.path + "?" + all_view) self.wait_until_loaded() self.browser.snapshot("events-v2 - all events empty state") @@ -50,7 +53,7 @@ def test_all_events(self, mock_now): ) with self.feature(FEATURE_NAME): - self.browser.get(self.path + "?view=all") + self.browser.get(self.path + "?" + all_view) self.wait_until_loaded() self.browser.snapshot("events-v2 - all events") @@ -90,7 +93,7 @@ def test_errors(self, mock_now): ) with self.feature(FEATURE_NAME): - self.browser.get(self.path + "?view=errors") + self.browser.get(self.path + "?" + error_view) self.wait_until_loaded() self.browser.snapshot("events-v2 - errors") @@ -114,7 +117,7 @@ def test_modal_from_all_events(self, mock_now): with self.feature(FEATURE_NAME): # Get the list page. - self.browser.get(self.path + "?view=all") + self.browser.get(self.path + "?" + all_view) self.wait_until_loaded() # Click the event link to open the modal @@ -152,8 +155,9 @@ def test_modal_from_errors_view(self, mock_now): event_ids.append(event.event_id) with self.feature(FEATURE_NAME): + # Get the list page - self.browser.get(self.path + "?view=errors&statsPeriod=24h") + self.browser.get(self.path + "?" + error_view + "&statsPeriod=24h") self.wait_until_loaded() # Click the event link to open the modal diff --git a/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx b/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx index bc333739eec99d..18a422e46db0c7 100644 --- a/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx +++ b/tests/js/spec/views/organizationEventsV2/eventDetails.spec.jsx @@ -4,11 +4,14 @@ import {initializeOrg} from 'app-test/helpers/initializeOrg'; import {browserHistory} from 'react-router'; import EventDetails from 'app/views/organizationEventsV2/eventDetails'; -import {ALL_VIEWS} from 'app/views/organizationEventsV2/data'; +import {ALL_VIEWS, DEFAULT_EVENT_VIEW_V1} from 'app/views/organizationEventsV2/data'; +import EventView from 'app/views/organizationEventsV2/eventView'; describe('OrganizationEventsV2 > EventDetails', function() { - const allEventsView = ALL_VIEWS.find(view => view.id === 'all'); - const errorsView = ALL_VIEWS.find(view => view.id === 'errors'); + const allEventsView = EventView.fromEventViewv1(DEFAULT_EVENT_VIEW_V1); + const errorsView = EventView.fromEventViewv1( + ALL_VIEWS.find(view => view.id === 'errors') + ); beforeEach(function() { MockApiClient.addMockResponse({ @@ -153,7 +156,7 @@ describe('OrganizationEventsV2 > EventDetails', function() { organization={TestStubs.Organization({projects: [TestStubs.Project()]})} eventSlug="project-slug:deadbeef" location={{query: {eventSlug: 'project-slug:deadbeef'}}} - view={allEventsView} + eventView={allEventsView} />, TestStubs.routerContext() ); @@ -170,7 +173,7 @@ describe('OrganizationEventsV2 > EventDetails', function() { organization={TestStubs.Organization({projects: [TestStubs.Project()]})} eventSlug="project-slug:abad1" location={{query: {eventSlug: 'project-slug:abad1'}}} - view={allEventsView} + eventView={allEventsView} />, TestStubs.routerContext() ); @@ -184,7 +187,7 @@ describe('OrganizationEventsV2 > EventDetails', function() { organization={TestStubs.Organization({projects: [TestStubs.Project()]})} eventSlug="project-slug:deadbeef" location={{query: {eventSlug: 'project-slug:deadbeef'}}} - view={errorsView} + eventView={errorsView} />, TestStubs.routerContext() ); @@ -204,7 +207,7 @@ describe('OrganizationEventsV2 > EventDetails', function() { pathname: '/organizations/org-slug/events/', query: {eventSlug: 'project-slug:deadbeef'}, }} - view={allEventsView} + eventView={allEventsView} />, TestStubs.routerContext() ); @@ -233,7 +236,7 @@ describe('OrganizationEventsV2 > EventDetails', function() { organization={organization} eventSlug="project-slug:deadbeef" location={{query: {eventSlug: 'project-slug:deadbeef'}}} - view={allEventsView} + eventView={allEventsView} />, routerContext ); @@ -269,7 +272,7 @@ describe('OrganizationEventsV2 > EventDetails', function() { organization={organization} eventSlug="project-slug:deadbeef" location={{query: {eventSlug: 'project-slug:deadbeef'}}} - view={allEventsView} + eventView={allEventsView} />, routerContext ); diff --git a/tests/js/spec/views/organizationEventsV2/eventView.spec.jsx b/tests/js/spec/views/organizationEventsV2/eventView.spec.jsx new file mode 100644 index 00000000000000..846fc1f0341a17 --- /dev/null +++ b/tests/js/spec/views/organizationEventsV2/eventView.spec.jsx @@ -0,0 +1,34 @@ +import EventView from 'app/views/organizationEventsV2/eventView'; + +describe('EventView.getEventsAPIPayload()', function() { + it('appends any additional conditions defined for view', function() { + const eventView = new EventView({ + fields: ['id'], + sorts: [], + tags: [], + query: 'event.type:csp', + }); + + const location = {}; + + expect(eventView.getEventsAPIPayload(location).query).toEqual('event.type:csp'); + }); + + it('appends query conditions in location', function() { + const eventView = new EventView({ + fields: ['id'], + sorts: [], + tags: [], + query: 'event.type:csp', + }); + + const location = { + query: { + query: 'TypeError', + }, + }; + expect(eventView.getEventsAPIPayload(location).query).toEqual( + 'event.type:csp TypeError' + ); + }); +}); diff --git a/tests/js/spec/views/organizationEventsV2/index.spec.jsx b/tests/js/spec/views/organizationEventsV2/index.spec.jsx index beeeb075ad3c95..2993ef996c9550 100644 --- a/tests/js/spec/views/organizationEventsV2/index.spec.jsx +++ b/tests/js/spec/views/organizationEventsV2/index.spec.jsx @@ -2,6 +2,26 @@ import React from 'react'; import {mount} from 'enzyme'; import {OrganizationEventsV2} from 'app/views/organizationEventsV2'; +import {encodeFields} from 'app/views/organizationEventsV2/eventView'; + +const FIELDS = [ + { + snuba_column: 'title', + title: 'Custom Title', + }, + { + snuba_column: 'timestamp', + title: 'Custom Time', + }, + { + snuba_column: 'user', + title: 'Custom User', + }, +]; + +const generateFields = () => { + return encodeFields(FIELDS); +}; describe('OrganizationEventsV2', function() { const eventTitle = 'Oh no something bad'; @@ -16,10 +36,12 @@ describe('OrganizationEventsV2', function() { title: 'string', 'project.name': 'string', timestamp: 'date', + 'user.id': 'string', }, data: [ { id: 'deadbeef', + 'user.id': 'alberto leal', title: eventTitle, 'project.name': 'project-slug', timestamp: '2019-05-23T22:12:48+00:00', @@ -67,20 +89,21 @@ describe('OrganizationEventsV2', function() { const wrapper = mount( , TestStubs.routerContext() ); const content = wrapper.find('PageContent'); - expect(content.find('Events Cell').length).toBeGreaterThan(0); + expect(content.find('Events PanelHeaderCell').length).toBeGreaterThan(0); + expect(content.find('Events PanelItemCell').length).toBeGreaterThan(0); }); it('handles no projects', function() { const wrapper = mount( , TestStubs.routerContext() @@ -94,7 +117,7 @@ describe('OrganizationEventsV2', function() { const wrapper = mount( , TestStubs.routerContext() @@ -107,6 +130,7 @@ describe('OrganizationEventsV2', function() { .find('StyledLink'); const timestamp = findLink('timestamp'); + // Sort should be active expect( timestamp @@ -116,18 +140,25 @@ describe('OrganizationEventsV2', function() { ).toEqual('icon-chevron-down'); // Sort link should reverse. - expect(timestamp.props().to.query).toEqual({view: 'all', sort: 'timestamp'}); + expect(timestamp.props().to.query).toEqual({ + field: generateFields(), + sort: 'timestamp', + }); const userlink = findLink('user.id'); + // User link should be descending. - expect(userlink.props().to.query).toEqual({view: 'all', sort: '-user.id'}); + expect(userlink.props().to.query).toEqual({ + field: generateFields(), + sort: '-user.id', + }); }); it('generates links to modals', async function() { const wrapper = mount( , TestStubs.routerContext() @@ -135,8 +166,8 @@ describe('OrganizationEventsV2', function() { const link = wrapper.find(`Table Link[aria-label="${eventTitle}"]`).first(); expect(link.props().to.query).toEqual({ - view: 'all', eventSlug: 'project-slug:deadbeef', + field: generateFields(), }); }); diff --git a/tests/js/spec/views/organizationEventsV2/tags.spec.jsx b/tests/js/spec/views/organizationEventsV2/tags.spec.jsx index 771f216258c356..080a8c8f9efb05 100644 --- a/tests/js/spec/views/organizationEventsV2/tags.spec.jsx +++ b/tests/js/spec/views/organizationEventsV2/tags.spec.jsx @@ -3,6 +3,7 @@ import {mount} from 'enzyme'; import {Client} from 'app/api'; import {Tags} from 'app/views/organizationEventsV2/tags'; +import EventView from 'app/views/organizationEventsV2/eventView'; describe('Tags', function() { const org = TestStubs.Organization(); @@ -58,17 +59,17 @@ describe('Tags', function() { it('renders', async function() { const api = new Client(); - const view = { - id: 'test', - name: 'Test', - data: { - query: 'event.type:csp', - }, + + const view = new EventView({ + fields: [], + sorts: [], tags: ['release', 'environment'], - }; + query: 'event.type:csp', + }); + const wrapper = mount(