@atlaskit/global-search
Version:
A cross-product search component (batteries included)
313 lines (270 loc) • 8.7 kB
JavaScript
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;
}