diff --git a/.rubocop.yml b/.rubocop.yml index abdda90a33e..3edd1643d48 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -606,6 +606,18 @@ Style/YodaCondition: Style/Proc: Enabled: true +# Use `spam?` instead of `is_spam?` +# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. +# NamePrefix: is_, has_, have_ +# NamePrefixBlacklist: is_, has_, have_ +# NameWhitelist: is_a? +Style/PredicateName: + Enabled: true + NamePrefixBlacklist: is_ + Exclude: + - 'spec/**/*' + - 'features/**/*' + # Metrics ##################################################################### # A calculated magnitude based on number of assignments, @@ -631,7 +643,7 @@ Metrics/ClassLength: # of test cases needed to validate a method. Metrics/CyclomaticComplexity: Enabled: true - Max: 16 + Max: 15 # Limit lines to 80 characters. Metrics/LineLength: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4b4f14efea4..cdf97d1d842 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -237,14 +237,6 @@ Style/PercentLiteralDelimiters: Style/PerlBackrefs: Enabled: false -# Offense count: 105 -# Configuration parameters: NamePrefix, NamePrefixBlacklist, NameWhitelist. -# NamePrefix: is_, has_, have_ -# NamePrefixBlacklist: is_, has_, have_ -# NameWhitelist: is_a? -Style/PredicateName: - Enabled: false - # Offense count: 58 # Cop supports --auto-correct. # Configuration parameters: EnforcedStyle, SupportedStyles. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 12fb34b24be..6cc34f1de08 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -164,7 +164,7 @@ Assigning a team label makes sure issues get the attention of the appropriate people. The current team labels are ~Build, ~CI, ~Discussion, ~Documentation, ~Edge, -~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". +~Geo, ~Gitaly, ~Platform, ~Prometheus, ~Release, and ~"UX". The descriptions on the [labels page][labels-page] explain what falls under the responsibility of each team. diff --git a/Gemfile b/Gemfile index 782bbfab5db..8bcf1278267 100644 --- a/Gemfile +++ b/Gemfile @@ -284,7 +284,7 @@ group :metrics do gem 'influxdb', '~> 0.2', require: false # Prometheus - gem 'prometheus-client-mmap', '~>0.7.0.beta12' + gem 'prometheus-client-mmap', '~>0.7.0.beta14' gem 'raindrops', '~> 0.18' end diff --git a/Gemfile.lock b/Gemfile.lock index 404e1ec2df0..3cb458c1707 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -619,7 +619,7 @@ GEM parser unparser procto (0.0.3) - prometheus-client-mmap (0.7.0.beta12) + prometheus-client-mmap (0.7.0.beta14) mmap2 (~> 2.2, >= 2.2.7) pry (0.10.4) coderay (~> 1.1.0) @@ -1093,7 +1093,7 @@ DEPENDENCIES pg (~> 0.18.2) poltergeist (~> 1.9.0) premailer-rails (~> 1.9.7) - prometheus-client-mmap (~> 0.7.0.beta12) + prometheus-client-mmap (~> 0.7.0.beta14) pry-byebug (~> 3.4.1) pry-rails (~> 0.3.4) rack-attack (~> 4.4.1) diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 70cd337fb8a..3901bb177fe 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -85,6 +85,13 @@ class DropDown { const renderableList = this.list.querySelector('ul[data-dynamic]') || this.list; renderableList.innerHTML = children.join(''); + + const listEvent = new CustomEvent('render.dl', { + detail: { + list: this, + }, + }); + this.list.dispatchEvent(listEvent); } renderChildren(data) { diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js new file mode 100644 index 00000000000..f9bbbf0cbc1 --- /dev/null +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -0,0 +1,82 @@ +/* global Flash */ + +import Ajax from '~/droplab/plugins/ajax'; +import Filter from '~/droplab/plugins/filter'; +import './filtered_search_dropdown'; + +class DropdownEmoji extends gl.FilteredSearchDropdown { + constructor(options = {}) { + super(options); + this.config = { + Ajax: { + endpoint: `${gon.relative_url_root || ''}/autocomplete/award_emojis`, + method: 'setData', + loadingTemplate: this.loadingTemplate, + onError() { + /* eslint-disable no-new */ + new Flash('An error occured fetching the dropdown data.'); + /* eslint-enable no-new */ + }, + }, + Filter: { + template: 'name', + }, + }; + + import(/* webpackChunkName: 'emoji' */ '~/emoji') + .then(({ glEmojiTag }) => { this.glEmojiTag = glEmojiTag; }) + .catch(() => { /* ignore error and leave emoji name in the search bar */ }); + + this.unbindEvents(); + this.bindEvents(); + } + + bindEvents() { + super.bindEvents(); + + this.listRenderedWrapper = this.listRendered.bind(this); + this.dropdown.addEventListener('render.dl', this.listRenderedWrapper); + } + + unbindEvents() { + this.dropdown.removeEventListener('render.dl', this.listRenderedWrapper); + super.unbindEvents(); + } + + listRendered() { + this.replaceEmojiElement(); + } + + itemClicked(e) { + super.itemClicked(e, (selected) => { + const name = selected.querySelector('.js-data-value').innerText.trim(); + return gl.DropdownUtils.getEscapedText(name); + }); + } + + renderContent(forceShowList = false) { + this.droplab.changeHookList(this.hookId, this.dropdown, [Ajax, Filter], this.config); + super.renderContent(forceShowList); + } + + replaceEmojiElement() { + if (!this.glEmojiTag) return; + + // Replace empty gl-emoji tag to real content + const dropdownItems = [...this.dropdown.querySelectorAll('.filter-dropdown-item')]; + dropdownItems.forEach((dropdownItem) => { + const name = dropdownItem.querySelector('.js-data-value').innerText; + const emojiTag = this.glEmojiTag(name); + const emojiElement = dropdownItem.querySelector('gl-emoji'); + emojiElement.outerHTML = emojiTag; + }); + } + + init() { + this.droplab + .addHook(this.input, this.dropdown, [Ajax, Filter], this.config).init(); + } +} + +window.gl = window.gl || {}; +gl.DropdownEmoji = DropdownEmoji; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index a81389ab088..1c5ca1d3cf9 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -61,7 +61,7 @@ class DropdownHint extends gl.FilteredSearchDropdown { .map(tokenKey => ({ icon: `fa-${tokenKey.icon}`, hint: tokenKey.key, - tag: `<${tokenKey.symbol}${tokenKey.key}>`, + tag: `<${tokenKey.tag}>`, type: tokenKey.type, })); diff --git a/app/assets/javascripts/filtered_search/filtered_search_bundle.js b/app/assets/javascripts/filtered_search/filtered_search_bundle.js index 132b6fe698a..6d5dd747224 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_bundle.js +++ b/app/assets/javascripts/filtered_search/filtered_search_bundle.js @@ -1,3 +1,4 @@ +import './dropdown_emoji'; import './dropdown_hint'; import './dropdown_non_user'; import './dropdown_user'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js index dd1c067df87..46c80dfd45e 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js @@ -58,6 +58,11 @@ class FilteredSearchDropdownManager { }, element: this.container.querySelector('#js-dropdown-label'), }, + 'my-reaction': { + reference: null, + gl: 'DropdownEmoji', + element: this.container.querySelector('#js-dropdown-my-reaction'), + }, hint: { reference: null, gl: 'DropdownHint', diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index a31be2b0bc7..038239bf466 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -439,8 +439,13 @@ class FilteredSearchManager { const match = this.filteredSearchTokenKeys.searchByKeyParam(keyParam); if (match) { - const indexOf = keyParam.indexOf('_'); - const sanitizedKey = indexOf !== -1 ? keyParam.slice(0, keyParam.indexOf('_')) : keyParam; + // Use lastIndexOf because the token key is allowed to contain underscore + // e.g. 'my_reaction' is the token key of 'my_reaction_emoji' + const lastIndexOf = keyParam.lastIndexOf('_'); + let sanitizedKey = lastIndexOf !== -1 ? keyParam.slice(0, lastIndexOf) : keyParam; + // Replace underscore with hyphen in the sanitizedkey. + // e.g. 'my_reaction' => 'my-reaction' + sanitizedKey = sanitizedKey.replace('_', '-'); const symbol = match.symbol; let quotationsToUse = ''; @@ -515,7 +520,10 @@ class FilteredSearchManager { const condition = this.filteredSearchTokenKeys .searchByConditionKeyValue(token.key, token.value.toLowerCase()); const { param } = this.filteredSearchTokenKeys.searchByKey(token.key) || {}; - const keyParam = param ? `${token.key}_${param}` : token.key; + // Replace hyphen with underscore to use as request parameter + // e.g. 'my-reaction' => 'my_reaction' + const underscoredKey = token.key.replace('-', '_'); + const keyParam = param ? `${underscoredKey}_${param}` : underscoredKey; let tokenPath = ''; if (condition) { diff --git a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js index 025d4d8795b..be595d7df1a 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_token_keys.js +++ b/app/assets/javascripts/filtered_search/filtered_search_token_keys.js @@ -4,26 +4,42 @@ const tokenKeys = [{ param: 'username', symbol: '@', icon: 'pencil', + tag: '@author', }, { key: 'assignee', type: 'string', param: 'username', symbol: '@', icon: 'user', + tag: '@assignee', }, { key: 'milestone', type: 'string', param: 'title', symbol: '%', icon: 'clock-o', + tag: '%milestone', }, { key: 'label', type: 'array', param: 'name[]', symbol: '~', icon: 'tag', + tag: '~label', }]; +if (gon.current_user_id) { + // Appending tokenkeys only logged-in + tokenKeys.push({ + key: 'my-reaction', + type: 'string', + param: 'emoji', + symbol: '', + icon: 'thumbs-up', + tag: 'emoji', + }); +} + const alternativeTokenKeys = [{ key: 'label', type: 'string', @@ -84,6 +100,10 @@ class FilteredSearchTokenKeys { return tokenKeysWithAlternative.find((tokenKey) => { let tokenKeyParam = tokenKey.key; + // Replace hyphen with underscore to compare keyParam with tokenKeyParam + // e.g. 'my-reaction' => 'my_reaction' + tokenKeyParam = tokenKeyParam.replace('-', '_'); + if (tokenKey.param) { tokenKeyParam += `_${tokenKey.param}`; } diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 243ee4d723a..28e8240169d 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -132,6 +132,23 @@ class FilteredSearchVisualTokens { .catch(() => { }); } + static updateEmojiTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) { + const container = tokenValueContainer; + const element = tokenValueElement; + + return import(/* webpackChunkName: 'emoji' */ '../emoji') + .then((Emoji) => { + if (!Emoji.isEmojiNameValid(tokenValue)) { + return; + } + + container.dataset.originalValue = tokenValue; + element.innerHTML = Emoji.glEmojiTag(tokenValue); + }) + // ignore error and leave emoji name in the search bar + .catch(() => { }); + } + static renderVisualTokenValue(parentElement, tokenName, tokenValue) { const tokenValueContainer = parentElement.querySelector('.value-container'); const tokenValueElement = tokenValueContainer.querySelector('.value'); @@ -144,6 +161,10 @@ class FilteredSearchVisualTokens { FilteredSearchVisualTokens.updateUserTokenAppearance( tokenValueContainer, tokenValueElement, tokenValue, ); + } else if (tokenType === 'my-reaction') { + FilteredSearchVisualTokens.updateEmojiTokenAppearance( + tokenValueContainer, tokenValueElement, tokenValue, + ); } } diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index cb133cf7535..2060410e991 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,10 +1,10 @@