utils.tsx 23 KB

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