import {Component} from 'react'; // eslint-disable-next-line no-restricted-imports import {withRouter, WithRouterProps} from 'react-router'; import * as Sentry from '@sentry/react'; import debounce from 'lodash/debounce'; import flatten from 'lodash/flatten'; import {Client, ResponseMeta} from 'sentry/api'; import {t} from 'sentry/locale'; import { DocIntegration, EventIdResponse, IntegrationProvider, Member, Organization, PluginWithProjectList, Project, SentryApp, ShortIdResponse, Team, } from 'sentry/types'; import {defined} from 'sentry/utils'; import {createFuzzySearch, Fuse} from 'sentry/utils/fuzzySearch'; import {singleLineRenderer as markedSingleLine} from 'sentry/utils/marked'; import withLatestContext from 'sentry/utils/withLatestContext'; import {ChildProps, Result, ResultItem} from './types'; import {strGetFn} from './utils'; // event ids must have string length of 32 const shouldSearchEventIds = (query?: string) => typeof query === 'string' && query.length === 32; // STRING-HEXVAL const shouldSearchShortIds = (query: string) => /[\w\d]+-[\w\d]+/.test(query); // Helper functions to create result objects async function createOrganizationResults( organizationsPromise: Promise ): Promise { const organizations = (await organizationsPromise) || []; return flatten( organizations.map(org => [ { title: t('%s Dashboard', org.slug), description: t('Organization Dashboard'), model: org, sourceType: 'organization', resultType: 'route', to: `/${org.slug}/`, }, { title: t('%s Settings', org.slug), description: t('Organization Settings'), model: org, sourceType: 'organization', resultType: 'settings', to: `/settings/${org.slug}/`, }, ]) ); } async function createProjectResults( projectsPromise: Promise, orgId?: string ): Promise { const projects = (await projectsPromise) || []; return flatten( projects.map(project => { const projectResults: ResultItem[] = [ { title: t('%s Settings', project.slug), description: t('Project Settings'), model: project, sourceType: 'project', resultType: 'settings', to: `/settings/${orgId}/projects/${project.slug}/`, }, { title: t('%s Alerts', project.slug), description: t('List of project alert rules'), model: project, sourceType: 'project', resultType: 'route', to: `/organizations/${orgId}/alerts/rules/?project=${project.id}`, }, ]; projectResults.unshift({ title: t('%s Dashboard', project.slug), description: t('Project Details'), model: project, sourceType: 'project', resultType: 'route', to: `/organizations/${orgId}/projects/${project.slug}/?project=${project.id}`, }); return projectResults; }) ); } async function createTeamResults( teamsPromise: Promise, orgId?: string ): Promise { const teams = (await teamsPromise) || []; return teams.map(team => ({ title: `#${team.slug}`, description: 'Team Settings', model: team, sourceType: 'team', resultType: 'settings', to: `/settings/${orgId}/teams/${team.slug}/`, })); } async function createMemberResults( membersPromise: Promise, orgId?: string ): Promise { const members = (await membersPromise) || []; return members.map(member => ({ title: member.name, description: member.email, model: member, sourceType: 'member', resultType: 'settings', to: `/settings/${orgId}/members/${member.id}/`, })); } async function createPluginResults( pluginsPromise: Promise, orgId?: string ): Promise { const plugins = (await pluginsPromise) || []; return plugins .filter(plugin => { // show a plugin if it is not hidden (aka legacy) or if we have projects with it configured return !plugin.isHidden || !!plugin.projectList.length; }) .map(plugin => ({ title: plugin.isHidden ? `${plugin.name} (Legacy)` : plugin.name, description: ( ), model: plugin, sourceType: 'plugin', resultType: 'integration', to: `/settings/${orgId}/plugins/${plugin.id}/`, })); } async function createIntegrationResults( integrationsPromise: Promise<{providers: IntegrationProvider[]}>, orgId?: string ): Promise { const {providers} = (await integrationsPromise) || {}; return ( (providers && providers.map(provider => ({ title: provider.name, description: ( ), model: provider, sourceType: 'integration', resultType: 'integration', to: `/settings/${orgId}/integrations/${provider.slug}/`, configUrl: `/api/0/organizations/${orgId}/integrations/?provider_key=${provider.slug}&includeConfig=0`, }))) || [] ); } async function createSentryAppResults( sentryAppPromise: Promise, orgId?: string ): Promise { const sentryApps = (await sentryAppPromise) || []; return sentryApps.map(sentryApp => ({ title: sentryApp.name, description: ( ), model: sentryApp, sourceType: 'sentryApp', resultType: 'sentryApp', to: `/settings/${orgId}/sentry-apps/${sentryApp.slug}/`, })); } async function createDocIntegrationResults( docIntegrationPromise: Promise, orgId?: string ): Promise { const docIntegrations = (await docIntegrationPromise) || []; return docIntegrations.map(docIntegration => ({ title: docIntegration.name, description: ( ), model: docIntegration, sourceType: 'docIntegration', resultType: 'docIntegration', to: `/settings/${orgId}/document-integrations/${docIntegration.slug}/`, })); } async function createShortIdLookupResult( shortIdLookupPromise: Promise ): Promise { const shortIdLookup = await shortIdLookupPromise; if (!shortIdLookup) { return null; } const issue = shortIdLookup && shortIdLookup.group; return { item: { title: `${ (issue && issue.metadata && issue.metadata.type) || shortIdLookup.shortId }`, description: `${(issue && issue.metadata && issue.metadata.value) || t('Issue')}`, model: shortIdLookup.group, sourceType: 'issue', resultType: 'issue', to: `/${shortIdLookup.organizationSlug}/${shortIdLookup.projectSlug}/issues/${shortIdLookup.groupId}/`, }, score: 1, refIndex: 0, }; } async function createEventIdLookupResult( eventIdLookupPromise: Promise ): Promise { const eventIdLookup = await eventIdLookupPromise; if (!eventIdLookup) { return null; } const event = eventIdLookup && eventIdLookup.event; return { item: { title: `${(event && event.metadata && event.metadata.type) || t('Event')}`, description: `${event && event.metadata && event.metadata.value}`, sourceType: 'event', resultType: 'event', to: `/${eventIdLookup.organizationSlug}/${eventIdLookup.projectSlug}/issues/${eventIdLookup.groupId}/events/${eventIdLookup.eventId}/`, }, score: 1, refIndex: 0, }; } type Props = WithRouterProps<{orgId: string}> & { children: (props: ChildProps) => React.ReactElement; /** * search term */ query: string; organization?: Organization; /** * fuse.js options */ searchOptions?: Fuse.IFuseOptions; }; type State = { directResults: null | Result[]; fuzzy: null | Fuse; loading: boolean; searchResults: null | Result[]; }; class ApiSource extends Component { static defaultProps = { searchOptions: {}, }; state: State = { loading: false, searchResults: null, directResults: null, fuzzy: null, }; componentDidMount() { if (typeof this.props.query !== 'undefined') { this.doSearch(this.props.query); } } UNSAFE_componentWillReceiveProps(nextProps: Props) { // Limit the number of times we perform API queries by only attempting API queries // using first two characters, otherwise perform in-memory search. // // Otherwise it'd be constant :spinning_loading_wheel: if ( (nextProps.query.length <= 2 && nextProps.query.substr(0, 2) !== this.props.query.substr(0, 2)) || // Also trigger a search if next query value satisfies an eventid/shortid query shouldSearchShortIds(nextProps.query) || shouldSearchEventIds(nextProps.query) ) { this.setState({loading: true}); this.doSearch(nextProps.query); } } api = new Client(); // Debounced method to handle querying all API endpoints (when necessary) doSearch = debounce((query: string) => { const {params, organization} = this.props; const orgId = (params && params.orgId) || (organization && organization.slug); let searchUrls = ['/organizations/']; let directUrls: (string | null)[] = []; // Only run these queries when we have an org in context if (orgId) { searchUrls = [ ...searchUrls, `/organizations/${orgId}/projects/`, `/organizations/${orgId}/teams/`, `/organizations/${orgId}/members/`, `/organizations/${orgId}/plugins/configs/`, `/organizations/${orgId}/config/integrations/`, '/sentry-apps/?status=published', '/doc-integrations/', ]; directUrls = [ shouldSearchShortIds(query) ? `/organizations/${orgId}/shortids/${query}/` : null, shouldSearchEventIds(query) ? `/organizations/${orgId}/eventids/${query}/` : null, ]; } const searchRequests = searchUrls.map(url => this.api .requestPromise(url, { query: { query, }, }) .then( resp => resp, err => { this.handleRequestError(err, {orgId, url}); return null; } ) ); const directRequests = directUrls.map(url => { if (!url) { return Promise.resolve(null); } return this.api.requestPromise(url).then( resp => resp, (err: ResponseMeta) => { // No need to log 404 errors if (err && err.status === 404) { return null; } this.handleRequestError(err, {orgId, url}); return null; } ); }); this.handleSearchRequest(searchRequests, directRequests); }, 150); handleRequestError = (err: ResponseMeta, {url, orgId}) => { Sentry.withScope(scope => { scope.setExtra( 'url', url.replace(`/organizations/${orgId}/`, '/organizations/:orgId/') ); Sentry.captureException( new Error(`API Source Failed: ${err?.responseJSON?.detail}`) ); }); }; // Handles a list of search request promises, and then updates state with response objects async handleSearchRequest( searchRequests: Promise[], directRequests: Promise[] ) { const {searchOptions} = this.props; // Note we don't wait for all requests to resolve here (e.g. `await Promise.all(reqs)`) // so that we can start processing before all API requests are resolved // // This isn't particularly helpful in its current form because we still wait for all requests to finish before // updating state, but you could potentially optimize rendering direct results before all requests are finished. const [ organizations, projects, teams, members, plugins, integrations, sentryApps, docIntegrations, ] = searchRequests; const [shortIdLookup, eventIdLookup] = directRequests; const [searchResults, directResults] = await Promise.all([ this.getSearchableResults([ organizations, projects, teams, members, plugins, integrations, sentryApps, docIntegrations, ]), this.getDirectResults([shortIdLookup, eventIdLookup]), ]); // TODO(XXX): Might consider adding logic to maintain consistent ordering // of results so things don't switch positions const fuzzy = await createFuzzySearch(searchResults, { ...searchOptions, keys: ['title', 'description'], getFn: strGetFn, }); this.setState({ loading: false, fuzzy, directResults, }); } // Process API requests that create result objects that should be searchable async getSearchableResults(requests) { const {params, organization} = this.props; const orgId = (params && params.orgId) || (organization && organization.slug); const [ organizations, projects, teams, members, plugins, integrations, sentryApps, docIntegrations, ] = requests; const searchResults = flatten( await Promise.all([ createOrganizationResults(organizations), createProjectResults(projects, orgId), createTeamResults(teams, orgId), createMemberResults(members, orgId), createIntegrationResults(integrations, orgId), createPluginResults(plugins, orgId), createSentryAppResults(sentryApps, orgId), createDocIntegrationResults(docIntegrations, orgId), ]) ); return searchResults; } // Create result objects from API requests that do not require fuzzy search // i.e. these responses only return 1 object or they should always be displayed regardless of query input async getDirectResults(requests: Promise[]): Promise { const [shortIdLookup, eventIdLookup] = requests; const directResults = ( await Promise.all([ createShortIdLookupResult(shortIdLookup), createEventIdLookupResult(eventIdLookup), ]) ).filter(defined); if (!directResults.length) { return []; } return directResults; } render() { const {children, query} = this.props; const {fuzzy, directResults} = this.state; const results = fuzzy?.search(query) ?? []; return children({ isLoading: this.state.loading, results: flatten([results, directResults].filter(defined)) || [], }); } } export {ApiSource}; export default withLatestContext(withRouter(ApiSource));