visualize.tsx 37 KB

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