visualize.tsx 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077
  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. // Depending on the dataset and the display type, we use different options for
  302. // displaying in the column select.
  303. // For charts, we show aggregate parameter options for the y-axis as primary options.
  304. // For tables, we show all string tags and fields as primary options, as well
  305. // as aggregates that don't take parameters.
  306. const columnFilterMethod = isChartWidget
  307. ? datasetConfig.filterYAxisAggregateParams?.(
  308. field,
  309. state.displayType ?? DisplayType.LINE
  310. )
  311. : field.kind === FieldValueKind.FUNCTION
  312. ? datasetConfig.filterAggregateParams
  313. : datasetConfig.filterTableOptions;
  314. const columnOptions = getColumnOptions(
  315. state.dataset ?? WidgetType.ERRORS,
  316. field,
  317. fieldOptions,
  318. // If no column filter method is provided, show all options
  319. columnFilterMethod ?? (() => true)
  320. );
  321. let aggregateOptions = aggregates.map(option => ({
  322. value: option.value.meta.name,
  323. label: option.value.meta.name,
  324. }));
  325. aggregateOptions =
  326. isChartWidget ||
  327. isBigNumberWidget ||
  328. (state.dataset === WidgetType.RELEASE && !canDelete)
  329. ? aggregateOptions
  330. : [NONE_AGGREGATE, ...aggregateOptions];
  331. let matchingAggregate: any;
  332. if (
  333. fields[index]!.kind === FieldValueKind.FUNCTION &&
  334. FieldValueKind.FUNCTION in fields[index]!
  335. ) {
  336. matchingAggregate = aggregates.find(
  337. option =>
  338. option.value.meta.name ===
  339. parseFunction(stringFields?.[index] ?? '')?.name
  340. );
  341. }
  342. const parameterRefinements =
  343. matchingAggregate?.value.meta.parameters.length > 1
  344. ? matchingAggregate?.value.meta.parameters.slice(1)
  345. : [];
  346. // Apdex and User Misery are special cases where the column parameter is not applicable
  347. const isApdexOrUserMisery =
  348. matchingAggregate?.value.meta.name === 'apdex' ||
  349. matchingAggregate?.value.meta.name === 'user_misery';
  350. const hasColumnParameter =
  351. (fields[index]!.kind === FieldValueKind.FUNCTION &&
  352. !isApdexOrUserMisery &&
  353. matchingAggregate?.value.meta.parameters.length !== 0) ||
  354. fields[index]!.kind === FieldValueKind.FIELD;
  355. return (
  356. <FieldRow key={index}>
  357. {fields.length > 1 && state.displayType === DisplayType.BIG_NUMBER && (
  358. <RadioLineItem
  359. index={index}
  360. role="radio"
  361. aria-label="aggregate-selector"
  362. >
  363. <Radio
  364. checked={index === state.selectedAggregate}
  365. onChange={() => {
  366. dispatch({
  367. type: BuilderStateAction.SET_SELECTED_AGGREGATE,
  368. payload: index,
  369. });
  370. }}
  371. onClick={() => {
  372. setSelectedAggregateSet(true);
  373. trackAnalytics('dashboards_views.widget_builder.change', {
  374. builder_version: WidgetBuilderVersion.SLIDEOUT,
  375. field: 'visualize.selectAggregate',
  376. from: source,
  377. new_widget: !isEditing,
  378. value: '',
  379. widget_type: state.dataset ?? '',
  380. organization,
  381. });
  382. }}
  383. aria-label={'field' + index}
  384. />
  385. </RadioLineItem>
  386. )}
  387. <FieldBar data-testid={'field-bar'}>
  388. {field.kind === FieldValueKind.EQUATION ? (
  389. <StyledArithmeticInput
  390. name="arithmetic"
  391. key="parameter:text"
  392. type="text"
  393. required
  394. value={field.field}
  395. onUpdate={value => {
  396. dispatch({
  397. type: updateAction,
  398. payload: fields.map((_field, i) =>
  399. i === index ? {..._field, field: value} : _field
  400. ),
  401. });
  402. setError?.({...error, queries: []});
  403. trackAnalytics('dashboards_views.widget_builder.change', {
  404. builder_version: WidgetBuilderVersion.SLIDEOUT,
  405. field: 'visualize.updateEquation',
  406. from: source,
  407. new_widget: !isEditing,
  408. value: '',
  409. widget_type: state.dataset ?? '',
  410. organization,
  411. });
  412. }}
  413. options={fields}
  414. placeholder={t('Equation')}
  415. aria-label={t('Equation')}
  416. />
  417. ) : (
  418. <Fragment>
  419. <PrimarySelectRow hasColumnParameter={hasColumnParameter}>
  420. {hasColumnParameter && (
  421. <ColumnCompactSelect
  422. searchable
  423. options={
  424. state.dataset === WidgetType.SPANS &&
  425. field.kind !== FieldValueKind.FUNCTION
  426. ? spanColumnOptions
  427. : columnOptions
  428. }
  429. value={
  430. field.kind === FieldValueKind.FUNCTION
  431. ? parseFunction(stringFields?.[index] ?? '')
  432. ?.arguments[0] ?? ''
  433. : field.field
  434. }
  435. onChange={newField => {
  436. const newFields = cloneDeep(fields);
  437. const currentField = newFields[index]!;
  438. // Update the current field's aggregate with the new aggregate
  439. if (currentField.kind === FieldValueKind.FUNCTION) {
  440. currentField.function[1] = newField.value as string;
  441. }
  442. if (currentField.kind === FieldValueKind.FIELD) {
  443. currentField.field = newField.value as string;
  444. }
  445. dispatch({
  446. type: updateAction,
  447. payload: newFields,
  448. });
  449. setError?.({...error, queries: []});
  450. trackAnalytics('dashboards_views.widget_builder.change', {
  451. builder_version: WidgetBuilderVersion.SLIDEOUT,
  452. field: 'visualize.updateColumn',
  453. from: source,
  454. new_widget: !isEditing,
  455. value:
  456. currentField.kind === FieldValueKind.FIELD
  457. ? 'column'
  458. : 'aggregate',
  459. widget_type: state.dataset ?? '',
  460. organization,
  461. });
  462. }}
  463. triggerProps={{
  464. 'aria-label': t('Column Selection'),
  465. }}
  466. />
  467. )}
  468. <AggregateCompactSelect
  469. searchable
  470. hasColumnParameter={hasColumnParameter}
  471. disabled={aggregateOptions.length <= 1}
  472. options={aggregateOptions}
  473. value={parseFunction(stringFields?.[index] ?? '')?.name ?? ''}
  474. onChange={aggregateSelection => {
  475. const isNone = aggregateSelection.value === NONE;
  476. const newFields = cloneDeep(fields);
  477. const currentField = newFields[index]!;
  478. const newAggregate = aggregates.find(
  479. option =>
  480. option.value.meta.name === aggregateSelection.value
  481. );
  482. // Update the current field's aggregate with the new aggregate
  483. if (!isNone) {
  484. if (currentField.kind === FieldValueKind.FUNCTION) {
  485. // Handle setting an aggregate from an aggregate
  486. currentField.function[0] =
  487. aggregateSelection.value as AggregationKeyWithAlias;
  488. if (
  489. newAggregate?.value.meta &&
  490. 'parameters' in newAggregate.value.meta
  491. ) {
  492. // There are aggregates that have no parameters, so wipe out the argument
  493. // if it's supposed to be empty
  494. if (newAggregate.value.meta.parameters.length === 0) {
  495. currentField.function[1] = '';
  496. } else {
  497. // Check if the column is a valid column for the new aggregate
  498. const newColumnOptions = getColumnOptions(
  499. state.dataset ?? WidgetType.ERRORS,
  500. currentField,
  501. fieldOptions,
  502. // If no column filter method is provided, show all options
  503. columnFilterMethod ?? (() => true)
  504. );
  505. const newAggregateIsApdexOrUserMisery =
  506. newAggregate?.value.meta.name === 'apdex' ||
  507. newAggregate?.value.meta.name === 'user_misery';
  508. const isValidColumn =
  509. !newAggregateIsApdexOrUserMisery &&
  510. Boolean(
  511. newColumnOptions.find(
  512. option =>
  513. option.value === currentField.function[1]
  514. )?.value
  515. );
  516. currentField.function[1] =
  517. (isValidColumn
  518. ? currentField.function[1]
  519. : newAggregate.value.meta.parameters[0]!
  520. .defaultValue) ?? '';
  521. // Set the remaining parameters for the new aggregate
  522. for (
  523. let i = 1; // The first parameter is the column selection
  524. i < newAggregate.value.meta.parameters.length;
  525. i++
  526. ) {
  527. // Increment by 1 to skip past the aggregate name
  528. currentField.function[i + 1] =
  529. newAggregate.value.meta.parameters[
  530. i
  531. ]!.defaultValue;
  532. }
  533. }
  534. // Wipe out the remaining parameters that are unnecessary
  535. // This is necessary for transitioning between aggregates that have
  536. // more parameters to ones of fewer parameters
  537. for (
  538. let i = newAggregate.value.meta.parameters.length;
  539. i < MAX_FUNCTION_PARAMETERS;
  540. i++
  541. ) {
  542. currentField.function[i + 1] = undefined;
  543. }
  544. }
  545. } else {
  546. if (
  547. !newAggregate ||
  548. !('parameters' in newAggregate.value.meta)
  549. ) {
  550. return;
  551. }
  552. // Handle setting an aggregate from a field
  553. const newFunction: AggregateFunction = [
  554. aggregateSelection.value as AggregationKeyWithAlias,
  555. ((newAggregate?.value.meta?.parameters.length > 0 &&
  556. currentField.field) ||
  557. newAggregate?.value.meta?.parameters?.[0]
  558. ?.defaultValue) ??
  559. '',
  560. newAggregate?.value.meta?.parameters?.[1]
  561. ?.defaultValue ?? undefined,
  562. newAggregate?.value.meta?.parameters?.[2]
  563. ?.defaultValue ?? undefined,
  564. ];
  565. const newColumnOptions = getColumnOptions(
  566. state.dataset ?? WidgetType.ERRORS,
  567. {
  568. kind: FieldValueKind.FUNCTION,
  569. function: newFunction,
  570. },
  571. fieldOptions,
  572. // If no column filter method is provided, show all options
  573. columnFilterMethod ?? (() => true)
  574. );
  575. if (
  576. newAggregate?.value.meta &&
  577. 'parameters' in newAggregate.value.meta
  578. ) {
  579. newAggregate?.value.meta.parameters.forEach(
  580. (parameter, parameterIndex) => {
  581. const isValidParameter = validateParameter(
  582. newColumnOptions,
  583. parameter,
  584. newFunction[parameterIndex + 1]
  585. );
  586. // Increment by 1 to skip past the aggregate name
  587. newFunction[parameterIndex + 1] =
  588. (isValidParameter
  589. ? newFunction[parameterIndex + 1]
  590. : parameter.defaultValue) ?? '';
  591. }
  592. );
  593. }
  594. newFields[index] = {
  595. kind: FieldValueKind.FUNCTION,
  596. function: newFunction,
  597. };
  598. }
  599. trackAnalytics('dashboards_views.widget_builder.change', {
  600. builder_version: WidgetBuilderVersion.SLIDEOUT,
  601. field: 'visualize.updateAggregate',
  602. from: source,
  603. new_widget: !isEditing,
  604. value: 'aggregate',
  605. widget_type: state.dataset ?? '',
  606. organization,
  607. });
  608. } else {
  609. // Handle selecting None so we can select just a field, e.g. for samples
  610. // If none is selected, set the field to a field value
  611. // When selecting None, the next possible columns may be different from the
  612. // possible columns for the previous aggregate. Calculate the valid columns,
  613. // see if the current field's function argument is in the valid columns, and if so,
  614. // set the field to a field value. Otherwise, set the field to the first valid column.
  615. const validColumnFields = Object.values(
  616. datasetConfig.getTableFieldOptions?.(
  617. organization,
  618. tags,
  619. customMeasurements,
  620. api
  621. ) ?? []
  622. ).filter(
  623. option =>
  624. option.value.kind !== FieldValueKind.FUNCTION &&
  625. (datasetConfig.filterTableOptions?.(option) ?? true)
  626. );
  627. const functionArgInValidColumnFields =
  628. ('function' in currentField &&
  629. validColumnFields.find(
  630. option =>
  631. option.value.meta.name === currentField.function[1]
  632. )) ||
  633. undefined;
  634. const validColumn =
  635. functionArgInValidColumnFields?.value.meta.name ??
  636. validColumnFields?.[0]?.value.meta.name ??
  637. '';
  638. newFields[index] = {
  639. kind: FieldValueKind.FIELD,
  640. field: validColumn,
  641. };
  642. trackAnalytics('dashboards_views.widget_builder.change', {
  643. builder_version: WidgetBuilderVersion.SLIDEOUT,
  644. field: 'visualize.updateAggregate',
  645. from: source,
  646. new_widget: !isEditing,
  647. value: 'column',
  648. widget_type: state.dataset ?? '',
  649. organization,
  650. });
  651. }
  652. dispatch({
  653. type: updateAction,
  654. payload: newFields,
  655. });
  656. setError?.({...error, queries: []});
  657. }}
  658. triggerProps={{
  659. 'aria-label': t('Aggregate Selection'),
  660. }}
  661. />
  662. </PrimarySelectRow>
  663. {field.kind === FieldValueKind.FUNCTION &&
  664. parameterRefinements.length > 0 && (
  665. <ParameterRefinements>
  666. {parameterRefinements.map(
  667. (parameter: any, parameterIndex: any) => {
  668. // The current value is displaced by 2 because the first two parameters
  669. // are the aggregate name and the column selection
  670. const currentValue =
  671. field.function[parameterIndex + 2] || '';
  672. const key = `${field.function.join('_')}-${parameterIndex}`;
  673. return (
  674. <AggregateParameterField
  675. key={key}
  676. parameter={parameter}
  677. fieldValue={field}
  678. currentValue={currentValue}
  679. onChange={value => {
  680. const newFields = cloneDeep(fields);
  681. if (
  682. newFields[index]!.kind !== FieldValueKind.FUNCTION
  683. ) {
  684. return;
  685. }
  686. newFields[index]!.function[parameterIndex + 2] =
  687. value;
  688. dispatch({
  689. type: updateAction,
  690. payload: newFields,
  691. });
  692. setError?.({...error, queries: []});
  693. }}
  694. />
  695. );
  696. }
  697. )}
  698. </ParameterRefinements>
  699. )}
  700. {isApdexOrUserMisery && field.kind === FieldValueKind.FUNCTION && (
  701. <AggregateParameterField
  702. parameter={matchingAggregate?.value.meta.parameters[0]}
  703. fieldValue={field}
  704. currentValue={field.function[1]}
  705. onChange={value => {
  706. const newFields = cloneDeep(fields);
  707. if (newFields[index]!.kind !== FieldValueKind.FUNCTION) {
  708. return;
  709. }
  710. newFields[index]!.function[1] = value;
  711. dispatch({
  712. type: updateAction,
  713. payload: newFields,
  714. });
  715. setError?.({...error, queries: []});
  716. }}
  717. />
  718. )}
  719. </Fragment>
  720. )}
  721. </FieldBar>
  722. <FieldExtras isChartWidget={isChartWidget || isBigNumberWidget}>
  723. {!isChartWidget && !isBigNumberWidget && (
  724. <LegendAliasInput
  725. type="text"
  726. name="name"
  727. placeholder={t('Add Alias')}
  728. value={field.alias ?? ''}
  729. onChange={e => {
  730. const newFields = cloneDeep(fields);
  731. newFields[index]!.alias = e.target.value;
  732. dispatch({
  733. type: updateAction,
  734. payload: newFields,
  735. });
  736. }}
  737. onBlur={() => {
  738. trackAnalytics('dashboards_views.widget_builder.change', {
  739. builder_version: WidgetBuilderVersion.SLIDEOUT,
  740. field: 'visualize.legendAlias',
  741. from: source,
  742. new_widget: !isEditing,
  743. value: '',
  744. widget_type: state.dataset ?? '',
  745. organization,
  746. });
  747. }}
  748. />
  749. )}
  750. <StyledDeleteButton
  751. borderless
  752. icon={<IconDelete />}
  753. size="zero"
  754. disabled={fields.length <= 1 || !canDelete}
  755. onClick={() => {
  756. dispatch({
  757. type: updateAction,
  758. payload: fields?.filter((_field, i) => i !== index) ?? [],
  759. });
  760. if (
  761. state.displayType === DisplayType.BIG_NUMBER &&
  762. selectedAggregateSet
  763. ) {
  764. // Unset the selected aggregate if it's the last one
  765. // so the state will automatically choose the last aggregate
  766. // as new fields are added
  767. if (state.selectedAggregate === fields.length - 1) {
  768. dispatch({
  769. type: BuilderStateAction.SET_SELECTED_AGGREGATE,
  770. payload: undefined,
  771. });
  772. }
  773. }
  774. trackAnalytics('dashboards_views.widget_builder.change', {
  775. builder_version: WidgetBuilderVersion.SLIDEOUT,
  776. field:
  777. field.kind === FieldValueKind.EQUATION
  778. ? 'visualize.deleteEquation'
  779. : 'visualize.deleteField',
  780. from: source,
  781. new_widget: !isEditing,
  782. value: '',
  783. widget_type: state.dataset ?? '',
  784. organization,
  785. });
  786. }}
  787. aria-label={t('Remove field')}
  788. />
  789. </FieldExtras>
  790. </FieldRow>
  791. );
  792. })}
  793. </Fields>
  794. </StyledFieldGroup>
  795. <AddButtons>
  796. <AddButton
  797. priority="link"
  798. aria-label={isChartWidget ? t('Add Series') : t('Add Field')}
  799. onClick={() => {
  800. dispatch({
  801. type: updateAction,
  802. payload: [...(fields ?? []), cloneDeep(datasetConfig.defaultField)],
  803. });
  804. trackAnalytics('dashboards_views.widget_builder.change', {
  805. builder_version: WidgetBuilderVersion.SLIDEOUT,
  806. field: 'visualize.addField',
  807. from: source,
  808. new_widget: !isEditing,
  809. value: '',
  810. widget_type: state.dataset ?? '',
  811. organization,
  812. });
  813. }}
  814. >
  815. {isChartWidget ? t('+ Add Series') : t('+ Add Field')}
  816. </AddButton>
  817. {datasetConfig.enableEquations && (
  818. <AddButton
  819. priority="link"
  820. aria-label={t('Add Equation')}
  821. onClick={() => {
  822. dispatch({
  823. type: updateAction,
  824. payload: [...(fields ?? []), {kind: FieldValueKind.EQUATION, field: ''}],
  825. });
  826. trackAnalytics('dashboards_views.widget_builder.change', {
  827. builder_version: WidgetBuilderVersion.SLIDEOUT,
  828. field: 'visualize.addEquation',
  829. from: source,
  830. new_widget: !isEditing,
  831. value: '',
  832. widget_type: state.dataset ?? '',
  833. organization,
  834. });
  835. }}
  836. >
  837. {t('+ Add Equation')}
  838. </AddButton>
  839. )}
  840. </AddButtons>
  841. </Fragment>
  842. );
  843. }
  844. export default Visualize;
  845. function AggregateParameterField({
  846. parameter,
  847. fieldValue,
  848. onChange,
  849. currentValue,
  850. }: {
  851. currentValue: string;
  852. fieldValue: QueryFieldValue;
  853. onChange: (value: string) => void;
  854. parameter: ParameterDescription;
  855. }) {
  856. if (parameter.kind === 'value') {
  857. const inputProps = {
  858. required: parameter.required,
  859. value:
  860. currentValue ?? ('defaultValue' in parameter && parameter?.defaultValue) ?? '',
  861. onUpdate: (value: any) => {
  862. onChange(value);
  863. },
  864. onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
  865. if (e.key === 'Enter') {
  866. onChange(e.currentTarget.value);
  867. }
  868. },
  869. placeholder: parameter.placeholder,
  870. };
  871. switch (parameter.dataType) {
  872. case 'number':
  873. return (
  874. <BufferedInput
  875. name="refinement"
  876. key={`parameter:number-${currentValue}`}
  877. type="text"
  878. inputMode="numeric"
  879. pattern="[0-9]*(\.[0-9]*)?"
  880. aria-label={t('Numeric Input')}
  881. {...inputProps}
  882. />
  883. );
  884. case 'integer':
  885. return (
  886. <BufferedInput
  887. name="refinement"
  888. key="parameter:integer"
  889. type="text"
  890. inputMode="numeric"
  891. pattern="[0-9]*"
  892. aria-label={t('Integer Input')}
  893. {...inputProps}
  894. />
  895. );
  896. default:
  897. return (
  898. <BufferedInput
  899. name="refinement"
  900. key="parameter:text"
  901. type="text"
  902. aria-label={t('Text Input')}
  903. {...inputProps}
  904. />
  905. );
  906. }
  907. }
  908. if (parameter.kind === 'dropdown') {
  909. return (
  910. <SelectControl
  911. key="dropdown"
  912. name="dropdown"
  913. menuPlacement="auto"
  914. placeholder={t('Select value')}
  915. options={parameter.options}
  916. value={currentValue}
  917. required={parameter.required}
  918. onChange={({value}: any) => {
  919. onChange(value);
  920. }}
  921. searchable
  922. />
  923. );
  924. }
  925. throw new Error(`Unknown parameter type encountered for ${fieldValue}`);
  926. }
  927. const ColumnCompactSelect = styled(CompactSelect)`
  928. flex: 1 1 auto;
  929. min-width: 0;
  930. > button {
  931. width: 100%;
  932. }
  933. `;
  934. const AggregateCompactSelect = styled(CompactSelect)<{hasColumnParameter: boolean}>`
  935. ${p =>
  936. p.hasColumnParameter
  937. ? `
  938. width: fit-content;
  939. max-width: 150px;
  940. left: -1px;
  941. `
  942. : `
  943. width: 100%;
  944. `}
  945. > button {
  946. width: 100%;
  947. }
  948. `;
  949. const LegendAliasInput = styled(Input)``;
  950. const ParameterRefinements = styled('div')`
  951. display: flex;
  952. flex-direction: row;
  953. gap: ${space(1)};
  954. > * {
  955. flex: 1;
  956. }
  957. `;
  958. const FieldBar = styled('div')`
  959. display: grid;
  960. grid-template-columns: 1fr;
  961. gap: ${space(1)};
  962. flex: 3;
  963. `;
  964. const PrimarySelectRow = styled('div')<{hasColumnParameter: boolean}>`
  965. display: flex;
  966. width: 100%;
  967. flex: 3;
  968. & > ${ColumnCompactSelect} > button {
  969. border-top-right-radius: 0;
  970. border-bottom-right-radius: 0;
  971. }
  972. & > ${AggregateCompactSelect} > button {
  973. ${p =>
  974. p.hasColumnParameter &&
  975. `
  976. border-top-left-radius: 0;
  977. border-bottom-left-radius: 0;
  978. `}
  979. }
  980. `;
  981. const FieldRow = styled('div')`
  982. display: flex;
  983. flex-direction: row;
  984. gap: ${space(1)};
  985. `;
  986. const StyledDeleteButton = styled(Button)``;
  987. const FieldExtras = styled('div')<{isChartWidget: boolean}>`
  988. display: flex;
  989. flex-direction: row;
  990. gap: ${space(1)};
  991. flex: ${p => (p.isChartWidget ? '0' : '1')};
  992. `;
  993. const AddButton = styled(Button)`
  994. margin-top: ${space(1)};
  995. `;
  996. const AddButtons = styled('div')`
  997. display: inline-flex;
  998. gap: ${space(1.5)};
  999. `;
  1000. const Fields = styled('div')`
  1001. display: flex;
  1002. flex-direction: column;
  1003. gap: ${space(1)};
  1004. `;
  1005. const StyledArithmeticInput = styled(ArithmeticInput)`
  1006. width: 100%;
  1007. `;
  1008. const StyledFieldGroup = styled(FieldGroup)`
  1009. width: 100%;
  1010. padding: 0px;
  1011. border-bottom: none;
  1012. `;