@atlaskit/mention
Version:
A React component used to display user profiles in a list for 'Mention' functionality
327 lines (318 loc) • 9.99 kB
JavaScript
import { utils as serviceUtils } from '@atlaskit/util-service-support';
import { isAppMention, isTeamMention, MentionNameStatus, SliNames, Actions } from '../types';
import debug from '../util/logger';
const MAX_QUERY_ITEMS = 100;
const MAX_NOTIFIED_ITEMS = 20;
// Re-exporting types to prevent breaking change
// Re-exporting types to prevent breaking change
import { SLI_EVENT_TYPE } from '../util/analytics';
import debounce from 'lodash/debounce';
/**
* Support
*/
const emptySecurityProvider = () => {
return {
params: {},
headers: {}
};
};
class AbstractResource {
constructor() {
this.changeListeners = new Map();
this.allResultsListeners = new Map();
this.errListeners = new Map();
this.infoListeners = new Map();
this.analyticsListeners = new Map();
}
subscribe(key, callback, errCallback, infoCallback, allResultsCallback, analyticsListeners) {
if (callback) {
this.changeListeners.set(key, callback);
}
if (errCallback) {
this.errListeners.set(key, errCallback);
}
if (infoCallback) {
this.infoListeners.set(key, infoCallback);
}
if (allResultsCallback) {
this.allResultsListeners.set(key, allResultsCallback);
}
if (analyticsListeners) {
this.analyticsListeners.set(key, analyticsListeners);
}
}
unsubscribe(key) {
this.changeListeners.delete(key);
this.errListeners.delete(key);
this.infoListeners.delete(key);
this.allResultsListeners.delete(key);
this.analyticsListeners.delete(key);
}
}
class AbstractMentionResource extends AbstractResource {
shouldHighlightMention(_mention) {
return false;
}
// eslint-disable-next-line class-methods-use-this
filter(query) {
throw new Error(`not yet implemented.\nParams: query=${query}`);
}
// eslint-disable-next-line class-methods-use-this, no-unused-vars
recordMentionSelection(_mention) {
// Do nothing
}
isFiltering(_query) {
return false;
}
_notifyListeners(mentionsResult, stats) {
debug('ak-mention-resource._notifyListeners', mentionsResult && mentionsResult.mentions && mentionsResult.mentions.length, this.changeListeners);
this.changeListeners.forEach((listener, key) => {
try {
listener(mentionsResult.mentions.slice(0, MAX_NOTIFIED_ITEMS), mentionsResult.query, stats);
} catch (e) {
// ignore error from listener
debug(`error from listener '${key}', ignoring`, e);
}
});
}
_notifyAllResultsListeners(mentionsResult) {
debug('ak-mention-resource._notifyAllResultsListeners', mentionsResult && mentionsResult.mentions && mentionsResult.mentions.length, this.changeListeners);
this.allResultsListeners.forEach((listener, key) => {
try {
listener(mentionsResult.mentions.slice(0, MAX_NOTIFIED_ITEMS), mentionsResult.query);
} catch (e) {
// ignore error from listener
debug(`error from listener '${key}', ignoring`, e);
}
});
}
_notifyErrorListeners(error, query) {
this.errListeners.forEach((listener, key) => {
try {
listener(error, query);
} catch (e) {
// ignore error from listener
debug(`error from listener '${key}', ignoring`, e);
}
});
}
_notifyInfoListeners(info) {
this.infoListeners.forEach((listener, key) => {
try {
listener(info);
} catch (e) {
// ignore error fromr listener
debug(`error from listener '${key}', ignoring`, e);
}
});
}
_notifyAnalyticsListeners(event, actionSubject, action, attributes) {
this.analyticsListeners.forEach((listener, key) => {
try {
listener(event, actionSubject, action, attributes);
} catch (e) {
// ignore error from listener
debug(`error from listener '${key}', ignoring`, e);
}
});
}
}
/**
* Provides a Javascript API
*/
export class MentionResource extends AbstractMentionResource {
constructor(config) {
super();
this.verifyMentionConfig(config);
this.config = config;
this.lastReturnedSearch = 0;
this.activeSearches = new Set();
this.productName = config.productName;
this.shouldEnableInvite = !!config.shouldEnableInvite;
this.onInviteItemClick = config.onInviteItemClick;
this.inviteXProductUser = config.inviteXProductUser;
this.userRole = config.userRole || 'basic';
if (this.config.debounceTime) {
this.filter = debounce(this.filter, this.config.debounceTime);
}
}
shouldHighlightMention(mention) {
if (this.config.shouldHighlightMention) {
return this.config.shouldHighlightMention(mention);
}
return false;
}
notify(searchTime, mentionResult, query) {
if (searchTime > this.lastReturnedSearch) {
this.lastReturnedSearch = searchTime;
this._notifyListeners(mentionResult, {
duration: Date.now() - searchTime
});
} else {
const date = new Date(searchTime).toISOString().substr(17, 6);
debug('Stale search result, skipping', date, query); // eslint-disable-line no-console, max-len
}
this._notifyAllResultsListeners(mentionResult);
}
notifyError(error, query) {
this._notifyErrorListeners(error, query);
if (query) {
this.activeSearches.delete(query);
}
}
async filter(query, contextIdentifier) {
try {
const searchTime = Date.now();
let results;
if (!query) {
results = await this.initialState(contextIdentifier);
} else {
this.activeSearches.add(query);
const searchResponse = this.search(query, contextIdentifier);
results = await searchResponse.mentions;
}
this.notify(searchTime, results, query);
} catch (error) {
this.notifyError(error, query);
}
}
isFiltering(query) {
return this.activeSearches.has(query);
}
resolveMentionName(id) {
if (!this.config.mentionNameResolver) {
return {
id,
name: '',
status: MentionNameStatus.UNKNOWN
};
}
return this.config.mentionNameResolver.lookupName(id);
}
cacheMentionName(id, mentionName) {
if (!this.config.mentionNameResolver) {
return;
}
this.config.mentionNameResolver.cacheName(id, mentionName);
}
supportsMentionNameResolving() {
return !!this.config.mentionNameResolver;
}
updateActiveSearches(query) {
this.activeSearches.add(query);
}
verifyMentionConfig(config) {
if (!config.url) {
throw new Error('config.url is a required parameter');
}
if (!config.securityProvider) {
config.securityProvider = emptySecurityProvider;
}
}
initialState(contextIdentifier) {
return this.remoteInitialState(contextIdentifier);
}
/**
* Clear a context object to generate query params by removing empty
* strings, `undefined` and empty values.
*
* @param contextIdentifier the current context identifier
* @returns a safe context for query encoding
*/
clearContext(contextIdentifier = {}) {
return Object.keys(contextIdentifier).filter(key => contextIdentifier[key]).reduce((context, key) => ({
[key]: contextIdentifier[key],
...context
}), {});
}
getQueryParams(contextIdentifier) {
const configParams = {};
if (this.config.containerId) {
configParams['containerId'] = this.config.containerId;
}
if (this.config.productId) {
configParams['productIdentifier'] = this.config.productId;
}
// if contextParams exist then it will override configParams for containerId
return {
...configParams,
...this.clearContext(contextIdentifier)
};
}
/**
* Returns the initial mention display list before a search is performed for the specified
* container.
*
* @param contextIdentifier
* @returns Promise
*/
async remoteInitialState(contextIdentifier) {
const queryParams = this.getQueryParams(contextIdentifier);
const options = {
path: 'bootstrap',
queryParams
};
try {
const result = await serviceUtils.requestService(this.config, options);
this._notifyAnalyticsListeners(SLI_EVENT_TYPE, SliNames.INITIAL_STATE, Actions.SUCCEEDED);
return this.transformServiceResponse(result, '');
} catch (error) {
this._notifyAnalyticsListeners(SLI_EVENT_TYPE, SliNames.INITIAL_STATE, Actions.FAILED);
throw error;
}
}
search(query, contextIdentifier) {
return {
mentions: this.remoteSearch(query, contextIdentifier)
};
}
async remoteSearch(query, contextIdentifier) {
const options = {
path: 'search',
queryParams: {
query,
limit: MAX_QUERY_ITEMS,
...this.getQueryParams(contextIdentifier)
}
};
try {
const result = await serviceUtils.requestService(this.config, options);
this._notifyAnalyticsListeners(SLI_EVENT_TYPE, SliNames.SEARCH, Actions.SUCCEEDED);
return this.transformServiceResponse(result, query);
} catch (error) {
this._notifyAnalyticsListeners(SLI_EVENT_TYPE, SliNames.SEARCH, Actions.FAILED);
throw error;
}
}
transformServiceResponse(result, query) {
const mentions = result.mentions.map(mention => {
let lozenge;
if (isAppMention(mention)) {
lozenge = mention.userType;
} else if (isTeamMention(mention)) {
lozenge = mention.userType;
}
return {
...mention,
lozenge,
query
};
});
return {
...result,
mentions,
query: result.query || query
};
}
}
export class HttpError {
constructor(statusCode, statusMessage) {
this.statusCode = statusCode;
this.message = statusMessage;
this.name = 'HttpError';
this.stack = new Error().stack;
}
}
export const isResolvingMentionProvider = p => !!(p && p.supportsMentionNameResolving && p.supportsMentionNameResolving());
export { AbstractResource, AbstractMentionResource };
export default MentionResource;