widgetQueryFields.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import {Fragment} 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 {FieldValueOption, 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 isReleaseWidget = widgetType === WidgetType.RELEASE;
  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. if (widgetType === WidgetType.RELEASE) {
  99. if (displayType === DisplayType.TABLE) {
  100. return [
  101. FieldValueKind.FUNCTION,
  102. FieldValueKind.TAG,
  103. FieldValueKind.NUMERIC_METRICS,
  104. ].includes(option.value.kind);
  105. }
  106. if (displayType === DisplayType.TOP_N) {
  107. return option.value.kind === FieldValueKind.TAG;
  108. }
  109. }
  110. // Only validate function names for timeseries widgets and
  111. // world map widgets.
  112. if (!doNotValidateYAxis && option.value.kind === FieldValueKind.FUNCTION) {
  113. const primaryOutput = aggregateFunctionOutputType(
  114. option.value.meta.name,
  115. undefined
  116. );
  117. if (primaryOutput) {
  118. // If a function returns a specific type, then validate it.
  119. return isLegalYAxisType(primaryOutput);
  120. }
  121. }
  122. return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
  123. option.value.kind
  124. );
  125. };
  126. const filterMetricsOptions = option => {
  127. return [FieldValueKind.FUNCTION, FieldValueKind.NUMERIC_METRICS].includes(
  128. option.value.kind
  129. );
  130. };
  131. const filterAggregateParameters =
  132. (fieldValue: QueryFieldValue) => (option: FieldValueOption) => {
  133. // Only validate function parameters for timeseries widgets and
  134. // world map widgets.
  135. if (doNotValidateYAxis) {
  136. return true;
  137. }
  138. if (fieldValue.kind !== FieldValueKind.FUNCTION) {
  139. return true;
  140. }
  141. if (isReleaseWidget || option.value.kind === FieldValueKind.METRICS) {
  142. return true;
  143. }
  144. const functionName = fieldValue.function[0];
  145. const primaryOutput = aggregateFunctionOutputType(
  146. functionName as string,
  147. option.value.meta.name
  148. );
  149. if (primaryOutput) {
  150. return isLegalYAxisType(primaryOutput);
  151. }
  152. if (
  153. option.value.kind === FieldValueKind.FUNCTION ||
  154. option.value.kind === FieldValueKind.EQUATION
  155. ) {
  156. // Functions and equations are not legal options as an aggregate/function parameter.
  157. return false;
  158. }
  159. return isLegalYAxisType(option.value.meta.dataType);
  160. };
  161. const hideAddYAxisButton =
  162. (['world_map', 'big_number'].includes(displayType) && fields.length === 1) ||
  163. (['line', 'area', 'stacked_area', 'bar'].includes(displayType) &&
  164. fields.length === 3);
  165. const canDelete = fields.length > 1;
  166. if (displayType === 'table') {
  167. return (
  168. <Field
  169. data-test-id="columns"
  170. label={t('Columns')}
  171. inline={false}
  172. style={{padding: `${space(1)} 0`, ...(style ?? {})}}
  173. error={errors?.fields}
  174. flexibleControlStateSize
  175. stacked
  176. required
  177. >
  178. <StyledColumnEditCollection
  179. columns={fields}
  180. onChange={handleColumnChange}
  181. fieldOptions={fieldOptions}
  182. organization={organization}
  183. filterPrimaryOptions={isReleaseWidget ? filterPrimaryOptions : undefined}
  184. source={widgetType}
  185. />
  186. </Field>
  187. );
  188. }
  189. if (displayType === 'top_n') {
  190. const fieldValue = fields[fields.length - 1];
  191. const columns = fields.slice(0, fields.length - 1);
  192. return (
  193. <Fragment>
  194. <Field
  195. data-test-id="columns"
  196. label={t('Columns')}
  197. inline={false}
  198. style={{padding: `${space(1)} 0`, ...(style ?? {})}}
  199. error={errors?.fields}
  200. flexibleControlStateSize
  201. stacked
  202. required
  203. >
  204. <StyledColumnEditCollection
  205. columns={columns}
  206. onChange={handleTopNColumnChange}
  207. fieldOptions={fieldOptions}
  208. organization={organization}
  209. filterPrimaryOptions={isReleaseWidget ? filterPrimaryOptions : undefined}
  210. source={widgetType}
  211. />
  212. </Field>
  213. <Field
  214. data-test-id="y-axis"
  215. label={t('Y-Axis')}
  216. inline={false}
  217. style={{padding: `${space(2)} 0 24px 0`, ...(style ?? {})}}
  218. flexibleControlStateSize
  219. error={errors?.fields}
  220. required
  221. stacked
  222. >
  223. <QueryFieldWrapper key={`${fieldValue}:0`}>
  224. <QueryField
  225. fieldValue={fieldValue}
  226. fieldOptions={
  227. isReleaseWidget ? fieldOptions : generateFieldOptions({organization})
  228. }
  229. onChange={value => handleTopNChangeField(value)}
  230. filterPrimaryOptions={
  231. isReleaseWidget ? filterMetricsOptions : filterPrimaryOptions
  232. }
  233. filterAggregateParameters={filterAggregateParameters(fieldValue)}
  234. />
  235. </QueryFieldWrapper>
  236. </Field>
  237. </Fragment>
  238. );
  239. }
  240. return (
  241. <Field
  242. data-test-id="y-axis"
  243. label={t('Y-Axis')}
  244. inline={false}
  245. style={{padding: `${space(2)} 0 24px 0`, ...(style ?? {})}}
  246. flexibleControlStateSize
  247. error={errors?.fields}
  248. required
  249. stacked
  250. >
  251. {fields.map((field, i) => {
  252. return (
  253. <QueryFieldWrapper key={`${field}:${i}`}>
  254. <QueryField
  255. fieldValue={field}
  256. fieldOptions={fieldOptions}
  257. onChange={value => handleChangeField(value, i)}
  258. filterPrimaryOptions={filterPrimaryOptions}
  259. filterAggregateParameters={filterAggregateParameters(field)}
  260. otherColumns={fields}
  261. />
  262. {(canDelete || field.kind === 'equation') && (
  263. <Button
  264. size="zero"
  265. borderless
  266. onClick={event => handleRemove(event, i)}
  267. icon={<IconDelete />}
  268. title={t('Remove this Y-Axis')}
  269. aria-label={t('Remove this Y-Axis')}
  270. />
  271. )}
  272. </QueryFieldWrapper>
  273. );
  274. })}
  275. {!hideAddYAxisButton && (
  276. <Actions>
  277. <Button size="sm" icon={<IconAdd isCircled />} onClick={handleAdd}>
  278. {t('Add Overlay')}
  279. </Button>
  280. {!isReleaseWidget && (
  281. <Button
  282. size="sm"
  283. aria-label={t('Add an Equation')}
  284. onClick={handleAddEquation}
  285. icon={<IconAdd isCircled />}
  286. >
  287. {t('Add an Equation')}
  288. </Button>
  289. )}
  290. </Actions>
  291. )}
  292. </Field>
  293. );
  294. }
  295. const StyledColumnEditCollection = styled(ColumnEditCollection)`
  296. margin-top: ${space(1)};
  297. `;
  298. export const QueryFieldWrapper = styled('div')`
  299. display: flex;
  300. align-items: center;
  301. justify-content: space-between;
  302. margin-bottom: ${space(1)};
  303. > * + * {
  304. margin-left: ${space(1)};
  305. }
  306. `;
  307. const Actions = styled('div')`
  308. grid-column: 2 / 3;
  309. & button {
  310. margin-right: ${space(1)};
  311. }
  312. `;
  313. export default WidgetQueryFields;