toolbarVisualize.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. import {Fragment, useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import type {SelectKey, SelectOption} from 'sentry/components/compactSelect';
  5. import {CompactSelect} from 'sentry/components/compactSelect';
  6. import {Tooltip} from 'sentry/components/tooltip';
  7. import {IconAdd} from 'sentry/icons';
  8. import {IconDelete} from 'sentry/icons/iconDelete';
  9. import {t} from 'sentry/locale';
  10. import {defined} from 'sentry/utils';
  11. import type {ParsedFunction} from 'sentry/utils/discover/fields';
  12. import {parseFunction, prettifyTagKey} from 'sentry/utils/discover/fields';
  13. import {ALLOWED_EXPLORE_VISUALIZE_AGGREGATES} from 'sentry/utils/fields';
  14. import {
  15. useExploreVisualizes,
  16. useSetExploreVisualizes,
  17. } from 'sentry/views/explore/contexts/pageParamsContext';
  18. import type {Visualize} from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
  19. import {
  20. DEFAULT_VISUALIZATION,
  21. MAX_VISUALIZES,
  22. } from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
  23. import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext';
  24. import {ChartType} from 'sentry/views/insights/common/components/chart';
  25. import {
  26. ToolbarFooter,
  27. ToolbarFooterButton,
  28. ToolbarHeader,
  29. ToolbarHeaderButton,
  30. ToolbarLabel,
  31. ToolbarRow,
  32. ToolbarSection,
  33. } from './styles';
  34. type ParsedVisualize = {
  35. func: ParsedFunction;
  36. label: string;
  37. };
  38. export function ToolbarVisualize() {
  39. const visualizes = useExploreVisualizes();
  40. const setVisualizes = useSetExploreVisualizes();
  41. const numberTags = useSpanTags('number');
  42. const parsedVisualizeGroups: ParsedVisualize[][] = useMemo(() => {
  43. return visualizes.map(visualize =>
  44. visualize.yAxes
  45. .map(parseFunction)
  46. .filter(defined)
  47. .map(func => {
  48. return {
  49. func,
  50. label: visualize.label,
  51. };
  52. })
  53. );
  54. }, [visualizes]);
  55. const fieldOptions: SelectOption<string>[] = useMemo(() => {
  56. const unknownOptions = parsedVisualizeGroups
  57. .flatMap(group =>
  58. group.flatMap(entry => {
  59. return entry.func.arguments;
  60. })
  61. )
  62. .filter(option => {
  63. return !numberTags.hasOwnProperty(option);
  64. });
  65. const options = [
  66. ...unknownOptions.map(option => ({
  67. label: prettifyTagKey(option),
  68. value: option,
  69. textValue: option,
  70. })),
  71. ...Object.values(numberTags).map(tag => {
  72. return {
  73. label: tag.name,
  74. value: tag.key,
  75. textValue: tag.name,
  76. };
  77. }),
  78. ];
  79. options.sort((a, b) => {
  80. if (a.label < b.label) {
  81. return -1;
  82. }
  83. if (a.label > b.label) {
  84. return 1;
  85. }
  86. return 0;
  87. });
  88. return options;
  89. }, [numberTags, parsedVisualizeGroups]);
  90. const aggregateOptions: SelectOption<string>[] = useMemo(() => {
  91. return ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => {
  92. return {
  93. label: aggregate,
  94. value: aggregate,
  95. textValue: aggregate,
  96. };
  97. });
  98. }, []);
  99. const addChart = useCallback(() => {
  100. setVisualizes([
  101. ...visualizes,
  102. {yAxes: [DEFAULT_VISUALIZATION], chartType: ChartType.LINE},
  103. ]);
  104. }, [setVisualizes, visualizes]);
  105. const addOverlay = useCallback(
  106. (group: number) => {
  107. const newVisualizes = visualizes.slice();
  108. newVisualizes[group]!.yAxes.push(DEFAULT_VISUALIZATION);
  109. setVisualizes(newVisualizes);
  110. },
  111. [setVisualizes, visualizes]
  112. );
  113. const setChartField = useCallback(
  114. (group: number, index: number, {value}: SelectOption<SelectKey>) => {
  115. const newVisualizes = visualizes.slice();
  116. newVisualizes[group]!.yAxes[index] =
  117. `${parsedVisualizeGroups[group]![index]!.func.name}(${value})`;
  118. setVisualizes(newVisualizes);
  119. },
  120. [parsedVisualizeGroups, setVisualizes, visualizes]
  121. );
  122. const setChartAggregate = useCallback(
  123. (group: number, index: number, {value}: SelectOption<SelectKey>) => {
  124. const newVisualizes = visualizes.slice();
  125. newVisualizes[group]!.yAxes[index] =
  126. `${value}(${parsedVisualizeGroups[group]![index]!.func.arguments[0]})`;
  127. setVisualizes(newVisualizes);
  128. },
  129. [parsedVisualizeGroups, setVisualizes, visualizes]
  130. );
  131. const deleteOverlay = useCallback(
  132. (group: number, index: number) => {
  133. const newVisualizes: Visualize[] = visualizes
  134. .map((visualize, orgGroup) => {
  135. if (group !== orgGroup) {
  136. return visualize;
  137. }
  138. return {
  139. ...visualize,
  140. yAxes: visualize.yAxes.filter((_, orgIndex) => index !== orgIndex),
  141. };
  142. })
  143. .filter(visualize => visualize.yAxes.length > 0);
  144. setVisualizes(newVisualizes);
  145. },
  146. [setVisualizes, visualizes]
  147. );
  148. const lastVisualization =
  149. parsedVisualizeGroups
  150. .map(parsedVisualizeGroup => parsedVisualizeGroup.length)
  151. .reduce((a, b) => a + b, 0) <= 1;
  152. const shouldRenderLabel = visualizes.length > 1;
  153. return (
  154. <ToolbarSection data-test-id="section-visualizes">
  155. <ToolbarHeader>
  156. <Tooltip
  157. position="right"
  158. title={t(
  159. 'Primary metric that appears in your chart. You can also overlay a series onto an existing chart or add an equation.'
  160. )}
  161. >
  162. <ToolbarLabel>{t('Visualize')}</ToolbarLabel>
  163. </Tooltip>
  164. <Tooltip title={t('Add a new chart')}>
  165. <ToolbarHeaderButton
  166. size="zero"
  167. icon={<IconAdd />}
  168. onClick={addChart}
  169. aria-label={t('Add Chart')}
  170. borderless
  171. disabled={visualizes.length >= MAX_VISUALIZES}
  172. />
  173. </Tooltip>
  174. </ToolbarHeader>
  175. <div>
  176. {parsedVisualizeGroups.map((parsedVisualizeGroup, group) => {
  177. return (
  178. <Fragment key={group}>
  179. {parsedVisualizeGroup.map((parsedVisualize, index) => (
  180. <ToolbarRow key={index}>
  181. {shouldRenderLabel && <ChartLabel>{parsedVisualize.label}</ChartLabel>}
  182. <ColumnCompactSelect
  183. searchable
  184. options={fieldOptions}
  185. value={parsedVisualize.func.arguments[0]}
  186. onChange={newField => setChartField(group, index, newField)}
  187. />
  188. <AggregateCompactSelect
  189. options={aggregateOptions}
  190. value={parsedVisualize.func.name}
  191. onChange={newAggregate =>
  192. setChartAggregate(group, index, newAggregate)
  193. }
  194. />
  195. <Button
  196. borderless
  197. icon={<IconDelete />}
  198. size="zero"
  199. disabled={lastVisualization}
  200. onClick={() => deleteOverlay(group, index)}
  201. aria-label={t('Remove Overlay')}
  202. />
  203. </ToolbarRow>
  204. ))}
  205. <ToolbarFooter>
  206. <ToolbarFooterButton
  207. borderless
  208. size="zero"
  209. icon={<IconAdd />}
  210. onClick={() => addOverlay(group)}
  211. priority="link"
  212. aria-label={t('Add Series')}
  213. >
  214. {t('Add Series')}
  215. </ToolbarFooterButton>
  216. </ToolbarFooter>
  217. </Fragment>
  218. );
  219. })}
  220. </div>
  221. </ToolbarSection>
  222. );
  223. }
  224. const ChartLabel = styled('div')`
  225. background-color: ${p => p.theme.purple100};
  226. border-radius: ${p => p.theme.borderRadius};
  227. text-align: center;
  228. width: 38px;
  229. color: ${p => p.theme.purple400};
  230. white-space: nowrap;
  231. font-weight: ${p => p.theme.fontWeightBold};
  232. align-content: center;
  233. `;
  234. const ColumnCompactSelect = styled(CompactSelect)`
  235. flex: 1 1;
  236. min-width: 0;
  237. > button {
  238. width: 100%;
  239. }
  240. `;
  241. const AggregateCompactSelect = styled(CompactSelect)`
  242. width: 100px;
  243. > button {
  244. width: 100%;
  245. }
  246. `;