Skip to content

Commit 68f0092

Browse files
committed
Limit autocomplete menu to applied labels
1 parent d199ecd commit 68f0092

File tree

6 files changed

+199
-26
lines changed

6 files changed

+199
-26
lines changed

app/assets/javascripts/gfm_auto_complete.js

+61-14
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ class GfmAutoComplete {
287287
}
288288

289289
setupLabels($input) {
290+
const fetchData = this.fetchData.bind(this);
291+
const LABEL_COMMAND = { LABEL: '/label', UNLABEL: '/unlabel', RELABEL: '/relabel' };
292+
let command = '';
293+
290294
$input.atwho({
291295
at: '~',
292296
alias: 'labels',
@@ -309,8 +313,45 @@ class GfmAutoComplete {
309313
title: sanitize(m.title),
310314
color: m.color,
311315
search: m.title,
316+
set: m.set,
312317
}));
313318
},
319+
matcher(flag, subtext) {
320+
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
321+
const subtextNodes = subtext.split(/\n+/g).pop().split(GfmAutoComplete.regexSubtext);
322+
323+
// Check if ~ is followed by '/label', '/relabel' or '/unlabel' commands.
324+
command = subtextNodes.find((node) => {
325+
if (node === LABEL_COMMAND.LABEL ||
326+
node === LABEL_COMMAND.RELABEL ||
327+
node === LABEL_COMMAND.UNLABEL) { return node; }
328+
return null;
329+
});
330+
331+
return match && match.length ? match[1] : null;
332+
},
333+
filter(query, data, searchKey) {
334+
if (GfmAutoComplete.isLoading(data)) {
335+
fetchData(this.$inputor, this.at);
336+
return data;
337+
}
338+
339+
if (data === GfmAutoComplete.defaultLoadingData) {
340+
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
341+
}
342+
343+
// The `LABEL_COMMAND.RELABEL` is intentionally skipped
344+
// because we want to return all the labels (unfiltered) for that command.
345+
if (command === LABEL_COMMAND.LABEL) {
346+
// Return labels with set: undefined.
347+
return data.filter(label => !label.set);
348+
} else if (command === LABEL_COMMAND.UNLABEL) {
349+
// Return labels with set: true.
350+
return data.filter(label => label.set);
351+
}
352+
353+
return data;
354+
},
314355
},
315356
});
316357
}
@@ -346,20 +387,7 @@ class GfmAutoComplete {
346387
return resultantValue;
347388
},
348389
matcher(flag, subtext) {
349-
// The below is taken from At.js source
350-
// Tweaked to commands to start without a space only if char before is a non-word character
351-
// https://github.com/ichord/At.js
352-
const atSymbolsWithBar = Object.keys(this.app.controllers).join('|');
353-
const atSymbolsWithoutBar = Object.keys(this.app.controllers).join('');
354-
const targetSubtext = subtext.split(/\s+/g).pop();
355-
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
356-
357-
const accentAChar = decodeURI('%C3%80');
358-
const accentYChar = decodeURI('%C3%BF');
359-
360-
const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
361-
362-
const match = regexp.exec(targetSubtext);
390+
const match = GfmAutoComplete.defaultMatcher(flag, subtext, this.app.controllers);
363391

364392
if (match) {
365393
return match[1];
@@ -420,8 +448,27 @@ class GfmAutoComplete {
420448
return dataToInspect &&
421449
(dataToInspect === loadingState || dataToInspect.name === loadingState);
422450
}
451+
452+
static defaultMatcher(flag, subtext, controllers) {
453+
// The below is taken from At.js source
454+
// Tweaked to commands to start without a space only if char before is a non-word character
455+
// https://github.com/ichord/At.js
456+
const atSymbolsWithBar = Object.keys(controllers).join('|');
457+
const atSymbolsWithoutBar = Object.keys(controllers).join('');
458+
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
459+
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
460+
461+
const accentAChar = decodeURI('%C3%80');
462+
const accentYChar = decodeURI('%C3%BF');
463+
464+
const regexp = new RegExp(`^(?:\\B|[^a-zA-Z0-9_${atSymbolsWithoutBar}]|\\s)${resultantFlag}(?!${atSymbolsWithBar})((?:[A-Za-z${accentAChar}-${accentYChar}0-9_'.+-]|[^\\x00-\\x7a])*)$`, 'gi');
465+
466+
return regexp.exec(targetSubtext);
467+
}
423468
}
424469

470+
GfmAutoComplete.regexSubtext = new RegExp(/\s+/g);
471+
425472
GfmAutoComplete.defaultLoadingData = ['loading'];
426473

427474
GfmAutoComplete.atTypeMap = {

app/controllers/projects/autocomplete_sources_controller.rb

+8-8
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
22
before_action :load_autocomplete_service, except: [:members]
33

44
def members
5-
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
5+
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(target)
66
end
77

88
def issues
@@ -14,15 +14,15 @@ def merge_requests
1414
end
1515

1616
def labels
17-
render json: @autocomplete_service.labels
17+
render json: @autocomplete_service.labels(target)
1818
end
1919

2020
def milestones
2121
render json: @autocomplete_service.milestones
2222
end
2323

2424
def commands
25-
render json: @autocomplete_service.commands(noteable, params[:type])
25+
render json: @autocomplete_service.commands(target, params[:type])
2626
end
2727

2828
private
@@ -31,13 +31,13 @@ def load_autocomplete_service
3131
@autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user)
3232
end
3333

34-
def noteable
35-
case params[:type]
36-
when 'Issue'
34+
def target
35+
case params[:type]&.downcase
36+
when 'issue'
3737
IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
38-
when 'MergeRequest'
38+
when 'mergerequest'
3939
MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
40-
when 'Commit'
40+
when 'commit'
4141
@project.commit(params[:type_id])
4242
end
4343
end

app/services/projects/autocomplete_service.rb

+18-3
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,23 @@ def merge_requests
2020
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
2121
end
2222

23-
def labels
24-
LabelsFinder.new(current_user, project_id: project.id).execute.select([:title, :color])
23+
def labels(target = nil)
24+
labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title])
25+
26+
return labels unless target&.respond_to?(:labels)
27+
28+
issuable_label_titles = target.labels.pluck(:title)
29+
30+
if issuable_label_titles
31+
labels = labels.as_json(only: [:title, :color])
32+
33+
issuable_label_titles.each do |issuable_label_title|
34+
found_label = labels.find { |label| label['title'] == issuable_label_title }
35+
found_label[:set] = true if found_label
36+
end
37+
end
38+
39+
labels
2540
end
2641

