utils.tsx 23 KB

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