123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521 |
- 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<Organization[]>
- ): Promise<ResultItem[]> {
- 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<Project[]>,
- orgId?: string
- ): Promise<ResultItem[]> {
- 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<Team[]>,
- orgId?: string
- ): Promise<ResultItem[]> {
- 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<Member[]>,
- orgId?: string
- ): Promise<ResultItem[]> {
- 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<PluginWithProjectList[]>,
- orgId?: string
- ): Promise<ResultItem[]> {
- 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: (
- <span
- dangerouslySetInnerHTML={{
- __html: markedSingleLine(plugin.description ?? ''),
- }}
- />
- ),
- model: plugin,
- sourceType: 'plugin',
- resultType: 'integration',
- to: `/settings/${orgId}/plugins/${plugin.id}/`,
- }));
- }
- async function createIntegrationResults(
- integrationsPromise: Promise<{providers: IntegrationProvider[]}>,
- orgId?: string
- ): Promise<ResultItem[]> {
- const {providers} = (await integrationsPromise) || {};
- return (
- (providers &&
- providers.map(provider => ({
- title: provider.name,
- description: (
- <span
- dangerouslySetInnerHTML={{
- __html: markedSingleLine(provider.metadata.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<SentryApp[]>,
- orgId?: string
- ): Promise<ResultItem[]> {
- const sentryApps = (await sentryAppPromise) || [];
- return sentryApps.map(sentryApp => ({
- title: sentryApp.name,
- description: (
- <span
- dangerouslySetInnerHTML={{
- __html: markedSingleLine(sentryApp.overview || ''),
- }}
- />
- ),
- model: sentryApp,
- sourceType: 'sentryApp',
- resultType: 'sentryApp',
- to: `/settings/${orgId}/sentry-apps/${sentryApp.slug}/`,
- }));
- }
- async function createDocIntegrationResults(
- docIntegrationPromise: Promise<DocIntegration[]>,
- orgId?: string
- ): Promise<ResultItem[]> {
- const docIntegrations = (await docIntegrationPromise) || [];
- return docIntegrations.map(docIntegration => ({
- title: docIntegration.name,
- description: (
- <span
- dangerouslySetInnerHTML={{
- __html: markedSingleLine(docIntegration.description || ''),
- }}
- />
- ),
- model: docIntegration,
- sourceType: 'docIntegration',
- resultType: 'docIntegration',
- to: `/settings/${orgId}/document-integrations/${docIntegration.slug}/`,
- }));
- }
- async function createShortIdLookupResult(
- shortIdLookupPromise: Promise<ShortIdResponse>
- ): Promise<Result | null> {
- 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<EventIdResponse>
- ): Promise<Result | null> {
- 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<ResultItem>;
- };
- type State = {
- directResults: null | Result[];
- fuzzy: null | Fuse<ResultItem>;
- loading: boolean;
- searchResults: null | Result[];
- };
- class ApiSource extends Component<Props, State> {
- 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<ResultItem[]>[],
- directRequests: Promise<Result | null>[]
- ) {
- 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<any>[]): Promise<Result[]> {
- 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));
|