2742
def commands(noteable, type)
@@ -33,7 +48,7 @@ def commands(noteable, type)
3348
@project.merge_requests.build
3449
end
3550

36-
return [] unless noteable && noteable.is_a?(Issuable)
51+
return [] unless noteable&.is_a?(Issuable)
3752

3853
opts = {
3954
project: project,

app/views/layouts/_init_auto_complete.html.haml

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
1111
issues: "#{issues_project_autocomplete_sources_path(project)}",
1212
mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}",
13-
labels: "#{labels_project_autocomplete_sources_path(project)}",
13+
labels: "#{labels_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
1414
milestones: "#{milestones_project_autocomplete_sources_path(project)}",
1515
commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
1616
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: Limit autocomplete menu to applied labels
3+
merge_request: 11110
4+
author: Vitaliy @blackst0ne Klachkov
5+
type: added

spec/features/issues/gfm_autocomplete_spec.rb

+106
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,89 @@
220220
end
221221
end
222222

223+
# This context has jsut one example in each contexts in order to improve spec performance.
224+
context 'labels' do
225+
let!(:backend) { create(:label, project: project, title: 'backend') }
226+
let!(:bug) { create(:label, project: project, title: 'bug') }
227+
let!(:feature_proposal) { create(:label, project: project, title: 'feature proposal') }
228+
229+
context 'when no labels are assigned' do
230+
it 'shows labels' do
231+
note = find('#note-body')
232+
233+
# It should show all the labels on "~".
234+
type(note, '~')
235+
expect_labels(shown: [backend, bug, feature_proposal])
236+
237+
# It should show all the labels on "/label ~".
238+
type(note, '/label ~')
239+
expect_labels(shown: [backend, bug, feature_proposal])
240+
241+
# It should show all the labels on "/relabel ~".
242+
type(note, '/relabel ~')
243+
expect_labels(shown: [backend, bug, feature_proposal])
244+
245+
# It should show no labels on "/unlabel ~".
246+
type(note, '/unlabel ~')
247+
expect_labels(not_shown: [backend, bug, feature_proposal])
248+
end
249+
end
250+
251+
context 'when some labels are assigned' do
252+
before do
253+
issue.labels << [backend]
254+
end
255+
256+
it 'shows labels' do
257+
note = find('#note-body')
258+
259+
# It should show all the labels on "~".
260+
type(note, '~')
261+
expect_labels(shown: [backend, bug, feature_proposal])
262+
263+
# It should show only unset labels on "/label ~".
264+
type(note, '/label ~')
265+
expect_labels(shown: [bug, feature_proposal], not_shown: [backend])
266+
267+
# It should show all the labels on "/relabel ~".
268+
type(note, '/relabel ~')
269+
expect_labels(shown: [backend, bug, feature_proposal])
270+
271+
# It should show only set labels on "/unlabel ~".
272+
type(note, '/unlabel ~')
273+
expect_labels(shown: [backend], not_shown: [bug, feature_proposal])
274+
end
275+
end
276+
277+
context 'when all labels are assigned' do
278+
before do
279+
issue.labels << [backend, bug, feature_proposal]
280+
end
281+
282+
it 'shows labels' do
283+
note = find('#note-body')
284+
285+
# It should show all the labels on "~".
286+
type(note, '~')
287+
expect_labels(shown: [backend, bug, feature_proposal])
288+
289+
# It should show no labels on "/label ~".
290+
type(note, '/label ~')
291+
expect_labels(not_shown: [backend, bug, feature_proposal])
292+
293+
# It should show all the labels on "/relabel ~".
294+
type(note, '/relabel ~')
295+
expect_labels(shown: [backend, bug, feature_proposal])
296+
297+
# It should show all the labels on "/unlabel ~".
298+
type(note, '/unlabel ~')
299+
expect_labels(shown: [backend, bug, feature_proposal])
300+
end
301+
end
302+
end
303+
304+
private
305+
223306
def expect_to_wrap(should_wrap, item, note, value)
224307
expect(item).to have_content(value)
225308
expect(item).not_to have_content("\"#{value}\"")
@@ -232,4 +315,27 @@ def expect_to_wrap(should_wrap, item, note, value)
232315
expect(note.value).not_to include("\"#{value}\"")
233316
end
234317
end
318+
319+
def expect_labels(shown: nil, not_shown: nil)
320+
page.within('.atwho-container') do
321+
if shown
322+
expect(page).to have_selector('.atwho-view li', count: shown.size)
323+
shown.each { |label| expect(page).to have_content(label.title) }
324+
end
325+
326+
if not_shown
327+
expect(page).not_to have_selector('.atwho-view li') unless shown
328+
not_shown.each { |label| expect(page).not_to have_content(label.title) }
329+
end
330+
end
331+
end
332+
333+
# `note` is a textarea where the given text should be typed.
334+
# We don't want to find it each time this function gets called.
335+
def type(note, text)
336+
page.within('.timeline-content-form') do
337+
note.set('')
338+
note.native.send_keys(text)
339+
end
340+
end
235341
end

0 commit comments

Comments
 (0)