PageRenderTime 65ms CodeModel.GetById 34ms RepoModel.GetById 1ms app.codeStats 0ms

/jira-project/jira-components/jira-webapp/src/main/webapp/includes/jira/mention/Mention.js

https://bitbucket.org/ahmed_bilal_360factors/jira7-core
JavaScript | 985 lines | 873 code | 53 blank | 59 comment | 44 complexity | ad5abbbf21eb98e23c9e516a3bd5ba58 MD5 | raw file
Possible License(s): Apache-2.0
  1. define('jira/mention/mention', [
  2. 'jira/ajs/control',
  3. 'jira/dialog/dialog',
  4. 'jira/mention/mention-user',
  5. 'jira/mention/mention-group',
  6. 'jira/mention/mention-matcher',
  7. 'jira/mention/scroll-pusher',
  8. 'jira/mention/contextual-mention-analytics-event',
  9. 'jira/mention/uncomplicated-inline-layer',
  10. 'jira/ajs/layer/inline-layer/standard-positioning',
  11. 'jira/ajs/dropdown/dropdown-list-item',
  12. 'jira/util/events',
  13. 'jira/util/navigator',
  14. 'aui/progressive-data-set',
  15. 'jquery',
  16. 'underscore',
  17. 'wrm/context-path',
  18. 'jira/util/logger'
  19. ], function (Control,
  20. Dialog,
  21. UserModel,
  22. MentionGroup,
  23. MentionMatcher,
  24. ScrollPusher,
  25. ContextualMentionAnalyticsEvent,
  26. UncomplicatedInlineLayer,
  27. InlineLayerStandardPositioning,
  28. DropdownListItem,
  29. Events,
  30. Navigator,
  31. ProgressiveDataSet,
  32. jQuery,
  33. _,
  34. contextPath,
  35. logger) {
  36. "use strict";
  37. var mentioning = false;
  38. var seedData = {};
  39. /**
  40. * Gets the specified attribute from an HTML element.
  41. * @param {jQuery|HTMLElement} element
  42. * @param {String} attribute - name of the attribute
  43. */
  44. function attributeOf(element, attribute) {
  45. var ret = '';
  46. var el;
  47. if (element) {
  48. el = (element instanceof jQuery) ? element.get(0) : element;
  49. ret = el.querySelector('a').getAttribute(attribute);
  50. }
  51. return ret;
  52. }
  53. function idOf(element) {
  54. return attributeOf(element, 'rel');
  55. }
  56. function isMentioning() {
  57. return mentioning;
  58. }
  59. function comparator(a, b) {
  60. var result = compareMaybeUndefined(
  61. a.get('highestIssueInvolvementRank'),
  62. b.get('highestIssueInvolvementRank'),
  63. function (a, b) { return a - b; }
  64. );
  65. if(result === 0) {
  66. result = compareMaybeUndefined(
  67. a.get('latestCommentCreationTime'),
  68. b.get('latestCommentCreationTime'),
  69. // We negate the comparator result because we want descending
  70. // time ordering and the default is ascending
  71. function (a, b) { return b - a; }
  72. );
  73. if (result === 0) {
  74. result = a.get('displayName').localeCompare(b.get('displayName'));
  75. }
  76. }
  77. return result;
  78. }
  79. // Compares two values which may or may not be undefined
  80. function compareMaybeUndefined(a, b, comparator) {
  81. if (a !== undefined) {
  82. return (b !== undefined) ? comparator(a, b) : -1;
  83. } else {
  84. return (b !== undefined) ? 1 : 0;
  85. }
  86. }
  87. function isRolesEnabled(issueKey) {
  88. return !!issueKey;
  89. }
  90. /**
  91. * Chooses the appropriate REST endpoint for retrieving mentionable users
  92. * based on a few static criterion.
  93. *
  94. * @param {String} [issueKey] the key for the issue. If empty, assumes no issue exists,
  95. * which means we fall back to a generic list of users with a browse permission.
  96. *
  97. * @returns {Object} the URL the mention controller should hit.
  98. */
  99. function getDataSourceConfig(issueKey) {
  100. var config = {
  101. model: UserModel,
  102. comparator: comparator
  103. };
  104. if (isRolesEnabled(issueKey)) {
  105. config.queryEndpoint = contextPath() + "/rest/internal/2/user/mention/search";
  106. config.queryParamKey = "query";
  107. } else {
  108. config.queryEndpoint = contextPath() + "/rest/api/2/user/viewissue/search";
  109. config.queryParamKey = "username";
  110. }
  111. return config;
  112. }
  113. /**
  114. * Provides autocomplete for username mentions in textareas.
  115. *
  116. * @class Mention
  117. * @extends Control
  118. */
  119. return Control.extend({
  120. CLASS_SIGNATURE: "AJS_MENTION",
  121. lastInvalidUsername: "",
  122. lastRequestMatch: true,
  123. lastValidUsername: "",
  124. init: function (issueKey) {
  125. var config = getDataSourceConfig(issueKey);
  126. this.listController = new MentionGroup();
  127. this.isRolesEnabled = isRolesEnabled(issueKey);
  128. this.dataSource = new ProgressiveDataSet([], _.extend({
  129. queryData: this._getQueryParams.bind(this),
  130. matcher: this._matcher.bind(this)
  131. }, config));
  132. this.dataSource.bind('respond', function (response) {
  133. var results = response.results;
  134. var username = response.query;
  135. if (!username) {
  136. return;
  137. }
  138. if (!isMentioning()) {
  139. return;
  140. }
  141. // Update the state of mentions matches
  142. if (!results.length) {
  143. if (username) {
  144. if (this.dataSource.hasQueryCache(username)) {
  145. if (!this.lastInvalidUsername || username.length <= this.lastInvalidUsername.length) {
  146. this.lastInvalidUsername = username;
  147. }
  148. }
  149. }
  150. this.lastRequestMatch = false;
  151. } else {
  152. this.lastInvalidUsername = "";
  153. this.lastValidUsername = username;
  154. this.lastRequestMatch = true;
  155. }
  156. // Set the results
  157. var $suggestions = this.generateSuggestions(results, username);
  158. this.updateSuggestions($suggestions);
  159. logger.trace("mention.suggestions.updated");
  160. }.bind(this));
  161. this.dataSource.bind('activity', function (response) {
  162. if (response.activity) {
  163. this.layerController._showLoading();
  164. } else {
  165. this.layerController._hideLoading();
  166. }
  167. }.bind(this));
  168. },
  169. updateSuggestions: function ($suggestions) {
  170. if (this.layerController) {
  171. this.layerController.content($suggestions);
  172. this.layerController.show();
  173. this.layerController.refreshContent();
  174. }
  175. },
  176. _getQueryParams: function () {
  177. return this.restParams;
  178. },
  179. _setQueryParams: function () {
  180. var params = {
  181. issueKey: this.$textarea.attr("data-issuekey"),
  182. projectKey: this.$textarea.attr("data-projectkey"),
  183. maxResults: 10
  184. };
  185. if (Dialog.current && Dialog.current.options.id === "create-issue-dialog") {
  186. delete params.issueKey;
  187. }
  188. this.restParams = params;
  189. },
  190. /**
  191. * Creates a custom event for follow-scroll attribute.
  192. * This custom event will call setPosition() when the element referenced in "textarea[follow-scroll]" attribute
  193. *
  194. * @param customEvents
  195. * @returns {*}
  196. * @private
  197. */
  198. _composeCustomEventForFollowScroll: function (customEvents) {
  199. customEvents = customEvents || {};
  200. var followScroll = this.$textarea.attr("follow-scroll");
  201. if (followScroll && followScroll.length) {
  202. customEvents[followScroll] = {
  203. "scroll": function () {
  204. this.setPosition();
  205. }
  206. };
  207. }
  208. return customEvents;
  209. },
  210. _matcher: function (model, query) {
  211. return (
  212. this._stringPartStartsWith(model.get("name"), query) ||
  213. this._stringPartStartsWith(model.get("displayName"), query) ||
  214. _.chain(model.get("issueInvolvements"))
  215. // We only match against the assignee and reporter involvement types
  216. .filter(function (i) { return i.id === "assignee" || i.id === "reporter"; })
  217. .any(function (i) { return i.label.indexOf(query) === 0; })
  218. .value()
  219. );
  220. },
  221. _getOffsetTarget: function () {
  222. return this.textarea();
  223. },
  224. _setMentioning: function (m) {
  225. mentioning = m;
  226. },
  227. textarea: function (textarea) {
  228. var instance = this;
  229. if (textarea) {
  230. this.$textarea = jQuery(textarea);
  231. jQuery("#mentionDropDown").remove();
  232. if (this.$textarea.attr("push-scroll")) {
  233. /**
  234. * If we are pushing the scroll, force the layer to use standard positioning. Otherwise
  235. * it might end using {@see WindowPositioning} that conflicts with the intention of
  236. * pushing scroll
  237. */
  238. var positioningController = new InlineLayerStandardPositioning();
  239. var scrollPusher = ScrollPusher(this.$textarea, 10);
  240. }
  241. this.layerController = new UncomplicatedInlineLayer({
  242. offsetTarget: this._getOffsetTarget(),
  243. allowDownsize: true,
  244. positioningController: positioningController, // Will be undefined if no push-scroll, that is ok
  245. customEvents: this._composeCustomEventForFollowScroll(),
  246. /**
  247. * Allows for shared object between comment boxes.
  248. *
  249. * Closure returns the width of the focused comment form.
  250. * This comes into effect on the View Issue page where the top and
  251. * bottom comment textareas are the same element moved up and down.
  252. * @ignore
  253. */
  254. width: function () {
  255. return instance.$textarea.width();
  256. }
  257. });
  258. this.layerController.bind("showLayer", function () {
  259. // Binds events to handle list navigation
  260. instance.listController.trigger("focus");
  261. instance._assignEvents("win", window);
  262. }).bind("hideLayer", function () {
  263. // Unbinds events to handle list navigation
  264. instance.listController.trigger("blur");
  265. instance._unassignEvents("win", window);
  266. // Try to reset the scroll
  267. if (scrollPusher) {
  268. scrollPusher.reset();
  269. }
  270. }).bind("contentChanged", function () {
  271. if (!instance.layerController.$content) {
  272. return;
  273. }
  274. var oldSelectedItemIndex = instance.listController.index;
  275. var oldSelectedItem = instance.listController.highlighted || instance.listController.items[oldSelectedItemIndex];
  276. var oldId = oldSelectedItem ? idOf(oldSelectedItem.$element) : '';
  277. var newSelectedItem;
  278. instance.listController.removeAllItems();
  279. instance.layerController.$content.off('click.jiraMentions');
  280. instance.layerController.$content.on('click.jiraMentions', 'li', function (e) {
  281. var li = e.currentTarget;
  282. instance._acceptSuggestion(li);
  283. e.preventDefault();
  284. });
  285. instance.layerController.$content.find("li").each(function () {
  286. var li = this;
  287. var id = idOf(li);
  288. var ddItem = new DropdownListItem({
  289. element: li,
  290. autoScroll: true
  291. });
  292. if (id === oldId) {
  293. newSelectedItem = ddItem;
  294. }
  295. instance.listController.addItem(ddItem);
  296. });
  297. instance.listController.prepareForInput();
  298. if (newSelectedItem) {
  299. newSelectedItem.trigger('focus');
  300. } else {
  301. instance.listController.shiftFocus(0);
  302. }
  303. }).bind("setLayerPosition", function (event, positioning, inlineLayer) {
  304. if (Dialog.current && Dialog.current.$form) {
  305. var buttonRow = Dialog.current.$popup.find(".buttons-container:visible");
  306. if (buttonRow.length && positioning.top > buttonRow.offset().top) {
  307. positioning.top = buttonRow.offset().top;
  308. }
  309. }
  310. if (positioning.left < 0) {
  311. positioning.left = 0;
  312. }
  313. if (positioning.left + inlineLayer.layer().width() > jQuery(window).width()) {
  314. positioning.left = Math.max(jQuery(window).width() - inlineLayer.layer().width(), 0);
  315. }
  316. // Try to make the scroll element bigger so we have room for rendering the layer
  317. if (scrollPusher) {
  318. scrollPusher.push(positioning.top + inlineLayer.layer().outerHeight(true));
  319. }
  320. });
  321. this.layerController.layer().attr("id", "mentionDropDown");
  322. this._assignEvents("inlineLayer", instance.layerController.layer());
  323. this._assignEvents("textarea", instance.$textarea);
  324. this._setQueryParams();
  325. this._seedData().then(function (data) {
  326. var users = [];
  327. users.push.apply(users, data);
  328. instance.dataSource.add(users);
  329. });
  330. } else {
  331. return this.$textarea;
  332. }
  333. },
  334. /**
  335. * Generates autocomplete suggestions for usernames from the server response.
  336. * @param data The server response.
  337. * @param {string} username The selected username
  338. * @param {boolean} [noQuery=false] if there's no query
  339. */
  340. generateSuggestions: function (data, username, noQuery) {
  341. var highlight = function (text) {
  342. var result = {
  343. text: text
  344. };
  345. if (!noQuery) {
  346. if (text && text.length) {
  347. var matchStart = this._indexOfFirstMatch(text.toLowerCase(), username.toLowerCase());
  348. if (matchStart !== -1) {
  349. var matchEnd = matchStart + username.length;
  350. result = {
  351. prefix: text.substring(0, matchStart),
  352. match: text.substring(matchStart, matchEnd),
  353. suffix: text.substring(matchEnd)
  354. };
  355. }
  356. }
  357. }
  358. return result;
  359. }.bind(this);
  360. var filteredData = _.map(data, function (model) {
  361. var user = model.toJSON();
  362. user.username = user.name;
  363. user.displayName = highlight(user.displayName);
  364. user.name = highlight(user.name);
  365. user.issueRoles = _.map(user.roles, function (role) {
  366. return highlight(role.label);
  367. });
  368. return user;
  369. });
  370. return jQuery(JIRA.Templates.mentionsSuggestions({
  371. suggestions: filteredData,
  372. query: username,
  373. activity: (this.dataSource.activeQueryCount > 0),
  374. isRolesEnabled: this.isRolesEnabled
  375. }));
  376. },
  377. _indexOfFirstMatch: function (text, query) {
  378. // Separators copied from:
  379. // com.atlassian.jira.bc.user.search.UserSearchUtilities.SEPARATORS
  380. var separators = / |@|\.|-|"|,|'|\(/;
  381. var index = 0;
  382. var _i;
  383. while (true) {
  384. if (text.indexOf(query) === 0) {
  385. return index;
  386. }
  387. _i = text.search(separators);
  388. // If no separator found, then we've searched all the parts and the
  389. // query isn't matched
  390. if (_i === -1) {
  391. return -1;
  392. }
  393. index = index + _i + 1;
  394. text = text.substring(_i + 1);
  395. }
  396. },
  397. _seedData: function getSeedData() {
  398. var resolution;
  399. var issueKey = this._getQueryParams().issueKey;
  400. if (issueKey) {
  401. if (!seedData[issueKey]) {
  402. seedData[issueKey] = jQuery.ajax({
  403. method: 'GET',
  404. url: contextPath() + "/rest/internal/2/user/mention/search",
  405. data: this._getQueryParams(),
  406. dataType: 'json',
  407. contentType: 'application/json; charset=UTF-8'
  408. });
  409. }
  410. resolution = seedData[issueKey].promise();
  411. } else {
  412. resolution = new jQuery.Deferred().reject();
  413. }
  414. return resolution;
  415. },
  416. /**
  417. * Triggered when a user clicks on or presses enter on a highlighted username entry.
  418. *
  419. * The username value is stored in the rel attribute
  420. *
  421. * @param li The selected element.
  422. */
  423. _acceptSuggestion: function (li) {
  424. this._hide();
  425. ContextualMentionAnalyticsEvent.fireUserMayAcceptSuggestionByUsingContextualMentionEvent(this._getCurrentUserName());
  426. this._replaceCurrentUserName(idOf(li), attributeOf(li, 'data-displayname'));
  427. this.listController.removeAllItems();
  428. mentioning = false;
  429. },
  430. /**
  431. * Heavy-handed method to insert the selected user's username.
  432. *
  433. * Replaces the keyword used to search for the selected user with the
  434. * selected user's username.
  435. *
  436. * If a user is searched for with wiki-markup, the wiki-markup is replaced
  437. * with the @format mention.
  438. *
  439. * @param selectedUserName The username of the selected user.
  440. * @param selectedUserDisplayName The display name of the selected user.
  441. */
  442. _replaceCurrentUserName: function (selectedUserName, selectedUserDisplayName) {
  443. var raw = this._rawInputValue();
  444. var caretPos = this._getCaretPosition();
  445. var beforeCaret = raw.substr(0, caretPos);
  446. var wordStartIndex = MentionMatcher.getLastWordBoundaryIndex(beforeCaret, true);
  447. var before = raw.substr(0, wordStartIndex + 1).replace(/\r\n/g, "\n");
  448. var username = "[~" + selectedUserName + "]";
  449. var after = raw.substr(caretPos);
  450. this._rawInputValue([before, username, after].join(""));
  451. this._setCursorPosition(before.length + username.length);
  452. },
  453. /**
  454. * Sets the cursor position to the specified index.
  455. *
  456. * @param index The index to move the cursor to.
  457. */
  458. _setCursorPosition: function (index) {
  459. var input = this.$textarea.get(0);
  460. if (input.setSelectionRange) {
  461. input.focus();
  462. input.setSelectionRange(index, index);
  463. } else if (input.createTextRange) {
  464. var range = input.createTextRange();
  465. range.collapse(true);
  466. range.moveEnd('character', index);
  467. range.moveStart('character', index);
  468. range.select();
  469. }
  470. },
  471. /**
  472. * Returns the position of the cursor in the textarea.
  473. */
  474. _getCaretPosition: function () {
  475. var element = this.$textarea.get(0);
  476. var rawElementValue = this._rawInputValue();
  477. var caretPosition;
  478. var range;
  479. var offset;
  480. var normalizedElementValue;
  481. var elementRange;
  482. if (typeof element.selectionStart === "number") {
  483. return element.selectionStart;
  484. }
  485. if (document.selection && element.createTextRange) {
  486. range = document.selection.createRange();
  487. if (range) {
  488. elementRange = element.createTextRange();
  489. elementRange.moveToBookmark(range.getBookmark());
  490. if (elementRange.compareEndPoints("StartToEnd", element.createTextRange()) >= 0) {
  491. return rawElementValue.length;
  492. } else {
  493. normalizedElementValue = rawElementValue.replace(/\r\n/g, "\n");
  494. offset = elementRange.moveStart("character", -rawElementValue.length);
  495. caretPosition = normalizedElementValue.slice(0, -offset).split("\n").length - 1;
  496. return caretPosition - offset;
  497. }
  498. }
  499. else {
  500. return rawElementValue.length;
  501. }
  502. }
  503. return 0;
  504. },
  505. /**
  506. * Gets or sets the text value of our input via the browser, not jQuery.
  507. * @return The precise value of the input element as provided by the browser (and OS).
  508. * @private
  509. */
  510. _rawInputValue: function () {
  511. var el = this.$textarea.get(0);
  512. if (typeof arguments[0] === "string") {
  513. el.value = arguments[0];
  514. }
  515. return el.value;
  516. },
  517. /**
  518. * Sets the current username and triggers a content refresh.
  519. */
  520. fetchUserNames: function (username) {
  521. this.dataSource.query(username);
  522. },
  523. /**
  524. * Returns the current username search key.
  525. */
  526. _getCurrentUserName: function () {
  527. return this.currentUserName;
  528. },
  529. /**
  530. * Hides the autocomplete dropdown.
  531. */
  532. _hide: function () {
  533. this.layerController.hide();
  534. },
  535. /**
  536. * Shows the autocomplete dropdown.
  537. */
  538. _show: function () {
  539. this.layerController.show();
  540. },
  541. /**
  542. * Key up listener.
  543. *
  544. * Figure out what our input is, then if we need to, get some suggestions.
  545. */
  546. _keyUp: function () {
  547. var caret = this._getCaretPosition();
  548. var u = this._getUserNameFromInput(caret);
  549. var username = jQuery.trim(u || "");
  550. if (this._isNewRequestRequired(username)) {
  551. this.fetchUserNames(username);
  552. mentioning = true;
  553. } else if (this._showHintSuggestions(u)) {
  554. var data = this.dataSource.first(5);
  555. var $suggestions = this.generateSuggestions(data, username, true);
  556. this.updateSuggestions($suggestions);
  557. mentioning = true;
  558. } else if (!this._keepSuggestWindowOpen(username)) {
  559. this._hide();
  560. mentioning = false;
  561. }
  562. this.lastQuery = username;
  563. delete this.willCheck;
  564. },
  565. /**
  566. * @return {Boolean} true if we have suggestions and hints to show the user
  567. */
  568. _showHintSuggestions: function (username) {
  569. return typeof username === 'string' && username.length === 0;
  570. },
  571. /**
  572. * Checks if suggest window should be open
  573. * @return {Boolean}
  574. */
  575. _keepSuggestWindowOpen: function (username) {
  576. if (!username) {
  577. return false;
  578. }
  579. if (this.layerController.isVisible()) {
  580. return this.dataSource.activeQueryCount || this.lastRequestMatch;
  581. }
  582. return false;
  583. },
  584. /**
  585. * Checks if server pool for user names is needed
  586. * @param username
  587. * @return {Boolean}
  588. */
  589. _isNewRequestRequired: function (username) {
  590. if (!username) {
  591. return false;
  592. }
  593. username = jQuery.trim(username);
  594. if (username === this.lastQuery) {
  595. return false;
  596. } else if (this.lastInvalidUsername) {
  597. // We use indexOf instead of stringPartStartsWith here, because we want to check the whole input, not parts.
  598. //Do not do a new request if they have continued typing after typing an invalid username.
  599. if (username.indexOf(this.lastInvalidUsername) === 0 && (this.lastInvalidUsername.length < username.length)) {
  600. return false;
  601. }
  602. } else if (!this.lastRequestMatch && username === this.lastValidUsername) {
  603. return true;
  604. }
  605. return true;
  606. },
  607. _stringPartStartsWith: function (text, startsWith) {
  608. text = jQuery.trim(text || "").toLowerCase();
  609. startsWith = (startsWith || "").toLowerCase();
  610. if (!text || !startsWith) {
  611. return false;
  612. }
  613. return this._indexOfFirstMatch(text, startsWith) !== -1;
  614. },
  615. /**
  616. * Gets the username which the caret is currently next to from the textarea's value.
  617. *
  618. * WIKI markup form is matched, and then if nothing is found, @format.
  619. */
  620. _getUserNameFromInput: function (caret) {
  621. if (typeof caret !== "number") {
  622. caret = this._getCaretPosition();
  623. }
  624. return this.currentUserName = MentionMatcher.getUserNameFromCurrentWord(this._rawInputValue(), caret);
  625. },
  626. _events: {
  627. win: {
  628. resize: function () {
  629. this.layerController.setWidth(this.$textarea.width());
  630. }
  631. },
  632. textarea: {
  633. /**
  634. * Makes a check to update the suggestions after the field's value changes.
  635. *
  636. * Prevents the blurring of the field or closure of a dialog when the drop down is visible.
  637. *
  638. * Also takes into account IE removing text from an input when escape is pressed.
  639. *
  640. * When in a dialog, the general convention is that when escape is pressed when focused on an
  641. * input the dialog will close immediately rather then just unfocus the input. We follow this convetion
  642. * here.
  643. *
  644. * Please don't hurt me for using stopPropagation.
  645. *
  646. * @param e The key down event.
  647. */
  648. "keydown": function (e) {
  649. if (e.keyCode === jQuery.ui.keyCode.ESCAPE) {
  650. if (this.layerController.isVisible()) {
  651. if (Dialog.current) {
  652. Events.one("Dialog.beforeHide", function (e) {
  653. e.preventDefault();
  654. });
  655. }
  656. this.$textarea.one("keyup", function (keyUpEvent) {
  657. if (keyUpEvent.keyCode === jQuery.ui.keyCode.ESCAPE) {
  658. keyUpEvent.stopPropagation(); // Prevent unfocusing the input when esc is pressed
  659. Events.trigger("Mention.afterHide");
  660. }
  661. });
  662. }
  663. if (Navigator.isIE() && Navigator.majorVersion() < 11) {
  664. e.preventDefault();
  665. }
  666. } else if (!this.willCheck) {
  667. //Only trigger keyUp if the key is not ESCAPE
  668. this.willCheck = _.defer(_.bind(this._keyUp, this));
  669. _.defer(_.bind(function () {
  670. var username = this._getUserNameFromInput(this._getCaretPosition());
  671. ContextualMentionAnalyticsEvent.fireContextualMentionIsBeingLookedUpAnalyticsEvent(username, e.keyCode, e.ctrlKey, e.metaKey);
  672. }, this));
  673. }
  674. },
  675. "focus": function () {
  676. this._keyUp();
  677. },
  678. "mouseup": function () {
  679. this._keyUp();
  680. },
  681. /**
  682. * Prevents a bug where another inline layer will focus on comment textarea when
  683. * an item in it is selected (quick admin search).
  684. */
  685. "blur": function () {
  686. this.listController.removeAllItems();
  687. this.lastQuery = this.lastValidUsername = this.lastInvalidUsername = "";
  688. }
  689. },
  690. inlineLayer: {
  691. /**
  692. * JRADEV-21950
  693. * Prevents the blurring of the textarea when the InlineLayer is clicked;
  694. */
  695. mousedown: function (e) {
  696. e.preventDefault();
  697. }
  698. }
  699. }
  700. });
  701. });
  702. define('jira/mention/mention-user', [
  703. 'backbone',
  704. 'underscore'
  705. ], function (Backbone,
  706. _) {
  707. return Backbone.Model.extend({
  708. idAttribute: "name",
  709. initialize: function () {
  710. this.on('change:issueInvolvements', function (model, val) {
  711. var involvements = _.union(model.previous('issueInvolvements'), val);
  712. model.attributes.issueInvolvements = _.uniq(involvements, false, function (item) {
  713. return item.id;
  714. });
  715. });
  716. },
  717. parse: function (resp, options) {
  718. if (!resp.issueInvolvements) {
  719. resp.issueInvolvements = [];
  720. }
  721. return resp;
  722. },
  723. toJSON: function () {
  724. var data = _.clone(this.attributes);
  725. data.roles = data.issueInvolvements;
  726. delete data.issueInvolvements;
  727. return data;
  728. }
  729. });
  730. });
  731. define('jira/mention/mention-matcher', [
  732. 'jquery'
  733. ], function (jQuery) {
  734. return {
  735. AT_USERNAME_START_REGEX: /^@(.*)/i,
  736. AT_USERNAME_REGEX: /[^\[]@(.*)/i,
  737. WIKI_MARKUP_REGEX: /\[[~@]+([^~@]*)/i,
  738. ACCEPTED_USER_REGEX: /\[~[^~\]]*\]/i,
  739. WORD_LIMIT: 3,
  740. /**
  741. * Searches a string for a mention. Searching starts at the caret position
  742. * and works backwards until it finds a mention trigger (e.g., the '@' symbol).
  743. * @param text - the full text to search
  744. * @param caretPosition - position of the caret, or the point at which to start looking from
  745. * @returns {String|null} a string value if there's a valid mention between the caret and
  746. * a mention trigger, or null if no mention is active (e.g., no mention trigger, or caret
  747. * is before the mention trigger, etc.)
  748. */
  749. getUserNameFromCurrentWord: function (text, caretPosition) {
  750. var before = text.substr(0, caretPosition);
  751. var lastWordStartIndex = this.getLastWordBoundaryIndex(before, false);
  752. var prevChar = before.charAt(lastWordStartIndex - 1);
  753. var currentWord;
  754. var foundMatch = null;
  755. if (!prevChar || !/\w/i.test(prevChar)) {
  756. currentWord = this._removeAcceptedUsernames(before.substr(lastWordStartIndex));
  757. if (/[\r\n]/.test(currentWord)) {
  758. return null;
  759. }
  760. jQuery.each([this.AT_USERNAME_START_REGEX, this.AT_USERNAME_REGEX, this.WIKI_MARKUP_REGEX], function (i, regex) {
  761. var match = regex.exec(currentWord);
  762. if (match) {
  763. foundMatch = match[1];
  764. return false;
  765. }
  766. });
  767. }
  768. return (foundMatch != null && this.lengthWithinLimit(foundMatch, this.WORD_LIMIT)) ? foundMatch : null;
  769. },
  770. lengthWithinLimit: function (input, length) {
  771. var parts = jQuery.trim(input).split(/\s+/);
  772. return parts.length <= ~~length;
  773. },
  774. getLastWordBoundaryIndex: function (text, strip) {
  775. var lastAt = text.lastIndexOf("@");
  776. var lastWiki = text.lastIndexOf("[~");
  777. if (strip) {
  778. lastAt = lastAt - 1;
  779. lastWiki = lastWiki - 1;
  780. }
  781. return (lastAt > lastWiki) ? lastAt : lastWiki;
  782. },
  783. _removeAcceptedUsernames: function (phrase) {
  784. var match = this.ACCEPTED_USER_REGEX.exec(phrase);
  785. if (match) {
  786. return phrase.split(match)[1];
  787. }
  788. else {
  789. return phrase;
  790. }
  791. }
  792. };
  793. });
  794. define('jira/mention/scroll-pusher', ['jquery'], function (jQuery) {
  795. return function ($el, defaultMargin) {
  796. defaultMargin = defaultMargin || 0;
  797. var $scroll = jQuery($el.attr("push-scroll"));
  798. var originalScrollHeight;
  799. /**
  800. * Push the scroll of $scroll element to make room for inlineLayer
  801. * @param layerBottom {number} Bottom position of the layer (relative to the page)
  802. * @param margin {number} Extra space to left between layer and scroll
  803. */
  804. function push(layerBottom, margin) {
  805. if (typeof margin === "undefined") {
  806. margin = defaultMargin;
  807. }
  808. var scrollBottom = $scroll.offset().top + $scroll.outerHeight();
  809. var overflow = layerBottom - scrollBottom;
  810. if (overflow + margin > 0) {
  811. if (!originalScrollHeight) {
  812. originalScrollHeight = $scroll.height();
  813. }
  814. $scroll.height($scroll.height() + overflow + margin);
  815. }
  816. }
  817. /**
  818. * Resets the scroll
  819. */
  820. function reset() {
  821. if (originalScrollHeight) {
  822. $scroll.height(originalScrollHeight);
  823. }
  824. }
  825. return {
  826. push: push,
  827. reset: reset
  828. };
  829. };
  830. });
  831. define('jira/mention/contextual-mention-analytics-event', [
  832. 'jquery',
  833. 'underscore'
  834. ], function ($,
  835. _) {
  836. var USER_IS_LOOKING_FOR_CONTEXTUAL_MENTION_REGEX = /^(assi(gnee?)|repo(rter?))$/;
  837. var contextualMentionIsBeingLookedUpEvent = _.debounce(function (username, keyCode, ctrlKey, metaKey) {
  838. var isBackSpaceOrSelectAllKey = isBackSpacePressed(keyCode) || isSelectAllOperationPerforming(keyCode, ctrlKey, metaKey);
  839. var isUserLookingForContextualMention = USER_IS_LOOKING_FOR_CONTEXTUAL_MENTION_REGEX.test(username);
  840. if (isBackSpaceOrSelectAllKey === false && isUserLookingForContextualMention) {
  841. AJS.trigger("analytics", {name: 'issue.comment.contextualMention.lookup'});
  842. }
  843. }, 500);
  844. var isBackSpacePressed = function (keyCode) {
  845. return keyCode === $.ui.keyCode.BACKSPACE;
  846. };
  847. var isSelectAllOperationPerforming = function (keyCode, ctrlKey, metaKey) {
  848. return (keyCode === 'A'.charCodeAt() && (ctrlKey || metaKey)) || ctrlKey || metaKey;
  849. };
  850. return {
  851. fireAcceptedContextualMentionAnalyticsEvent: function () {
  852. AJS.trigger("analytics", {name: 'issue.comment.contextualMention.accepted'});
  853. },
  854. fireContextualMentionIsBeingLookedUpAnalyticsEvent: function (username, keyCode, ctrlKey, metaKey) {
  855. contextualMentionIsBeingLookedUpEvent(username, keyCode, ctrlKey, metaKey);
  856. },
  857. fireUserMayAcceptSuggestionByUsingContextualMentionEvent: function (username) {
  858. _.any(["assignee", "reporter"], function (contextualMention) {
  859. if (contextualMention.indexOf(username) === 0 && contextualMention !== username) {
  860. AJS.trigger("analytics", {name: 'issue.comment.contextualMention.mayAccepted'});
  861. return true;
  862. }
  863. });
  864. }
  865. };
  866. });
  867. AJS.namespace('JIRA.MentionUserModel', null, require('jira/mention/mention-user'));
  868. AJS.namespace('JIRA.Mention', null, require('jira/mention/mention'));
  869. AJS.namespace('JIRA.Mention.Matcher', null, require('jira/mention/mention-matcher'));
  870. AJS.namespace('JIRA.Mention.ScrollPusher', null, require('jira/mention/scroll-pusher'));
  871. AJS.namespace('JIRA.Mention.ContextualMentionAnalyticsEvent', null, require('jira/mention/contextual-mention-analytics-event'));