visualize.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. import {Fragment, useMemo} 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 SelectControl from 'sentry/components/forms/controls/selectControl';
  7. import Input from 'sentry/components/input';
  8. import {IconDelete} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {
  12. type AggregationKeyWithAlias,
  13. type AggregationRefinement,
  14. classifyTagKey,
  15. generateFieldAsString,
  16. parseFunction,
  17. prettifyTagKey,
  18. type QueryFieldValue,
  19. } from 'sentry/utils/discover/fields';
  20. import {FieldKind} from 'sentry/utils/fields';
  21. import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
  22. import useOrganization from 'sentry/utils/useOrganization';
  23. import useTags from 'sentry/utils/useTags';
  24. import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
  25. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  26. import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
  27. import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
  28. import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
  29. import ArithmeticInput from 'sentry/views/discover/table/arithmeticInput';
  30. import {
  31. BufferedInput,
  32. type ParameterDescription,
  33. } from 'sentry/views/discover/table/queryField';
  34. import {FieldValueKind} from 'sentry/views/discover/table/types';
  35. import {TypeBadge} from 'sentry/views/explore/components/typeBadge';
  36. import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext';
  37. type AggregateFunction = [
  38. AggregationKeyWithAlias,
  39. string,
  40. AggregationRefinement,
  41. AggregationRefinement,
  42. ];
  43. const MAX_FUNCTION_PARAMETERS = 4;
  44. const NONE = 'none';
  45. const NONE_AGGREGATE = {
  46. label: t('None'),
  47. value: NONE,
  48. };
  49. function Visualize() {
  50. const organization = useOrganization();
  51. const {state, dispatch} = useWidgetBuilderContext();
  52. let tags = useTags();
  53. const {customMeasurements} = useCustomMeasurements();
  54. const isChartWidget =
  55. state.displayType !== DisplayType.TABLE &&
  56. state.displayType !== DisplayType.BIG_NUMBER;
  57. const isBigNumberWidget = state.displayType === DisplayType.BIG_NUMBER;
  58. const numericSpanTags = useSpanTags('number');
  59. const stringSpanTags = useSpanTags('string');
  60. // Span column options are explicitly defined and bypass all of the
  61. // fieldOptions filtering and logic used for showing options for
  62. // chart types.
  63. let spanColumnOptions;
  64. if (state.dataset === WidgetType.SPANS) {
  65. // Explicitly merge numeric and string tags to ensure filtering
  66. // compatibility for timeseries chart types.
  67. tags = {...numericSpanTags, ...stringSpanTags};
  68. const columns =
  69. state.fields
  70. ?.filter(field => field.kind === FieldValueKind.FIELD)
  71. .map(field => field.field) ?? [];
  72. spanColumnOptions = [
  73. // Columns that are not in the tag responses, e.g. old tags
  74. ...columns
  75. .filter(
  76. column =>
  77. column !== '' &&
  78. !stringSpanTags.hasOwnProperty(column) &&
  79. !numericSpanTags.hasOwnProperty(column)
  80. )
  81. .map(column => {
  82. return {
  83. label: prettifyTagKey(column),
  84. value: column,
  85. textValue: column,
  86. trailingItems: <TypeBadge kind={classifyTagKey(column)} />,
  87. };
  88. }),
  89. ...Object.values(stringSpanTags).map(tag => {
  90. return {
  91. label: tag.name,
  92. value: tag.key,
  93. textValue: tag.name,
  94. trailingItems: <TypeBadge kind={FieldKind.TAG} />,
  95. };
  96. }),
  97. ...Object.values(numericSpanTags).map(tag => {
  98. return {
  99. label: tag.name,
  100. value: tag.key,
  101. textValue: tag.name,
  102. trailingItems: <TypeBadge kind={FieldKind.MEASUREMENT} />,
  103. };
  104. }),
  105. ];
  106. spanColumnOptions.sort((a, b) => {
  107. if (a.label < b.label) {
  108. return -1;
  109. }
  110. if (a.label > b.label) {
  111. return 1;
  112. }
  113. return 0;
  114. });
  115. }
  116. const datasetConfig = useMemo(() => getDatasetConfig(state.dataset), [state.dataset]);
  117. const fields = isChartWidget ? state.yAxis : state.fields;
  118. const updateAction = isChartWidget
  119. ? BuilderStateAction.SET_Y_AXIS
  120. : BuilderStateAction.SET_FIELDS;
  121. const fieldOptions = useMemo(
  122. () => datasetConfig.getTableFieldOptions(organization, tags, customMeasurements),
  123. [organization, tags, customMeasurements, datasetConfig]
  124. );
  125. const aggregates = useMemo(
  126. () =>
  127. Object.values(fieldOptions).filter(option =>
  128. datasetConfig.filterYAxisOptions?.(state.displayType ?? DisplayType.TABLE)(option)
  129. ),
  130. [fieldOptions, state.displayType, datasetConfig]
  131. );
  132. // Used to extract selected aggregates and parameters from the fields
  133. const stringFields = fields?.map(generateFieldAsString);
  134. return (
  135. <Fragment>
  136. <SectionHeader
  137. title={t('Visualize')}
  138. tooltipText={t(
  139. 'Primary metric that appears in your chart. You can also overlay a series onto an existing chart or add an equation.'
  140. )}
  141. />
  142. <Fields>
  143. {fields?.map((field, index) => {
  144. // Depending on the dataset and the display type, we use different options for
  145. // displaying in the column select.
  146. // For charts, we show aggregate parameter options for the y-axis as primary options.
  147. // For tables, we show all string tags and fields as primary options, as well
  148. // as aggregates that don't take parameters.
  149. const columnFilterMethod = isChartWidget
  150. ? datasetConfig.filterYAxisAggregateParams?.(
  151. field,
  152. state.displayType ?? DisplayType.LINE
  153. )
  154. : field.kind === FieldValueKind.FUNCTION
  155. ? datasetConfig.filterAggregateParams
  156. : datasetConfig.filterTableOptions;
  157. const columnOptions = Object.values(fieldOptions)
  158. .filter(option => {
  159. // Don't show any aggregates under the columns, and if
  160. // there isn't a filter method, just show the option
  161. return (
  162. option.value.kind !== FieldValueKind.FUNCTION &&
  163. (columnFilterMethod?.(option, field) ?? true)
  164. );
  165. })
  166. .map(option => ({
  167. value: option.value.meta.name,
  168. label:
  169. state.dataset === WidgetType.SPANS
  170. ? prettifyTagKey(option.value.meta.name)
  171. : option.value.meta.name,
  172. // For the spans dataset, all of the options are measurements,
  173. // so we force the number badge to show
  174. trailingItems:
  175. state.dataset === WidgetType.SPANS ? (
  176. <TypeBadge kind={FieldKind.MEASUREMENT} />
  177. ) : null,
  178. }));
  179. let aggregateOptions = aggregates.map(option => ({
  180. value: option.value.meta.name,
  181. label: option.value.meta.name,
  182. }));
  183. aggregateOptions =
  184. isChartWidget || isBigNumberWidget
  185. ? aggregateOptions
  186. : [NONE_AGGREGATE, ...aggregateOptions];
  187. let matchingAggregate;
  188. if (
  189. fields[index]!.kind === FieldValueKind.FUNCTION &&
  190. FieldValueKind.FUNCTION in fields[index]!
  191. ) {
  192. matchingAggregate = aggregates.find(
  193. option =>
  194. option.value.meta.name ===
  195. parseFunction(stringFields?.[index] ?? '')?.name
  196. );
  197. }
  198. const parameterRefinements =
  199. matchingAggregate?.value.meta.parameters.length > 1
  200. ? matchingAggregate?.value.meta.parameters.slice(1)
  201. : [];
  202. return (
  203. <FieldRow key={index}>
  204. <FieldBar data-testid={'field-bar'}>
  205. {field.kind === FieldValueKind.EQUATION ? (
  206. <StyledArithmeticInput
  207. name="arithmetic"
  208. key="parameter:text"
  209. type="text"
  210. required
  211. value={field.field}
  212. onUpdate={value =>
  213. dispatch({
  214. type: updateAction,
  215. payload: fields.map((_field, i) =>
  216. i === index ? {..._field, field: value} : _field
  217. ),
  218. })
  219. }
  220. options={fields}
  221. placeholder={t('Equation')}
  222. aria-label={t('Equation')}
  223. />
  224. ) : (
  225. <Fragment>
  226. <PrimarySelectRow>
  227. <ColumnCompactSelect
  228. searchable
  229. options={
  230. state.dataset === WidgetType.SPANS &&
  231. field.kind !== FieldValueKind.FUNCTION
  232. ? spanColumnOptions
  233. : columnOptions
  234. }
  235. value={
  236. field.kind === FieldValueKind.FUNCTION
  237. ? parseFunction(stringFields?.[index] ?? '')?.arguments[0] ??
  238. ''
  239. : field.field
  240. }
  241. onChange={newField => {
  242. const newFields = cloneDeep(fields);
  243. const currentField = newFields[index]!;
  244. // Update the current field's aggregate with the new aggregate
  245. if (currentField.kind === FieldValueKind.FUNCTION) {
  246. currentField.function[1] = newField.value as string;
  247. }
  248. if (currentField.kind === FieldValueKind.FIELD) {
  249. currentField.field = newField.value as string;
  250. }
  251. dispatch({
  252. type: updateAction,
  253. payload: newFields,
  254. });
  255. }}
  256. triggerProps={{
  257. 'aria-label': t('Column Selection'),
  258. }}
  259. disabled={
  260. fields[index]!.kind === FieldValueKind.FUNCTION &&
  261. matchingAggregate?.value.meta.parameters.length === 0
  262. }
  263. />
  264. <AggregateCompactSelect
  265. disabled={aggregateOptions.length <= 1}
  266. options={aggregateOptions}
  267. value={parseFunction(stringFields?.[index] ?? '')?.name ?? ''}
  268. onChange={aggregateSelection => {
  269. const isNone = aggregateSelection.value === NONE;
  270. const newFields = cloneDeep(fields);
  271. const currentField = newFields[index]!;
  272. const newAggregate = aggregates.find(
  273. option => option.value.meta.name === aggregateSelection.value
  274. );
  275. // Update the current field's aggregate with the new aggregate
  276. if (!isNone) {
  277. if (currentField.kind === FieldValueKind.FUNCTION) {
  278. // Handle setting an aggregate from an aggregate
  279. currentField.function[0] =
  280. aggregateSelection.value as AggregationKeyWithAlias;
  281. if (
  282. newAggregate?.value.meta &&
  283. 'parameters' in newAggregate.value.meta
  284. ) {
  285. // There are aggregates that have no parameters, so wipe out the argument
  286. // if it's supposed to be empty
  287. if (newAggregate.value.meta.parameters.length === 0) {
  288. currentField.function[1] = '';
  289. } else {
  290. currentField.function[1] =
  291. (currentField.function[1] ||
  292. newAggregate.value.meta.parameters[0]!
  293. .defaultValue) ??
  294. '';
  295. // Set the remaining parameters for the new aggregate
  296. for (
  297. let i = 1; // The first parameter is the column selection
  298. i < newAggregate.value.meta.parameters.length;
  299. i++
  300. ) {
  301. // Increment by 1 to skip past the aggregate name
  302. currentField.function[i + 1] =
  303. newAggregate.value.meta.parameters[i]!.defaultValue;
  304. }
  305. }
  306. // Wipe out the remaining parameters that are unnecessary
  307. // This is necessary for transitioning between aggregates that have
  308. // more parameters to ones of fewer parameters
  309. for (
  310. let i = newAggregate.value.meta.parameters.length;
  311. i < MAX_FUNCTION_PARAMETERS;
  312. i++
  313. ) {
  314. currentField.function[i + 1] = undefined;
  315. }
  316. }
  317. } else {
  318. if (
  319. !newAggregate ||
  320. !('parameters' in newAggregate.value.meta)
  321. ) {
  322. return;
  323. }
  324. // Handle setting an aggregate from a field
  325. const newFunction: AggregateFunction = [
  326. aggregateSelection.value as AggregationKeyWithAlias,
  327. (currentField.field ||
  328. newAggregate?.value.meta?.parameters?.[0]
  329. ?.defaultValue) ??
  330. '',
  331. newAggregate?.value.meta?.parameters?.[1]?.defaultValue ??
  332. undefined,
  333. newAggregate?.value.meta?.parameters?.[2]?.defaultValue ??
  334. undefined,
  335. ];
  336. if (
  337. newAggregate?.value.meta &&
  338. 'parameters' in newAggregate.value.meta
  339. ) {
  340. newAggregate?.value.meta.parameters.forEach(
  341. (parameter, parameterIndex) => {
  342. // Increment by 1 to skip past the aggregate name
  343. newFunction[parameterIndex + 1] =
  344. newFunction[parameterIndex + 1] ??
  345. parameter.defaultValue;
  346. }
  347. );
  348. }
  349. newFields[index] = {
  350. kind: FieldValueKind.FUNCTION,
  351. function: newFunction,
  352. };
  353. }
  354. } else {
  355. // Handle selecting None so we can select just a field, e.g. for samples
  356. // If none is selected, set the field to a field value
  357. newFields[index] = {
  358. kind: FieldValueKind.FIELD,
  359. field:
  360. 'function' in currentField
  361. ? (currentField.function[1] as string) ??
  362. columnOptions[0]!.value
  363. : '',
  364. };
  365. }
  366. dispatch({
  367. type: updateAction,
  368. payload: newFields,
  369. });
  370. }}
  371. triggerProps={{
  372. 'aria-label': t('Aggregate Selection'),
  373. }}
  374. />
  375. </PrimarySelectRow>
  376. {field.kind === FieldValueKind.FUNCTION &&
  377. parameterRefinements.length > 0 && (
  378. <ParameterRefinements>
  379. {parameterRefinements.map((parameter, parameterIndex) => {
  380. // The current value is displaced by 2 because the first two parameters
  381. // are the aggregate name and the column selection
  382. const currentValue = field.function[parameterIndex + 2] || '';
  383. const key = `${field.function.join('_')}-${parameterIndex}`;
  384. return (
  385. <AggregateParameter
  386. key={key}
  387. parameter={parameter}
  388. fieldValue={field}
  389. currentValue={currentValue}
  390. onChange={value => {
  391. const newFields = cloneDeep(fields);
  392. if (
  393. newFields[index]!.kind !== FieldValueKind.FUNCTION
  394. ) {
  395. return;
  396. }
  397. newFields[index]!.function[parameterIndex + 2] = value;
  398. dispatch({
  399. type: updateAction,
  400. payload: newFields,
  401. });
  402. }}
  403. />
  404. );
  405. })}
  406. </ParameterRefinements>
  407. )}
  408. </Fragment>
  409. )}
  410. </FieldBar>
  411. <FieldExtras isChartWidget={isChartWidget || isBigNumberWidget}>
  412. {!isChartWidget && !isBigNumberWidget && (
  413. <LegendAliasInput
  414. type="text"
  415. name="name"
  416. placeholder={t('Add Alias')}
  417. value={field.alias}
  418. onChange={e => {
  419. const newFields = cloneDeep(fields);
  420. newFields[index]!.alias = e.target.value;
  421. dispatch({
  422. type: updateAction,
  423. payload: newFields,
  424. });
  425. }}
  426. />
  427. )}
  428. <StyledDeleteButton
  429. borderless
  430. icon={<IconDelete />}
  431. size="zero"
  432. disabled={fields.length <= 1}
  433. onClick={() =>
  434. dispatch({
  435. type: updateAction,
  436. payload: fields?.filter((_field, i) => i !== index) ?? [],
  437. })
  438. }
  439. aria-label={t('Remove field')}
  440. />
  441. </FieldExtras>
  442. </FieldRow>
  443. );
  444. })}
  445. </Fields>
  446. <AddButtons>
  447. <AddButton
  448. priority="link"
  449. aria-label={isChartWidget ? t('Add Series') : t('Add Field')}
  450. onClick={() =>
  451. dispatch({
  452. type: updateAction,
  453. payload: [...(fields ?? []), cloneDeep(datasetConfig.defaultField)],
  454. })
  455. }
  456. >
  457. {isChartWidget ? t('+ Add Series') : t('+ Add Field')}
  458. </AddButton>
  459. {datasetConfig.enableEquations && (
  460. <AddButton
  461. priority="link"
  462. aria-label={t('Add Equation')}
  463. onClick={() =>
  464. dispatch({
  465. type: updateAction,
  466. payload: [...(fields ?? []), {kind: FieldValueKind.EQUATION, field: ''}],
  467. })
  468. }
  469. >
  470. {t('+ Add Equation')}
  471. </AddButton>
  472. )}
  473. </AddButtons>
  474. </Fragment>
  475. );
  476. }
  477. export default Visualize;
  478. function AggregateParameter({
  479. parameter,
  480. fieldValue,
  481. onChange,
  482. currentValue,
  483. }: {
  484. currentValue: string;
  485. fieldValue: QueryFieldValue;
  486. onChange: (value: string) => void;
  487. parameter: ParameterDescription;
  488. }) {
  489. if (parameter.kind === 'value') {
  490. const inputProps = {
  491. required: parameter.required,
  492. value:
  493. parameter.value ?? ('defaultValue' in parameter && parameter?.defaultValue) ?? '',
  494. onUpdate: value => {
  495. onChange(value);
  496. },
  497. placeholder: parameter.placeholder,
  498. };
  499. switch (parameter.dataType) {
  500. case 'number':
  501. return (
  502. <BufferedInput
  503. name="refinement"
  504. key="parameter:number"
  505. type="text"
  506. inputMode="numeric"
  507. pattern="[0-9]*(\.[0-9]*)?"
  508. {...inputProps}
  509. />
  510. );
  511. case 'integer':
  512. return (
  513. <BufferedInput
  514. name="refinement"
  515. key="parameter:integer"
  516. type="text"
  517. inputMode="numeric"
  518. pattern="[0-9]*"
  519. {...inputProps}
  520. />
  521. );
  522. default:
  523. return (
  524. <BufferedInput
  525. name="refinement"
  526. key="parameter:text"
  527. type="text"
  528. {...inputProps}
  529. />
  530. );
  531. }
  532. }
  533. if (parameter.kind === 'dropdown') {
  534. return (
  535. <SelectControl
  536. key="dropdown"
  537. name="dropdown"
  538. menuPlacement="auto"
  539. placeholder={t('Select value')}
  540. options={parameter.options}
  541. value={currentValue}
  542. required={parameter.required}
  543. onChange={({value}) => {
  544. onChange(value);
  545. }}
  546. />
  547. );
  548. }
  549. throw new Error(`Unknown parameter type encountered for ${fieldValue}`);
  550. }
  551. const ColumnCompactSelect = styled(CompactSelect)`
  552. flex: 1 1 auto;
  553. min-width: 0;
  554. > button {
  555. width: 100%;
  556. }
  557. `;
  558. const AggregateCompactSelect = styled(CompactSelect)`
  559. width: fit-content;
  560. max-width: 150px;
  561. left: -1px;
  562. > button {
  563. width: 100%;
  564. }
  565. `;
  566. const LegendAliasInput = styled(Input)``;
  567. const ParameterRefinements = styled('div')`
  568. display: flex;
  569. flex-direction: row;
  570. gap: ${space(1)};
  571. > * {
  572. flex: 1;
  573. }
  574. `;
  575. const FieldBar = styled('div')`
  576. display: grid;
  577. grid-template-columns: 1fr;
  578. gap: ${space(1)};
  579. flex: 3;
  580. `;
  581. const PrimarySelectRow = styled('div')`
  582. display: flex;
  583. width: 100%;
  584. flex: 3;
  585. & > ${ColumnCompactSelect} > button {
  586. border-top-right-radius: 0;
  587. border-bottom-right-radius: 0;
  588. }
  589. & > ${AggregateCompactSelect} > button {
  590. border-top-left-radius: 0;
  591. border-bottom-left-radius: 0;
  592. }
  593. `;
  594. const FieldRow = styled('div')`
  595. display: flex;
  596. flex-direction: row;
  597. gap: ${space(1)};
  598. `;
  599. const StyledDeleteButton = styled(Button)``;
  600. const FieldExtras = styled('div')<{isChartWidget: boolean}>`
  601. display: flex;
  602. flex-direction: row;
  603. gap: ${space(1)};
  604. flex: ${p => (p.isChartWidget ? '0' : '1')};
  605. `;
  606. const AddButton = styled(Button)`
  607. margin-top: ${space(1)};
  608. `;
  609. const AddButtons = styled('div')`
  610. display: inline-flex;
  611. gap: ${space(1.5)};
  612. `;
  613. const Fields = styled('div')`
  614. display: flex;
  615. flex-direction: column;
  616. gap: ${space(1)};
  617. `;
  618. const StyledArithmeticInput = styled(ArithmeticInput)`
  619. width: 100%;
  620. `;