widgetQueriesForm.tsx 11 KB

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