toolbarVisualize.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import {Fragment, useCallback, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {ArithmeticBuilder} from 'sentry/components/arithmeticBuilder';
  4. import type {Expression} from 'sentry/components/arithmeticBuilder/expression';
  5. import {isTokenFunction} from 'sentry/components/arithmeticBuilder/token';
  6. import type {SelectKey, SelectOption} from 'sentry/components/compactSelect';
  7. import {CompactSelect} from 'sentry/components/compactSelect';
  8. import {Button} from 'sentry/components/core/button';
  9. import {Tooltip} from 'sentry/components/tooltip';
  10. import {IconAdd} from 'sentry/icons';
  11. import {IconDelete} from 'sentry/icons/iconDelete';
  12. import {t} from 'sentry/locale';
  13. import {parseFunction} from 'sentry/utils/discover/fields';
  14. import {ALLOWED_EXPLORE_VISUALIZE_AGGREGATES} from 'sentry/utils/fields';
  15. import {
  16. useExploreVisualizes,
  17. useSetExploreVisualizes,
  18. } from 'sentry/views/explore/contexts/pageParamsContext';
  19. import type {Visualize} from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
  20. import {
  21. DEFAULT_VISUALIZATION,
  22. DEFAULT_VISUALIZATION_FIELD,
  23. MAX_VISUALIZES,
  24. } from 'sentry/views/explore/contexts/pageParamsContext/visualizes';
  25. import {useVisualizeFields} from 'sentry/views/explore/hooks/useVisualizeFields';
  26. import {ChartType} from 'sentry/views/insights/common/components/chart';
  27. import {
  28. ToolbarFooter,
  29. ToolbarFooterButton,
  30. ToolbarHeader,
  31. ToolbarHeaderButton,
  32. ToolbarLabel,
  33. ToolbarRow,
  34. ToolbarSection,
  35. } from './styles';
  36. interface ToolbarVisualizeProps {
  37. equationSupport?: boolean;
  38. }
  39. export function ToolbarVisualize({equationSupport}: ToolbarVisualizeProps) {
  40. const visualizes = useExploreVisualizes();
  41. const setVisualizes = useSetExploreVisualizes();
  42. const addChart = useCallback(() => {
  43. setVisualizes(
  44. [...visualizes, {yAxes: [DEFAULT_VISUALIZATION], chartType: ChartType.LINE}],
  45. [DEFAULT_VISUALIZATION_FIELD]
  46. );
  47. }, [setVisualizes, visualizes]);
  48. const addOverlay = useCallback(
  49. (group: number) => {
  50. const newVisualizes = visualizes.slice();
  51. newVisualizes[group]!.yAxes.push(DEFAULT_VISUALIZATION);
  52. setVisualizes(newVisualizes, [DEFAULT_VISUALIZATION_FIELD]);
  53. },
  54. [setVisualizes, visualizes]
  55. );
  56. const deleteOverlay = useCallback(
  57. (group: number, index: number) => {
  58. const newVisualizes: Visualize[] = visualizes
  59. .map((visualize, orgGroup) => {
  60. if (group !== orgGroup) {
  61. return visualize;
  62. }
  63. return {
  64. ...visualize,
  65. yAxes: visualize.yAxes.filter((_, orgIndex) => index !== orgIndex),
  66. };
  67. })
  68. .filter(visualize => visualize.yAxes.length > 0);
  69. setVisualizes(newVisualizes);
  70. },
  71. [setVisualizes, visualizes]
  72. );
  73. const canDelete =
  74. visualizes.map(visualize => visualize.yAxes.length).reduce((a, b) => a + b, 0) > 1;
  75. const shouldRenderLabel = visualizes.length > 1;
  76. return (
  77. <ToolbarSection data-test-id="section-visualizes">
  78. <ToolbarHeader>
  79. <Tooltip
  80. position="right"
  81. title={t(
  82. 'Primary metric that appears in your chart. You can also overlay a series onto an existing chart or add an equation.'
  83. )}
  84. >
  85. <ToolbarLabel>{t('Visualize')}</ToolbarLabel>
  86. </Tooltip>
  87. <Tooltip title={t('Add a new chart')}>
  88. <ToolbarHeaderButton
  89. size="zero"
  90. icon={<IconAdd />}
  91. onClick={addChart}
  92. aria-label={t('Add Chart')}
  93. borderless
  94. disabled={visualizes.length >= MAX_VISUALIZES}
  95. />
  96. </Tooltip>
  97. </ToolbarHeader>
  98. <div>
  99. {visualizes.map((visualize, group) => {
  100. return (
  101. <Fragment key={group}>
  102. {visualize.yAxes.map((yAxis, index) => (
  103. <Fragment key={index}>
  104. {equationSupport ? (
  105. <VisualizeEquation
  106. canDelete={canDelete}
  107. deleteOverlay={deleteOverlay}
  108. group={group}
  109. index={index}
  110. label={shouldRenderLabel ? visualize.label : undefined}
  111. yAxis={visualizes[group]?.yAxes?.[index]}
  112. visualizes={visualizes}
  113. setVisualizes={setVisualizes}
  114. />
  115. ) : (
  116. <VisualizeDropdown
  117. canDelete={canDelete}
  118. deleteOverlay={deleteOverlay}
  119. group={group}
  120. index={index}
  121. label={shouldRenderLabel ? visualize.label : undefined}
  122. yAxis={yAxis}
  123. visualizes={visualizes}
  124. setVisualizes={setVisualizes}
  125. />
  126. )}
  127. </Fragment>
  128. ))}
  129. <ToolbarFooter>
  130. <ToolbarFooterButton
  131. borderless
  132. size="zero"
  133. icon={<IconAdd />}
  134. onClick={() => addOverlay(group)}
  135. priority="link"
  136. aria-label={t('Add Series')}
  137. >
  138. {t('Add Series')}
  139. </ToolbarFooterButton>
  140. </ToolbarFooter>
  141. </Fragment>
  142. );
  143. })}
  144. </div>
  145. </ToolbarSection>
  146. );
  147. }
  148. interface VisualizeDropdownProps {
  149. canDelete: boolean;
  150. deleteOverlay: (group: number, index: number) => void;
  151. group: number;
  152. index: number;
  153. setVisualizes: (visualizes: Visualize[], fields?: string[]) => void;
  154. visualizes: Visualize[];
  155. yAxis: string;
  156. label?: string;
  157. }
  158. function VisualizeDropdown({
  159. canDelete,
  160. deleteOverlay,
  161. group,
  162. index,
  163. setVisualizes,
  164. visualizes,
  165. yAxis,
  166. label,
  167. }: VisualizeDropdownProps) {
  168. const yAxes: string[] = useMemo(() => {
  169. return visualizes.flatMap(visualize => visualize.yAxes);
  170. }, [visualizes]);
  171. const fieldOptions: Array<SelectOption<string>> = useVisualizeFields({yAxes});
  172. const aggregateOptions: Array<SelectOption<string>> = useMemo(() => {
  173. return ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => {
  174. return {
  175. label: aggregate,
  176. value: aggregate,
  177. textValue: aggregate,
  178. };
  179. });
  180. }, []);
  181. const parsedVisualize = useMemo(() => parseFunction(yAxis)!, [yAxis]);
  182. const setChartField = useCallback(
  183. ({value}: SelectOption<SelectKey>) => {
  184. const newVisualizes = visualizes.slice();
  185. newVisualizes[group]!.yAxes[index] = `${parsedVisualize.name}(${value})`;
  186. setVisualizes(newVisualizes, [String(value)]);
  187. },
  188. [group, index, parsedVisualize, setVisualizes, visualizes]
  189. );
  190. const setChartAggregate = useCallback(
  191. ({value}: SelectOption<SelectKey>) => {
  192. const newVisualizes = visualizes.slice();
  193. newVisualizes[group]!.yAxes[index] = `${value}(${parsedVisualize.arguments[0]})`;
  194. setVisualizes(newVisualizes);
  195. },
  196. [group, index, parsedVisualize, setVisualizes, visualizes]
  197. );
  198. return (
  199. <ToolbarRow>
  200. {label && <ChartLabel>{label}</ChartLabel>}
  201. <AggregateCompactSelect
  202. options={aggregateOptions}
  203. value={parsedVisualize.name}
  204. onChange={setChartAggregate}
  205. />
  206. <ColumnCompactSelect
  207. searchable
  208. options={fieldOptions}
  209. value={parsedVisualize.arguments[0]}
  210. onChange={setChartField}
  211. />
  212. <Button
  213. borderless
  214. icon={<IconDelete />}
  215. size="zero"
  216. disabled={!canDelete}
  217. onClick={() => deleteOverlay(group, index)}
  218. aria-label={t('Remove Overlay')}
  219. />
  220. </ToolbarRow>
  221. );
  222. }
  223. interface VisualizeEquationProps {
  224. canDelete: boolean;
  225. deleteOverlay: (group: number, index: number) => void;
  226. group: number;
  227. index: number;
  228. setVisualizes: (visualizes: Visualize[], fields?: string[]) => void;
  229. visualizes: Visualize[];
  230. label?: string;
  231. yAxis?: string;
  232. }
  233. function VisualizeEquation({
  234. canDelete,
  235. deleteOverlay,
  236. group,
  237. index,
  238. setVisualizes,
  239. label,
  240. yAxis,
  241. visualizes,
  242. }: VisualizeEquationProps) {
  243. const setChartYAxis = useCallback(
  244. (expression: Expression) => {
  245. if (expression.isValid) {
  246. const functions = expression.tokens.filter(isTokenFunction);
  247. const newVisualizes = visualizes.slice();
  248. newVisualizes[group]!.yAxes[index] = expression.text;
  249. setVisualizes(
  250. newVisualizes,
  251. functions.flatMap(func => func.attributes.map(attr => attr.format()))
  252. );
  253. }
  254. },
  255. [group, index, setVisualizes, visualizes]
  256. );
  257. const aggregateFunctions = useMemo(() => {
  258. return ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => {
  259. return {
  260. name: aggregate,
  261. label: `${aggregate}(\u2026)`,
  262. };
  263. });
  264. }, []);
  265. const yAxes: string[] = useMemo(() => {
  266. return visualizes.flatMap(visualize => visualize.yAxes);
  267. }, [visualizes]);
  268. const fieldOptions: Array<SelectOption<string>> = useVisualizeFields({yAxes});
  269. const functionArguments = useMemo(() => {
  270. return fieldOptions.map(o => {
  271. return {
  272. name: o.value,
  273. label: o.label,
  274. };
  275. });
  276. }, [fieldOptions]);
  277. return (
  278. <ToolbarRow>
  279. {label && <ChartLabel>{label}</ChartLabel>}
  280. <ArithmeticBuilder
  281. expression={yAxis || ''}
  282. setExpression={setChartYAxis}
  283. aggregateFunctions={aggregateFunctions}
  284. functionArguments={functionArguments}
  285. />
  286. <Button
  287. borderless
  288. icon={<IconDelete />}
  289. size="zero"
  290. disabled={!canDelete}
  291. onClick={() => deleteOverlay(group, index)}
  292. aria-label={t('Remove Overlay')}
  293. />
  294. </ToolbarRow>
  295. );
  296. }
  297. const ChartLabel = styled('div')`
  298. background-color: ${p => p.theme.purple100};
  299. border-radius: ${p => p.theme.borderRadius};
  300. text-align: center;
  301. width: 38px;
  302. color: ${p => p.theme.purple400};
  303. white-space: nowrap;
  304. font-weight: ${p => p.theme.fontWeightBold};
  305. align-content: center;
  306. `;
  307. const ColumnCompactSelect = styled(CompactSelect)`
  308. flex: 1 1;
  309. min-width: 0;
  310. > button {
  311. width: 100%;
  312. }
  313. `;
  314. const AggregateCompactSelect = styled(CompactSelect)`
  315. width: 100px;
  316. > button {
  317. width: 100%;
  318. }
  319. `;