Style people in issuable search bar (!11402)
This commit is contained in:
parent
f032731e47
commit
0583916d2d
10 changed files with 237 additions and 22 deletions
|
@ -102,10 +102,13 @@ class DropdownUtils {
|
|||
if (token.classList.contains('js-visual-token')) {
|
||||
const name = token.querySelector('.name');
|
||||
const value = token.querySelector('.value');
|
||||
const valueContainer = token.querySelector('.value-container');
|
||||
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
|
||||
let valueText = '';
|
||||
|
||||
if (value && value.innerText) {
|
||||
if (valueContainer && valueContainer.dataset.originalValue) {
|
||||
valueText = valueContainer.dataset.originalValue;
|
||||
} else if (value && value.innerText) {
|
||||
valueText = value.innerText;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import AjaxCache from '~/lib/utils/ajax_cache';
|
||||
import '~/flash'; /* global Flash */
|
||||
import AjaxCache from '../lib/utils/ajax_cache';
|
||||
import '../flash'; /* global Flash */
|
||||
import FilteredSearchContainer from './container';
|
||||
import UsersCache from '../lib/utils/users_cache';
|
||||
|
||||
class FilteredSearchVisualTokens {
|
||||
static getLastVisualTokenBeforeInput() {
|
||||
|
@ -82,12 +83,42 @@ class FilteredSearchVisualTokens {
|
|||
.catch(() => new Flash('An error occurred while fetching label colors.'));
|
||||
}
|
||||
|
||||
static updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue) {
|
||||
if (tokenValue === 'none') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const username = tokenValue.replace(/^@/, '');
|
||||
return UsersCache.retrieve(username)
|
||||
.then((user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
tokenValueContainer.dataset.originalValue = tokenValue;
|
||||
tokenValueElement.innerHTML = `
|
||||
<img class="avatar s20" src="${user.avatar_url}" alt="${user.name}'s avatar">
|
||||
${user.name}
|
||||
`;
|
||||
/* eslint-enable no-param-reassign */
|
||||
})
|
||||
// ignore error and leave username in the search bar
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
static renderVisualTokenValue(parentElement, tokenName, tokenValue) {
|
||||
const tokenValueContainer = parentElement.querySelector('.value-container');
|
||||
tokenValueContainer.querySelector('.value').innerText = tokenValue;
|
||||
const tokenValueElement = tokenValueContainer.querySelector('.value');
|
||||
tokenValueElement.innerText = tokenValue;
|
||||
|
||||
if (tokenName.toLowerCase() === 'label') {
|
||||
const tokenType = tokenName.toLowerCase();
|
||||
if (tokenType === 'label') {
|
||||
FilteredSearchVisualTokens.updateLabelTokenColor(tokenValueContainer, tokenValue);
|
||||
} else if ((tokenType === 'author') || (tokenType === 'assignee')) {
|
||||
FilteredSearchVisualTokens.updateUserTokenAppearance(
|
||||
tokenValueContainer, tokenValueElement, tokenValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -153,6 +184,12 @@ class FilteredSearchVisualTokens {
|
|||
|
||||
if (!lastVisualToken) return '';
|
||||
|
||||
const valueContainer = lastVisualToken.querySelector('.value-container');
|
||||
const originalValue = valueContainer && valueContainer.dataset.originalValue;
|
||||
if (originalValue) {
|
||||
return originalValue;
|
||||
}
|
||||
|
||||
const value = lastVisualToken.querySelector('.value');
|
||||
const name = lastVisualToken.querySelector('.name');
|
||||
|
||||
|
@ -205,17 +242,28 @@ class FilteredSearchVisualTokens {
|
|||
const inputLi = input.parentElement;
|
||||
tokenContainer.replaceChild(inputLi, token);
|
||||
|
||||
const name = token.querySelector('.name');
|
||||
const value = token.querySelector('.value');
|
||||
const nameElement = token.querySelector('.name');
|
||||
let value;
|
||||
|
||||
if (token.classList.contains('filtered-search-token') && value) {
|
||||
FilteredSearchVisualTokens.addFilterVisualToken(name.innerText);
|
||||
input.value = value.innerText;
|
||||
} else {
|
||||
// token is a search term
|
||||
input.value = name.innerText;
|
||||
if (token.classList.contains('filtered-search-token')) {
|
||||
FilteredSearchVisualTokens.addFilterVisualToken(nameElement.innerText);
|
||||
|
||||
const valueContainerElement = token.querySelector('.value-container');
|
||||
value = valueContainerElement.dataset.originalValue;
|
||||
|
||||
if (!value) {
|
||||
const valueElement = valueContainerElement.querySelector('.value');
|
||||
value = valueElement.innerText;
|
||||
}
|
||||
}
|
||||
|
||||
// token is a search term
|
||||
if (!value) {
|
||||
value = nameElement.innerText;
|
||||
}
|
||||
|
||||
input.value = value;
|
||||
|
||||
// Opens dropdown
|
||||
const inputEvent = new Event('input');
|
||||
input.dispatchEvent(inputEvent);
|
||||
|
|
|
@ -90,6 +90,7 @@
|
|||
.filtered-search-term {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin-top: 5px;
|
||||
margin-bottom: 5px;
|
||||
|
||||
|
|
4
changelogs/unreleased/winh-styled-people-search-bar.yml
Normal file
4
changelogs/unreleased/winh-styled-people-search-bar.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
title: Style people in issuable search bar
|
||||
merge_request: 11402
|
||||
author:
|
|
@ -89,7 +89,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
|
|||
page.within('.add-issues-modal') do
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_selector('.js-visual-token', text: user2.username)
|
||||
expect(page).to have_selector('.js-visual-token', text: user2.name)
|
||||
expect(page).to have_selector('.card', count: 1)
|
||||
end
|
||||
end
|
||||
|
@ -125,7 +125,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
|
|||
page.within('.add-issues-modal') do
|
||||
wait_for_requests
|
||||
|
||||
expect(page).to have_selector('.js-visual-token', text: user2.username)
|
||||
expect(page).to have_selector('.js-visual-token', text: user2.name)
|
||||
expect(page).to have_selector('.card', count: 1)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,7 +6,7 @@ describe 'Filter issues', js: true, feature: true do
|
|||
|
||||
let!(:group) { create(:group) }
|
||||
let!(:project) { create(:project, group: group) }
|
||||
let!(:user) { create(:user, username: 'joe') }
|
||||
let!(:user) { create(:user, username: 'joe', name: 'Joe') }
|
||||
let!(:user2) { create(:user, username: 'jane') }
|
||||
let!(:label) { create(:label, project: project) }
|
||||
let!(:wontfix) { create(:label, project: project, title: "Won't fix") }
|
||||
|
|
|
@ -2,6 +2,7 @@ require 'rails_helper'
|
|||
|
||||
describe 'Visual tokens', js: true, feature: true do
|
||||
include FilteredSearchHelpers
|
||||
include WaitForRequests
|
||||
|
||||
let!(:project) { create(:empty_project) }
|
||||
let!(:user) { create(:user, name: 'administrator', username: 'root') }
|
||||
|
@ -70,7 +71,8 @@ describe 'Visual tokens', js: true, feature: true do
|
|||
end
|
||||
|
||||
it 'changes value in visual token' do
|
||||
expect(first('.tokens-container .filtered-search-token .value').text).to eq("@#{user_rock.username}")
|
||||
wait_for_requests
|
||||
expect(first('.tokens-container .filtered-search-token .value').text).to eq("#{user_rock.name}")
|
||||
end
|
||||
|
||||
it 'moves input to the right' do
|
||||
|
|
|
@ -2,8 +2,12 @@ import '~/extensions/array';
|
|||
import '~/filtered_search/dropdown_utils';
|
||||
import '~/filtered_search/filtered_search_tokenizer';
|
||||
import '~/filtered_search/filtered_search_dropdown_manager';
|
||||
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
|
||||
|
||||
describe('Dropdown Utils', () => {
|
||||
const issueListFixture = 'issues/issue_list.html.raw';
|
||||
preloadFixtures(issueListFixture);
|
||||
|
||||
describe('getEscapedText', () => {
|
||||
it('should return same word when it has no space', () => {
|
||||
const escaped = gl.DropdownUtils.getEscapedText('textWithoutSpace');
|
||||
|
@ -314,4 +318,29 @@ describe('Dropdown Utils', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchQuery', () => {
|
||||
let authorToken;
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtures(issueListFixture);
|
||||
|
||||
authorToken = FilteredSearchSpecHelper.createFilterVisualToken('author', '@user');
|
||||
const searchTermToken = FilteredSearchSpecHelper.createSearchVisualToken('search term');
|
||||
|
||||
const tokensContainer = document.querySelector('.tokens-container');
|
||||
tokensContainer.appendChild(searchTermToken);
|
||||
tokensContainer.appendChild(authorToken);
|
||||
});
|
||||
|
||||
it('uses original value if present', () => {
|
||||
const originalValue = 'original dance';
|
||||
const valueContainer = authorToken.querySelector('.value-container');
|
||||
valueContainer.dataset.originalValue = originalValue;
|
||||
|
||||
const searchQuery = gl.DropdownUtils.getSearchQuery();
|
||||
|
||||
expect(searchQuery).toBe(' search term author:original dance');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import AjaxCache from '~/lib/utils/ajax_cache';
|
||||
import UsersCache from '~/lib/utils/users_cache';
|
||||
|
||||
import '~/filtered_search/filtered_search_visual_tokens';
|
||||
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
|
||||
|
@ -406,6 +407,22 @@ describe('Filtered Search Visual Tokens', () => {
|
|||
expect(subject.getLastTokenPartial()).toEqual(value);
|
||||
});
|
||||
|
||||
it('should get last token original value if available', () => {
|
||||
const originalValue = '@user';
|
||||
const valueContainer = authorToken.querySelector('.value-container');
|
||||
valueContainer.dataset.originalValue = originalValue;
|
||||
const avatar = document.createElement('img');
|
||||
const valueElement = valueContainer.querySelector('.value');
|
||||
valueElement.insertAdjacentElement('afterbegin', avatar);
|
||||
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
|
||||
authorToken.outerHTML,
|
||||
);
|
||||
|
||||
const lastTokenValue = subject.getLastTokenPartial();
|
||||
|
||||
expect(lastTokenValue).toEqual(originalValue);
|
||||
});
|
||||
|
||||
it('should get last token name if there is no value', () => {
|
||||
const name = 'assignee';
|
||||
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
|
||||
|
@ -534,6 +551,16 @@ describe('Filtered Search Visual Tokens', () => {
|
|||
expect(input.value).toEqual('none');
|
||||
});
|
||||
|
||||
it('input contains the original value if present', () => {
|
||||
const originalValue = '@user';
|
||||
const valueContainer = token.querySelector('.value-container');
|
||||
valueContainer.dataset.originalValue = originalValue;
|
||||
|
||||
subject.editToken(token);
|
||||
|
||||
expect(input.value).toEqual(originalValue);
|
||||
});
|
||||
|
||||
describe('selected token is a search term token', () => {
|
||||
beforeEach(() => {
|
||||
token = document.querySelector('.filtered-search-term');
|
||||
|
@ -633,6 +660,7 @@ describe('Filtered Search Visual Tokens', () => {
|
|||
const milestoneToken = FilteredSearchSpecHelper.createFilterVisualToken('milestone', 'upcoming');
|
||||
|
||||
let updateLabelTokenColorSpy;
|
||||
let updateUserTokenAppearanceSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(`
|
||||
|
@ -644,6 +672,24 @@ describe('Filtered Search Visual Tokens', () => {
|
|||
|
||||
spyOn(subject, 'updateLabelTokenColor');
|
||||
updateLabelTokenColorSpy = subject.updateLabelTokenColor;
|
||||
|
||||
spyOn(subject, 'updateUserTokenAppearance');
|
||||
updateUserTokenAppearanceSpy = subject.updateUserTokenAppearance;
|
||||
});
|
||||
|
||||
it('renders a author token value element', () => {
|
||||
const { tokenNameElement, tokenValueContainer, tokenValueElement } =
|
||||
findElements(authorToken);
|
||||
const tokenName = tokenNameElement.innerText;
|
||||
const tokenValue = 'new value';
|
||||
|
||||
subject.renderVisualTokenValue(authorToken, tokenName, tokenValue);
|
||||
|
||||
expect(tokenValueElement.innerText).toBe(tokenValue);
|
||||
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(1);
|
||||
const expectedArgs = [tokenValueContainer, tokenValueElement, tokenValue];
|
||||
expect(updateUserTokenAppearanceSpy.calls.argsFor(0)).toEqual(expectedArgs);
|
||||
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('renders a label token value element', () => {
|
||||
|
@ -658,6 +704,7 @@ describe('Filtered Search Visual Tokens', () => {
|
|||
expect(updateLabelTokenColorSpy.calls.count()).toBe(1);
|
||||
const expectedArgs = [tokenValueContainer, tokenValue];
|
||||
expect(updateLabelTokenColorSpy.calls.argsFor(0)).toEqual(expectedArgs);
|
||||
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
|
||||
});
|
||||
|
||||
it('renders a milestone token value element', () => {
|
||||
|
@ -669,6 +716,84 @@ describe('Filtered Search Visual Tokens', () => {
|
|||
|
||||
expect(tokenValueElement.innerText).toBe(tokenValue);
|
||||
expect(updateLabelTokenColorSpy.calls.count()).toBe(0);
|
||||
expect(updateUserTokenAppearanceSpy.calls.count()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUserTokenAppearance', () => {
|
||||
let usersCacheSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(UsersCache, 'retrieve').and.callFake(username => usersCacheSpy(username));
|
||||
});
|
||||
|
||||
it('ignores special value "none"', (done) => {
|
||||
usersCacheSpy = (username) => {
|
||||
expect(username).toBe('none');
|
||||
done.fail('Should not resolve "none"!');
|
||||
};
|
||||
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
|
||||
|
||||
subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, 'none')
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
it('ignores error if UsersCache throws', (done) => {
|
||||
spyOn(window, 'Flash');
|
||||
const dummyError = new Error('Earth rotated backwards');
|
||||
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
|
||||
const tokenValue = tokenValueElement.innerText;
|
||||
usersCacheSpy = (username) => {
|
||||
expect(`@${username}`).toBe(tokenValue);
|
||||
return Promise.reject(dummyError);
|
||||
};
|
||||
|
||||
subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
|
||||
.then(() => {
|
||||
expect(window.Flash.calls.count()).toBe(0);
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
it('does nothing if user cannot be found', (done) => {
|
||||
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
|
||||
const tokenValue = tokenValueElement.innerText;
|
||||
usersCacheSpy = (username) => {
|
||||
expect(`@${username}`).toBe(tokenValue);
|
||||
return Promise.resolve(undefined);
|
||||
};
|
||||
|
||||
subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
|
||||
.then(() => {
|
||||
expect(tokenValueElement.innerText).toBe(tokenValue);
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
it('replaces author token with avatar and display name', (done) => {
|
||||
const dummyUser = {
|
||||
name: 'Important Person',
|
||||
avatar_url: 'https://host.invalid/mypics/avatar.png',
|
||||
};
|
||||
const { tokenValueContainer, tokenValueElement } = findElements(authorToken);
|
||||
const tokenValue = tokenValueElement.innerText;
|
||||
usersCacheSpy = (username) => {
|
||||
expect(`@${username}`).toBe(tokenValue);
|
||||
return Promise.resolve(dummyUser);
|
||||
};
|
||||
|
||||
subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue)
|
||||
.then(() => {
|
||||
expect(tokenValueContainer.dataset.originalValue).toBe(tokenValue);
|
||||
expect(tokenValueElement.innerText.trim()).toBe(dummyUser.name);
|
||||
const avatar = tokenValueElement.querySelector('img.avatar');
|
||||
expect(avatar.src).toBe(dummyUser.avatar_url);
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -30,12 +30,15 @@ export default class FilteredSearchSpecHelper {
|
|||
`;
|
||||
}
|
||||
|
||||
static createSearchVisualToken(name) {
|
||||
const li = document.createElement('li');
|
||||
li.classList.add('js-visual-token', 'filtered-search-term');
|
||||
li.innerHTML = `<div class="name">${name}</div>`;
|
||||
return li;
|
||||
}
|
||||
|
||||
static createSearchVisualTokenHTML(name) {
|
||||
return `
|
||||
<li class="js-visual-token filtered-search-term">
|
||||
<div class="name">${name}</div>
|
||||
</li>
|
||||
`;
|
||||
return FilteredSearchSpecHelper.createSearchVisualToken(name).outerHTML;
|
||||
}
|
||||
|
||||
static createInputHTML(placeholder = '', value = '') {
|
||||
|
|
Loading…
Reference in a new issue