UNPKG

@atlaskit/global-search

Version:

A cross-product search component (batteries included)

480 lines (429 loc) 18.1 kB
import _extends from "@babel/runtime/helpers/extends"; import _defineProperty from "@babel/runtime/helpers/defineProperty"; import React from 'react'; import { injectIntl, FormattedHTMLMessage } from 'react-intl'; // @ts-ignore import { withAnalytics } from '@atlaskit/analytics'; import { FilterType } from '../../api/CrossProductSearchClient'; import { Scope } from '../../api/types'; import { SearchScreenCounter } from '../../util/ScreenCounter'; import { ConfluenceAdvancedSearchTypes, redirectToConfluenceAdvancedSearch, handlePromiseError, ADVANCED_CONFLUENCE_SEARCH_RESULT_ID } from '../SearchResultsUtil'; import performanceNow from '../../util/performance-now'; import { BaseConfluenceQuickSearchContainer } from '../common/QuickSearchContainer'; import { messages } from '../../messages'; import NoResultsState from './NoResultsState'; import SearchResultsComponent from '../common/SearchResults'; import { getConfluenceAdvancedSearchLink } from '../SearchResultsUtil'; import AdvancedSearchGroup from './AdvancedSearchGroup'; import { mapRecentResultsToUIGroups, mapSearchResultsToUIGroups, MAX_RECENT_RESULTS_TO_SHOW } from './ConfluenceSearchResultsMapper'; import { CONF_MAX_DISPLAYED_RESULTS } from '../../util/experiment-utils'; import { appendListWithoutDuplication } from '../../util/search-results-utils'; import { buildConfluenceModelParams } from '../../util/model-parameters'; import ConfluenceFilterGroup from './ConfluenceFilterGroup'; import NoResultsInFilterState from './NoResultsInFilterState'; import some from 'lodash/some'; import { injectFeatures } from '../FeaturesProvider'; const getRecentItemMatches = (query, recentItems) => { if (!recentItems) { return []; } return recentItems.objects.items.filter(result => { return result.name.toLowerCase().indexOf(query.toLowerCase()) > -1; }).slice(0, MAX_RECENT_RESULTS_TO_SHOW); }; const mergeSearchResultsWithRecentItems = (searchResults, recentItems) => { const defaultSearchResults = { objects: { items: [], totalSize: 0 }, spaces: { items: [], totalSize: 0 }, people: { items: [], totalSize: 0 } }; const results = { ...defaultSearchResults, ...searchResults }; return { objects: { items: appendListWithoutDuplication(recentItems, results.objects.items), // We don't add the 3 extra results to the total. // The rationale here is that the server results will eventually contain the 3 recent items // so the total here already includes the recent items. // In the case where we don't know the number from the server, we also can't show more so // this numeber should just be the size of the current list. totalSize: results.objects.totalSize, numberOfCurrentItems: results.objects.numberOfCurrentItems }, spaces: results.spaces, people: results.people }; }; const LOGGER_NAME = 'AK.GlobalSearch.ConfluenceQuickSearchContainer'; const CCS_AUTOCOMPLETE = 'ccsearch-autocomplete'; /** * Container Component that handles the data fetching when the user interacts with Search. */ export class ConfluenceQuickSearchContainer extends React.Component { constructor(...args) { super(...args); _defineProperty(this, "screenCounters", { preQueryScreenCounter: new SearchScreenCounter(), postQueryScreenCounter: new SearchScreenCounter() }); _defineProperty(this, "handleSearchSubmit", (event, searchSessionId) => { const { onAdvancedSearch = () => {} } = this.props; const target = event.target; const query = target.value; let defaultPrevented = false; onAdvancedSearch(Object.assign({}, event, { preventDefault() { defaultPrevented = true; event.preventDefault(); event.stopPropagation(); }, stopPropagation() {} }), ConfluenceAdvancedSearchTypes.Content, query, searchSessionId); if (!defaultPrevented) { redirectToConfluenceAdvancedSearch(query); } }); _defineProperty(this, "handleSearchErrorAnalyticsThunk", source => error => { this.handleSearchErrorAnalytics(error, source); this.props.logger.safeError(LOGGER_NAME, `error in promise ${source}`, error); }); _defineProperty(this, "getSearchResults", (query, sessionId, startTime, queryVersion, filters) => { const confXpSearchPromise = this.searchCrossProductConfluence(query, sessionId, queryVersion, filters); confXpSearchPromise.catch(this.handleSearchErrorAnalyticsThunk('xpsearch-confluence')); const mapPromiseToPerformanceTime = p => p.then(() => performanceNow() - startTime); return Promise.all([confXpSearchPromise, mapPromiseToPerformanceTime(confXpSearchPromise)]).then(([xpsearchResults, confSearchElapsedMs]) => { const spaces = xpsearchResults.results[Scope.ConfluenceSpace]; const objects = xpsearchResults.results[Scope.ConfluencePageBlogAttachment]; const people = xpsearchResults.results[Scope.People]; return { results: { objects: { items: objects ? objects.items : [], totalSize: objects ? objects.totalSize : 0 }, spaces: { items: spaces ? spaces.items : [], totalSize: spaces ? spaces.totalSize : 0 }, people: { items: people ? people.items : [], totalSize: people ? people.totalSize : 0 } }, timings: { confSearchElapsedMs } }; }); }); _defineProperty(this, "getRecentPeople", sessionId => { const { peopleSearchClient, crossProductSearchClient, features, referralContextIdentifiers } = this.props; const referrerId = referralContextIdentifiers && referralContextIdentifiers.searchReferrerId; // We want to be consistent with the search results when prefetching is enabled so we will use URS (via aggregator) to get the // bootstrapped people results, see prefetchResults.ts. return !features.useUrsForBootstrapping ? peopleSearchClient.getRecentPeople() : crossProductSearchClient.getPeople({ query: '', sessionId, referrerId, currentQuickSearchContext: 'confluence', resultLimit: 3 }).then(xProductResult => { const recentPeople = xProductResult.results[Scope.UserConfluence]; return recentPeople ? recentPeople.items : []; }); }); _defineProperty(this, "getRecentItems", sessionId => { const { confluenceClient } = this.props; const recentActivityPromisesMap = { 'recent-confluence-items': confluenceClient.getRecentItems(), 'recent-confluence-spaces': confluenceClient.getRecentSpaces() }; const recentActivityPromises = Object.keys(recentActivityPromisesMap).map(key => handlePromiseError(recentActivityPromisesMap[key], [], this.handleSearchErrorAnalyticsThunk(key))); // NOTE: // We lose type safety here as typescript assumes there's no guarantee the order in which a map // gets converted into promises. Also there is currently no way (and no way in the forseeable future) // to get typescript to convert union types into tuple types (https://github.com/Microsoft/TypeScript/issues/13298) const required = Promise.all(recentActivityPromises).then(([recentlyViewedPages, recentlyViewedSpaces]) => { return { results: { objects: { items: recentlyViewedPages, totalSize: recentlyViewedPages.length }, spaces: { items: recentlyViewedSpaces, totalSize: recentlyViewedSpaces.length }, people: { items: [], totalSize: 0 } } }; }); return { eagerRecentItemsPromise: required, lazyLoadedRecentItemsPromise: this.getRecentPeople(sessionId).then(recentPeople => ({ people: { items: recentPeople, totalSize: recentPeople.length } })) }; }); _defineProperty(this, "getAutocompleteSuggestions", query => { const { autocompleteClient, features } = this.props; if (features.isNavAutocompleteEnabled) { const { crossProductSearchClient } = this.props; const navAutocompletePromise = handlePromiseError(crossProductSearchClient.getNavAutocompleteSuggestions(query), [query], this.handleSearchErrorAnalyticsThunk(CCS_AUTOCOMPLETE)); return navAutocompletePromise; } else if (features.isAutocompleteEnabled) { const autocompletePromise = handlePromiseError(autocompleteClient.getAutocompleteSuggestions(query), [query], this.handleSearchErrorAnalyticsThunk(CCS_AUTOCOMPLETE)); return autocompletePromise; } return Promise.resolve([]); }); _defineProperty(this, "getPreQueryDisplayedResults", (recentItems, searchSessionId) => { const { features } = this.props; return mapRecentResultsToUIGroups(recentItems, features, searchSessionId); }); _defineProperty(this, "getPostQueryDisplayedResults", (searchResults, latestSearchQuery, recentItems, isLoading, searchSessionId) => { const { features } = this.props; const currentSearchResults = isLoading || !searchResults ? {} : searchResults; const recentResults = getRecentItemMatches(latestSearchQuery, recentItems); const mergedRecentSearchResults = mergeSearchResultsWithRecentItems(currentSearchResults, recentResults); return mapSearchResultsToUIGroups(mergedRecentSearchResults, features, searchSessionId, isLoading // Hide the lozenge for the faster search screen ); }); _defineProperty(this, "getNoResultsStateComponent", (latestSearchQuery, searchSessionId, currentFilters, onFilterChanged) => { const { onAdvancedSearch = () => {}, referralContextIdentifiers } = this.props; const filtersAreApplied = currentFilters.length > 0; if (filtersAreApplied && referralContextIdentifiers && referralContextIdentifiers.currentContainerName) { return /*#__PURE__*/React.createElement(NoResultsInFilterState, { spaceTitle: referralContextIdentifiers.currentContainerName, query: latestSearchQuery, onClickAdvancedSearch: (event, entity) => onAdvancedSearch(event, entity, latestSearchQuery, searchSessionId), onClearFilters: () => { onFilterChanged([]); } }); } return /*#__PURE__*/React.createElement(NoResultsState, { query: latestSearchQuery, onClick: (event, entity) => onAdvancedSearch(event, entity, latestSearchQuery, searchSessionId) }); }); _defineProperty(this, "getFilterComponent", searchResults => ({ latestSearchQuery, searchResultsTotalSize, isLoading, searchSessionId, currentFilters, onFilterChanged }) => { const { onAdvancedSearch = () => {}, features } = this.props; if (!features.spaceballsExperimentEnabled || searchResults === null) { return; } // only show filter post-query if (!latestSearchQuery) { return; } // don't show space filter if there are no results in all spaces if (currentFilters.length === 0 && searchResultsTotalSize === 0) { return; } const filterObject = this.createQueryBasedSpaceFilter(currentFilters, searchResults); if (filterObject && filterObject.filter.spaceKeys[0] && filterObject.metadata) { const { filter } = filterObject; const spaces = filter ? filter.spaceKeys : []; return /*#__PURE__*/React.createElement(ConfluenceFilterGroup, { onFilterChanged: onFilterChanged, isDisabled: isLoading, spaceTitle: filterObject.metadata.spaceTitle, spaceAvatar: filterObject.metadata.spaceAvatar, spaceKey: filter.spaceKeys[0], isFilterOn: currentFilters.length !== 0, searchSessionId: searchSessionId, onAdvancedSearch: event => onAdvancedSearch(event, ConfluenceAdvancedSearchTypes.Content, latestSearchQuery, searchSessionId, spaces) }); } }); _defineProperty(this, "createQueryBasedSpaceFilter", (currentFilters, searchResults) => { function instanceOfSpaceFilter(filter) { return filter.filter['@type'] === FilterType.Spaces; } function createSpaceFilter({ spaces }) { const space = spaces.items[0]; return space && space.key && space.avatarUrl ? { filter: { '@type': FilterType.Spaces, spaceKeys: [space.key] }, metadata: { spaceAvatar: space.avatarUrl, spaceTitle: space.name } } : undefined; } const existingSpaceFilter = currentFilters.find(instanceOfSpaceFilter); if (existingSpaceFilter) { return existingSpaceFilter; } return createSpaceFilter(searchResults); }); _defineProperty(this, "getSearchResultsComponent", ({ retrySearch, latestSearchQuery, isError, searchResults, isLoading, recentItems, searchSessionId, searchMore, currentFilters, onFilterChanged }) => { const { onAdvancedSearch = () => {} } = this.props; const onSearchMoreAdvancedSearchClicked = event => { onAdvancedSearch(event, ConfluenceAdvancedSearchTypes.Content, latestSearchQuery, searchSessionId); }; return /*#__PURE__*/React.createElement(SearchResultsComponent, _extends({ query: latestSearchQuery, isPreQuery: !latestSearchQuery, isError: isError, onFilterChanged: onFilterChanged, getFilterComponent: this.getFilterComponent(searchResults), isLoading: isLoading, retrySearch: retrySearch, searchMore: searchMore, currentFilters: currentFilters, onSearchMoreAdvancedSearchClicked: onSearchMoreAdvancedSearchClicked, keepPreQueryState: false, searchSessionId: searchSessionId }, this.screenCounters, { referralContextIdentifiers: this.props.referralContextIdentifiers, renderNoRecentActivity: () => /*#__PURE__*/React.createElement(FormattedHTMLMessage, _extends({}, messages.no_recent_activity_body, { values: { url: getConfluenceAdvancedSearchLink() } })), renderAdvancedSearchGroup: analyticsData => /*#__PURE__*/React.createElement(AdvancedSearchGroup, { key: "advanced", query: latestSearchQuery, analyticsData: analyticsData, onClick: (event, entity) => onAdvancedSearch(event, entity, latestSearchQuery, searchSessionId) }), getPreQueryGroups: () => this.getPreQueryDisplayedResults(recentItems, searchSessionId), getPostQueryGroups: () => this.getPostQueryDisplayedResults(searchResults, latestSearchQuery, recentItems, isLoading, searchSessionId), renderNoResult: () => this.getNoResultsStateComponent(latestSearchQuery, searchSessionId, currentFilters, onFilterChanged) })); }); } async searchCrossProductConfluence(query, sessionId, queryVersion, filters) { const { crossProductSearchClient, modelContext, referralContextIdentifiers } = this.props; let scopes = [Scope.ConfluencePageBlogAttachment]; if (!some(filters, filter => filter['@type'] === 'spaces')) { scopes = scopes.concat([Scope.ConfluenceSpace, Scope.People]); } const modelParams = buildConfluenceModelParams(queryVersion, modelContext || {}); const referrerId = referralContextIdentifiers && referralContextIdentifiers.searchReferrerId; const results = await crossProductSearchClient.search({ query, sessionId, referrerId, scopes, modelParams, resultLimit: CONF_MAX_DISPLAYED_RESULTS, filters }); return results; } // TODO extract handleSearchErrorAnalytics(error, source) { const { firePrivateAnalyticsEvent } = this.props; if (firePrivateAnalyticsEvent) { try { firePrivateAnalyticsEvent('atlassian.fabric.global-search.search-error', { name: error.name, message: error.message, source: source }); } catch (e) { this.props.logger.safeError(LOGGER_NAME, 'Can not fire event atlassian.fabric.global-search.search-error', e); } } } render() { const { linkComponent, logger, inputControls, features } = this.props; const { isAutocompleteEnabled } = features; const { isNavAutocompleteEnabled } = features; return /*#__PURE__*/React.createElement(BaseConfluenceQuickSearchContainer, { placeholder: this.props.intl.formatMessage(messages.confluence_search_placeholder), linkComponent: linkComponent, getSearchResultsComponent: this.getSearchResultsComponent, referralContextIdentifiers: this.props.referralContextIdentifiers, getRecentItems: this.getRecentItems, getSearchResults: this.getSearchResults, getAutocompleteSuggestions: isAutocompleteEnabled || isNavAutocompleteEnabled ? this.getAutocompleteSuggestions : undefined, product: "confluence", handleSearchSubmit: this.handleSearchSubmit, getPreQueryDisplayedResults: this.getPreQueryDisplayedResults, getPostQueryDisplayedResults: this.getPostQueryDisplayedResults, logger: logger, inputControls: inputControls, features: features, advancedSearchId: ADVANCED_CONFLUENCE_SEARCH_RESULT_ID }); } } export default injectFeatures(injectIntl(withAnalytics(ConfluenceQuickSearchContainer, {}, {})));