sidebar.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import isEqual from 'lodash/isEqual';
  4. import map from 'lodash/map';
  5. import Input from 'sentry/components/input';
  6. import LoadingIndicator from 'sentry/components/loadingIndicator';
  7. import {
  8. joinQuery,
  9. ParseResult,
  10. parseSearch,
  11. Token,
  12. TokenResult,
  13. } from 'sentry/components/searchSyntax/parser';
  14. import * as SidebarSection from 'sentry/components/sidebarSection';
  15. import {IconClose} from 'sentry/icons/iconClose';
  16. import {t} from 'sentry/locale';
  17. import space from 'sentry/styles/space';
  18. import {Tag, TagCollection} from 'sentry/types';
  19. import IssueListTagFilter from './tagFilter';
  20. import {TagValueLoader} from './types';
  21. type DefaultProps = {
  22. onQueryChange: (query: string) => void;
  23. query: string;
  24. tags: TagCollection;
  25. };
  26. type Props = DefaultProps & {
  27. parsedQuery: ParseResult;
  28. tagValueLoader: TagValueLoader;
  29. loading?: boolean;
  30. };
  31. type State = {
  32. filters: Record<string, TokenResult<Token.Filter>>;
  33. textFilter: string;
  34. };
  35. class IssueListSidebar extends Component<Props, State> {
  36. static defaultProps: DefaultProps = {
  37. tags: {},
  38. query: '',
  39. onQueryChange: function () {},
  40. };
  41. state: State = this.parsedQueryToState(this.props.parsedQuery);
  42. componentWillReceiveProps(nextProps: Props) {
  43. if (!isEqual(nextProps.query, this.props.query)) {
  44. this.setState(this.parsedQueryToState(nextProps.parsedQuery));
  45. }
  46. }
  47. parsedQueryToState(parsedQuery: ParseResult): State {
  48. const parsedFilters = parsedQuery.filter(
  49. (p): p is TokenResult<Token.Filter> => p.type === Token.Filter
  50. );
  51. return {
  52. filters: Object.fromEntries(parsedFilters.map(p => [p.key.text, p])),
  53. textFilter: joinQuery(parsedQuery.filter(p => p.type === Token.FreeText)),
  54. };
  55. }
  56. onSelectTag = (tag: Tag, value: string | null) => {
  57. const parsedResult: TokenResult<Token.Filter>[] = (
  58. parseSearch(`${tag.key}:${value}`) ?? []
  59. ).filter((p): p is TokenResult<Token.Filter> => p.type === Token.Filter);
  60. if (parsedResult.length !== 1 || parsedResult[0].type !== Token.Filter) {
  61. return;
  62. }
  63. const newEntry = parsedResult[0] as TokenResult<Token.Filter>;
  64. const newFilters = {...this.state.filters};
  65. if (value) {
  66. newFilters[tag.key] = newEntry;
  67. } else {
  68. delete newFilters[tag.key];
  69. }
  70. this.setState(
  71. {
  72. filters: newFilters,
  73. },
  74. this.onQueryChange
  75. );
  76. };
  77. onTextChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
  78. this.setState({textFilter: evt.target.value});
  79. };
  80. onQueryChange = () => {
  81. const newQuery = [
  82. joinQuery(Object.values(this.state.filters), false, true),
  83. this.state.textFilter,
  84. ]
  85. .filter(f => f) // filter out empty strings
  86. .join(' ');
  87. this.props.onQueryChange && this.props.onQueryChange(newQuery);
  88. };
  89. onClearSearch = () => {
  90. this.setState(
  91. {
  92. textFilter: '',
  93. },
  94. this.onQueryChange
  95. );
  96. };
  97. render() {
  98. const {loading, tagValueLoader, tags} = this.props;
  99. // TODO: @taylangocmen: 1. We need to render negated tags better, 2. We need an option to add negated tags to query
  100. return (
  101. <StreamSidebar>
  102. {loading ? (
  103. <LoadingIndicator />
  104. ) : (
  105. <Fragment>
  106. <SidebarSection.Wrap>
  107. <SidebarSection.Title>{t('Text')}</SidebarSection.Title>
  108. <SidebarSection.Content>
  109. <form onSubmit={this.onQueryChange}>
  110. <Input
  111. placeholder={t('Search title and culprit text body')}
  112. onChange={this.onTextChange}
  113. value={this.state.textFilter}
  114. />
  115. {this.state.textFilter && (
  116. <StyledIconClose size="xs" onClick={this.onClearSearch} />
  117. )}
  118. </form>
  119. <StyledHr />
  120. </SidebarSection.Content>
  121. </SidebarSection.Wrap>
  122. {map(tags, tag => (
  123. <IssueListTagFilter
  124. value={this.state.filters[tag.key]?.value.text || undefined}
  125. key={tag.key}
  126. tag={tag}
  127. onSelect={this.onSelectTag}
  128. tagValueLoader={tagValueLoader}
  129. />
  130. ))}
  131. </Fragment>
  132. )}
  133. </StreamSidebar>
  134. );
  135. }
  136. }
  137. export default IssueListSidebar;
  138. const StreamSidebar = styled('div')`
  139. display: flex;
  140. flex-direction: column;
  141. width: 100%;
  142. `;
  143. const StyledIconClose = styled(IconClose)`
  144. cursor: pointer;
  145. position: absolute;
  146. top: 13px;
  147. right: 10px;
  148. color: ${p => p.theme.gray200};
  149. &:hover {
  150. color: ${p => p.theme.gray300};
  151. }
  152. `;
  153. const StyledHr = styled('hr')`
  154. margin: ${space(2)} 0 0;
  155. border-top: solid 1px ${p => p.theme.innerBorder};
  156. `;