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 {
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: '${atwho-at}${username}',
limit: 10,
searchKey: 'search',
alwaysHighlightFirst: true,
skipSpecialCharacterTest: true,
......@@ -311,6 +312,38 @@ class GfmAutoComplete {
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 = {
title,
)}${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 = {
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
the following characters, GitLab progressively autocompletes against a set of
matching values. The string matching is not case sensitive.
| Character | Autocompletes |
| :-------- | :------------ |
| `~` | Labels |
| `%` | Milestones |
| `@` | Users and groups |
| `#` | Issues |
| `!` | Merge requests |
| `&` | Epics |
| `$` | Snippets |
| `:` | Emoji |
| `/` | Quick Actions |
Up to 5 of the most relevant matches are displayed in a popup list. When you
select an item from the list, the value is entered in the field. The more
characters you enter, the more precise the matches are.
| Character | Autocompletes | Relevant matches shown |
| :-------- | :------------ | :---- |
| `~` | Labels | 20 |
| `%` | Milestones | 5 |
| `@` | Users and groups | 10 |
| `#` | Issues | 5 |
| `!` | Merge requests | 5 |
| `&` | Epics | 5 |
| `$` | Snippets | 5 |
| `:` | Emoji | 5 |
| `/` | Quick Actions | 100 |
When you select an item from the list, the value is entered in the field.
The more characters you enter, the more precise the matches are.
Autocomplete characters are useful when combined with [Quick Actions](quick_actions.md).
## Example
## User autocomplete
Assume your GitLab instance includes the following users:
......@@ -49,17 +48,9 @@ Assume your GitLab instance includes the following users:
<!-- vale gitlab.Spelling = YES -->
In an Issue comment, entering `@l` results in the following popup list
appearing. Note that user `shelba` is not included, because the list includes
only the 5 users most relevant to the Issue.
![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)
User autocompletion sorts by the users whose username or name start with your query first.
For example, typing `@lea` shows `leanna` first and typing `@ros` shows `Rosemarie Rogahn` and `Rosy Grant` first.
Any usernames or names that include your query are shown afterwards in the autocomplete menu.
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
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) { 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(:child_group) { create(:group, parent: group, name: 'My group') }
let_it_be(:project) { create(:project, group: child_group) }
......@@ -16,6 +17,7 @@ RSpec.describe 'GFM autocomplete', :js do
before_all do
project.add_maintainer(user)
project.add_maintainer(user_xss)
project.add_maintainer(user2)
end
describe 'when tribute_autocomplete feature flag is off' do
......@@ -86,11 +88,7 @@ RSpec.describe 'GFM autocomplete', :js do
wait_for_requests
expect(page).to have_selector('.atwho-container')
page.within '.atwho-container #at-view-users' do
expect(find('li').text).to have_content(user_xss.username)
end
expect(find_highlighted_autocomplete_item).to have_content(user_xss.username)
end
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
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
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
wait_for_requests
end
def find_highlighted_autocomplete_item
find('.atwho-view li.cur', visible: true)
end
end
......@@ -576,55 +576,95 @@ describe('GfmAutoComplete', () => {
});
});
describe('Members.templateFunction', () => {
it('should return html with avatarTag and username', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
describe('GfmAutoComplete.Members', () => {
const member = {
name: 'Marge Simpson',
username: 'msimpson',
search: 'MargeSimpson msimpson',
};
it('should add icon if icon is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> <i class="icon"/></li>');
});
describe('templateFunction', () => {
it('should return html with avatarTag and username', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></small> </li>');
});
it('should add escaped title if title is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: 'MyGroup+',
icon: '<i class="icon"/>',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small>MyGroup+</small> <i class="icon"/></li>');
});
it('should add icon if icon is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: '',
icon: '<i class="icon"/>',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small></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>',
);
it('should add escaped title if title is set', () => {
expect(
GfmAutoComplete.Members.templateFunction({
avatarTag: 'IMG',
username: 'my-group',
title: 'MyGroup+',
icon: '<i class="icon"/>',
availabilityStatus: '',
}),
).toBe('<li>IMG my-group <small>MyGroup+</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