Commit 0b12ce97 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '118597-improve-autofill-suggestions-for-usernames' into 'master'

Improve at.js members autocomplete matching

See merge request gitlab-org/gitlab!54681
parents 368c666b 3b0d5230
...@@ -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,7 +576,14 @@ describe('GfmAutoComplete', () => { ...@@ -576,7 +576,14 @@ describe('GfmAutoComplete', () => {
}); });
}); });
describe('Members.templateFunction', () => { describe('GfmAutoComplete.Members', () => {
const member = {
name: 'Marge Simpson',
username: 'msimpson',
search: 'MargeSimpson msimpson',
};
describe('templateFunction', () => {
it('should return html with avatarTag and username', () => { it('should return html with avatarTag and username', () => {
expect( expect(
GfmAutoComplete.Members.templateFunction({ GfmAutoComplete.Members.templateFunction({
...@@ -626,6 +633,39 @@ describe('GfmAutoComplete', () => { ...@@ -626,6 +633,39 @@ describe('GfmAutoComplete', () => {
'<li>IMG my-group <small><span class="gl-text-gray-500"> (Busy)</span></small> <i class="icon"/></li>', '<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);
});
});
});
}); });
describe('labels', () => { describe('labels', () => {
......
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