widgetQueriesForm.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import cloneDeep from 'lodash/cloneDeep';
  4. import Button from 'sentry/components/button';
  5. import SearchBar from 'sentry/components/events/searchBar';
  6. import Input from 'sentry/components/forms/controls/input';
  7. import Field from 'sentry/components/forms/field';
  8. import SelectControl from 'sentry/components/forms/selectControl';
  9. import {MAX_QUERY_LENGTH} from 'sentry/constants';
  10. import {IconAdd, IconDelete} from 'sentry/icons';
  11. import {t} from 'sentry/locale';
  12. import space from 'sentry/styles/space';
  13. import {Organization, PageFilters, SelectValue} from 'sentry/types';
  14. import {
  15. explodeField,
  16. generateFieldAsString,
  17. getAggregateAlias,
  18. isEquation,
  19. stripEquationPrefix,
  20. } from 'sentry/utils/discover/fields';
  21. import {Widget, WidgetQuery, WidgetType} from 'sentry/views/dashboardsV2/types';
  22. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  23. import MetricsSearchBar from 'sentry/views/performance/metricsSearchBar';
  24. import WidgetQueryFields from './widgetQueryFields';
  25. export const generateOrderOptions = (fields: string[]): SelectValue<string>[] => {
  26. const options: SelectValue<string>[] = [];
  27. let equations = 0;
  28. fields.forEach(field => {
  29. let alias = getAggregateAlias(field);
  30. const label = stripEquationPrefix(field);
  31. // Equations are referenced via a standard alias following this pattern
  32. if (isEquation(field)) {
  33. alias = `equation[${equations}]`;
  34. equations += 1;
  35. }
  36. options.push({label: t('%s asc', label), value: alias});
  37. options.push({label: t('%s desc', label), value: `-${alias}`});
  38. });
  39. return options;
  40. };
  41. type Props = {
  42. canAddSearchConditions: boolean;
  43. displayType: Widget['displayType'];
  44. fieldOptions: ReturnType<typeof generateFieldOptions>;
  45. handleAddSearchConditions: () => void;
  46. handleDeleteQuery: (queryIndex: number) => void;
  47. onChange: (queryIndex: number, widgetQuery: WidgetQuery) => void;
  48. organization: Organization;
  49. queries: WidgetQuery[];
  50. selection: PageFilters;
  51. errors?: Array<Record<string, any>>;
  52. widgetType?: Widget['widgetType'];
  53. };
  54. /**
  55. * Contain widget queries interactions and signal changes via the onChange
  56. * callback. This component's state should live in the parent.
  57. */
  58. class WidgetQueriesForm extends React.Component<Props> {
  59. blurTimeout: number | null = null;
  60. // Handle scalar field values changing.
  61. handleFieldChange = (queryIndex: number, field: string) => {
  62. const {queries, onChange} = this.props;
  63. const widgetQuery = queries[queryIndex];
  64. return function handleChange(value: string) {
  65. const newQuery = {...widgetQuery, [field]: value};
  66. onChange(queryIndex, newQuery);
  67. };
  68. };
  69. getFirstQueryError(key: string) {
  70. const {errors} = this.props;
  71. if (!errors) {
  72. return undefined;
  73. }
  74. return errors.find(queryError => queryError && queryError[key]);
  75. }
  76. renderSearchBar(widgetQuery: WidgetQuery, queryIndex: number) {
  77. const {organization, selection, widgetType} = this.props;
  78. return widgetType === WidgetType.METRICS ? (
  79. <StyledMetricsSearchBar
  80. searchSource="widget_builder"
  81. orgSlug={organization.slug}
  82. query={widgetQuery.conditions}
  83. onSearch={field => {
  84. // SearchBar will call handlers for both onSearch and onBlur
  85. // when selecting a value from the autocomplete dropdown. This can
  86. // cause state issues for the search bar in our use case. To prevent
  87. // this, we set a timer in our onSearch handler to block our onBlur
  88. // handler from firing if it is within 200ms, ie from clicking an
  89. // autocomplete value.
  90. this.blurTimeout = window.setTimeout(() => {
  91. this.blurTimeout = null;
  92. }, 200);
  93. return this.handleFieldChange(queryIndex, 'conditions')(field);
  94. }}
  95. maxQueryLength={MAX_QUERY_LENGTH}
  96. projectIds={selection.projects}
  97. />
  98. ) : (
  99. <StyledSearchBar
  100. searchSource="widget_builder"
  101. organization={organization}
  102. projectIds={selection.projects}
  103. query={widgetQuery.conditions}
  104. fields={[]}
  105. onSearch={field => {
  106. // SearchBar will call handlers for both onSearch and onBlur
  107. // when selecting a value from the autocomplete dropdown. This can
  108. // cause state issues for the search bar in our use case. To prevent
  109. // this, we set a timer in our onSearch handler to block our onBlur
  110. // handler from firing if it is within 200ms, ie from clicking an
  111. // autocomplete value.
  112. this.blurTimeout = window.setTimeout(() => {
  113. this.blurTimeout = null;
  114. }, 200);
  115. this.handleFieldChange(queryIndex, 'conditions')(field);
  116. }}
  117. onBlur={field => {
  118. if (!this.blurTimeout) {
  119. this.handleFieldChange(queryIndex, 'conditions')(field);
  120. }
  121. }}
  122. useFormWrapper={false}
  123. maxQueryLength={MAX_QUERY_LENGTH}
  124. />
  125. );
  126. }
  127. render() {
  128. const {
  129. organization,
  130. errors,
  131. queries,
  132. canAddSearchConditions,
  133. handleAddSearchConditions,
  134. handleDeleteQuery,
  135. displayType,
  136. fieldOptions,
  137. onChange,
  138. widgetType = WidgetType.DISCOVER,
  139. } = this.props;
  140. const hideLegendAlias = ['table', 'world_map', 'big_number'].includes(displayType);
  141. const explodedFields = queries[0].fields.map(field => explodeField({field}));
  142. return (
  143. <QueryWrapper>
  144. {queries.map((widgetQuery, queryIndex) => {
  145. return (
  146. <Field
  147. key={queryIndex}
  148. label={queryIndex === 0 ? t('Query') : null}
  149. inline={false}
  150. style={{paddingBottom: `8px`}}
  151. flexibleControlStateSize
  152. stacked
  153. error={errors?.[queryIndex].conditions}
  154. >
  155. <SearchConditionsWrapper>
  156. {this.renderSearchBar(widgetQuery, queryIndex)}
  157. {!hideLegendAlias && (
  158. <LegendAliasInput
  159. type="text"
  160. name="name"
  161. required
  162. value={widgetQuery.name}
  163. placeholder={t('Legend Alias')}
  164. onChange={event =>
  165. this.handleFieldChange(queryIndex, 'name')(event.target.value)
  166. }
  167. />
  168. )}
  169. {queries.length > 1 && (
  170. <Button
  171. size="zero"
  172. borderless
  173. onClick={event => {
  174. event.preventDefault();
  175. handleDeleteQuery(queryIndex);
  176. }}
  177. icon={<IconDelete />}
  178. title={t('Remove query')}
  179. aria-label={t('Remove query')}
  180. />
  181. )}
  182. </SearchConditionsWrapper>
  183. </Field>
  184. );
  185. })}
  186. {canAddSearchConditions && (
  187. <Button
  188. size="small"
  189. icon={<IconAdd isCircled />}
  190. onClick={(event: React.MouseEvent) => {
  191. event.preventDefault();
  192. handleAddSearchConditions();
  193. }}
  194. >
  195. {t('Add Query')}
  196. </Button>
  197. )}
  198. <WidgetQueryFields
  199. widgetType={widgetType}
  200. displayType={displayType}
  201. fieldOptions={fieldOptions}
  202. errors={this.getFirstQueryError('fields')}
  203. fields={explodedFields}
  204. organization={organization}
  205. onChange={fields => {
  206. const fieldStrings = fields.map(field => generateFieldAsString(field));
  207. const aggregateAliasFieldStrings = fieldStrings.map(field =>
  208. getAggregateAlias(field)
  209. );
  210. queries.forEach((widgetQuery, queryIndex) => {
  211. const descending = widgetQuery.orderby.startsWith('-');
  212. const orderbyAggregateAliasField = widgetQuery.orderby.replace('-', '');
  213. const prevAggregateAliasFieldStrings = widgetQuery.fields.map(field =>
  214. getAggregateAlias(field)
  215. );
  216. const newQuery = cloneDeep(widgetQuery);
  217. newQuery.fields = fieldStrings;
  218. if (
  219. !aggregateAliasFieldStrings.includes(orderbyAggregateAliasField) &&
  220. widgetQuery.orderby !== ''
  221. ) {
  222. if (prevAggregateAliasFieldStrings.length === fields.length) {
  223. // The Field that was used in orderby has changed. Get the new field.
  224. newQuery.orderby = `${descending && '-'}${
  225. aggregateAliasFieldStrings[
  226. prevAggregateAliasFieldStrings.indexOf(orderbyAggregateAliasField)
  227. ]
  228. }`;
  229. } else {
  230. newQuery.orderby = '';
  231. }
  232. }
  233. onChange(queryIndex, newQuery);
  234. });
  235. }}
  236. />
  237. {['table', 'top_n'].includes(displayType) && widgetType !== WidgetType.METRICS && (
  238. <Field
  239. label={t('Sort by')}
  240. inline={false}
  241. flexibleControlStateSize
  242. stacked
  243. error={this.getFirstQueryError('orderby')?.orderby}
  244. style={{marginBottom: space(1)}}
  245. >
  246. <SelectControl
  247. value={queries[0].orderby}
  248. name="orderby"
  249. options={generateOrderOptions(queries[0].fields)}
  250. onChange={(option: SelectValue<string>) =>
  251. this.handleFieldChange(0, 'orderby')(option.value)
  252. }
  253. />
  254. </Field>
  255. )}
  256. </QueryWrapper>
  257. );
  258. }
  259. }
  260. const QueryWrapper = styled('div')`
  261. position: relative;
  262. `;
  263. export const SearchConditionsWrapper = styled('div')`
  264. display: flex;
  265. align-items: center;
  266. > * + * {
  267. margin-left: ${space(1)};
  268. }
  269. `;
  270. const StyledSearchBar = styled(SearchBar)`
  271. flex-grow: 1;
  272. `;
  273. const StyledMetricsSearchBar = styled(MetricsSearchBar)`
  274. flex-grow: 1;
  275. `;
  276. const LegendAliasInput = styled(Input)`
  277. width: 33%;
  278. `;
  279. export default WidgetQueriesForm;