mirror of
https://gitlab.com/gitlab-org/gitlab-foss.git
synced 2025-08-20 14:11:11 +00:00
Limit autocomplete menu to applied labels
This commit is contained in:
@ -287,6 +287,10 @@ class GfmAutoComplete {
|
||||
}
|
||||
|
||||
setupLabels($input) {
|
||||
const fetchData = this.fetchData.bind(this);
|
||||
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
|
||||
let command = '';
|
||||
|
||||
$input.atwho({
|
||||
at: '~',
|
||||
alias: 'labels',
|
||||
@ -309,8 +313,45 @@ class GfmAutoComplete {
|
||||
title: sanitize(m.title),
|
||||
color: m.color,
|
||||
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 {
|
||||
return resultantValue;
|
||||
},
|
||||
matcher(flag, subtext) {
|
||||
// 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(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);
|
||||
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
|
||||
|
||||
if (match) {
|
||||
return match[1];
|
||||
@ -420,7 +448,26 @@ class GfmAutoComplete {
|
||||
return dataToInspect &&
|
||||
(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'];
|
||||
|
||||
|
@ -2,7 +2,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
|
||||
before_action :load_autocomplete_service, except: [:members]
|
||||
|
||||
def members
|
||||
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
|
||||
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
|
||||
end
|
||||
|
||||
def issues
|
||||
@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
|
||||
end
|
||||
|
||||
def labels
|
||||
render json: @autocomplete_service.labels
|
||||
render json: @autocomplete_service.labels(target)
|
||||
end
|
||||
|
||||
def milestones
|
||||
@ -22,7 +22,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
|
||||
end
|
||||
|
||||
def commands
|
||||
render json: @autocomplete_service.commands(noteable, params[:type])
|
||||
render json: @autocomplete_service.commands(target, params[:type])
|
||||
end
|
||||
|
||||
private
|
||||
@ -31,13 +31,13 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
|
||||
@autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user)
|
||||
end
|
||||
|
||||
def noteable
|
||||
case params[:type]
|
||||
when 'Issue'
|
||||
def target
|
||||
case params[:type]&.downcase
|
||||
when 'issue'
|
||||
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])
|
||||
when 'Commit'
|
||||
when 'commit'
|
||||
@project.commit(params[:type_id])
|
||||
end
|
||||
end
|
||||
|
@ -20,8 +20,23 @@ module Projects
|
||||
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
|
||||
end
|
||||
|
||||
def labels
|
||||
LabelsFinder.new(current_user, project_id: project.id).execute.select([:title, :color])
|
||||
def labels(target = nil)
|
||||
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
|
||||
|
||||
def commands(noteable, type)
|
||||
@ -33,7 +48,7 @@ module Projects
|
||||
@project.merge_requests.build
|
||||
end
|
||||
|
||||
return [] unless noteable && noteable.is_a?(Issuable)
|
||||
return [] unless noteable&.is_a?(Issuable)
|
||||
|
||||
opts = {
|
||||
project: project,
|
||||
|
@ -10,7 +10,7 @@
|
||||
members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
|
||||
issues: "#{issues_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)}",
|
||||
commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
|
||||
};
|
||||
|
@ -0,0 +1,5 @@
|
||||
---
|
||||
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
|
||||
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)
|
||||
expect(item).to have_content(value)
|
||||
expect(item).not_to have_content("\"#{value}\"")
|
||||
@ -232,4 +315,27 @@ feature 'GFM autocomplete', :js do
|
||||
expect(note.value).not_to include("\"#{value}\"")
|
||||
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
|
||||
|
Reference in New Issue
Block a user