widgetQueryFields.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import Button from 'sentry/components/button';
  4. import Field from 'sentry/components/forms/field';
  5. import {IconAdd, IconDelete} from 'sentry/icons';
  6. import {t} from 'sentry/locale';
  7. import space from 'sentry/styles/space';
  8. import {Organization} from 'sentry/types';
  9. import {
  10. aggregateFunctionOutputType,
  11. isLegalYAxisType,
  12. QueryFieldValue,
  13. } from 'sentry/utils/discover/fields';
  14. import {DisplayType, Widget, WidgetType} from 'sentry/views/dashboardsV2/types';
  15. import ColumnEditCollection from 'sentry/views/eventsV2/table/columnEditCollection';
  16. import {QueryField} from 'sentry/views/eventsV2/table/queryField';
  17. import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
  18. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  19. type Props = {
  20. /**
  21. * The widget display type. Used to render different fieldsets.
  22. */
  23. displayType: Widget['displayType'];
  24. fieldOptions: ReturnType<typeof generateFieldOptions>;
  25. /**
  26. * The field list for the widget.
  27. */
  28. fields: QueryFieldValue[];
  29. /**
  30. * Fired when fields are added/removed/modified/reordered.
  31. */
  32. onChange: (fields: QueryFieldValue[]) => void;
  33. /**
  34. * Any errors that need to be rendered.
  35. */
  36. organization: Organization;
  37. widgetType: Widget['widgetType'];
  38. errors?: Record<string, any>;
  39. style?: React.CSSProperties;
  40. };
  41. function WidgetQueryFields({
  42. widgetType,
  43. displayType,
  44. errors,
  45. fields,
  46. fieldOptions,
  47. organization,
  48. onChange,
  49. style,
  50. }: Props) {
  51. const isMetricWidget = widgetType === WidgetType.METRICS;
  52. // Handle new fields being added.
  53. function handleAdd(event: React.MouseEvent) {
  54. event.preventDefault();
  55. const newFields = [
  56. ...fields,
  57. {kind: FieldValueKind.FIELD, field: ''} as QueryFieldValue,
  58. ];
  59. onChange(newFields);
  60. }
  61. function handleAddEquation(event: React.MouseEvent) {
  62. event.preventDefault();
  63. const newFields = [
  64. ...fields,
  65. {kind: FieldValueKind.EQUATION, field: ''} as QueryFieldValue,
  66. ];
  67. onChange(newFields);
  68. }
  69. function handleRemove(event: React.MouseEvent, fieldIndex: number) {
  70. event.preventDefault();
  71. const newFields = [...fields];
  72. newFields.splice(fieldIndex, 1);
  73. onChange(newFields);
  74. }
  75. function handleChangeField(value: QueryFieldValue, fieldIndex: number) {
  76. const newFields = [...fields];
  77. newFields[fieldIndex] = value;
  78. onChange(newFields);
  79. }
  80. function handleTopNChangeField(value: QueryFieldValue) {
  81. const newFields = [...fields];
  82. newFields[fields.length - 1] = value;
  83. onChange(newFields);
  84. }
  85. function handleTopNColumnChange(columns: QueryFieldValue[]) {
  86. const newFields = [...columns, fields[fields.length - 1]];
  87. onChange(newFields);
  88. }
  89. function handleColumnChange(columns: QueryFieldValue[]) {
  90. onChange(columns);
  91. }
  92. // Any function/field choice for Big Number widgets is legal since the
  93. // data source is from an endpoint that is not timeseries-based.
  94. // The function/field choice for World Map widget will need to be numeric-like.
  95. // Column builder for Table widget is already handled above.
  96. const doNotValidateYAxis = displayType === 'big_number';
  97. const filterPrimaryOptions = option => {
  98. // Only validate function names for timeseries widgets and
  99. // world map widgets.
  100. if (!doNotValidateYAxis && option.value.kind === FieldValueKind.FUNCTION) {
  101. const primaryOutput = aggregateFunctionOutputType(
  102. option.value.meta.name,
  103. undefined
  104. );
  105. if (primaryOutput) {
  106. // If a function returns a specific type, then validate it.
  107. return isLegalYAxisType(primaryOutput);
  108. }
  109. }
  110. if (
  111. widgetType === WidgetType.METRICS &&
  112. (displayType === DisplayType.TABLE || displayType === DisplayType.TOP_N)
  113. ) {
  114. return (
  115. option.value.kind === FieldValueKind.FUNCTION ||
  116. option.value.kind === FieldValueKind.TAG
  117. );
  118. }
  119. return option.value.kind === FieldValueKind.FUNCTION;
  120. };
  121. const filterMetricsOptions = option => {
  122. return option.value.kind === FieldValueKind.FUNCTION;
  123. };
  124. const filterAggregateParameters = fieldValue => option => {
  125. // Only validate function parameters for timeseries widgets and
  126. // world map widgets.
  127. if (doNotValidateYAxis) {
  128. return true;
  129. }
  130. if (fieldValue.kind !== 'function') {
  131. return true;
  132. }
  133. if (isMetricWidget) {
  134. return true;
  135. }
  136. const functionName = fieldValue.function[0];
  137. const primaryOutput = aggregateFunctionOutputType(
  138. functionName as string,
  139. option.value.meta.name
  140. );
  141. if (primaryOutput) {
  142. return isLegalYAxisType(primaryOutput);
  143. }
  144. if (option.value.kind === FieldValueKind.FUNCTION) {
  145. // Functions are not legal options as an aggregate/function parameter.
  146. return false;
  147. }
  148. return isLegalYAxisType(option.value.meta.dataType);
  149. };
  150. const hideAddYAxisButton =
  151. (['world_map', 'big_number'].includes(displayType) && fields.length === 1) ||
  152. (['line', 'area', 'stacked_area', 'bar'].includes(displayType) &&
  153. fields.length === 3);
  154. const canDelete = fields.length > 1;
  155. if (displayType === 'table') {
  156. return (
  157. <Field
  158. data-test-id="columns"
  159. label={t('Columns')}
  160. inline={false}
  161. style={{padding: `${space(1)} 0`, ...(style ?? {})}}
  162. error={errors?.fields}
  163. flexibleControlStateSize
  164. stacked
  165. required
  166. >
  167. <StyledColumnEditCollection
  168. columns={fields}
  169. onChange={handleColumnChange}
  170. fieldOptions={fieldOptions}
  171. organization={organization}
  172. filterPrimaryOptions={isMetricWidget ? filterPrimaryOptions : undefined}
  173. source={widgetType}
  174. />
  175. </Field>
  176. );
  177. }
  178. if (displayType === 'top_n') {
  179. const fieldValue = fields[fields.length - 1];
  180. const columns = fields.slice(0, fields.length - 1);
  181. return (
  182. <React.Fragment>
  183. <Field
  184. data-test-id="columns"
  185. label={t('Columns')}
  186. inline={false}
  187. style={{padding: `${space(1)} 0`, ...(style ?? {})}}
  188. error={errors?.fields}
  189. flexibleControlStateSize
  190. stacked
  191. required
  192. >
  193. <StyledColumnEditCollection
  194. columns={columns}
  195. onChange={handleTopNColumnChange}
  196. fieldOptions={fieldOptions}
  197. organization={organization}
  198. filterPrimaryOptions={isMetricWidget ? filterPrimaryOptions : undefined}
  199. source={widgetType}
  200. />
  201. </Field>
  202. <Field
  203. data-test-id="y-axis"
  204. label={t('Y-Axis')}
  205. inline={false}
  206. style={{padding: `${space(2)} 0 24px 0`, ...(style ?? {})}}
  207. flexibleControlStateSize
  208. error={errors?.fields}
  209. required
  210. stacked
  211. >
  212. <QueryFieldWrapper key={`${fieldValue}:0`}>
  213. <QueryField
  214. fieldValue={fieldValue}
  215. fieldOptions={
  216. isMetricWidget ? fieldOptions : generateFieldOptions({organization})
  217. }
  218. onChange={value => handleTopNChangeField(value)}
  219. filterPrimaryOptions={
  220. isMetricWidget ? filterMetricsOptions : filterPrimaryOptions
  221. }
  222. filterAggregateParameters={filterAggregateParameters(fieldValue)}
  223. />
  224. </QueryFieldWrapper>
  225. </Field>
  226. </React.Fragment>
  227. );
  228. }
  229. return (
  230. <Field
  231. data-test-id="y-axis"
  232. label={t('Y-Axis')}
  233. inline={false}
  234. style={{padding: `${space(2)} 0 24px 0`, ...(style ?? {})}}
  235. flexibleControlStateSize
  236. error={errors?.fields}
  237. required
  238. stacked
  239. >
  240. {fields.map((field, i) => {
  241. return (
  242. <QueryFieldWrapper key={`${field}:${i}`}>
  243. <QueryField
  244. fieldValue={field}
  245. fieldOptions={fieldOptions}
  246. onChange={value => handleChangeField(value, i)}
  247. filterPrimaryOptions={filterPrimaryOptions}
  248. filterAggregateParameters={filterAggregateParameters(field)}
  249. otherColumns={fields}
  250. />
  251. {(canDelete || field.kind === 'equation') && (
  252. <Button
  253. size="zero"
  254. borderless
  255. onClick={event => handleRemove(event, i)}
  256. icon={<IconDelete />}
  257. title={t('Remove this Y-Axis')}
  258. aria-label={t('Remove this Y-Axis')}
  259. />
  260. )}
  261. </QueryFieldWrapper>
  262. );
  263. })}
  264. {!hideAddYAxisButton && (
  265. <Actions>
  266. <Button size="small" icon={<IconAdd isCircled />} onClick={handleAdd}>
  267. {t('Add Overlay')}
  268. </Button>
  269. <Button
  270. size="small"
  271. aria-label={t('Add an Equation')}
  272. onClick={handleAddEquation}
  273. icon={<IconAdd isCircled />}
  274. >
  275. {t('Add an Equation')}
  276. </Button>
  277. </Actions>
  278. )}
  279. </Field>
  280. );
  281. }
  282. const StyledColumnEditCollection = styled(ColumnEditCollection)`
  283. margin-top: ${space(1)};
  284. `;
  285. export const QueryFieldWrapper = styled('div')`
  286. display: flex;
  287. align-items: center;
  288. justify-content: space-between;
  289. margin-bottom: ${space(1)};
  290. > * + * {
  291. margin-left: ${space(1)};
  292. }
  293. `;
  294. const Actions = styled('div')`
  295. grid-column: 2 / 3;
  296. & button {
  297. margin-right: ${space(1)};
  298. }
  299. `;
  300. export default WidgetQueryFields;