Commit 3b0d5230 authored by Coung Ngo's avatar Coung Ngo Committed by Natalia Tepluhina

Improve at.js members autocomplete matching

When searching for a user, e.g. `@win`, users whose
name OR username starts with `win` are shown as
the top result in the autocomplete menu.

This commit also increases the results list from 5 to 10
for members autocomplete.

The improvements are discussed in
https://gitlab.com/gitlab-org/gitlab/-/issues/118597
parent fe4ba437
...@@ -266,6 +266,7 @@ class GfmAutoComplete { ...@@ -266,6 +266,7 @@ class GfmAutoComplete {
}, },
// eslint-disable-next-line no-template-curly-in-string // eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}', insertTpl: '${atwho-at}${username}',
limit: 10,
searchKey: 'search', searchKey: 'search',
alwaysHighlightFirst: true, alwaysHighlightFirst: true,
skipSpecialCharacterTest: true, skipSpecialCharacterTest: true,
...@@ -311,6 +312,38 @@ class GfmAutoComplete { ...@@ -311,6 +312,38 @@ class GfmAutoComplete {
return data; return data;
}, },
sorter(query, items) {
if (!query) {
return items;
}
// Disable auto-selecting the loading icon
this.setting.highlightFirst = this.setting.alwaysHighlightFirst;
if (GfmAutoComplete.isLoading(items)) {
this.setting.highlightFirst = false;
return items;
}
const lowercaseQuery = query.toLowerCase();
const members = items.slice();
const { nameOrUsernameStartsWith, nameOrUsernameIncludes } = GfmAutoComplete.Members;
return members.sort((a, b) => {
if (nameOrUsernameStartsWith(a, lowercaseQuery)) {
return -1;
}
if (nameOrUsernameStartsWith(b, lowercaseQuery)) {
return 1;
}
if (nameOrUsernameIncludes(a, lowercaseQuery)) {
return -1;
}
if (nameOrUsernameIncludes(b, lowercaseQuery)) {
return 1;
}
return 0;
});
},
}, },
}); });
} }
...@@ -772,6 +805,14 @@ GfmAutoComplete.Members = { ...@@ -772,6 +805,14 @@ GfmAutoComplete.Members = {
title, title,
)}${availabilityStatus}</small> ${icon}</li>`; )}${availabilityStatus}</small> ${icon}</li>`;
}, },
nameOrUsernameStartsWith(member, query) {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.split(' ').some((name) => name.toLowerCase().startsWith(query));
},
nameOrUsernameIncludes(member, query) {
// `member.search` is a name:username string like `MargeSimpson msimpson`
return member.search.toLowerCase().includes(query);
},
}; };
GfmAutoComplete.Labels = { GfmAutoComplete.Labels = {
templateFunction(color, title) { templateFunction(color, title) {
......
---
title: Improve at.js members autocomplete matching
merge_request: 54681
author:
type: changed
...@@ -15,25 +15,24 @@ Markdown fields. When you start typing a word in a Markdown field with one of ...@@ -15,25 +15,24 @@ Markdown fields. When you start typing a word in a Markdown field with one of
the following characters, GitLab progressively autocompletes against a set of the following characters, GitLab progressively autocompletes against a set of
matching values. The string matching is not case sensitive. matching values. The string matching is not case sensitive.
| Character | Autocompletes | | Character | Autocompletes | Relevant matches shown |
| :-------- | :------------ | | :-------- | :------------ | :---- |
| `~` | Labels | | `~` | Labels | 20 |
| `%` | Milestones | | `%` | Milestones | 5 |
| `@` | Users and groups | | `@` | Users and groups | 10 |
| `#` | Issues | | `#` | Issues | 5 |
| `!` | Merge requests | | `!` | Merge requests | 5 |
| `&` | Epics | | `&` | Epics | 5 |
| `$` | Snippets | | `$` | Snippets | 5 |
| `:` | Emoji | | `:` | Emoji | 5 |
| `/` | Quick Actions | | `/` | Quick Actions | 100 |
Up to 5 of the most relevant matches are displayed in a popup list. When you When you select an item from the list, the value is entered in the field.
select an item from the list, the value is entered in the field. The more The more characters you enter, the more precise the matches are.
characters you enter, the more precise the matches are.
Autocomplete characters are useful when combined with [Quick Actions](quick_actions.md). Autocomplete characters are useful when combined with [Quick Actions](quick_actions.md).
## Example ## User autocomplete
Assume your GitLab instance includes the following users: Assume your GitLab instance includes the following users:
...@@ -49,17 +48,9 @@ Assume your GitLab instance includes the following users: ...@@ -49,17 +48,9 @@ Assume your GitLab instance includes the following users:
<!-- vale gitlab.Spelling = YES --> <!-- vale gitlab.Spelling = YES -->
In an Issue comment, entering `@l` results in the following popup list User autocompletion sorts by the users whose username or name start with your query first.
appearing. Note that user `shelba` is not included, because the list includes For example, typing `@lea` shows `leanna` first and typing `@ros` shows `Rosemarie Rogahn` and `Rosy Grant` first.
only the 5 users most relevant to the Issue. Any usernames or names that include your query are shown afterwards in the autocomplete menu.
![Popup list which includes users whose username or name contains the letter `l`](img/autocomplete_characters_example1_v12_0.png)
If you continue to type, `@le`, the popup list changes to the following. The
popup now only includes users where `le` appears in their username, or a word in
their name.
![Popup list which includes users whose username or name contains the string](img/autocomplete_characters_example2_v12_0.png)
You can also search across the full name to find a user. You can also search across the full name to find a user.
To find `Rosy Grant`, even if their username is for example `hunter2`, you can type their full name without spaces like `@rosygrant`. To find `Rosy Grant`, even if their username is for example `alessandra`, you can type their full name without spaces like `@rosygrant`.
...@@ -6,6 +6,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -6,6 +6,7 @@ RSpec.describe 'GFM autocomplete', :js do
let_it_be(:user_xss_title) { 'eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' } let_it_be(:user_xss_title) { 'eve <img src=x onerror=alert(2)&lt;img src=x onerror=alert(1)&gt;' }
let_it_be(:user_xss) { create(:user, name: user_xss_title, username: 'xss.user') } let_it_be(:user_xss) { create(:user, name: user_xss_title, username: 'xss.user') }
let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') } let_it_be(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') }
let_it_be(:user2) { create(:user, name: 'Marge Simpson', username: 'msimpson') }
let_it_be(:group) { create(:group, name: 'Ancestor') } let_it_be(:group) { create(:group, name: 'Ancestor') }
let_it_be(:child_group) { create(:group, parent: group, name: 'My group') } let_it_be(:child_group) { create(:group, parent: group, name: 'My group') }
let_it_be(:project) { create(:project, group: child_group) } let_it_be(:project) { create(:project, group: child_group) }
...@@ -16,6 +17,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -16,6 +17,7 @@ RSpec.describe 'GFM autocomplete', :js do
before_all do before_all do
project.add_maintainer(user) project.add_maintainer(user)
project.add_maintainer(user_xss) project.add_maintainer(user_xss)
project.add_maintainer(user2)
end end
describe 'when tribute_autocomplete feature flag is off' do describe 'when tribute_autocomplete feature flag is off' do
...@@ -86,11 +88,7 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -86,11 +88,7 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
expect(page).to have_selector('.atwho-container') expect(find_highlighted_autocomplete_item).to have_content(user_xss.username)
page.within '.atwho-container #at-view-users' do
expect(find('li').text).to have_content(user_xss.username)
end
end end
it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do it 'opens autocomplete menu for Milestone when field starts with text with item escaping HTML characters' do
...@@ -190,7 +188,30 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -190,7 +188,30 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
expect(find('.atwho-view li', visible: true)).to have_content(user.name) expect(find_highlighted_autocomplete_item).to have_content(user.name)
end
it 'shows names that start with the query as the top result' do
type(find('#note-body'), '@mar')
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_content(user2.name)
end
it 'shows usernames that start with the query as the top result' do
type(find('#note-body'), '@msi')
wait_for_requests
expect(find_highlighted_autocomplete_item).to have_content(user2.name)
end
# Regression test for https://gitlab.com/gitlab-org/gitlab/-/issues/321925
it 'shows username when pasting then pressing Enter' do
fill_in 'Description', with: "@#{user.username}\n"
expect(find_field('Description').value).to have_content "@#{user.username}"
end end
it 'selects the first item for non-assignee dropdowns if a query is entered' do it 'selects the first item for non-assignee dropdowns if a query is entered' do
...@@ -1004,4 +1025,8 @@ RSpec.describe 'GFM autocomplete', :js do ...@@ -1004,4 +1025,8 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
end end
def find_highlighted_autocomplete_item
find('.atwho-view li.cur', visible: true)
end
end end
...@@ -576,55 +576,95 @@ describe('GfmAutoComplete', () => { ...@@ -576,55 +576,95 @@ describe('GfmAutoComplete', () => {
}); });
}); });
describe('Members.templateFunction', () => { describe('GfmAutoComplete.Members', () => {
it('should return html with avatarTag and username', () => { const member = {
expect( name: 'Marge Simpson',
GfmAutoComplete.Members.templateFunction({ username: 'msimpson',
avatarTag: 'IMG', search: 'MargeSimpson msimpson',
username: 'my-group', };
title: '',
icon: '',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
it('should add icon if icon is set', () => { describe('templateFunction', () => {
expect( it('should return html with avatarTag and username', () => {
GfmAutoComplete.Members.templateFunction({ expect(
avatarTag: 'IMG', GfmAutoComplete.Members.templateFunction({
username: 'my-group', avatarTag: 'IMG',
title: '', username: 'my-group',
icon: '<i class="icon"/>', title: '',
availabilityStatus: '', icon: '',
}), availabilityStatus: '',
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>'); }),
}); ).toBe('<li>IMG my-group <small></small> </li>');
});
it('should add escaped title if title is set', () => { it('should add icon if icon is set', () => {
expect( expect(
GfmAutoComplete.Members.templateFunction({ GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG', avatarTag: 'IMG',
username: 'my-group', username: 'my-group',
title: 'MyGroup+', title: '',
icon: '<i class="icon"/>', icon: '<i class="icon"/>',
availabilityStatus: '', availabilityStatus: '',
}), }),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>'); ).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
}); });
it('should add user availability status if availabilityStatus is set', () => { it('should add escaped title if title is set', () => {
expect( expect(
GfmAutoComplete.Members.templateFunction({ GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG', avatarTag: 'IMG',
username: 'my-group', username: 'my-group',
title: '', title: 'MyGroup+',
icon: '<i class="icon"/>', icon: '<i class="icon"/>',
availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>', availabilityStatus: '',
}), }),
).toBe( ).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
'<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', });
);
it('should add user availability status if availabilityStatus is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
availabilityStatus: '<span class="gl-text-gray-500"> (Busy)</span>',
}),
).toBe(
'<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>',
);
});
describe('nameOrUsernameStartsWith', () => {
it.each`
query | result
${'mar'} | ${true}
${'msi'} | ${true}
${'margesimpson'} | ${true}
${'msimpson'} | ${true}
${'arge'} | ${false}
${'rgesimp'} | ${false}
${'maria'} | ${false}
${'homer'} | ${false}
`('returns $result for $query', ({ query, result }) => {
expect(GfmAutoComplete.Members.nameOrUsernameStartsWith(member, query)).toBe(result);
});
});
describe('nameOrUsernameIncludes', () => {
it.each`
query | result
${'mar'} | ${true}
${'msi'} | ${true}
${'margesimpson'} | ${true}
${'msimpson'} | ${true}
${'arge'} | ${true}
${'rgesimp'} | ${true}
${'maria'} | ${false}
${'homer'} | ${false}
`('returns $result for $query', ({ query, result }) => {
expect(GfmAutoComplete.Members.nameOrUsernameIncludes(member, query)).toBe(result);
});
});
}); });
}); });
......
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