utils.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  1. import type {InjectedRouter} from 'react-router';
  2. import {urlEncode} from '@sentry/utils';
  3. import type {Location, Query} from 'history';
  4. import * as Papa from 'papaparse';
  5. import {openAddToDashboardModal} from 'sentry/actionCreators/modal';
  6. import {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
  7. import {URL_PARAM} from 'sentry/constants/pageFilters';
  8. import {t} from 'sentry/locale';
  9. import type {
  10. NewQuery,
  11. Organization,
  12. OrganizationSummary,
  13. Project,
  14. SelectValue,
  15. } from 'sentry/types';
  16. import type {Event} from 'sentry/types/event';
  17. import {browserHistory} from 'sentry/utils/browserHistory';
  18. import {getUtcDateString} from 'sentry/utils/dates';
  19. import type {TableDataRow} from 'sentry/utils/discover/discoverQuery';
  20. import type {EventData} from 'sentry/utils/discover/eventView';
  21. import type EventView from 'sentry/utils/discover/eventView';
  22. import type {
  23. Aggregation,
  24. Column,
  25. ColumnType,
  26. ColumnValueType,
  27. Field,
  28. } from 'sentry/utils/discover/fields';
  29. import {
  30. aggregateFunctionOutputType,
  31. AGGREGATIONS,
  32. explodeFieldString,
  33. getAggregateAlias,
  34. getAggregateArg,
  35. getColumnsAndAggregates,
  36. getEquation,
  37. isAggregateEquation,
  38. isEquation,
  39. isMeasurement,
  40. isSpanOperationBreakdownField,
  41. measurementType,
  42. PROFILING_FIELDS,
  43. TRACING_FIELDS,
  44. } from 'sentry/utils/discover/fields';
  45. import {type DisplayModes, SavedQueryDatasets, TOP_N} from 'sentry/utils/discover/types';
  46. import {getTitle} from 'sentry/utils/events';
  47. import {DISCOVER_FIELDS, FieldValueType, getFieldDefinition} from 'sentry/utils/fields';
  48. import localStorage from 'sentry/utils/localStorage';
  49. import {MutableSearch} from 'sentry/utils/tokenizeSearch';
  50. import {DataSet} from 'sentry/views/dashboards/widgetBuilder/utils';
  51. import type {WidgetQuery} from '../dashboards/types';
  52. import {DashboardWidgetSource, DisplayType} from '../dashboards/types';
  53. import {transactionSummaryRouteWithQuery} from '../performance/transactionSummary/utils';
  54. import {displayModeToDisplayType, getSavedQueryDataset} from './savedQuery/utils';
  55. import type {FieldValue, TableColumn} from './table/types';
  56. import {FieldValueKind} from './table/types';
  57. import {ALL_VIEWS, TRANSACTION_VIEWS, WEB_VITALS_VIEWS} from './data';
  58. export type QueryWithColumnState =
  59. | Query
  60. | {
  61. field: string | string[] | null | undefined;
  62. sort: string | string[] | null | undefined;
  63. };
  64. const TEMPLATE_TABLE_COLUMN: TableColumn<string> = {
  65. key: '',
  66. name: '',
  67. type: 'never',
  68. isSortable: false,
  69. column: Object.freeze({kind: 'field', field: ''}),
  70. width: COL_WIDTH_UNDEFINED,
  71. };
  72. // TODO(mark) these types are coupled to the gridEditable component types and
  73. // I'd prefer the types to be more general purpose but that will require a second pass.
  74. export function decodeColumnOrder(fields: Readonly<Field[]>): TableColumn<string>[] {
  75. return fields.map((f: Field) => {
  76. const column: TableColumn<string> = {...TEMPLATE_TABLE_COLUMN};
  77. const col = explodeFieldString(f.field, f.alias);
  78. const columnName = f.field;
  79. if (isEquation(f.field)) {
  80. column.key = f.field;
  81. column.name = getEquation(columnName);
  82. column.type = 'number';
  83. } else {
  84. column.key = columnName;
  85. column.name = columnName;
  86. }
  87. column.width = f.width || COL_WIDTH_UNDEFINED;
  88. if (col.kind === 'function') {
  89. // Aggregations can have a strict outputType or they can inherit from their field.
  90. // Otherwise use the FIELDS data to infer types.
  91. const outputType = aggregateFunctionOutputType(col.function[0], col.function[1]);
  92. if (outputType !== null) {
  93. column.type = outputType;
  94. }
  95. const aggregate = AGGREGATIONS[col.function[0]];
  96. column.isSortable = aggregate?.isSortable;
  97. } else if (col.kind === 'field') {
  98. if (getFieldDefinition(col.field) !== null) {
  99. column.type = getFieldDefinition(col.field)?.valueType as ColumnValueType;
  100. } else if (isMeasurement(col.field)) {
  101. column.type = measurementType(col.field);
  102. } else if (isSpanOperationBreakdownField(col.field)) {
  103. column.type = 'duration';
  104. }
  105. }
  106. column.column = col;
  107. return column;
  108. });
  109. }
  110. export function pushEventViewToLocation(props: {
  111. location: Location;
  112. nextEventView: EventView;
  113. extraQuery?: Query;
  114. }) {
  115. const {location, nextEventView} = props;
  116. const extraQuery = props.extraQuery || {};
  117. const queryStringObject = nextEventView.generateQueryStringObject();
  118. browserHistory.push({
  119. ...location,
  120. query: {
  121. ...extraQuery,
  122. ...queryStringObject,
  123. },
  124. });
  125. }
  126. export function generateTitle({
  127. eventView,
  128. event,
  129. organization,
  130. }: {
  131. eventView: EventView;
  132. event?: Event;
  133. organization?: Organization;
  134. }) {
  135. const titles = [t('Discover')];
  136. const eventViewName = eventView.name;
  137. if (typeof eventViewName === 'string' && String(eventViewName).trim().length > 0) {
  138. titles.push(String(eventViewName).trim());
  139. }
  140. const eventTitle = event ? getTitle(event, organization?.features).title : undefined;
  141. if (eventTitle) {
  142. titles.push(eventTitle);
  143. }
  144. titles.reverse();
  145. return titles.join(' — ');
  146. }
  147. export function getPrebuiltQueries(organization: Organization) {
  148. const views = [...ALL_VIEWS];
  149. if (organization.features.includes('performance-view')) {
  150. // insert transactions queries at index 2
  151. views.splice(2, 0, ...TRANSACTION_VIEWS);
  152. views.push(...WEB_VITALS_VIEWS);
  153. }
  154. return views;
  155. }
  156. function disableMacros(value: string | null | boolean | number) {
  157. const unsafeCharacterRegex = /^[\=\+\-\@]/;
  158. if (typeof value === 'string' && `${value}`.match(unsafeCharacterRegex)) {
  159. return `'${value}`;
  160. }
  161. return value;
  162. }
  163. export function downloadAsCsv(tableData, columnOrder, filename) {
  164. const {data} = tableData;
  165. const headings = columnOrder.map(column => column.name);
  166. const keys = columnOrder.map(column => column.key);
  167. const csvContent = Papa.unparse({
  168. fields: headings,
  169. data: data.map(row =>
  170. keys.map(key => {
  171. return disableMacros(row[key]);
  172. })
  173. ),
  174. });
  175. // Need to also manually replace # since encodeURI skips them
  176. const encodedDataUrl = `data:text/csv;charset=utf8,${encodeURIComponent(csvContent)}`;
  177. // Create a download link then click it, this is so we can get a filename
  178. const link = document.createElement('a');
  179. const now = new Date();
  180. link.setAttribute('href', encodedDataUrl);
  181. link.setAttribute('download', `${filename} ${getUtcDateString(now)}.csv`);
  182. link.click();
  183. link.remove();
  184. // Make testing easier
  185. return encodedDataUrl;
  186. }
  187. const ALIASED_AGGREGATES_COLUMN = {
  188. last_seen: 'timestamp',
  189. failure_count: 'transaction.status',
  190. };
  191. /**
  192. * Convert an aggregate into the resulting column from a drilldown action.
  193. * The result is null if the drilldown results in the aggregate being removed.
  194. */
  195. function drilldownAggregate(
  196. func: Extract<Column, {kind: 'function'}>
  197. ): Extract<Column, {kind: 'field'}> | null {
  198. const key = func.function[0];
  199. const aggregation = AGGREGATIONS[key];
  200. let column = func.function[1];
  201. if (ALIASED_AGGREGATES_COLUMN.hasOwnProperty(key)) {
  202. // Some aggregates are just shortcuts to other aggregates with
  203. // predefined arguments so we can directly map them to the result.
  204. column = ALIASED_AGGREGATES_COLUMN[key];
  205. } else if (aggregation?.parameters?.[0]) {
  206. const parameter = aggregation.parameters[0];
  207. if (parameter.kind !== 'column') {
  208. // The aggregation does not accept a column as a parameter,
  209. // so we clear the column.
  210. column = '';
  211. } else if (!column && parameter.required === false) {
  212. // The parameter was not given for a non-required parameter,
  213. // so we fall back to the default.
  214. column = parameter.defaultValue;
  215. }
  216. } else {
  217. // The aggregation does not exist or does not have any parameters,
  218. // so we clear the column.
  219. column = '';
  220. }
  221. return column ? {kind: 'field', field: column} : null;
  222. }
  223. /**
  224. * Convert an aggregated query into one that does not have aggregates.
  225. * Will also apply additions conditions defined in `additionalConditions`
  226. * and generate conditions based on the `dataRow` parameter and the current fields
  227. * in the `eventView`.
  228. */
  229. export function getExpandedResults(
  230. eventView: EventView,
  231. additionalConditions: Record<string, string>,
  232. dataRow?: TableDataRow | Event
  233. ): EventView {
  234. const fieldSet = new Set();
  235. // Expand any functions in the resulting column, and dedupe the result.
  236. // Mark any column as null to remove it.
  237. const expandedColumns: (Column | null)[] = eventView.fields.map((field: Field) => {
  238. const exploded = explodeFieldString(field.field, field.alias);
  239. const column = exploded.kind === 'function' ? drilldownAggregate(exploded) : exploded;
  240. if (
  241. // if expanding the function failed
  242. column === null ||
  243. // the new column is already present
  244. fieldSet.has(column.field) ||
  245. // Skip aggregate equations, their functions will already be added so we just want to remove it
  246. isAggregateEquation(field.field)
  247. ) {
  248. return null;
  249. }
  250. fieldSet.add(column.field);
  251. return column;
  252. });
  253. // id should be default column when expanded results in no columns; but only if
  254. // the Discover query's columns is non-empty.
  255. // This typically occurs in Discover drilldowns.
  256. if (fieldSet.size === 0 && expandedColumns.length) {
  257. expandedColumns[0] = {kind: 'field', field: 'id'};
  258. }
  259. // update the columns according the expansion above
  260. const nextView = expandedColumns.reduceRight(
  261. (newView, column, index) =>
  262. column === null
  263. ? newView.withDeletedColumn(index, undefined)
  264. : newView.withUpdatedColumn(index, column, undefined),
  265. eventView.clone()
  266. );
  267. nextView.query = generateExpandedConditions(nextView, additionalConditions, dataRow);
  268. return nextView;
  269. }
  270. /**
  271. * Create additional conditions based on the fields in an EventView
  272. * and a datarow/event
  273. */
  274. function generateAdditionalConditions(
  275. eventView: EventView,
  276. dataRow?: TableDataRow | Event
  277. ): Record<string, string | string[]> {
  278. const specialKeys = Object.values(URL_PARAM);
  279. const conditions: Record<string, string | string[]> = {};
  280. if (!dataRow) {
  281. return conditions;
  282. }
  283. eventView.fields.forEach((field: Field) => {
  284. const column = explodeFieldString(field.field, field.alias);
  285. // Skip aggregate fields
  286. if (column.kind === 'function') {
  287. return;
  288. }
  289. const dataKey = getAggregateAlias(field.field);
  290. // Append the current field as a condition if it exists in the dataRow
  291. // Or is a simple key in the event. More complex deeply nested fields are
  292. // more challenging to get at as their location in the structure does not
  293. // match their name.
  294. if (dataRow.hasOwnProperty(dataKey)) {
  295. let value = dataRow[dataKey];
  296. if (Array.isArray(value)) {
  297. if (value.length > 1) {
  298. conditions[column.field] = value;
  299. return;
  300. }
  301. // An array with only one value is equivalent to the value itself.
  302. value = value[0];
  303. }
  304. // if the value will be quoted, then do not trim it as the whitespaces
  305. // may be important to the query and should not be trimmed
  306. const shouldQuote =
  307. value === null || value === undefined
  308. ? false
  309. : /[\s\(\)\\"]/g.test(String(value).trim());
  310. const nextValue =
  311. value === null || value === undefined
  312. ? ''
  313. : shouldQuote
  314. ? String(value)
  315. : String(value).trim();
  316. if (isMeasurement(column.field) && !nextValue) {
  317. // Do not add measurement conditions if nextValue is falsey.
  318. // It's expected that nextValue is a numeric value.
  319. return;
  320. }
  321. switch (column.field) {
  322. case 'timestamp':
  323. // normalize the "timestamp" field to ensure the payload works
  324. conditions[column.field] = getUtcDateString(nextValue);
  325. break;
  326. default:
  327. conditions[column.field] = nextValue;
  328. }
  329. }
  330. // If we have an event, check tags as well.
  331. if (dataRow.tags && Array.isArray(dataRow.tags)) {
  332. const tagIndex = dataRow.tags.findIndex(item => item.key === dataKey);
  333. if (tagIndex > -1) {
  334. const key = specialKeys.includes(column.field)
  335. ? `tags[${column.field}]`
  336. : column.field;
  337. const tagValue = dataRow.tags[tagIndex].value;
  338. conditions[key] = tagValue;
  339. }
  340. }
  341. });
  342. return conditions;
  343. }
  344. /**
  345. * Discover queries can query either Errors, Transactions or a combination
  346. * of the two datasets. This is a util to determine if the query will excusively
  347. * hit the Transactions dataset.
  348. */
  349. export function usesTransactionsDataset(eventView: EventView, yAxisValue: string[]) {
  350. let usesTransactions: boolean = false;
  351. const parsedQuery = new MutableSearch(eventView.query);
  352. for (let index = 0; index < yAxisValue.length; index++) {
  353. const yAxis = yAxisValue[index];
  354. const aggregateArg = getAggregateArg(yAxis) ?? '';
  355. if (isMeasurement(aggregateArg) || aggregateArg === 'transaction.duration') {
  356. usesTransactions = true;
  357. break;
  358. }
  359. const eventTypeFilter = parsedQuery.getFilterValues('event.type');
  360. if (
  361. eventTypeFilter.length > 0 &&
  362. eventTypeFilter.every(filter => filter === 'transaction')
  363. ) {
  364. usesTransactions = true;
  365. break;
  366. }
  367. }
  368. return usesTransactions;
  369. }
  370. function generateExpandedConditions(
  371. eventView: EventView,
  372. additionalConditions: Record<string, string>,
  373. dataRow?: TableDataRow | Event
  374. ): string {
  375. const parsedQuery = new MutableSearch(eventView.query);
  376. // Remove any aggregates from the search conditions.
  377. // otherwise, it'll lead to an invalid query result.
  378. for (const key in parsedQuery.filters) {
  379. const column = explodeFieldString(key);
  380. if (column.kind === 'function') {
  381. parsedQuery.removeFilter(key);
  382. }
  383. }
  384. const conditions: Record<string, string | string[]> = Object.assign(
  385. {},
  386. additionalConditions,
  387. generateAdditionalConditions(eventView, dataRow)
  388. );
  389. // Add additional conditions provided and generated.
  390. for (const key in conditions) {
  391. const value = conditions[key];
  392. if (Array.isArray(value)) {
  393. parsedQuery.setFilterValues(key, value);
  394. continue;
  395. }
  396. if (key === 'project.id') {
  397. eventView.project = [...eventView.project, parseInt(value, 10)];
  398. continue;
  399. }
  400. if (key === 'environment') {
  401. if (!eventView.environment.includes(value)) {
  402. eventView.environment = [...eventView.environment, value];
  403. }
  404. continue;
  405. }
  406. const column = explodeFieldString(key);
  407. // Skip aggregates as they will be invalid.
  408. if (column.kind === 'function') {
  409. continue;
  410. }
  411. parsedQuery.setFilterValues(key, [value]);
  412. }
  413. return parsedQuery.formatString();
  414. }
  415. type FieldGeneratorOpts = {
  416. organization: OrganizationSummary;
  417. aggregations?: Record<string, Aggregation>;
  418. customMeasurements?: {functions: string[]; key: string}[] | null;
  419. fieldKeys?: string[];
  420. measurementKeys?: string[] | null;
  421. spanOperationBreakdownKeys?: string[];
  422. tagKeys?: string[] | null;
  423. };
  424. export function generateFieldOptions({
  425. organization,
  426. tagKeys,
  427. measurementKeys,
  428. spanOperationBreakdownKeys,
  429. customMeasurements,
  430. aggregations = AGGREGATIONS,
  431. fieldKeys = DISCOVER_FIELDS,
  432. }: FieldGeneratorOpts) {
  433. let functions = Object.keys(aggregations);
  434. // Strip tracing features if the org doesn't have access.
  435. if (!organization.features.includes('performance-view')) {
  436. fieldKeys = fieldKeys.filter(item => !TRACING_FIELDS.includes(item));
  437. functions = functions.filter(item => !TRACING_FIELDS.includes(item));
  438. }
  439. // Strip profiling features if the org doesn't have access.
  440. if (!organization.features.includes('profiling')) {
  441. fieldKeys = fieldKeys.filter(item => !PROFILING_FIELDS.includes(item));
  442. }
  443. const fieldOptions: Record<string, SelectValue<FieldValue>> = {};
  444. // Index items by prefixed keys as custom tags can overlap both fields and
  445. // function names. Having a mapping makes finding the value objects easier
  446. // later as well.
  447. functions.forEach(func => {
  448. const ellipsis = aggregations[func].parameters.length ? '\u2026' : '';
  449. const parameters = aggregations[func].parameters.map(param => {
  450. const overrides = AGGREGATIONS[func].getFieldOverrides;
  451. if (typeof overrides === 'undefined') {
  452. return param;
  453. }
  454. return {
  455. ...param,
  456. ...overrides({parameter: param}),
  457. };
  458. });
  459. fieldOptions[`function:${func}`] = {
  460. label: `${func}(${ellipsis})`,
  461. value: {
  462. kind: FieldValueKind.FUNCTION,
  463. meta: {
  464. name: func,
  465. parameters,
  466. },
  467. },
  468. };
  469. });
  470. fieldKeys.forEach(field => {
  471. fieldOptions[`field:${field}`] = {
  472. label: field,
  473. value: {
  474. kind: FieldValueKind.FIELD,
  475. meta: {
  476. name: field,
  477. dataType: (getFieldDefinition(field)?.valueType ??
  478. FieldValueType.STRING) as ColumnType,
  479. },
  480. },
  481. };
  482. });
  483. if (measurementKeys !== undefined && measurementKeys !== null) {
  484. measurementKeys.sort();
  485. measurementKeys.forEach(measurement => {
  486. fieldOptions[`measurement:${measurement}`] = {
  487. label: measurement,
  488. value: {
  489. kind: FieldValueKind.MEASUREMENT,
  490. meta: {name: measurement, dataType: measurementType(measurement)},
  491. },
  492. };
  493. });
  494. }
  495. if (customMeasurements !== undefined && customMeasurements !== null) {
  496. customMeasurements.sort(({key: currentKey}, {key: nextKey}) =>
  497. currentKey > nextKey ? 1 : currentKey === nextKey ? 0 : -1
  498. );
  499. customMeasurements.forEach(({key, functions: supportedFunctions}) => {
  500. fieldOptions[`measurement:${key}`] = {
  501. label: key,
  502. value: {
  503. kind: FieldValueKind.CUSTOM_MEASUREMENT,
  504. meta: {
  505. name: key,
  506. dataType: measurementType(key),
  507. functions: supportedFunctions,
  508. },
  509. },
  510. };
  511. });
  512. }
  513. if (Array.isArray(spanOperationBreakdownKeys)) {
  514. spanOperationBreakdownKeys.sort();
  515. spanOperationBreakdownKeys.forEach(breakdownField => {
  516. fieldOptions[`span_op_breakdown:${breakdownField}`] = {
  517. label: breakdownField,
  518. value: {
  519. kind: FieldValueKind.BREAKDOWN,
  520. meta: {name: breakdownField, dataType: 'duration'},
  521. },
  522. };
  523. });
  524. }
  525. if (tagKeys !== undefined && tagKeys !== null) {
  526. tagKeys.sort();
  527. tagKeys.forEach(tag => {
  528. const tagValue =
  529. fieldKeys.includes(tag) || AGGREGATIONS.hasOwnProperty(tag)
  530. ? `tags[${tag}]`
  531. : tag;
  532. fieldOptions[`tag:${tag}`] = {
  533. label: tag,
  534. value: {
  535. kind: FieldValueKind.TAG,
  536. meta: {name: tagValue, dataType: 'string'},
  537. },
  538. };
  539. });
  540. }
  541. return fieldOptions;
  542. }
  543. const RENDER_PREBUILT_KEY = 'discover-render-prebuilt';
  544. export function shouldRenderPrebuilt(): boolean {
  545. const shouldRender = localStorage.getItem(RENDER_PREBUILT_KEY);
  546. return shouldRender === 'true' || shouldRender === null;
  547. }
  548. export function setRenderPrebuilt(value: boolean) {
  549. localStorage.setItem(RENDER_PREBUILT_KEY, value ? 'true' : 'false');
  550. }
  551. export function eventViewToWidgetQuery({
  552. eventView,
  553. yAxis,
  554. displayType,
  555. }: {
  556. displayType: DisplayType;
  557. eventView: EventView;
  558. yAxis?: string | string[];
  559. }) {
  560. const fields = eventView.fields.map(({field}) => field);
  561. const {columns, aggregates} = getColumnsAndAggregates(fields);
  562. const sort = eventView.sorts[0];
  563. const queryYAxis = typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'];
  564. let orderby = '';
  565. // The orderby should only be set to sort.field if it is a Top N query
  566. // since the query uses all of the fields, or if the ordering is used in the y-axis
  567. if (sort) {
  568. let orderbyFunction = '';
  569. const aggregateFields = [...queryYAxis, ...aggregates];
  570. for (let i = 0; i < aggregateFields.length; i++) {
  571. if (sort.field === getAggregateAlias(aggregateFields[i])) {
  572. orderbyFunction = aggregateFields[i];
  573. break;
  574. }
  575. }
  576. const bareOrderby = orderbyFunction === '' ? sort.field : orderbyFunction;
  577. if (displayType === DisplayType.TOP_N || bareOrderby) {
  578. orderby = `${sort.kind === 'desc' ? '-' : ''}${bareOrderby}`;
  579. }
  580. }
  581. let newAggregates = aggregates;
  582. if (displayType !== DisplayType.TABLE) {
  583. newAggregates = queryYAxis;
  584. }
  585. const widgetQuery: WidgetQuery = {
  586. name: '',
  587. aggregates: newAggregates,
  588. columns: [...(displayType === DisplayType.TOP_N ? columns : [])],
  589. fields: [...(displayType === DisplayType.TOP_N ? fields : []), ...queryYAxis],
  590. conditions: eventView.query,
  591. orderby,
  592. };
  593. return widgetQuery;
  594. }
  595. export function handleAddQueryToDashboard({
  596. eventView,
  597. location,
  598. query,
  599. organization,
  600. router,
  601. yAxis,
  602. }: {
  603. eventView: EventView;
  604. location: Location;
  605. organization: Organization;
  606. router: InjectedRouter;
  607. query?: NewQuery;
  608. yAxis?: string | string[];
  609. }) {
  610. const displayType = displayModeToDisplayType(eventView.display as DisplayModes);
  611. const defaultWidgetQuery = eventViewToWidgetQuery({
  612. eventView,
  613. displayType,
  614. yAxis,
  615. });
  616. const dataset = getSavedQueryDataset(location, query);
  617. const {query: widgetAsQueryParams} = constructAddQueryToDashboardLink({
  618. eventView,
  619. query,
  620. organization,
  621. yAxis,
  622. location,
  623. });
  624. openAddToDashboardModal({
  625. organization,
  626. selection: {
  627. projects: eventView.project,
  628. environments: eventView.environment,
  629. datetime: {
  630. start: eventView.start,
  631. end: eventView.end,
  632. period: eventView.statsPeriod,
  633. utc: eventView.utc,
  634. },
  635. },
  636. widget: {
  637. title: query?.name ?? eventView.name,
  638. displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
  639. queries: [
  640. {
  641. ...defaultWidgetQuery,
  642. aggregates: [...(typeof yAxis === 'string' ? [yAxis] : yAxis ?? ['count()'])],
  643. },
  644. ],
  645. interval: eventView.interval,
  646. limit:
  647. displayType === DisplayType.TOP_N
  648. ? Number(eventView.topEvents) || TOP_N
  649. : undefined,
  650. widgetType: organization.features.includes('performance-discover-dataset-selector')
  651. ? getWidgetDataset(dataset)
  652. : undefined,
  653. },
  654. router,
  655. widgetAsQueryParams,
  656. location,
  657. });
  658. return;
  659. }
  660. export function getTargetForTransactionSummaryLink(
  661. dataRow: EventData,
  662. organization: Organization,
  663. projects?: Project[],
  664. nextView?: EventView,
  665. location?: Location
  666. ) {
  667. let projectID: string | string[] | undefined;
  668. const filterProjects = location?.query.project;
  669. if (typeof filterProjects === 'string' && filterProjects !== '-1') {
  670. // Project selector in discover has just one selected project
  671. projectID = filterProjects;
  672. } else {
  673. const projectMatch = projects?.find(
  674. project =>
  675. project.slug && [dataRow['project.name'], dataRow.project].includes(project.slug)
  676. );
  677. projectID = projectMatch ? [projectMatch.id] : undefined;
  678. }
  679. const target = transactionSummaryRouteWithQuery({
  680. orgSlug: organization.slug,
  681. transaction: String(dataRow.transaction),
  682. projectID,
  683. query: nextView?.getPageFiltersQuery() || {},
  684. });
  685. // Pass on discover filter params when there are multiple
  686. // projects associated with the transaction
  687. if (!projectID && filterProjects) {
  688. target.query.project = filterProjects;
  689. }
  690. return target;
  691. }
  692. export function constructAddQueryToDashboardLink({
  693. eventView,
  694. query,
  695. organization,
  696. yAxis,
  697. location,
  698. }: {
  699. eventView: EventView;
  700. organization: Organization;
  701. location?: Location;
  702. query?: NewQuery;
  703. yAxis?: string | string[];
  704. }) {
  705. const displayType = displayModeToDisplayType(eventView.display as DisplayModes);
  706. const defaultTableFields = eventView.fields.map(({field}) => field);
  707. const defaultWidgetQuery = eventViewToWidgetQuery({
  708. eventView,
  709. displayType,
  710. yAxis,
  711. });
  712. const dataset = getSavedQueryDataset(location, query);
  713. const defaultTitle =
  714. query?.name ?? (eventView.name !== 'All Events' ? eventView.name : undefined);
  715. return {
  716. pathname: `/organizations/${organization.slug}/dashboards/new/widget/new/`,
  717. query: {
  718. ...location?.query,
  719. source: DashboardWidgetSource.DISCOVERV2,
  720. start: eventView.start,
  721. end: eventView.end,
  722. statsPeriod: eventView.statsPeriod,
  723. defaultWidgetQuery: urlEncode(defaultWidgetQuery),
  724. defaultTableColumns: defaultTableFields,
  725. defaultTitle,
  726. displayType: displayType === DisplayType.TOP_N ? DisplayType.AREA : displayType,
  727. dataset: organization.features.includes('performance-discover-dataset-selector')
  728. ? getWidgetDataset(dataset)
  729. : undefined,
  730. limit:
  731. displayType === DisplayType.TOP_N
  732. ? Number(eventView.topEvents) || TOP_N
  733. : undefined,
  734. },
  735. };
  736. }
  737. function getWidgetDataset(dataset: SavedQueryDatasets) {
  738. switch (dataset) {
  739. case SavedQueryDatasets.TRANSACTIONS:
  740. return DataSet.TRANSACTIONS;
  741. case SavedQueryDatasets.ERRORS:
  742. return DataSet.ERRORS;
  743. default:
  744. return undefined;
  745. }
  746. }