visualize.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import {Fragment, useMemo} from 'react';
  2. import styled from '@emotion/styled';
  3. import {Button} from 'sentry/components/button';
  4. import {CompactSelect} from 'sentry/components/compactSelect';
  5. import Input from 'sentry/components/input';
  6. import {IconDelete} from 'sentry/icons';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. import {
  10. type AggregationKeyWithAlias,
  11. generateFieldAsString,
  12. parseFunction,
  13. prettifyTagKey,
  14. } from 'sentry/utils/discover/fields';
  15. import useCustomMeasurements from 'sentry/utils/useCustomMeasurements';
  16. import useOrganization from 'sentry/utils/useOrganization';
  17. import useTags from 'sentry/utils/useTags';
  18. import {getDatasetConfig} from 'sentry/views/dashboards/datasetConfig/base';
  19. import {DisplayType, WidgetType} from 'sentry/views/dashboards/types';
  20. import {SectionHeader} from 'sentry/views/dashboards/widgetBuilder/components/common/sectionHeader';
  21. import {useWidgetBuilderContext} from 'sentry/views/dashboards/widgetBuilder/contexts/widgetBuilderContext';
  22. import {BuilderStateAction} from 'sentry/views/dashboards/widgetBuilder/hooks/useWidgetBuilderState';
  23. import {FieldValueKind} from 'sentry/views/discover/table/types';
  24. import {useSpanTags} from 'sentry/views/explore/contexts/spanTagsContext';
  25. function Visualize() {
  26. const organization = useOrganization();
  27. const {state, dispatch} = useWidgetBuilderContext();
  28. let tags = useTags();
  29. const {customMeasurements} = useCustomMeasurements();
  30. const isChartWidget =
  31. state.displayType !== DisplayType.TABLE &&
  32. state.displayType !== DisplayType.BIG_NUMBER;
  33. const numericSpanTags = useSpanTags('number');
  34. const stringSpanTags = useSpanTags('string');
  35. if (state.dataset === WidgetType.SPANS && isChartWidget) {
  36. tags = numericSpanTags;
  37. } else if (state.dataset === WidgetType.SPANS && !isChartWidget) {
  38. tags = stringSpanTags;
  39. }
  40. const datasetConfig = useMemo(() => getDatasetConfig(state.dataset), [state.dataset]);
  41. const fields = isChartWidget ? state.yAxis : state.fields;
  42. const updateAction = isChartWidget
  43. ? BuilderStateAction.SET_Y_AXIS
  44. : BuilderStateAction.SET_FIELDS;
  45. const fieldOptions = useMemo(
  46. () => datasetConfig.getTableFieldOptions(organization, tags, customMeasurements),
  47. [organization, tags, customMeasurements, datasetConfig]
  48. );
  49. // TODO: no parameters should show up as primary options?
  50. // let aggregateOptions = Object.values(fieldOptions).filter(
  51. // option => option.value.kind === 'function' && option.value.meta.parameters.length > 0
  52. // );
  53. const aggregateOptions = useMemo(
  54. () =>
  55. Object.values(fieldOptions).filter(option =>
  56. datasetConfig.filterYAxisOptions?.(state.displayType ?? DisplayType.TABLE)(option)
  57. ),
  58. [fieldOptions, state.displayType, datasetConfig]
  59. );
  60. // Used to extract selected aggregates and parameters from the fields
  61. const stringFields = fields?.map(generateFieldAsString);
  62. return (
  63. <Fragment>
  64. <SectionHeader
  65. title={t('Visualize')}
  66. tooltipText={t(
  67. 'Primary metric that appears in your chart. You can also overlay a series onto an existing chart or add an equation.'
  68. )}
  69. />
  70. <Fields>
  71. {fields?.map((field, index) => {
  72. // Depending on the dataset and the display type, we use different options for
  73. // displaying in the column select.
  74. // For charts, we show aggregate parameter options for the y-axis as primary options.
  75. // For tables, we show all string tags and fields as primary options, as well
  76. // as aggregates that don't take parameters.
  77. const columnOptions = Object.values(fieldOptions)
  78. .filter(option => {
  79. return (
  80. // TODO: This should allow for aggregates without parameters
  81. option.value.kind !== FieldValueKind.FUNCTION &&
  82. (datasetConfig.filterYAxisAggregateParams?.(
  83. field,
  84. state.displayType ?? DisplayType.TABLE
  85. )?.(option) ??
  86. true)
  87. );
  88. })
  89. .map(option => ({
  90. value: option.value.meta.name,
  91. label:
  92. state.dataset === WidgetType.SPANS
  93. ? prettifyTagKey(option.value.meta.name)
  94. : option.value.meta.name,
  95. }));
  96. return (
  97. <FieldRow key={index}>
  98. <FieldBar data-testid={'field-bar'}>
  99. <ColumnCompactSelect
  100. searchable
  101. options={columnOptions}
  102. value={parseFunction(stringFields?.[index] ?? '')?.arguments[0] ?? ''}
  103. onChange={newField => {
  104. // TODO: Handle scalars (i.e. no aggregate, for tables)
  105. // Update the current field's aggregate with the new aggregate
  106. if (field.kind === 'function') {
  107. field.function[1] = newField.value as string;
  108. }
  109. dispatch({
  110. type: updateAction,
  111. payload: fields,
  112. });
  113. }}
  114. triggerProps={{
  115. 'aria-label': t('Column Selection'),
  116. }}
  117. />
  118. {/* TODO: Add equation options */}
  119. {/* TODO: Handle aggregates with no parameters */}
  120. {/* TODO: Handle aggregates with multiple parameters */}
  121. <AggregateCompactSelect
  122. options={aggregateOptions.map(option => ({
  123. value: option.value.meta.name,
  124. label: option.value.meta.name,
  125. }))}
  126. value={parseFunction(stringFields?.[index] ?? '')?.name ?? ''}
  127. onChange={newAggregate => {
  128. // TODO: Handle scalars (i.e. no aggregate, for tables)
  129. // Update the current field's aggregate with the new aggregate
  130. const currentField = fields?.[index];
  131. if (currentField.kind === 'function') {
  132. currentField.function[0] =
  133. newAggregate.value as AggregationKeyWithAlias;
  134. }
  135. dispatch({
  136. type: updateAction,
  137. payload: fields,
  138. });
  139. }}
  140. triggerProps={{
  141. 'aria-label': t('Aggregate Selection'),
  142. }}
  143. />
  144. </FieldBar>
  145. <FieldExtras>
  146. <LegendAliasInput
  147. type="text"
  148. name="name"
  149. placeholder={t('Add Alias')}
  150. onChange={() => {}}
  151. />
  152. <StyledDeleteButton
  153. borderless
  154. icon={<IconDelete />}
  155. size="zero"
  156. disabled={fields.length <= 1}
  157. onClick={() =>
  158. dispatch({
  159. type: updateAction,
  160. payload: fields?.filter((_field, i) => i !== index) ?? [],
  161. })
  162. }
  163. aria-label={t('Remove field')}
  164. />
  165. </FieldExtras>
  166. </FieldRow>
  167. );
  168. })}
  169. </Fields>
  170. <AddButtons>
  171. <AddButton
  172. priority="link"
  173. aria-label={isChartWidget ? t('Add Series') : t('Add Field')}
  174. onClick={() =>
  175. dispatch({
  176. type: updateAction,
  177. payload: [
  178. ...(fields ?? []),
  179. // TODO: Define a default aggregate/field for the datasets?
  180. {
  181. function: ['count', '', undefined, undefined],
  182. kind: 'function',
  183. },
  184. ],
  185. })
  186. }
  187. >
  188. {t('+ Add Series')}
  189. </AddButton>
  190. <AddButton priority="link" aria-label={t('Add Equation')} onClick={() => {}}>
  191. {t('+ Add Equation')}
  192. </AddButton>
  193. </AddButtons>
  194. </Fragment>
  195. );
  196. }
  197. export default Visualize;
  198. const ColumnCompactSelect = styled(CompactSelect)`
  199. flex: 1 1 auto;
  200. min-width: 0;
  201. > button {
  202. width: 100%;
  203. }
  204. `;
  205. const AggregateCompactSelect = styled(CompactSelect)`
  206. width: fit-content;
  207. max-width: 150px;
  208. left: -1px;
  209. > button {
  210. width: 100%;
  211. }
  212. `;
  213. const LegendAliasInput = styled(Input)``;
  214. const FieldBar = styled('div')`
  215. display: flex;
  216. flex: 3;
  217. & > ${ColumnCompactSelect} > button {
  218. border-top-right-radius: 0;
  219. border-bottom-right-radius: 0;
  220. }
  221. & > ${AggregateCompactSelect} > button {
  222. border-top-left-radius: 0;
  223. border-bottom-left-radius: 0;
  224. }
  225. `;
  226. const FieldRow = styled('div')`
  227. display: flex;
  228. flex-direction: row;
  229. gap: ${space(1)};
  230. `;
  231. const StyledDeleteButton = styled(Button)``;
  232. const FieldExtras = styled('div')`
  233. display: flex;
  234. flex-direction: row;
  235. gap: ${space(1)};
  236. flex: 1;
  237. `;
  238. const AddButton = styled(Button)`
  239. margin-top: ${space(1)};
  240. `;
  241. const AddButtons = styled('div')`
  242. display: inline-flex;
  243. gap: ${space(1.5)};
  244. `;
  245. const Fields = styled('div')`
  246. display: flex;
  247. flex-direction: column;
  248. gap: ${space(1)};
  249. `;