Commit 7619bdd6 authored by Tim Zallmann's avatar Tim Zallmann

Merge branch '22680-unlabel-slash-command-limit-autocomplete-to-applied-labels' into 'master'

Resolve "/unlabel quick action - limit autocomplete to applied labels"

Closes #22680 and #27319

See merge request gitlab-org/gitlab-ce!11110
parents 743451b2 68f0092c
...@@ -287,6 +287,10 @@ class GfmAutoComplete { ...@@ -287,6 +287,10 @@ class GfmAutoComplete {
} }
setupLabels($input) { setupLabels($input) {
const fetchData = this.fetchData.bind(this);
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
let command = '';
$input.atwho({ $input.atwho({
at: '~', at: '~',
alias: 'labels', alias: 'labels',
...@@ -309,8 +313,45 @@ class GfmAutoComplete { ...@@ -309,8 +313,45 @@ class GfmAutoComplete {
title: sanitize(m.title), title: sanitize(m.title),
color: m.color, color: m.color,
search: m.title, search: m.title,
set: m.set,
})); }));
}, },
matcher(flag, subtext) {
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext);
// Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands.
command = subtextNodes.find((node) => {
if (node === LABEL_COMMAND.LABEL ||
node === LABEL_COMMAND.RELABEL ||
node === LABEL_COMMAND.UNLABEL) { return node; }
return null;
});
return match && match.length ? match[1] : null;
},
filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) {
fetchData(this.$inputor, this.at);
return data;
}
if (data === GfmAutoComplete.defaultLoadingData) {
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
}
// The `LABEL_COMMAND.RELABEL` is intentionally skipped
// because we want to return all the labels (unfiltered) for that command.
if (command === LABEL_COMMAND.LABEL) {
// Return labels with set: undefined.
return data.filter(label => !label.set);
} else if (command === LABEL_COMMAND.UNLABEL) {
// Return labels with set: true.
return data.filter(label => label.set);
}
return data;
},
}, },
}); });
} }
...@@ -346,20 +387,7 @@ class GfmAutoComplete { ...@@ -346,20 +387,7 @@ class GfmAutoComplete {
return resultantValue; return resultantValue;
}, },
matcher(flag, subtext) { matcher(flag, subtext) {
// The below is taken from At.js source const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
// Tweaked to commands to start without a space only if char before is a non-word character
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
const atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
const targetSubtext = subtext.split(/\s+/g).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
const accentAChar = decodeURI('%C3%80');
const accentYChar = decodeURI('%C3%BF');
const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
const match = regexp.exec(targetSubtext);
if (match) { if (match) {
return match[1]; return match[1];
...@@ -420,8 +448,27 @@ class GfmAutoComplete { ...@@ -420,8 +448,27 @@ class GfmAutoComplete {
return dataToInspect && return dataToInspect &&
(dataToInspect === loadingState || dataToInspect.name === loadingState); (dataToInspect === loadingState || dataToInspect.name === loadingState);
} }
static defaultMatcher(flag, subtext, controllers) {
// The below is taken from At.js source
// Tweaked to commands to start without a space only if char before is a non-word character
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers).join('|');
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
const accentAChar = decodeURI('%C3%80');
const accentYChar = decodeURI('%C3%BF');
const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
return regexp.exec(targetSubtext);
}
} }
GfmAutoComplete.regexSubtext = new RegExp(/\s+/g);
GfmAutoComplete.defaultLoadingData = ['loading']; GfmAutoComplete.defaultLoadingData = ['loading'];
GfmAutoComplete.atTypeMap = { GfmAutoComplete.atTypeMap = {
......
...@@ -2,7 +2,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -2,7 +2,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :load_autocomplete_service, except: [:members] before_action :load_autocomplete_service, except: [:members]
def members def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
end end
def issues def issues
...@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end end
def labels def labels
render json: @autocomplete_service.labels render json: @autocomplete_service.labels(target)
end end
def milestones def milestones
...@@ -22,7 +22,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -22,7 +22,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end end
def commands def commands
render json: @autocomplete_service.commands(noteable, params[:type]) render json: @autocomplete_service.commands(target, params[:type])
end end
private private
...@@ -31,13 +31,13 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -31,13 +31,13 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
@autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user) @autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user)
end end
def noteable def target
case params[:type] case params[:type]&.downcase
when 'Issue' when 'issue'
IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id]) IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
when 'MergeRequest' when 'mergerequest'
MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id]) MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
when 'Commit' when 'commit'
@project.commit(params[:type_id]) @project.commit(params[:type_id])
end end
end end
......
...@@ -20,8 +20,23 @@ module Projects ...@@ -20,8 +20,23 @@ module Projects
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end end
def labels def labels(target = nil)
LabelsFinder.new(current_user, project_id: project.id).execute.select([:title, :color]) labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title])
return labels unless target&.respond_to?(:labels)
issuable_label_titles = target.labels.pluck(:title)
if issuable_label_titles
labels = labels.as_json(only: [:title, :color])
issuable_label_titles.each do |issuable_label_title|
found_label = labels.find { |label| label['title'] == issuable_label_title }
found_label[:set] = true if found_label
end
end
labels
end end
def commands(noteable, type) def commands(noteable, type)
...@@ -33,7 +48,7 @@ module Projects ...@@ -33,7 +48,7 @@ module Projects
@project.merge_requests.build @project.merge_requests.build
end end
return [] unless noteable && noteable.is_a?(Issuable) return [] unless noteable&.is_a?(Issuable)
opts = { opts = {
project: project, project: project,
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}", members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
issues: "#{issues_project_autocomplete_sources_path(project)}", issues: "#{issues_project_autocomplete_sources_path(project)}",
mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}", mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}",
labels: "#{labels_project_autocomplete_sources_path(project)}", labels: "#{labels_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
milestones: "#{milestones_project_autocomplete_sources_path(project)}", milestones: "#{milestones_project_autocomplete_sources_path(project)}",
commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}" commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
}; };
---
title: Limit autocomplete menu to applied labels
merge_request: 11110
author: Vitaliy @blackst0ne Klachkov
type: added
...@@ -220,6 +220,89 @@ feature 'GFM autocomplete', :js do ...@@ -220,6 +220,89 @@ feature 'GFM autocomplete', :js do
end end
end end
# This context has jsut one example in each contexts in order to improve spec performance.
context 'labels' do
let!(:backend) { create(:label, project: project, title: 'backend') }
let!(:bug) { create(:label, project: project, title: 'bug') }
let!(:feature_proposal) { create(:label, project: project, title: 'feature proposal') }
context 'when no labels are assigned' do
it 'shows labels' do
note = find('#note-body')
# It should show all the labels on "~".
type(note, '~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show all the labels on "/label ~".
type(note, '/label ~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show all the labels on "/relabel ~".
type(note, '/relabel ~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show no labels on "/unlabel ~".
type(note, '/unlabel ~')
expect_labels(not_shown: [backend, bug, feature_proposal])
end
end
context 'when some labels are assigned' do
before do
issue.labels << [backend]
end
it 'shows labels' do
note = find('#note-body')
# It should show all the labels on "~".
type(note, '~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show only unset labels on "/label ~".
type(note, '/label ~')
expect_labels(shown: [bug, feature_proposal], not_shown: [backend])
# It should show all the labels on "/relabel ~".
type(note, '/relabel ~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show only set labels on "/unlabel ~".
type(note, '/unlabel ~')
expect_labels(shown: [backend], not_shown: [bug, feature_proposal])
end
end
context 'when all labels are assigned' do
before do
issue.labels << [backend, bug, feature_proposal]
end
it 'shows labels' do
note = find('#note-body')
# It should show all the labels on "~".
type(note, '~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show no labels on "/label ~".
type(note, '/label ~')
expect_labels(not_shown: [backend, bug, feature_proposal])
# It should show all the labels on "/relabel ~".
type(note, '/relabel ~')
expect_labels(shown: [backend, bug, feature_proposal])
# It should show all the labels on "/unlabel ~".
type(note, '/unlabel ~')
expect_labels(shown: [backend, bug, feature_proposal])
end
end
end
private
def expect_to_wrap(should_wrap, item, note, value) def expect_to_wrap(should_wrap, item, note, value)
expect(item).to have_content(value) expect(item).to have_content(value)
expect(item).not_to have_content("\"#{value}\"") expect(item).not_to have_content("\"#{value}\"")
...@@ -232,4 +315,27 @@ feature 'GFM autocomplete', :js do ...@@ -232,4 +315,27 @@ feature 'GFM autocomplete', :js do
expect(note.value).not_to include("\"#{value}\"") expect(note.value).not_to include("\"#{value}\"")
end end
end end
def expect_labels(shown: nil, not_shown: nil)
page.within('.atwho-container') do
if shown
expect(page).to have_selector('.atwho-view li', count: shown.size)
shown.each { |label| expect(page).to have_content(label.title) }
end
if not_shown
expect(page).not_to have_selector('.atwho-view li') unless shown
not_shown.each { |label| expect(page).not_to have_content(label.title) }
end
end
end
# `note` is a textarea where the given text should be typed.
# We don't want to find it each time this function gets called.
def type(note, text)
page.within('.timeline-content-form') do
note.set('')
note.native.send_keys(text)
end
end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment