UNPKG

@atlaskit/global-search

Version:

A cross-product search component (batteries included)

313 lines (270 loc) 8.7 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; import { ResultType, AnalyticsType, ContentType } from '../model/Result'; import { mapJiraItemToResult } from './JiraItemMapper'; import { mapConfluenceItemToResult } from './ConfluenceItemMapper'; import { utils } from '@atlaskit/util-service-support'; import { Scope } from './types'; export const DEFAULT_AB_TEST = Object.freeze({ experimentId: 'default', abTestId: 'default', controlId: 'default' }); const QUICKSEARCH_API_URL = 'quicksearch/v1'; export const EMPTY_CROSS_PRODUCT_SEARCH_RESPONSE = { results: {} }; export let FilterType; (function (FilterType) { FilterType["Spaces"] = "spaces"; FilterType["Contributors"] = "contributors"; })(FilterType || (FilterType = {})); export default class CachingCrossProductSearchClientImpl { // result limit per scope constructor(url, cloudId, isUserAnonymous, prefetchResults) { _defineProperty(this, "RESULT_LIMIT", 10); this.serviceConfig = { url: url }; this.cloudId = cloudId; this.isUserAnonymous = isUserAnonymous; this.abTestDataCache = prefetchResults ? prefetchResults.abTestPromise : {}; this.crossProductRecentsCache = prefetchResults ? prefetchResults.crossProductRecentItemsPromise : undefined; } async getNavAutocompleteSuggestions(query) { const path = 'quicksearch/v1'; const results = await this.makeRequest(path, { cloudId: this.cloudId, scopes: [Scope.NavSearchCompleteConfluence], query }); const matchingScope = results.scopes.find(scope => scope.id === Scope.NavSearchCompleteConfluence); const matchingDocuments = matchingScope ? matchingScope.results : []; return matchingDocuments.map(mapItemToNavCompletionString); } async getPeople({ query, sessionId, referrerId, currentQuickSearchContext, resultLimit = 3 }) { const isBootstrapQuery = !query; // We will use the bootstrap people cache if the query is a bootstrap query and there is a result cached if (isBootstrapQuery && this.bootstrapPeopleCache) { return this.bootstrapPeopleCache; } const scope = currentQuickSearchContext === 'confluence' ? Scope.UserConfluence : currentQuickSearchContext === 'jira' ? Scope.UserJira : null; if (scope) { const searchPromise = this.search({ query, sessionId, referrerId, scopes: [scope], modelParams: [], resultLimit }); if (isBootstrapQuery) { this.bootstrapPeopleCache = searchPromise; } return searchPromise; } return { results: {} }; } async search({ query, sessionId, referrerId, scopes, modelParams, resultLimit = this.RESULT_LIMIT, filters = [], mapItemToResult = postQueryMapItemToResult }) { const body = { query: query, cloudId: this.cloudId, limit: resultLimit, scopes, filters: filters, searchSession: { sessionId, referrerId }, ...(modelParams.length > 0 ? { modelParams } : {}) }; const response = await this.makeRequest(QUICKSEARCH_API_URL, body); return this.parseResponse(response, mapItemToResult); } async getRecentItems({ context, modelParams, resultLimit = this.RESULT_LIMIT, filters = [], mapItemToResult }) { if (this.isUserAnonymous) { return EMPTY_CROSS_PRODUCT_SEARCH_RESPONSE; } const scopes = mapContextToScopes(context); if (this.crossProductRecentsCache) { const recents = await this.crossProductRecentsCache; if (areAllScopesInCache(scopes, recents)) { return { results: recents }; } } const body = { query: '', cloudId: this.cloudId, limit: resultLimit, scopes, filters: filters, ...(modelParams.length > 0 ? { modelParams } : {}) }; const response = await this.makeRequest(QUICKSEARCH_API_URL, body); return this.parseResponse(response, mapItemToResult); } async getAbTestDataForProduct(product) { let scope; switch (product) { case 'confluence': scope = Scope.ConfluencePageBlogAttachment; break; case 'jira': scope = Scope.JiraIssue; break; default: throw new Error('Invalid product for abtest'); } return await this.getAbTestData(scope); } /** * @deprecated use {getAbTestDataForProduct} instead. Using manually defined scopes here can * break caching behaviour. * * This will be moved into private scope in the near future. */ async getAbTestData(scope) { if (this.abTestDataCache[scope]) { return this.abTestDataCache[scope]; } const path = 'experiment/v1'; const body = { cloudId: this.cloudId, scopes: [scope] }; const response = await this.makeRequest(path, body); const scopeWithAbTest = response.scopes.find(s => s.id === scope); const abTestPromise = scopeWithAbTest ? Promise.resolve(scopeWithAbTest.abTest) : Promise.resolve(DEFAULT_AB_TEST); this.abTestDataCache[scope] = abTestPromise; return abTestPromise; } async makeRequest(path, body) { const options = { path, requestInit: { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } }; return utils.requestService(this.serviceConfig, options); } /** * Converts the raw xpsearch-aggregator response into a CrossProductSearchResults object containing * the results set and the experimentId that generated them. * * @param response * @param searchSessionId * @returns a CrossProductSearchResults object */ parseResponse(response, mapItemToResult) { let abTest; const results = response.scopes.filter(scope => scope.results).reduce((resultsMap, scopeResult) => { const items = scopeResult.results.map(result => mapItemToResult(scopeResult.id, result)); // mapItemToResult returns a generic result type, technically we can't guarantee that the // type returned by `mapItemToResult` can be coerced into the expected type, e.g. there's // no guarantee the `Result` can be casted to `ConfluenceObjectResult`. We just make the assumption // here for now and suppress the typescript error resultsMap[scopeResult.id] = { // @ts-ignore items, totalSize: scopeResult.size !== undefined ? scopeResult.size : items.length }; if (!abTest) { abTest = scopeResult.abTest; } return resultsMap; }, {}); return { results, abTest }; } } function mapPersonItemToResult(item) { const mention = item.nickname || item.name; return { resultType: ResultType.PersonResult, resultId: 'people-' + item.account_id, name: item.name, href: '/people/' + item.account_id, avatarUrl: item.picture, contentType: ContentType.Person, analyticsType: AnalyticsType.ResultPerson, mentionName: mention, presenceMessage: item.job_title || '' }; } function mapUrsResultItemToResult(item) { return { resultType: ResultType.PersonResult, resultId: 'people-' + item.id, name: item.name, href: '/people/' + item.id, avatarUrl: item.avatarUrl, contentType: ContentType.Person, analyticsType: AnalyticsType.ResultPerson, mentionName: item.nickname || '', presenceMessage: '' }; } function postQueryMapItemToResult(scope, item) { if (scope.startsWith('confluence')) { return mapConfluenceItemToResult(scope, item); } if (scope.startsWith('jira')) { return mapJiraItemToResult(AnalyticsType.ResultJira)(item); } if (scope === Scope.People) { return mapPersonItemToResult(item); } if (scope === Scope.UserConfluence || scope === Scope.UserJira) { return mapUrsResultItemToResult(item); } if (scope === Scope.NavSearchCompleteConfluence) { throw new Error('nav.completion-confluence cannot be transformed into a result because it is not a search result'); } throw new Error(`Non-exhaustive match for scope: ${scope}`); } function mapItemToNavCompletionString(item) { const completionItem = item; return completionItem.query; } function mapContextToScopes(context) { if (context === 'jira') { return [Scope.JiraIssue, Scope.JiraBoardProjectFilter]; } else { throw new Error(`Supplied contet ${context} is not supported for pre-fetching`); } } function areAllScopesInCache(scopes, cache) { return scopes.filter(scope => cache[scope] === undefined).length === 0; }