visualize.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083
  1. import {Fragment, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import cloneDeep from 'lodash/cloneDeep';
  4. import {Button} from 'sentry/components/button';
  5. import {CompactSelect} from 'sentry/components/compactSelect';
  6. import {RadioLineItem} from 'sentry/components/forms/controls/radioGroup';
  7. import SelectControl from 'sentry/components/forms/controls/selectControl';
  8. import FieldGroup from 'sentry/components/forms/fieldGroup';
  9. import Input from 'sentry/components/input';
  10. import Radio from 'sentry/components/radio';
  11. import {IconDelete} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import {space} from 'sentry/styles/space';
  14. import type {SelectValue} from 'sentry/types/core';
  15. import {defined} from 'sentry/utils';
  16. import {trackAnalytics} from 'sentry/utils/analytics';
  17. import {WidgetBuilderVersion} from 'sentry/utils/analytics/dashboardsAnalyticsEvents';
  18. import {
  19. type AggregateParameter,
  20. type AggregationKeyWithAlias,
  21. type AggregationRefinement,
  22. classifyTagKey,
  23. generateFieldAsString,
  24. parseFunction,
  25. prettifyTagKey,
  26. type QueryFieldValue,
  27. type ValidateColumnTypes,
  28. } from 'sentry/utils/discover/fields';
  29. import {FieldKind} from 'sentry/utils/fields';
  30. import {decodeScalar} from 'sentry/utils/queryString';
  31. import useLocationQuery from 'sentry/utils/url/useLocationQuery';
  32. import useApi from 'sentry/utils/useApi';
  33. import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
  34. import useOrganization from 'sentry/utils/useOrganization';
  35. import useTags from 'sentry/utils/useTags';
  36. import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
  37. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  38. import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
  39. import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
  40. import useDashboardWidgetSource from 'sentry/views/dashboards/widgetBuilder/hooks/useDashboardWidgetSource';
  41. import useIsEditingWidget from 'sentry/views/dashboards/widgetBuilder/hooks/useIsEditingWidget';
  42. import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
  43. import ArithmeticInput from 'sentry/views/discover/table/arithmeticInput';
  44. import {
  45. BufferedInput,
  46. type ParameterDescription,
  47. validateColumnTypes,
  48. } from 'sentry/views/discover/table/queryField';
  49. import {type FieldValue, FieldValueKind} from 'sentry/views/discover/table/types';
  50. import {TypeBadge} from 'sentry/views/explore/components/typeBadge';
  51. import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext';
  52. type AggregateFunction = [
  53. AggregationKeyWithAlias,
  54. string,
  55. AggregationRefinement,
  56. AggregationRefinement,
  57. ];
  58. const MAX_FUNCTION_PARAMETERS = 4;
  59. const NONE = 'none';
  60. const NONE_AGGREGATE = {
  61. label: t('None'),
  62. value: NONE,
  63. };
  64. function formatColumnOptions(
  65. dataset: WidgetType,
  66. options: Array<SelectValue<FieldValue>>,
  67. columnFilterMethod: (
  68. option: SelectValue<FieldValue>,
  69. field?: QueryFieldValue
  70. ) => boolean
  71. ) {
  72. return options
  73. .filter(option => {
  74. // Don't show any aggregates under the columns, and if
  75. // there isn't a filter method, just show the option
  76. return (
  77. option.value.kind !== FieldValueKind.FUNCTION &&
  78. (columnFilterMethod?.(option) ?? true)
  79. );
  80. })
  81. .map(option => ({
  82. value: option.value.meta.name,
  83. label:
  84. dataset === WidgetType.SPANS
  85. ? prettifyTagKey(option.value.meta.name)
  86. : option.value.meta.name,
  87. // For the spans dataset, all of the options are measurements,
  88. // so we force the number badge to show
  89. trailingItems:
  90. dataset === WidgetType.SPANS ? <TypeBadge kind={FieldKind.MEASUREMENT} /> : null,
  91. }));
  92. }
  93. function getColumnOptions(
  94. dataset: WidgetType,
  95. selectedField: QueryFieldValue,
  96. fieldOptions: Record<string, SelectValue<FieldValue>>,
  97. columnFilterMethod: (
  98. option: SelectValue<FieldValue>,
  99. field?: QueryFieldValue
  100. ) => boolean
  101. ) {
  102. const fieldValues = Object.values(fieldOptions);
  103. if (selectedField.kind !== FieldValueKind.FUNCTION || dataset === WidgetType.SPANS) {
  104. return formatColumnOptions(dataset, fieldValues, columnFilterMethod);
  105. }
  106. const field = fieldValues.find(
  107. option => option.value.meta.name === selectedField.function[0]
  108. )?.value;
  109. if (
  110. field &&
  111. field.kind === FieldValueKind.FUNCTION &&
  112. field.meta.parameters.length > 0 &&
  113. field.meta.parameters[0]
  114. ) {
  115. const parameter = field.meta.parameters[0];
  116. if (parameter && parameter.kind === 'dropdown') {
  117. // Parameters for dropdowns are already formatted in the correct manner
  118. // for select fields
  119. return parameter.options;
  120. }
  121. if (parameter && parameter.kind === 'column' && parameter.columnTypes) {
  122. return formatColumnOptions(
  123. dataset,
  124. fieldValues.filter(
  125. ({value}) =>
  126. (value.kind === FieldValueKind.FIELD ||
  127. value.kind === FieldValueKind.TAG ||
  128. value.kind === FieldValueKind.MEASUREMENT ||
  129. value.kind === FieldValueKind.CUSTOM_MEASUREMENT ||
  130. value.kind === FieldValueKind.METRICS ||
  131. value.kind === FieldValueKind.BREAKDOWN) &&
  132. validateColumnTypes(parameter.columnTypes as ValidateColumnTypes, value)
  133. ),
  134. columnFilterMethod
  135. );
  136. }
  137. }
  138. return formatColumnOptions(dataset, fieldValues, columnFilterMethod);
  139. }
  140. function validateParameter(
  141. columnOptions: Array<SelectValue<string>>,
  142. parameter: AggregateParameter,
  143. value: string | undefined
  144. ) {
  145. if (parameter.kind === 'dropdown') {
  146. return Boolean(parameter.options.find(option => option.value === value)?.value);
  147. }
  148. if (parameter.kind === 'column') {
  149. return Boolean(columnOptions.find(option => option.value === value)?.value);
  150. }
  151. if (parameter.kind === 'value') {
  152. if (parameter.dataType === 'number') {
  153. return !isNaN(Number(value));
  154. }
  155. return true;
  156. }
  157. return false;
  158. }
  159. function canDeleteField(
  160. dataset: WidgetType,
  161. selectedFields: QueryFieldValue[],
  162. field: QueryFieldValue
  163. ) {
  164. if (dataset === WidgetType.RELEASE) {
  165. // Release Health widgets are required to have at least one aggregate
  166. return (
  167. selectedFields.filter(
  168. selectedField => selectedField.kind === FieldValueKind.FUNCTION
  169. ).length > 1 || field.kind === FieldValueKind.FIELD
  170. );
  171. }
  172. return true;
  173. }
  174. interface VisualizeProps {
  175. error?: Record<string, any>;
  176. setError?: (error: Record<string, any>) => void;
  177. }
  178. function Visualize({error, setError}: VisualizeProps) {
  179. const organization = useOrganization();
  180. const api = useApi();
  181. const {state, dispatch} = useWidgetBuilderContext();
  182. let tags = useTags();
  183. const {customMeasurements} = useCustomMeasurements();
  184. const {selectedAggregate: queryParamSelectedAggregate} = useLocationQuery({
  185. fields: {
  186. selectedAggregate: decodeScalar,
  187. },
  188. });
  189. const [selectedAggregateSet, setSelectedAggregateSet] = useState(
  190. defined(queryParamSelectedAggregate)
  191. );
  192. const source = useDashboardWidgetSource();
  193. const isEditing = useIsEditingWidget();
  194. const isChartWidget =
  195. state.displayType !== DisplayType.TABLE &&
  196. state.displayType !== DisplayType.BIG_NUMBER;
  197. const isBigNumberWidget = state.displayType === DisplayType.BIG_NUMBER;
  198. const numericSpanTags = useSpanTags('number');
  199. const stringSpanTags = useSpanTags('string');
  200. // Span column options are explicitly defined and bypass all of the
  201. // fieldOptions filtering and logic used for showing options for
  202. // chart types.
  203. let spanColumnOptions: any;
  204. if (state.dataset === WidgetType.SPANS) {
  205. // Explicitly merge numeric and string tags to ensure filtering
  206. // compatibility for timeseries chart types.
  207. tags = {...numericSpanTags, ...stringSpanTags};
  208. const columns =
  209. state.fields
  210. ?.filter(field => field.kind === FieldValueKind.FIELD)
  211. .map(field => field.field) ?? [];
  212. spanColumnOptions = [
  213. // Columns that are not in the tag responses, e.g. old tags
  214. ...columns
  215. .filter(
  216. column =>
  217. column !== '' &&
  218. !stringSpanTags.hasOwnProperty(column) &&
  219. !numericSpanTags.hasOwnProperty(column)
  220. )
  221. .map(column => {
  222. return {
  223. label: prettifyTagKey(column),
  224. value: column,
  225. textValue: column,
  226. trailingItems: <TypeBadge kind={classifyTagKey(column)} />,
  227. };
  228. }),
  229. ...Object.values(stringSpanTags).map(tag => {
  230. return {
  231. label: tag.name,
  232. value: tag.key,
  233. textValue: tag.name,
  234. trailingItems: <TypeBadge kind={FieldKind.TAG} />,
  235. };
  236. }),
  237. ...Object.values(numericSpanTags).map(tag => {
  238. return {
  239. label: tag.name,
  240. value: tag.key,
  241. textValue: tag.name,
  242. trailingItems: <TypeBadge kind={FieldKind.MEASUREMENT} />,
  243. };
  244. }),
  245. ];
  246. // @ts-expect-error TS(7006): Parameter 'a' implicitly has an 'any' type.
  247. spanColumnOptions.sort((a, b) => {
  248. if (a.label < b.label) {
  249. return -1;
  250. }
  251. if (a.label > b.label) {
  252. return 1;
  253. }
  254. return 0;
  255. });
  256. }
  257. const datasetConfig = useMemo(() => getDatasetConfig(state.dataset), [state.dataset]);
  258. const fields = isChartWidget ? state.yAxis : state.fields;
  259. const updateAction = isChartWidget
  260. ? BuilderStateAction.SET_Y_AXIS
  261. : BuilderStateAction.SET_FIELDS;
  262. const fieldOptions = useMemo(
  263. () => datasetConfig.getTableFieldOptions(organization, tags, customMeasurements),
  264. [organization, tags, customMeasurements, datasetConfig]
  265. );
  266. const aggregates = useMemo(
  267. () =>
  268. Object.values(fieldOptions).filter(option =>
  269. datasetConfig.filterYAxisOptions?.(state.displayType ?? DisplayType.TABLE)(option)
  270. ),
  271. [fieldOptions, state.displayType, datasetConfig]
  272. );
  273. // Used to extract selected aggregates and parameters from the fields
  274. const stringFields = fields?.map(generateFieldAsString);
  275. const fieldErrors = error?.queries?.find(
  276. (queryError: any) => queryError?.fields
  277. )?.fields;
  278. const aggregateErrors = error?.queries?.find(
  279. (aggregateError: any) => aggregateError?.aggregates
  280. )?.aggregates;
  281. return (
  282. <Fragment>
  283. <SectionHeader
  284. title={t('Visualize')}
  285. tooltipText={t(
  286. 'Primary metric that appears in your chart. You can also overlay a series onto an existing chart or add an equation.'
  287. )}
  288. />
  289. <StyledFieldGroup
  290. error={isChartWidget ? aggregateErrors : fieldErrors}
  291. inline={false}
  292. flexibleControlStateSize
  293. >
  294. <Fields>
  295. {fields?.map((field, index) => {
  296. const canDelete = canDeleteField(
  297. state.dataset ?? WidgetType.ERRORS,
  298. fields,
  299. field
  300. );
  301. const isOnlyFieldOrAggregate =
  302. fields.length === 2 &&
  303. field.kind !== FieldValueKind.EQUATION &&
  304. fields.filter(fieldItem => fieldItem.kind === FieldValueKind.EQUATION)
  305. .length > 0;
  306. // Depending on the dataset and the display type, we use different options for
  307. // displaying in the column select.
  308. // For charts, we show aggregate parameter options for the y-axis as primary options.
  309. // For tables, we show all string tags and fields as primary options, as well
  310. // as aggregates that don't take parameters.
  311. const columnFilterMethod = isChartWidget
  312. ? datasetConfig.filterYAxisAggregateParams?.(
  313. field,
  314. state.displayType ?? DisplayType.LINE
  315. )
  316. : field.kind === FieldValueKind.FUNCTION
  317. ? datasetConfig.filterAggregateParams
  318. : datasetConfig.filterTableOptions;
  319. const columnOptions = getColumnOptions(
  320. state.dataset ?? WidgetType.ERRORS,
  321. field,
  322. fieldOptions,
  323. // If no column filter method is provided, show all options
  324. columnFilterMethod ?? (() => true)
  325. );
  326. let aggregateOptions = aggregates.map(option => ({
  327. value: option.value.meta.name,
  328. label: option.value.meta.name,
  329. }));
  330. aggregateOptions =
  331. isChartWidget ||
  332. isBigNumberWidget ||
  333. (state.dataset === WidgetType.RELEASE && !canDelete)
  334. ? aggregateOptions
  335. : [NONE_AGGREGATE, ...aggregateOptions];
  336. let matchingAggregate: any;
  337. if (
  338. fields[index]!.kind === FieldValueKind.FUNCTION &&
  339. FieldValueKind.FUNCTION in fields[index]!
  340. ) {
  341. matchingAggregate = aggregates.find(
  342. option =>
  343. option.value.meta.name ===
  344. parseFunction(stringFields?.[index] ?? '')?.name
  345. );
  346. }
  347. const parameterRefinements =
  348. matchingAggregate?.value.meta.parameters.length > 1
  349. ? matchingAggregate?.value.meta.parameters.slice(1)
  350. : [];
  351. // Apdex and User Misery are special cases where the column parameter is not applicable
  352. const isApdexOrUserMisery =
  353. matchingAggregate?.value.meta.name === 'apdex' ||
  354. matchingAggregate?.value.meta.name === 'user_misery';
  355. const hasColumnParameter =
  356. (fields[index]!.kind === FieldValueKind.FUNCTION &&
  357. !isApdexOrUserMisery &&
  358. matchingAggregate?.value.meta.parameters.length !== 0) ||
  359. fields[index]!.kind === FieldValueKind.FIELD;
  360. return (
  361. <FieldRow key={index}>
  362. {fields.length > 1 && state.displayType === DisplayType.BIG_NUMBER && (
  363. <RadioLineItem
  364. index={index}
  365. role="radio"
  366. aria-label="aggregate-selector"
  367. >
  368. <Radio
  369. checked={index === state.selectedAggregate}
  370. onChange={() => {
  371. dispatch({
  372. type: BuilderStateAction.SET_SELECTED_AGGREGATE,
  373. payload: index,
  374. });
  375. }}
  376. onClick={() => {
  377. setSelectedAggregateSet(true);
  378. trackAnalytics('dashboards_views.widget_builder.change', {
  379. builder_version: WidgetBuilderVersion.SLIDEOUT,
  380. field: 'visualize.selectAggregate',
  381. from: source,
  382. new_widget: !isEditing,
  383. value: '',
  384. widget_type: state.dataset ?? '',
  385. organization,
  386. });
  387. }}
  388. aria-label={'field' + index}
  389. />
  390. </RadioLineItem>
  391. )}
  392. <FieldBar data-testid={'field-bar'}>
  393. {field.kind === FieldValueKind.EQUATION ? (
  394. <StyledArithmeticInput
  395. name="arithmetic"
  396. key="parameter:text"
  397. type="text"
  398. required
  399. value={field.field}
  400. onUpdate={value => {
  401. dispatch({
  402. type: updateAction,
  403. payload: fields.map((_field, i) =>
  404. i === index ? {..._field, field: value} : _field
  405. ),
  406. });
  407. setError?.({...error, queries: []});
  408. trackAnalytics('dashboards_views.widget_builder.change', {
  409. builder_version: WidgetBuilderVersion.SLIDEOUT,
  410. field: 'visualize.updateEquation',
  411. from: source,
  412. new_widget: !isEditing,
  413. value: '',
  414. widget_type: state.dataset ?? '',
  415. organization,
  416. });
  417. }}
  418. options={fields}
  419. placeholder={t('Equation')}
  420. aria-label={t('Equation')}
  421. />
  422. ) : (
  423. <Fragment>
  424. <PrimarySelectRow hasColumnParameter={hasColumnParameter}>
  425. {hasColumnParameter && (
  426. <ColumnCompactSelect
  427. searchable
  428. options={
  429. state.dataset === WidgetType.SPANS &&
  430. field.kind !== FieldValueKind.FUNCTION
  431. ? spanColumnOptions
  432. : columnOptions
  433. }
  434. value={
  435. field.kind === FieldValueKind.FUNCTION
  436. ? parseFunction(stringFields?.[index] ?? '')
  437. ?.arguments[0] ?? ''
  438. : field.field
  439. }
  440. onChange={newField => {
  441. const newFields = cloneDeep(fields);
  442. const currentField = newFields[index]!;
  443. // Update the current field's aggregate with the new aggregate
  444. if (currentField.kind === FieldValueKind.FUNCTION) {
  445. currentField.function[1] = newField.value as string;
  446. }
  447. if (currentField.kind === FieldValueKind.FIELD) {
  448. currentField.field = newField.value as string;
  449. }
  450. dispatch({
  451. type: updateAction,
  452. payload: newFields,
  453. });
  454. setError?.({...error, queries: []});
  455. trackAnalytics('dashboards_views.widget_builder.change', {
  456. builder_version: WidgetBuilderVersion.SLIDEOUT,
  457. field: 'visualize.updateColumn',
  458. from: source,
  459. new_widget: !isEditing,
  460. value:
  461. currentField.kind === FieldValueKind.FIELD
  462. ? 'column'
  463. : 'aggregate',
  464. widget_type: state.dataset ?? '',
  465. organization,
  466. });
  467. }}
  468. triggerProps={{
  469. 'aria-label': t('Column Selection'),
  470. }}
  471. />
  472. )}
  473. <AggregateCompactSelect
  474. searchable
  475. hasColumnParameter={hasColumnParameter}
  476. disabled={aggregateOptions.length <= 1}
  477. options={aggregateOptions}
  478. value={parseFunction(stringFields?.[index] ?? '')?.name ?? ''}
  479. onChange={aggregateSelection => {
  480. const isNone = aggregateSelection.value === NONE;
  481. const newFields = cloneDeep(fields);
  482. const currentField = newFields[index]!;
  483. const newAggregate = aggregates.find(
  484. option =>
  485. option.value.meta.name === aggregateSelection.value
  486. );
  487. // Update the current field's aggregate with the new aggregate
  488. if (!isNone) {
  489. if (currentField.kind === FieldValueKind.FUNCTION) {
  490. // Handle setting an aggregate from an aggregate
  491. currentField.function[0] =
  492. aggregateSelection.value as AggregationKeyWithAlias;
  493. if (
  494. newAggregate?.value.meta &&
  495. 'parameters' in newAggregate.value.meta
  496. ) {
  497. // There are aggregates that have no parameters, so wipe out the argument
  498. // if it's supposed to be empty
  499. if (newAggregate.value.meta.parameters.length === 0) {
  500. currentField.function[1] = '';
  501. } else {
  502. // Check if the column is a valid column for the new aggregate
  503. const newColumnOptions = getColumnOptions(
  504. state.dataset ?? WidgetType.ERRORS,
  505. currentField,
  506. fieldOptions,
  507. // If no column filter method is provided, show all options
  508. columnFilterMethod ?? (() => true)
  509. );
  510. const newAggregateIsApdexOrUserMisery =
  511. newAggregate?.value.meta.name === 'apdex' ||
  512. newAggregate?.value.meta.name === 'user_misery';
  513. const isValidColumn =
  514. !newAggregateIsApdexOrUserMisery &&
  515. Boolean(
  516. newColumnOptions.find(
  517. option =>
  518. option.value === currentField.function[1]
  519. )?.value
  520. );
  521. currentField.function[1] =
  522. (isValidColumn
  523. ? currentField.function[1]
  524. : newAggregate.value.meta.parameters[0]!
  525. .defaultValue) ?? '';
  526. // Set the remaining parameters for the new aggregate
  527. for (
  528. let i = 1; // The first parameter is the column selection
  529. i < newAggregate.value.meta.parameters.length;
  530. i++
  531. ) {
  532. // Increment by 1 to skip past the aggregate name
  533. currentField.function[i + 1] =
  534. newAggregate.value.meta.parameters[
  535. i
  536. ]!.defaultValue;
  537. }
  538. }
  539. // Wipe out the remaining parameters that are unnecessary
  540. // This is necessary for transitioning between aggregates that have
  541. // more parameters to ones of fewer parameters
  542. for (
  543. let i = newAggregate.value.meta.parameters.length;
  544. i < MAX_FUNCTION_PARAMETERS;
  545. i++
  546. ) {
  547. currentField.function[i + 1] = undefined;
  548. }
  549. }
  550. } else {
  551. if (
  552. !newAggregate ||
  553. !('parameters' in newAggregate.value.meta)
  554. ) {
  555. return;
  556. }
  557. // Handle setting an aggregate from a field
  558. const newFunction: AggregateFunction = [
  559. aggregateSelection.value as AggregationKeyWithAlias,
  560. ((newAggregate?.value.meta?.parameters.length > 0 &&
  561. currentField.field) ||
  562. newAggregate?.value.meta?.parameters?.[0]
  563. ?.defaultValue) ??
  564. '',
  565. newAggregate?.value.meta?.parameters?.[1]
  566. ?.defaultValue ?? undefined,
  567. newAggregate?.value.meta?.parameters?.[2]
  568. ?.defaultValue ?? undefined,
  569. ];
  570. const newColumnOptions = getColumnOptions(
  571. state.dataset ?? WidgetType.ERRORS,
  572. {
  573. kind: FieldValueKind.FUNCTION,
  574. function: newFunction,
  575. },
  576. fieldOptions,
  577. // If no column filter method is provided, show all options
  578. columnFilterMethod ?? (() => true)
  579. );
  580. if (
  581. newAggregate?.value.meta &&
  582. 'parameters' in newAggregate.value.meta
  583. ) {
  584. newAggregate?.value.meta.parameters.forEach(
  585. (parameter, parameterIndex) => {
  586. const isValidParameter = validateParameter(
  587. newColumnOptions,
  588. parameter,
  589. newFunction[parameterIndex + 1]
  590. );
  591. // Increment by 1 to skip past the aggregate name
  592. newFunction[parameterIndex + 1] =
  593. (isValidParameter
  594. ? newFunction[parameterIndex + 1]
  595. : parameter.defaultValue) ?? '';
  596. }
  597. );
  598. }
  599. newFields[index] = {
  600. kind: FieldValueKind.FUNCTION,
  601. function: newFunction,
  602. };
  603. }
  604. trackAnalytics('dashboards_views.widget_builder.change', {
  605. builder_version: WidgetBuilderVersion.SLIDEOUT,
  606. field: 'visualize.updateAggregate',
  607. from: source,
  608. new_widget: !isEditing,
  609. value: 'aggregate',
  610. widget_type: state.dataset ?? '',
  611. organization,
  612. });
  613. } else {
  614. // Handle selecting None so we can select just a field, e.g. for samples
  615. // If none is selected, set the field to a field value
  616. // When selecting None, the next possible columns may be different from the
  617. // possible columns for the previous aggregate. Calculate the valid columns,
  618. // see if the current field's function argument is in the valid columns, and if so,
  619. // set the field to a field value. Otherwise, set the field to the first valid column.
  620. const validColumnFields = Object.values(
  621. datasetConfig.getTableFieldOptions?.(
  622. organization,
  623. tags,
  624. customMeasurements,
  625. api
  626. ) ?? []
  627. ).filter(
  628. option =>
  629. option.value.kind !== FieldValueKind.FUNCTION &&
  630. (datasetConfig.filterTableOptions?.(option) ?? true)
  631. );
  632. const functionArgInValidColumnFields =
  633. ('function' in currentField &&
  634. validColumnFields.find(
  635. option =>
  636. option.value.meta.name === currentField.function[1]
  637. )) ||
  638. undefined;
  639. const validColumn =
  640. functionArgInValidColumnFields?.value.meta.name ??
  641. validColumnFields?.[0]?.value.meta.name ??
  642. '';
  643. newFields[index] = {
  644. kind: FieldValueKind.FIELD,
  645. field: validColumn,
  646. };
  647. trackAnalytics('dashboards_views.widget_builder.change', {
  648. builder_version: WidgetBuilderVersion.SLIDEOUT,
  649. field: 'visualize.updateAggregate',
  650. from: source,
  651. new_widget: !isEditing,
  652. value: 'column',
  653. widget_type: state.dataset ?? '',
  654. organization,
  655. });
  656. }
  657. dispatch({
  658. type: updateAction,
  659. payload: newFields,
  660. });
  661. setError?.({...error, queries: []});
  662. }}
  663. triggerProps={{
  664. 'aria-label': t('Aggregate Selection'),
  665. }}
  666. />
  667. </PrimarySelectRow>
  668. {field.kind === FieldValueKind.FUNCTION &&
  669. parameterRefinements.length > 0 && (
  670. <ParameterRefinements>
  671. {parameterRefinements.map(
  672. (parameter: any, parameterIndex: any) => {
  673. // The current value is displaced by 2 because the first two parameters
  674. // are the aggregate name and the column selection
  675. const currentValue =
  676. field.function[parameterIndex + 2] || '';
  677. const key = `${field.function.join('_')}-${parameterIndex}`;
  678. return (
  679. <AggregateParameterField
  680. key={key}
  681. parameter={parameter}
  682. fieldValue={field}
  683. currentValue={currentValue}
  684. onChange={value => {
  685. const newFields = cloneDeep(fields);
  686. if (
  687. newFields[index]!.kind !== FieldValueKind.FUNCTION
  688. ) {
  689. return;
  690. }
  691. newFields[index]!.function[parameterIndex + 2] =
  692. value;
  693. dispatch({
  694. type: updateAction,
  695. payload: newFields,
  696. });
  697. setError?.({...error, queries: []});
  698. }}
  699. />
  700. );
  701. }
  702. )}
  703. </ParameterRefinements>
  704. )}
  705. {isApdexOrUserMisery && field.kind === FieldValueKind.FUNCTION && (
  706. <AggregateParameterField
  707. parameter={matchingAggregate?.value.meta.parameters[0]}
  708. fieldValue={field}
  709. currentValue={field.function[1]}
  710. onChange={value => {
  711. const newFields = cloneDeep(fields);
  712. if (newFields[index]!.kind !== FieldValueKind.FUNCTION) {
  713. return;
  714. }
  715. newFields[index]!.function[1] = value;
  716. dispatch({
  717. type: updateAction,
  718. payload: newFields,
  719. });
  720. setError?.({...error, queries: []});
  721. }}
  722. />
  723. )}
  724. </Fragment>
  725. )}
  726. </FieldBar>
  727. <FieldExtras isChartWidget={isChartWidget || isBigNumberWidget}>
  728. {!isChartWidget && !isBigNumberWidget && (
  729. <LegendAliasInput
  730. type="text"
  731. name="name"
  732. placeholder={t('Add Alias')}
  733. value={field.alias ?? ''}
  734. onChange={e => {
  735. const newFields = cloneDeep(fields);
  736. newFields[index]!.alias = e.target.value;
  737. dispatch({
  738. type: updateAction,
  739. payload: newFields,
  740. });
  741. }}
  742. onBlur={() => {
  743. trackAnalytics('dashboards_views.widget_builder.change', {
  744. builder_version: WidgetBuilderVersion.SLIDEOUT,
  745. field: 'visualize.legendAlias',
  746. from: source,
  747. new_widget: !isEditing,
  748. value: '',
  749. widget_type: state.dataset ?? '',
  750. organization,
  751. });
  752. }}
  753. />
  754. )}
  755. <StyledDeleteButton
  756. borderless
  757. icon={<IconDelete />}
  758. size="zero"
  759. disabled={fields.length <= 1 || !canDelete || isOnlyFieldOrAggregate}
  760. onClick={() => {
  761. dispatch({
  762. type: updateAction,
  763. payload: fields?.filter((_field, i) => i !== index) ?? [],
  764. });
  765. if (
  766. state.displayType === DisplayType.BIG_NUMBER &&
  767. selectedAggregateSet
  768. ) {
  769. // Unset the selected aggregate if it's the last one
  770. // so the state will automatically choose the last aggregate
  771. // as new fields are added
  772. if (state.selectedAggregate === fields.length - 1) {
  773. dispatch({
  774. type: BuilderStateAction.SET_SELECTED_AGGREGATE,
  775. payload: undefined,
  776. });
  777. }
  778. }
  779. trackAnalytics('dashboards_views.widget_builder.change', {
  780. builder_version: WidgetBuilderVersion.SLIDEOUT,
  781. field:
  782. field.kind === FieldValueKind.EQUATION
  783. ? 'visualize.deleteEquation'
  784. : 'visualize.deleteField',
  785. from: source,
  786. new_widget: !isEditing,
  787. value: '',
  788. widget_type: state.dataset ?? '',
  789. organization,
  790. });
  791. }}
  792. aria-label={t('Remove field')}
  793. />
  794. </FieldExtras>
  795. </FieldRow>
  796. );
  797. })}
  798. </Fields>
  799. </StyledFieldGroup>
  800. <AddButtons>
  801. <AddButton
  802. priority="link"
  803. aria-label={isChartWidget ? t('Add Series') : t('Add Field')}
  804. onClick={() => {
  805. dispatch({
  806. type: updateAction,
  807. payload: [...(fields ?? []), cloneDeep(datasetConfig.defaultField)],
  808. });
  809. trackAnalytics('dashboards_views.widget_builder.change', {
  810. builder_version: WidgetBuilderVersion.SLIDEOUT,
  811. field: 'visualize.addField',
  812. from: source,
  813. new_widget: !isEditing,
  814. value: '',
  815. widget_type: state.dataset ?? '',
  816. organization,
  817. });
  818. }}
  819. >
  820. {isChartWidget ? t('+ Add Series') : t('+ Add Field')}
  821. </AddButton>
  822. {datasetConfig.enableEquations && (
  823. <AddButton
  824. priority="link"
  825. aria-label={t('Add Equation')}
  826. onClick={() => {
  827. dispatch({
  828. type: updateAction,
  829. payload: [...(fields ?? []), {kind: FieldValueKind.EQUATION, field: ''}],
  830. });
  831. trackAnalytics('dashboards_views.widget_builder.change', {
  832. builder_version: WidgetBuilderVersion.SLIDEOUT,
  833. field: 'visualize.addEquation',
  834. from: source,
  835. new_widget: !isEditing,
  836. value: '',
  837. widget_type: state.dataset ?? '',
  838. organization,
  839. });
  840. }}
  841. >
  842. {t('+ Add Equation')}
  843. </AddButton>
  844. )}
  845. </AddButtons>
  846. </Fragment>
  847. );
  848. }
  849. export default Visualize;
  850. function AggregateParameterField({
  851. parameter,
  852. fieldValue,
  853. onChange,
  854. currentValue,
  855. }: {
  856. currentValue: string;
  857. fieldValue: QueryFieldValue;
  858. onChange: (value: string) => void;
  859. parameter: ParameterDescription;
  860. }) {
  861. if (parameter.kind === 'value') {
  862. const inputProps = {
  863. required: parameter.required,
  864. value:
  865. currentValue ?? ('defaultValue' in parameter && parameter?.defaultValue) ?? '',
  866. onUpdate: (value: any) => {
  867. onChange(value);
  868. },
  869. onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
  870. if (e.key === 'Enter') {
  871. onChange(e.currentTarget.value);
  872. }
  873. },
  874. placeholder: parameter.placeholder,
  875. };
  876. switch (parameter.dataType) {
  877. case 'number':
  878. return (
  879. <BufferedInput
  880. name="refinement"
  881. key={`parameter:number-${currentValue}`}
  882. type="text"
  883. inputMode="numeric"
  884. pattern="[0-9]*(\.[0-9]*)?"
  885. aria-label={t('Numeric Input')}
  886. {...inputProps}
  887. />
  888. );
  889. case 'integer':
  890. return (
  891. <BufferedInput
  892. name="refinement"
  893. key="parameter:integer"
  894. type="text"
  895. inputMode="numeric"
  896. pattern="[0-9]*"
  897. aria-label={t('Integer Input')}
  898. {...inputProps}
  899. />
  900. );
  901. default:
  902. return (
  903. <BufferedInput
  904. name="refinement"
  905. key="parameter:text"
  906. type="text"
  907. aria-label={t('Text Input')}
  908. {...inputProps}
  909. />
  910. );
  911. }
  912. }
  913. if (parameter.kind === 'dropdown') {
  914. return (
  915. <SelectControl
  916. key="dropdown"
  917. name="dropdown"
  918. menuPlacement="auto"
  919. placeholder={t('Select value')}
  920. options={parameter.options}
  921. value={currentValue}
  922. required={parameter.required}
  923. onChange={({value}: any) => {
  924. onChange(value);
  925. }}
  926. searchable
  927. />
  928. );
  929. }
  930. throw new Error(`Unknown parameter type encountered for ${fieldValue}`);
  931. }
  932. const ColumnCompactSelect = styled(CompactSelect)`
  933. flex: 1 1 auto;
  934. min-width: 0;
  935. > button {
  936. width: 100%;
  937. }
  938. `;
  939. const AggregateCompactSelect = styled(CompactSelect)<{hasColumnParameter: boolean}>`
  940. ${p =>
  941. p.hasColumnParameter
  942. ? `
  943. width: fit-content;
  944. max-width: 150px;
  945. left: -1px;
  946. `
  947. : `
  948. width: 100%;
  949. `}
  950. > button {
  951. width: 100%;
  952. }
  953. `;
  954. const LegendAliasInput = styled(Input)``;
  955. const ParameterRefinements = styled('div')`
  956. display: flex;
  957. flex-direction: row;
  958. gap: ${space(1)};
  959. > * {
  960. flex: 1;
  961. }
  962. `;
  963. const FieldBar = styled('div')`
  964. display: grid;
  965. grid-template-columns: 1fr;
  966. gap: ${space(1)};
  967. flex: 3;
  968. `;
  969. const PrimarySelectRow = styled('div')<{hasColumnParameter: boolean}>`
  970. display: flex;
  971. width: 100%;
  972. flex: 3;
  973. & > ${ColumnCompactSelect} > button {
  974. border-top-right-radius: 0;
  975. border-bottom-right-radius: 0;
  976. }
  977. & > ${AggregateCompactSelect} > button {
  978. ${p =>
  979. p.hasColumnParameter &&
  980. `
  981. border-top-left-radius: 0;
  982. border-bottom-left-radius: 0;
  983. `}
  984. }
  985. `;
  986. const FieldRow = styled('div')`
  987. display: flex;
  988. flex-direction: row;
  989. gap: ${space(1)};
  990. `;
  991. const StyledDeleteButton = styled(Button)``;
  992. const FieldExtras = styled('div')<{isChartWidget: boolean}>`
  993. display: flex;
  994. flex-direction: row;
  995. gap: ${space(1)};
  996. flex: ${p => (p.isChartWidget ? '0' : '1')};
  997. `;
  998. const AddButton = styled(Button)`
  999. margin-top: ${space(1)};
  1000. `;
  1001. const AddButtons = styled('div')`
  1002. display: inline-flex;
  1003. gap: ${space(1.5)};
  1004. `;
  1005. const Fields = styled('div')`
  1006. display: flex;
  1007. flex-direction: column;
  1008. gap: ${space(1)};
  1009. `;
  1010. const StyledArithmeticInput = styled(ArithmeticInput)`
  1011. width: 100%;
  1012. `;
  1013. const StyledFieldGroup = styled(FieldGroup)`
  1014. width: 100%;
  1015. padding: 0px;
  1016. border-bottom: none;
  1017. `;