resultGrid.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import {Component} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import {Location} from 'history';
  4. import {Client, RequestOptions} from 'sentry/api';
  5. import DropdownLink from 'sentry/components/dropdownLink';
  6. import MenuItem from 'sentry/components/menuItem';
  7. import Pagination from 'sentry/components/pagination';
  8. import {IconSearch} from 'sentry/icons';
  9. import withApi from 'sentry/utils/withApi';
  10. type Option = [value: string, label: string];
  11. type FilterProps = {
  12. location: Location;
  13. name: string;
  14. options: Option[];
  15. path: string;
  16. queryKey: string;
  17. value: string;
  18. };
  19. class Filter extends Component<FilterProps> {
  20. getCurrentLabel() {
  21. const selected = this.props.options.find(
  22. item => item[0] === (this.props.value ?? '')
  23. );
  24. if (selected) {
  25. return this.props.name + ': ' + selected[1];
  26. }
  27. return this.props.name + ': ' + 'Any';
  28. }
  29. getDefaultItem() {
  30. const query = {...this.props.location.query, cursor: ''};
  31. delete query[this.props.queryKey];
  32. return (
  33. <MenuItem
  34. key=""
  35. isActive={this.props.value === '' || !this.props.value}
  36. to={{pathname: this.props.path, query}}
  37. >
  38. Any
  39. </MenuItem>
  40. );
  41. }
  42. getSelector = () => (
  43. <DropdownLink title={this.getCurrentLabel()}>
  44. {this.getDefaultItem()}
  45. {this.props.options.map(([value, label]) => {
  46. const filterQuery = {
  47. [this.props.queryKey]: value,
  48. cursor: '',
  49. };
  50. const query = {...this.props.location.query, ...filterQuery};
  51. return (
  52. <MenuItem
  53. key={value}
  54. isActive={this.props.value === value}
  55. to={{pathname: this.props.path, query}}
  56. >
  57. {label}
  58. </MenuItem>
  59. );
  60. })}
  61. </DropdownLink>
  62. );
  63. render() {
  64. return (
  65. <div className="filter-options">
  66. {this.props.options.length === 1 ? (
  67. <strong>{this.getCurrentLabel()}</strong>
  68. ) : (
  69. this.getSelector()
  70. )}
  71. </div>
  72. );
  73. }
  74. }
  75. type SortByProps = {
  76. location: Location;
  77. options: Option[];
  78. path: string;
  79. value: string;
  80. };
  81. class SortBy extends Component<SortByProps> {
  82. getCurrentSortLabel() {
  83. return this.props.options.find(([value]) => value === this.props.value)?.[1];
  84. }
  85. getSortBySelector() {
  86. return (
  87. <DropdownLink title={this.getCurrentSortLabel()} className="sorted-by">
  88. {this.props.options.map(([value, label]) => {
  89. const query = {...this.props.location.query, sortBy: value, cursor: ''};
  90. return (
  91. <MenuItem
  92. isActive={this.props.value === value}
  93. key={value}
  94. to={{pathname: this.props.path, query}}
  95. >
  96. {label}
  97. </MenuItem>
  98. );
  99. })}
  100. </DropdownLink>
  101. );
  102. }
  103. render() {
  104. if (this.props.options.length === 0) {
  105. return null;
  106. }
  107. return (
  108. <div className="sort-options">
  109. Showing results sorted by
  110. {this.props.options.length === 1 ? (
  111. <strong className="sorted-by">{this.getCurrentSortLabel()}</strong>
  112. ) : (
  113. this.getSortBySelector()
  114. )}
  115. </div>
  116. );
  117. }
  118. }
  119. type FilterConfig = {
  120. name: string;
  121. options: Option[];
  122. };
  123. // XXX(ts): Using Partial here on the DefaultProps is not really correct, since
  124. // defaultProps guarantees they'll be set. But because this component is
  125. // wrapped with a HoC, we lose the defaultProps, and users of the component
  126. type Props = {
  127. api: Client;
  128. location: Location;
  129. } & Partial<DefaultProps>;
  130. type DefaultProps = {
  131. columns: React.ReactNode[];
  132. columnsForRow: (row: any) => React.ReactNode[];
  133. defaultParams: Record<string, any>;
  134. defaultSort: string;
  135. endpoint: string;
  136. filters: Record<string, FilterConfig>;
  137. hasPagination: boolean;
  138. hasSearch: boolean;
  139. keyForRow: (row: any) => string;
  140. method: RequestOptions['method'];
  141. path: string;
  142. sortOptions: Option[];
  143. };
  144. type State = {
  145. error: string | boolean;
  146. filters: Record<string, string>;
  147. loading: boolean;
  148. pageLinks: null | string;
  149. query: string;
  150. rows: any[];
  151. sortBy: string;
  152. };
  153. class ResultGrid extends Component<Props, State> {
  154. static defaultProps: DefaultProps = {
  155. path: '',
  156. endpoint: '',
  157. method: 'GET',
  158. columns: [],
  159. sortOptions: [],
  160. filters: {},
  161. defaultSort: '',
  162. keyForRow: row => row.id,
  163. columnsForRow: () => [],
  164. defaultParams: {
  165. per_page: 50,
  166. },
  167. hasPagination: true,
  168. hasSearch: false,
  169. };
  170. state: State = this.defaultState;
  171. componentWillMount() {
  172. this.fetchData();
  173. }
  174. componentWillReceiveProps() {
  175. const queryParams = this.query;
  176. this.setState(
  177. {
  178. query: queryParams.query ?? '',
  179. sortBy: queryParams.sortBy ?? this.props.defaultSort,
  180. filters: {...queryParams},
  181. pageLinks: null,
  182. loading: true,
  183. error: false,
  184. },
  185. this.fetchData
  186. );
  187. }
  188. get defaultState() {
  189. const queryParams = this.query;
  190. return {
  191. rows: [],
  192. loading: true,
  193. error: false,
  194. pageLinks: null,
  195. query: queryParams.query ?? '',
  196. sortBy: queryParams.sortBy ?? this.props.defaultSort,
  197. filters: {...queryParams},
  198. } as State;
  199. }
  200. get query() {
  201. return ((this.props.location ?? {}).query ?? {}) as {[k: string]: string};
  202. }
  203. remountComponent() {
  204. this.setState(this.defaultState, this.fetchData);
  205. }
  206. refresh() {
  207. this.setState({loading: true}, this.fetchData);
  208. }
  209. fetchData() {
  210. // TODO(dcramer): this should explicitly allow filters/sortBy/cursor/perPage
  211. const queryParams = {
  212. ...this.props.defaultParams,
  213. sortBy: this.state.sortBy,
  214. ...this.query,
  215. };
  216. this.props.api.request(this.props.endpoint!, {
  217. method: this.props.method,
  218. data: queryParams,
  219. success: (data, _, resp) => {
  220. this.setState({
  221. loading: false,
  222. error: false,
  223. rows: data,
  224. pageLinks: resp?.getResponseHeader('Link') ?? null,
  225. });
  226. },
  227. error: () => {
  228. this.setState({
  229. loading: false,
  230. error: true,
  231. });
  232. },
  233. });
  234. }
  235. onSearch = (e: React.FormEvent<HTMLFormElement>) => {
  236. const location = this.props.location ?? {};
  237. const {query} = this.state;
  238. const targetQueryParams = {...(location.query ?? {}), query, cursor: ''};
  239. e.preventDefault();
  240. browserHistory.push({
  241. pathname: this.props.path,
  242. query: targetQueryParams,
  243. });
  244. };
  245. onQueryChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
  246. this.setState({query: evt.target.value});
  247. };
  248. renderLoading() {
  249. return (
  250. <tr>
  251. <td colSpan={this.props.columns!.length}>
  252. <div className="loading">
  253. <div className="loading-indicator" />
  254. <div className="loading-message">Hold on to your butts!</div>
  255. </div>
  256. </td>
  257. </tr>
  258. );
  259. }
  260. renderError() {
  261. return (
  262. <tr>
  263. <td colSpan={this.props.columns!.length}>
  264. <div className="alert-block alert-error">Something bad happened :(</div>
  265. </td>
  266. </tr>
  267. );
  268. }
  269. renderNoResults() {
  270. return (
  271. <tr>
  272. <td colSpan={this.props.columns!.length}>No results found.</td>
  273. </tr>
  274. );
  275. }
  276. renderResults() {
  277. return this.state.rows.map(row => (
  278. <tr key={this.props.keyForRow?.(row)}>{this.props.columnsForRow?.(row)}</tr>
  279. ));
  280. }
  281. render() {
  282. const {filters, sortOptions, path, location} = this.props;
  283. return (
  284. <div className="result-grid">
  285. <div className="table-options">
  286. {this.props.hasSearch && (
  287. <div className="result-grid-search">
  288. <form onSubmit={this.onSearch}>
  289. <div className="form-group">
  290. <input
  291. type="text"
  292. className="form-control input-search"
  293. placeholder="search"
  294. style={{width: 300}}
  295. name="query"
  296. autoComplete="off"
  297. value={this.state.query}
  298. onChange={this.onQueryChange}
  299. />
  300. <button type="submit" className="btn btn-sm btn-primary">
  301. <IconSearch size="xs" />
  302. </button>
  303. </div>
  304. </form>
  305. </div>
  306. )}
  307. <SortBy
  308. options={sortOptions ?? []}
  309. value={this.state.sortBy}
  310. path={path ?? ''}
  311. location={location}
  312. />
  313. {Object.keys(filters ?? {}).map(filterKey => (
  314. <Filter
  315. key={filterKey}
  316. queryKey={filterKey}
  317. value={this.state.filters[filterKey]}
  318. path={path ?? ''}
  319. location={location}
  320. {...(filters?.[filterKey] as FilterConfig)}
  321. />
  322. ))}
  323. </div>
  324. <table className="table table-grid">
  325. <thead>
  326. <tr>{this.props.columns}</tr>
  327. </thead>
  328. <tbody>
  329. {this.state.loading
  330. ? this.renderLoading()
  331. : this.state.error
  332. ? this.renderError()
  333. : this.state.rows.length === 0
  334. ? this.renderNoResults()
  335. : this.renderResults()}
  336. </tbody>
  337. </table>
  338. {this.props.hasPagination && this.state.pageLinks && (
  339. <Pagination pageLinks={this.state.pageLinks} />
  340. )}
  341. </div>
  342. );
  343. }
  344. }
  345. export {ResultGrid};
  346. export default withApi(ResultGrid);