diff options
7 files changed, 190 insertions, 87 deletions
diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 index 081c2fa9aeb..d216fff07e3 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 @@ -3,6 +3,7 @@ constructor(page) { this.filteredSearchInput = document.querySelector('.filtered-search'); this.clearSearchButton = document.querySelector('.clear-search'); + this.tokensContainer = document.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; if (this.filteredSearchInput) { @@ -43,7 +44,9 @@ this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); + this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); + document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); } unbindEvents() { @@ -56,7 +59,9 @@ this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); + this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); + document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); } checkForBackspace(e) { @@ -99,6 +104,16 @@ } } + static selectToken(e) { + const button = e.target.closest('.selectable'); + + if (button) { + e.preventDefault(); + e.stopPropagation(); + gl.FilteredSearchVisualTokens.selectToken(button); + } + } + toggleClearSearchButton() { const query = gl.DropdownUtils.getSearchQuery(); const hidden = 'hidden'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js.es6 index 33f9d2e2651..3b04c6ef6fa 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js.es6 @@ -9,17 +9,37 @@ class FilteredSearchVisualTokens { }; } + static unselectTokens() { + const otherTokens = document.querySelectorAll('.js-visual-token .selectable.selected'); + [].forEach.call(otherTokens, t => t.classList.remove('selected')); + } + + static selectToken(tokenButton) { + const selected = tokenButton.classList.contains('selected'); + FilteredSearchVisualTokens.unselectTokens(); + + if (!selected) { + tokenButton.classList.add('selected'); + } + } + static addVisualTokenElement(name, value, isSearchTerm) { const li = document.createElement('li'); li.classList.add('js-visual-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); - li.innerHTML = '<div class="name"></div>'; - li.querySelector('.name').innerText = name; if (value) { - li.innerHTML += '<div class="value"></div>'; + li.innerHTML = ` + <div class="selectable" role="button"> + <div class="name"></div> + <div class="value"></div> + </div> + `; li.querySelector('.value').innerText = value; + } else { + li.innerHTML = '<div class="name"></div>'; } + li.querySelector('.name').innerText = name; const tokensContainer = document.querySelector('.tokens-container'); tokensContainer.appendChild(li); @@ -67,9 +87,11 @@ class FilteredSearchVisualTokens { const value = lastVisualToken.querySelector('.value'); if (value) { - lastVisualToken.removeChild(value); + const button = lastVisualToken.querySelector('.selectable'); + button.removeChild(value); + lastVisualToken.innerHTML = button.innerHTML; } else { - lastVisualToken.parentElement.removeChild(lastVisualToken); + lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken); } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index fbb2e728b0a..64e3251a849 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -49,6 +49,11 @@ margin-top: 5px; margin-bottom: 5px; + .selectable { + display: -webkit-flex; + display: flex; + } + .name, .value { display: inline-block; @@ -69,6 +74,16 @@ border-radius: 0 2px 2px 0; margin-right: 5px; } + + .selected { + .name { + background-color: $filter-name-selected-color; + } + + .value { + background-color: $filter-value-selected-color; + } + } } .filtered-search-term { @@ -77,6 +92,10 @@ color: $black; text-transform: none; } + + .selectable { + cursor: text; + } } .scroll-container { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index cbf7270bf65..b9f1d781ea8 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -547,3 +547,5 @@ Filtered Search $filter-name-resting-color: #f8f8f8; $filter-name-text-color: rgba(0, 0, 0, 0.55); $filter-value-text-color: rgba(0, 0, 0, 0.85); +$filter-name-selected-color: #ebebeb; +$filter-value-selected-color: #d7d7d7; diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 index 975fb1da40a..51582251cbe 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 @@ -9,30 +9,40 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper (() => { describe('Filtered Search Manager', () => { let input; + let manager; + let tokensContainer; + const placeholder = 'Search or filter results...'; + + beforeEach(() => { + setFixtures(` + <form> + <ul class="tokens-container list-unstyled"></ul> + <input type='text' class='filtered-search' placeholder='${placeholder}' /> + <button class="clear-search" type="button"> + <i class="fa fa-times"></i> + </button> + </form> + `); + + spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); + spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); + spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {}); + spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); + spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {}); + spyOn(gl.utils, 'getParameterByName').and.returnValue(null); + + input = document.querySelector('.filtered-search'); + tokensContainer = document.querySelector('.tokens-container'); + manager = new gl.FilteredSearchManager(); + }); + + afterEach(() => { + tokensContainer.innerHTML = ''; + }); describe('search', () => { - let manager; const defaultParams = '?scope=all&utf8=✓&state=opened'; - beforeEach(() => { - setFixtures(` - <input type='text' class='filtered-search' /> - `); - - spyOn(gl.FilteredSearchManager.prototype, 'bindEvents').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); - spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); - spyOn(gl.utils, 'getParameterByName').and.returnValue(null); - - input = document.querySelector('.filtered-search'); - manager = new gl.FilteredSearchManager(); - }); - - afterEach(() => { - input.outerHTML = ''; - }); - it('should search with a single word', () => { input.value = 'searchTerm'; @@ -65,35 +75,6 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); describe('handleInputPlaceholder', () => { - const placeholder = 'Search or filter results...'; - let tokensContainer; - - beforeEach(() => { - setFixtures(` - <form> - <ul class="tokens-container list-unstyled"></ul> - <input type='text' class='filtered-search' placeholder='${placeholder}' /> - <button class="clear-search" type="button"> - <i class="fa fa-times"></i> - </button> - </form> - `); - - spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); - spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); - spyOn(gl.utils, 'getParameterByName').and.returnValue(null); - - input = document.querySelector('.filtered-search'); - tokensContainer = document.querySelector('.tokens-container'); - return new gl.FilteredSearchManager(); - }); - - afterEach(() => { - input.outerHTML = ''; - tokensContainer.innerHTML = ''; - }); - it('should render placeholder when there is no input', () => { expect(input.placeholder).toEqual(placeholder); }); @@ -118,39 +99,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); describe('checkForBackspace', () => { - let tokensContainer; const backspaceKey = 8; const deleteKey = 46; - beforeEach(() => { - setFixtures(` - <form> - <ul class="tokens-container list-unstyled"></ul> - <input type='text' class='filtered-search' /> - <button class="clear-search" type="button"> - <i class="fa fa-times"></i> - </button> - </form> - `); - - spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'loadSearchParamsFromURL').and.callFake(() => {}); - spyOn(gl.FilteredSearchManager.prototype, 'tokenChange').and.callFake(() => {}); - spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); - spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {}); - - spyOn(gl.utils, 'getParameterByName').and.returnValue(null); - - input = document.querySelector('.filtered-search'); - tokensContainer = document.querySelector('.tokens-container'); - return new gl.FilteredSearchManager(); - }); - - afterEach(() => { - input.outerHTML = ''; - tokensContainer.innerHTML = ''; - }); - describe('tokens and no input', () => { beforeEach(() => { tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'); @@ -193,5 +144,35 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper expect(input.value).toEqual('text'); }); }); + + describe('unselects token', () => { + beforeEach(() => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug', true)} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} + `; + }); + + it('unselects token when input is clicked', () => { + const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); + + expect(selectedToken.classList.contains('selected')).toEqual(true); + + input.click(); + + expect(selectedToken.classList.contains('selected')).toEqual(false); + }); + + it('unselects token when document.body is clicked', () => { + const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); + + expect(selectedToken.classList.contains('selected')).toEqual(true); + + document.body.click(); + + expect(selectedToken.classList.contains('selected')).toEqual(false); + }); + }); }); })(); diff --git a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js.es6 index 8c5150a6134..ff21d096f9d 100644 --- a/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js.es6 +++ b/spec/javascripts/filtered_search/filtered_search_visual_tokens_spec.js.es6 @@ -76,6 +76,68 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); }); + describe('unselectTokens', () => { + it('does nothing when there are no tokens', () => { + const beforeHTML = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.unselectTokens(); + + expect(tokensContainer.innerHTML).toEqual(beforeHTML); + }); + + it('removes the selected class from buttons', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)} + `; + + const selected = tokensContainer.querySelector('.js-visual-token .selected'); + expect(selected.classList.contains('selected')).toEqual(true); + + gl.FilteredSearchVisualTokens.unselectTokens(); + + expect(selected.classList.contains('selected')).toEqual(false); + }); + }); + + describe('selectToken', () => { + beforeEach(() => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} + `; + }); + + it('removes the selected class if it has selected class', () => { + const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); + firstTokenButton.classList.add('selected'); + + gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + + expect(firstTokenButton.classList.contains('selected')).toEqual(false); + }); + + describe('has no selected class', () => { + it('adds selected class', () => { + const firstTokenButton = tokensContainer.querySelector('.js-visual-token .selectable'); + + gl.FilteredSearchVisualTokens.selectToken(firstTokenButton); + + expect(firstTokenButton.classList.contains('selected')).toEqual(true); + }); + + it('removes selected class from other tokens', () => { + const tokenButtons = tokensContainer.querySelectorAll('.js-visual-token .selectable'); + tokenButtons[1].classList.add('selected'); + + gl.FilteredSearchVisualTokens.selectToken(tokenButtons[0]); + + expect(tokenButtons[0].classList.contains('selected')).toEqual(true); + expect(tokenButtons[1].classList.contains('selected')).toEqual(false); + }); + }); + }); + describe('addVisualTokenElement', () => { it('renders search visual tokens', () => { gl.FilteredSearchVisualTokens.addVisualTokenElement('search term', null, true); diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 b/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 index 567efdd838c..31f4d93f8b8 100644 --- a/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 @@ -1,9 +1,11 @@ class FilteredSearchSpecHelper { - static createFilterVisualTokenHTML(name, value) { + static createFilterVisualTokenHTML(name, value, isSelected = false) { return ` <li class="js-visual-token filtered-search-token"> - <div class="name">${name}</div> - <div class="value">${value}</div> + <div class="selectable ${isSelected ? 'selected' : ''}" role="button"> + <div class="name">${name}</div> + <div class="value">${value}</div> + </div> </li> `; } |