apiSource.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import * as React from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import * as Sentry from '@sentry/react';
  4. import debounce from 'lodash/debounce';
  5. import flatten from 'lodash/flatten';
  6. import {Client, ResponseMeta} from 'sentry/api';
  7. import {t} from 'sentry/locale';
  8. import {
  9. DocIntegration,
  10. EventIdResponse,
  11. IntegrationProvider,
  12. Member,
  13. Organization,
  14. PluginWithProjectList,
  15. Project,
  16. SentryApp,
  17. ShortIdResponse,
  18. Team,
  19. } from 'sentry/types';
  20. import {defined} from 'sentry/utils';
  21. import {createFuzzySearch, Fuse} from 'sentry/utils/fuzzySearch';
  22. import {singleLineRenderer as markedSingleLine} from 'sentry/utils/marked';
  23. import withLatestContext from 'sentry/utils/withLatestContext';
  24. import {ChildProps, Result, ResultItem} from './types';
  25. import {strGetFn} from './utils';
  26. // event ids must have string length of 32
  27. const shouldSearchEventIds = (query?: string) =>
  28. typeof query === 'string' && query.length === 32;
  29. // STRING-HEXVAL
  30. const shouldSearchShortIds = (query: string) => /[\w\d]+-[\w\d]+/.test(query);
  31. // Helper functions to create result objects
  32. async function createOrganizationResults(
  33. organizationsPromise: Promise<Organization[]>
  34. ): Promise<ResultItem[]> {
  35. const organizations = (await organizationsPromise) || [];
  36. return flatten(
  37. organizations.map(org => [
  38. {
  39. title: t('%s Dashboard', org.slug),
  40. description: t('Organization Dashboard'),
  41. model: org,
  42. sourceType: 'organization',
  43. resultType: 'route',
  44. to: `/${org.slug}/`,
  45. },
  46. {
  47. title: t('%s Settings', org.slug),
  48. description: t('Organization Settings'),
  49. model: org,
  50. sourceType: 'organization',
  51. resultType: 'settings',
  52. to: `/settings/${org.slug}/`,
  53. },
  54. ])
  55. );
  56. }
  57. async function createProjectResults(
  58. projectsPromise: Promise<Project[]>,
  59. orgId: string
  60. ): Promise<ResultItem[]> {
  61. const projects = (await projectsPromise) || [];
  62. return flatten(
  63. projects.map(project => {
  64. const projectResults: ResultItem[] = [
  65. {
  66. title: t('%s Settings', project.slug),
  67. description: t('Project Settings'),
  68. model: project,
  69. sourceType: 'project',
  70. resultType: 'settings',
  71. to: `/settings/${orgId}/projects/${project.slug}/`,
  72. },
  73. ];
  74. projectResults.unshift({
  75. title: t('%s Dashboard', project.slug),
  76. description: t('Project Details'),
  77. model: project,
  78. sourceType: 'project',
  79. resultType: 'route',
  80. to: `/organizations/${orgId}/projects/${project.slug}/?project=${project.id}`,
  81. });
  82. return projectResults;
  83. })
  84. );
  85. }
  86. async function createTeamResults(
  87. teamsPromise: Promise<Team[]>,
  88. orgId: string
  89. ): Promise<ResultItem[]> {
  90. const teams = (await teamsPromise) || [];
  91. return teams.map(team => ({
  92. title: `#${team.slug}`,
  93. description: 'Team Settings',
  94. model: team,
  95. sourceType: 'team',
  96. resultType: 'settings',
  97. to: `/settings/${orgId}/teams/${team.slug}/`,
  98. }));
  99. }
  100. async function createMemberResults(
  101. membersPromise: Promise<Member[]>,
  102. orgId: string
  103. ): Promise<ResultItem[]> {
  104. const members = (await membersPromise) || [];
  105. return members.map(member => ({
  106. title: member.name,
  107. description: member.email,
  108. model: member,
  109. sourceType: 'member',
  110. resultType: 'settings',
  111. to: `/settings/${orgId}/members/${member.id}/`,
  112. }));
  113. }
  114. async function createPluginResults(
  115. pluginsPromise: Promise<PluginWithProjectList[]>,
  116. orgId: string
  117. ): Promise<ResultItem[]> {
  118. const plugins = (await pluginsPromise) || [];
  119. return plugins
  120. .filter(plugin => {
  121. // show a plugin if it is not hidden (aka legacy) or if we have projects with it configured
  122. return !plugin.isHidden || !!plugin.projectList.length;
  123. })
  124. .map(plugin => ({
  125. title: plugin.isHidden ? `${plugin.name} (Legacy)` : plugin.name,
  126. description: (
  127. <span
  128. dangerouslySetInnerHTML={{
  129. __html: markedSingleLine(plugin.description ?? ''),
  130. }}
  131. />
  132. ),
  133. model: plugin,
  134. sourceType: 'plugin',
  135. resultType: 'integration',
  136. to: `/settings/${orgId}/plugins/${plugin.id}/`,
  137. }));
  138. }
  139. async function createIntegrationResults(
  140. integrationsPromise: Promise<{providers: IntegrationProvider[]}>,
  141. orgId: string
  142. ): Promise<ResultItem[]> {
  143. const {providers} = (await integrationsPromise) || {};
  144. return (
  145. (providers &&
  146. providers.map(provider => ({
  147. title: provider.name,
  148. description: (
  149. <span
  150. dangerouslySetInnerHTML={{
  151. __html: markedSingleLine(provider.metadata.description),
  152. }}
  153. />
  154. ),
  155. model: provider,
  156. sourceType: 'integration',
  157. resultType: 'integration',
  158. to: `/settings/${orgId}/integrations/${provider.slug}/`,
  159. configUrl: `/api/0/organizations/${orgId}/integrations/?provider_key=${provider.slug}&includeConfig=0`,
  160. }))) ||
  161. []
  162. );
  163. }
  164. async function createSentryAppResults(
  165. sentryAppPromise: Promise<SentryApp[]>,
  166. orgId: string
  167. ): Promise<ResultItem[]> {
  168. const sentryApps = (await sentryAppPromise) || [];
  169. return sentryApps.map(sentryApp => ({
  170. title: sentryApp.name,
  171. description: (
  172. <span
  173. dangerouslySetInnerHTML={{
  174. __html: markedSingleLine(sentryApp.overview || ''),
  175. }}
  176. />
  177. ),
  178. model: sentryApp,
  179. sourceType: 'sentryApp',
  180. resultType: 'sentryApp',
  181. to: `/settings/${orgId}/sentry-apps/${sentryApp.slug}/`,
  182. }));
  183. }
  184. async function createDocIntegrationResults(
  185. docIntegrationPromise: Promise<DocIntegration[]>,
  186. orgId: string
  187. ): Promise<ResultItem[]> {
  188. const docIntegrations = (await docIntegrationPromise) || [];
  189. return docIntegrations.map(docIntegration => ({
  190. title: docIntegration.name,
  191. description: (
  192. <span
  193. dangerouslySetInnerHTML={{
  194. __html: markedSingleLine(docIntegration.description || ''),
  195. }}
  196. />
  197. ),
  198. model: docIntegration,
  199. sourceType: 'docIntegration',
  200. resultType: 'docIntegration',
  201. to: `/settings/${orgId}/document-integrations/${docIntegration.slug}/`,
  202. }));
  203. }
  204. async function createShortIdLookupResult(
  205. shortIdLookupPromise: Promise<ShortIdResponse>
  206. ): Promise<Result | null> {
  207. const shortIdLookup = await shortIdLookupPromise;
  208. if (!shortIdLookup) {
  209. return null;
  210. }
  211. const issue = shortIdLookup && shortIdLookup.group;
  212. return {
  213. item: {
  214. title: `${
  215. (issue && issue.metadata && issue.metadata.type) || shortIdLookup.shortId
  216. }`,
  217. description: `${(issue && issue.metadata && issue.metadata.value) || t('Issue')}`,
  218. model: shortIdLookup.group,
  219. sourceType: 'issue',
  220. resultType: 'issue',
  221. to: `/${shortIdLookup.organizationSlug}/${shortIdLookup.projectSlug}/issues/${shortIdLookup.groupId}/`,
  222. },
  223. score: 1,
  224. refIndex: 0,
  225. };
  226. }
  227. async function createEventIdLookupResult(
  228. eventIdLookupPromise: Promise<EventIdResponse>
  229. ): Promise<Result | null> {
  230. const eventIdLookup = await eventIdLookupPromise;
  231. if (!eventIdLookup) {
  232. return null;
  233. }
  234. const event = eventIdLookup && eventIdLookup.event;
  235. return {
  236. item: {
  237. title: `${(event && event.metadata && event.metadata.type) || t('Event')}`,
  238. description: `${event && event.metadata && event.metadata.value}`,
  239. sourceType: 'event',
  240. resultType: 'event',
  241. to: `/${eventIdLookup.organizationSlug}/${eventIdLookup.projectSlug}/issues/${eventIdLookup.groupId}/events/${eventIdLookup.eventId}/`,
  242. },
  243. score: 1,
  244. refIndex: 0,
  245. };
  246. }
  247. type Props = WithRouterProps<{orgId: string}> & {
  248. children: (props: ChildProps) => React.ReactElement;
  249. organization: Organization;
  250. /**
  251. * search term
  252. */
  253. query: string;
  254. /**
  255. * fuse.js options
  256. */
  257. searchOptions?: Fuse.IFuseOptions<ResultItem>;
  258. };
  259. type State = {
  260. directResults: null | Result[];
  261. fuzzy: null | Fuse<ResultItem>;
  262. loading: boolean;
  263. searchResults: null | Result[];
  264. };
  265. class ApiSource extends React.Component<Props, State> {
  266. static defaultProps = {
  267. searchOptions: {},
  268. };
  269. state: State = {
  270. loading: false,
  271. searchResults: null,
  272. directResults: null,
  273. fuzzy: null,
  274. };
  275. componentDidMount() {
  276. if (typeof this.props.query !== 'undefined') {
  277. this.doSearch(this.props.query);
  278. }
  279. }
  280. UNSAFE_componentWillReceiveProps(nextProps: Props) {
  281. // Limit the number of times we perform API queries by only attempting API queries
  282. // using first two characters, otherwise perform in-memory search.
  283. //
  284. // Otherwise it'd be constant :spinning_loading_wheel:
  285. if (
  286. (nextProps.query.length <= 2 &&
  287. nextProps.query.substr(0, 2) !== this.props.query.substr(0, 2)) ||
  288. // Also trigger a search if next query value satisfies an eventid/shortid query
  289. shouldSearchShortIds(nextProps.query) ||
  290. shouldSearchEventIds(nextProps.query)
  291. ) {
  292. this.setState({loading: true});
  293. this.doSearch(nextProps.query);
  294. }
  295. }
  296. api = new Client();
  297. // Debounced method to handle querying all API endpoints (when necessary)
  298. doSearch = debounce(async (query: string) => {
  299. const {params, organization} = this.props;
  300. const orgId = (params && params.orgId) || (organization && organization.slug);
  301. let searchUrls = ['/organizations/'];
  302. let directUrls: (string | null)[] = [];
  303. // Only run these queries when we have an org in context
  304. if (orgId) {
  305. searchUrls = [
  306. ...searchUrls,
  307. `/organizations/${orgId}/projects/`,
  308. `/organizations/${orgId}/teams/`,
  309. `/organizations/${orgId}/members/`,
  310. `/organizations/${orgId}/plugins/configs/`,
  311. `/organizations/${orgId}/config/integrations/`,
  312. '/sentry-apps/?status=published',
  313. '/doc-integrations/',
  314. ];
  315. directUrls = [
  316. shouldSearchShortIds(query) ? `/organizations/${orgId}/shortids/${query}/` : null,
  317. shouldSearchEventIds(query) ? `/organizations/${orgId}/eventids/${query}/` : null,
  318. ];
  319. }
  320. const searchRequests = searchUrls.map(url =>
  321. this.api
  322. .requestPromise(url, {
  323. query: {
  324. query,
  325. },
  326. })
  327. .then(
  328. resp => resp,
  329. err => {
  330. this.handleRequestError(err, {orgId, url});
  331. return null;
  332. }
  333. )
  334. );
  335. const directRequests = directUrls.map(url => {
  336. if (!url) {
  337. return Promise.resolve(null);
  338. }
  339. return this.api.requestPromise(url).then(
  340. resp => resp,
  341. (err: ResponseMeta) => {
  342. // No need to log 404 errors
  343. if (err && err.status === 404) {
  344. return null;
  345. }
  346. this.handleRequestError(err, {orgId, url});
  347. return null;
  348. }
  349. );
  350. });
  351. this.handleSearchRequest(searchRequests, directRequests);
  352. }, 150);
  353. handleRequestError = (err: ResponseMeta, {url, orgId}) => {
  354. Sentry.withScope(scope => {
  355. scope.setExtra(
  356. 'url',
  357. url.replace(`/organizations/${orgId}/`, '/organizations/:orgId/')
  358. );
  359. Sentry.captureException(
  360. new Error(`API Source Failed: ${err?.responseJSON?.detail}`)
  361. );
  362. });
  363. };
  364. // Handles a list of search request promises, and then updates state with response objects
  365. async handleSearchRequest(
  366. searchRequests: Promise<ResultItem[]>[],
  367. directRequests: Promise<Result | null>[]
  368. ) {
  369. const {searchOptions} = this.props;
  370. // Note we don't wait for all requests to resolve here (e.g. `await Promise.all(reqs)`)
  371. // so that we can start processing before all API requests are resolved
  372. //
  373. // This isn't particularly helpful in its current form because we still wait for all requests to finish before
  374. // updating state, but you could potentially optimize rendering direct results before all requests are finished.
  375. const [
  376. organizations,
  377. projects,
  378. teams,
  379. members,
  380. plugins,
  381. integrations,
  382. sentryApps,
  383. docIntegrations,
  384. ] = searchRequests;
  385. const [shortIdLookup, eventIdLookup] = directRequests;
  386. const [searchResults, directResults] = await Promise.all([
  387. this.getSearchableResults([
  388. organizations,
  389. projects,
  390. teams,
  391. members,
  392. plugins,
  393. integrations,
  394. sentryApps,
  395. docIntegrations,
  396. ]),
  397. this.getDirectResults([shortIdLookup, eventIdLookup]),
  398. ]);
  399. // TODO(XXX): Might consider adding logic to maintain consistent ordering
  400. // of results so things don't switch positions
  401. const fuzzy = await createFuzzySearch(searchResults, {
  402. ...searchOptions,
  403. keys: ['title', 'description'],
  404. getFn: strGetFn,
  405. });
  406. this.setState({
  407. loading: false,
  408. fuzzy,
  409. directResults,
  410. });
  411. }
  412. // Process API requests that create result objects that should be searchable
  413. async getSearchableResults(requests) {
  414. const {params, organization} = this.props;
  415. const orgId = (params && params.orgId) || (organization && organization.slug);
  416. const [
  417. organizations,
  418. projects,
  419. teams,
  420. members,
  421. plugins,
  422. integrations,
  423. sentryApps,
  424. docIntegrations,
  425. ] = requests;
  426. const searchResults = flatten(
  427. await Promise.all([
  428. createOrganizationResults(organizations),
  429. createProjectResults(projects, orgId),
  430. createTeamResults(teams, orgId),
  431. createMemberResults(members, orgId),
  432. createIntegrationResults(integrations, orgId),
  433. createPluginResults(plugins, orgId),
  434. createSentryAppResults(sentryApps, orgId),
  435. createDocIntegrationResults(docIntegrations, orgId),
  436. ])
  437. );
  438. return searchResults;
  439. }
  440. // Create result objects from API requests that do not require fuzzy search
  441. // i.e. these responses only return 1 object or they should always be displayed regardless of query input
  442. async getDirectResults(requests: Promise<any>[]): Promise<Result[]> {
  443. const [shortIdLookup, eventIdLookup] = requests;
  444. const directResults = (
  445. await Promise.all([
  446. createShortIdLookupResult(shortIdLookup),
  447. createEventIdLookupResult(eventIdLookup),
  448. ])
  449. ).filter(defined);
  450. if (!directResults.length) {
  451. return [];
  452. }
  453. return directResults;
  454. }
  455. render() {
  456. const {children, query} = this.props;
  457. const {fuzzy, directResults} = this.state;
  458. const results = fuzzy?.search(query) ?? [];
  459. return children({
  460. isLoading: this.state.loading,
  461. results: flatten([results, directResults].filter(defined)) || [],
  462. });
  463. }
  464. }
  465. export {ApiSource};
  466. export default withLatestContext(withRouter(ApiSource));