widgetQueriesForm.tsx 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  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 Field from 'sentry/components/forms/field';
  7. import SelectControl from 'sentry/components/forms/selectControl';
  8. import Input from 'sentry/components/input';
  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. onClose={field => {
  120. this.handleFieldChange(queryIndex, 'conditions')(field);
  121. }}
  122. pageFilters={selection}
  123. />
  124. ) : (
  125. <StyledSearchBar
  126. searchSource="widget_builder"
  127. organization={organization}
  128. projectIds={selection.projects}
  129. query={widgetQuery.conditions}
  130. fields={[]}
  131. onClose={field => {
  132. this.handleFieldChange(queryIndex, 'conditions')(field);
  133. }}
  134. useFormWrapper={false}
  135. maxQueryLength={MAX_QUERY_LENGTH}
  136. />
  137. );
  138. }
  139. render() {
  140. const {
  141. organization,
  142. errors,
  143. queries,
  144. canAddSearchConditions,
  145. handleAddSearchConditions,
  146. handleDeleteQuery,
  147. displayType,
  148. fieldOptions,
  149. onChange,
  150. widgetType = WidgetType.DISCOVER,
  151. } = this.props;
  152. const hideLegendAlias = ['table', 'world_map', 'big_number'].includes(displayType);
  153. const query = queries[0];
  154. const explodedFields = defined(query.fields)
  155. ? query.fields.map(field => explodeField({field}))
  156. : [...query.columns, ...query.aggregates].map(field => explodeField({field}));
  157. return (
  158. <QueryWrapper>
  159. {queries.map((widgetQuery, queryIndex) => {
  160. return (
  161. <Field
  162. key={queryIndex}
  163. label={queryIndex === 0 ? t('Query') : null}
  164. inline={false}
  165. style={{paddingBottom: `8px`}}
  166. flexibleControlStateSize
  167. stacked
  168. error={errors?.[queryIndex].conditions}
  169. >
  170. <SearchConditionsWrapper>
  171. {this.renderSearchBar(widgetQuery, queryIndex)}
  172. {!hideLegendAlias && (
  173. <LegendAliasInput
  174. type="text"
  175. name="name"
  176. required
  177. value={widgetQuery.name}
  178. placeholder={t('Legend Alias')}
  179. onChange={event =>
  180. this.handleFieldChange(queryIndex, 'name')(event.target.value)
  181. }
  182. />
  183. )}
  184. {queries.length > 1 && (
  185. <Button
  186. size="zero"
  187. borderless
  188. onClick={event => {
  189. event.preventDefault();
  190. handleDeleteQuery(queryIndex);
  191. }}
  192. icon={<IconDelete />}
  193. title={t('Remove query')}
  194. aria-label={t('Remove query')}
  195. />
  196. )}
  197. </SearchConditionsWrapper>
  198. </Field>
  199. );
  200. })}
  201. {canAddSearchConditions && (
  202. <Button
  203. size="sm"
  204. icon={<IconAdd isCircled />}
  205. onClick={(event: React.MouseEvent) => {
  206. event.preventDefault();
  207. handleAddSearchConditions();
  208. }}
  209. >
  210. {t('Add Query')}
  211. </Button>
  212. )}
  213. <WidgetQueryFields
  214. widgetType={widgetType}
  215. displayType={displayType}
  216. fieldOptions={fieldOptions}
  217. errors={this.getFirstQueryError('fields')}
  218. fields={explodedFields}
  219. organization={organization}
  220. onChange={fields => {
  221. const {aggregates, columns} = getColumnsAndAggregatesAsStrings(fields);
  222. const fieldStrings = fields.map(field => generateFieldAsString(field));
  223. queries.forEach((widgetQuery, queryIndex) => {
  224. const newQuery = cloneDeep(widgetQuery);
  225. newQuery.fields = fieldStrings;
  226. newQuery.aggregates = aggregates;
  227. newQuery.columns = columns;
  228. if (defined(widgetQuery.orderby)) {
  229. const descending = widgetQuery.orderby.startsWith('-');
  230. const orderby = widgetQuery.orderby.replace('-', '');
  231. const prevFieldStrings = defined(widgetQuery.fields)
  232. ? widgetQuery.fields
  233. : [...widgetQuery.columns, ...widgetQuery.aggregates];
  234. if (!aggregates.includes(orderby) && widgetQuery.orderby !== '') {
  235. if (prevFieldStrings.length === fields.length) {
  236. // The Field that was used in orderby has changed. Get the new field.
  237. newQuery.orderby = `${descending ? '-' : ''}${
  238. aggregates[prevFieldStrings.indexOf(orderby)]
  239. }`;
  240. } else {
  241. newQuery.orderby = '';
  242. }
  243. }
  244. }
  245. onChange(queryIndex, newQuery);
  246. });
  247. }}
  248. />
  249. {['table', 'top_n'].includes(displayType) && (
  250. <Field
  251. label={t('Sort by')}
  252. inline={false}
  253. flexibleControlStateSize
  254. stacked
  255. error={this.getFirstQueryError('orderby')?.orderby}
  256. style={{marginBottom: space(1)}}
  257. >
  258. <SelectControl
  259. value={queries[0].orderby}
  260. name="orderby"
  261. options={generateOrderOptions({
  262. widgetType,
  263. columns: queries[0].columns,
  264. aggregates: queries[0].aggregates,
  265. })}
  266. onChange={(option: SelectValue<string>) =>
  267. this.handleFieldChange(0, 'orderby')(option.value)
  268. }
  269. />
  270. </Field>
  271. )}
  272. </QueryWrapper>
  273. );
  274. }
  275. }
  276. const QueryWrapper = styled('div')`
  277. position: relative;
  278. `;
  279. export const SearchConditionsWrapper = styled('div')`
  280. display: flex;
  281. align-items: center;
  282. > * + * {
  283. margin-left: ${space(1)};
  284. }
  285. `;
  286. const StyledSearchBar = styled(SearchBar)`
  287. flex-grow: 1;
  288. `;
  289. const LegendAliasInput = styled(Input)`
  290. width: 33%;
  291. `;
  292. export default WidgetQueriesForm;