From ce9236c004d4daeb4389f4d8e2584e742846eb8f Mon Sep 17 00:00:00 2001 From: Clement Ho Date: Tue, 7 Mar 2017 17:55:12 +0000 Subject: Double click tokens editable --- app/assets/javascripts/droplab/droplab_ajax.js | 6 +- .../filtered_search/dropdown_utils.js.es6 | 6 +- .../filtered_search_dropdown.js.es6 | 2 +- .../filtered_search_dropdown_manager.js.es6 | 6 +- .../filtered_search/filtered_search_manager.js.es6 | 43 +- .../filtered_search_visual_tokens.js.es6 | 122 +++++- app/assets/stylesheets/framework/filters.scss | 2 - app/views/shared/issuable/_search_bar.html.haml | 3 +- .../issues/filtered_search/visual_tokens_spec.rb | 306 ++++++++++++++ .../filtered_search_dropdown_manager_spec.js.es6 | 50 +-- .../filtered_search_manager_spec.js.es6 | 77 ++-- .../filtered_search_visual_tokens_spec.js.es6 | 443 +++++++++++++++++---- .../helpers/filtered_search_spec_helper.js.es6 | 38 +- 13 files changed, 926 insertions(+), 178 deletions(-) create mode 100644 spec/features/issues/filtered_search/visual_tokens_spec.rb diff --git a/app/assets/javascripts/droplab/droplab_ajax.js b/app/assets/javascripts/droplab/droplab_ajax.js index 5cdf11c6a2c..f61be741b4a 100644 --- a/app/assets/javascripts/droplab/droplab_ajax.js +++ b/app/assets/javascripts/droplab/droplab_ajax.js @@ -37,11 +37,14 @@ require('../window')(function(w){ } } - self.hook.list[config.method].call(self.hook.list, data); + if (!self.destroyed) { + self.hook.list[config.method].call(self.hook.list, data); + } }, init: function init(hook) { var self = this; + self.destroyed = false; self.cache = self.cache || {}; var config = hook.config.droplabAjax; this.hook = hook; @@ -79,6 +82,7 @@ require('../window')(function(w){ destroy: function() { var dynamicList = this.hook.list.list.querySelector('[data-dynamic]'); + this.destroyed = true; if (this.listTemplate && dynamicList) { dynamicList.outerHTML = this.listTemplate; } diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 index b666cac5bf0..14f0d0d0ff2 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js.es6 @@ -72,7 +72,7 @@ const dataValue = selected.getAttribute('data-value'); if (dataValue) { - gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue); + gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true); } // Return boolean based on whether it was set @@ -100,8 +100,8 @@ } }); - const inputValue = document.querySelector('.filtered-search').value; - values.push(inputValue); + const input = document.querySelector('.filtered-search'); + values.push(input && input.value); return values.join(' '); } diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 index dd565da507e..134bdc6ad80 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown.js.es6 @@ -35,7 +35,7 @@ if (!dataValueSet) { const value = getValueFunction(selected); - gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value); + gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true); } this.dismissDropdown(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 index be4c610f6dd..c3247f52da8 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_dropdown_manager.js.es6 @@ -58,12 +58,16 @@ }; } - static addWordToInput(tokenName, tokenValue = '') { + static addWordToInput(tokenName, tokenValue = '', clicked = false) { const input = document.querySelector('.filtered-search'); gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue); input.value = ''; + if (clicked) { + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + } + // Get the string to replace // let newCaretPosition = input.selectionStart; // const { left, right } = gl.DropdownUtils.getInputSelectionPosition(input); 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 935daf3199b..9e6692e3d21 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js.es6 @@ -34,6 +34,7 @@ this.clearSearchWrapper = this.clearSearch.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); + this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.tokenChange = this.tokenChange.bind(this); this.filteredSearchInput.form.addEventListener('submit', this.handleFormSubmit); @@ -46,8 +47,10 @@ this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); + this.tokensContainer.addEventListener('dblclick', FilteredSearchManager.editToken); this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); + document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper); } @@ -62,8 +65,10 @@ this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); + this.tokensContainer.removeEventListener('dblclick', FilteredSearchManager.editToken); this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); + document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper); } @@ -71,7 +76,7 @@ // 8 = Backspace Key // 46 = Delete Key if (e.keyCode === 8 || e.keyCode === 46) { - const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualToken(); + const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (this.filteredSearchInput.value === '' && lastVisualToken) { this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); @@ -117,6 +122,26 @@ } } + unselectEditTokens(e) { + const inputContainer = document.querySelector('.filtered-search-input-container'); + const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); + const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; + const isElementScrollContainer = e.target.classList.contains('scroll-container'); + + if ((!isElementInFilteredSearch && !isElementInFilterDropdown) || isElementScrollContainer) { + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + this.dropdownManager.resetDropdowns(); + } + } + + static editToken(e) { + const token = e.target.closest('.js-visual-token'); + + if (token) { + gl.FilteredSearchVisualTokens.editToken(token); + } + } + toggleClearSearchButton() { const query = gl.DropdownUtils.getSearchQuery(); const hidden = 'hidden'; @@ -155,7 +180,19 @@ e.preventDefault(); this.filteredSearchInput.value = ''; - this.filteredSearchInput.parentElement.querySelector('.tokens-container').innerHTML = ''; + + const removeElements = []; + + [].forEach.call(this.tokensContainer.children, (t) => { + if (t.classList.contains('js-visual-token')) { + removeElements.push(t); + } + }); + + removeElements.forEach((el) => { + el.parentElement.removeChild(el); + }); + this.clearSearchButton.classList.add('hidden'); this.handleInputPlaceholder(); @@ -167,7 +204,7 @@ const { tokens, searchToken } = gl.FilteredSearchTokenizer.processTokens(input.value); const { isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualToken(); + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (isLastVisualTokenValid) { tokens.forEach((t) => { 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 4f3efea8e16..78b245726ee 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 @@ -1,11 +1,11 @@ class FilteredSearchVisualTokens { - static getLastVisualToken() { - const tokensContainer = document.querySelector('.tokens-container'); - const visualTokens = tokensContainer.children; - const lastVisualToken = visualTokens[visualTokens.length - 1]; + static getLastVisualTokenBeforeInput() { + const inputLi = document.querySelector('.input-token'); + const lastVisualToken = inputLi && inputLi.previousElementSibling; + return { lastVisualToken, - isLastVisualTokenValid: visualTokens.length === 0 || lastVisualToken.className.indexOf('filtered-search-term') !== -1 || (lastVisualToken && lastVisualToken.querySelector('.value') !== null), + isLastVisualTokenValid: lastVisualToken === null || lastVisualToken.className.indexOf('filtered-search-term') !== -1 || (lastVisualToken && lastVisualToken.querySelector('.value') !== null), }; } @@ -32,18 +32,22 @@ class FilteredSearchVisualTokens { } } + static createVisualTokenElementHTML() { + return ` +
+
+
+
+ `; + } + 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'); if (value) { - li.innerHTML = ` -
-
-
-
- `; + li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); li.querySelector('.value').innerText = value; } else { li.innerHTML = '
'; @@ -51,12 +55,25 @@ class FilteredSearchVisualTokens { li.querySelector('.name').innerText = name; const tokensContainer = document.querySelector('.tokens-container'); - tokensContainer.appendChild(li); + const input = document.querySelector('.filtered-search'); + tokensContainer.insertBefore(li, input.parentElement); + } + + static addValueToPreviousVisualTokenElement(value) { + const { lastVisualToken, isLastVisualTokenValid } = + FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) { + const name = FilteredSearchVisualTokens.getLastTokenPartial(); + lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); + lastVisualToken.querySelector('.name').innerText = name; + lastVisualToken.querySelector('.value').innerText = value; + } } static addFilterVisualToken(tokenName, tokenValue) { const { lastVisualToken, isLastVisualTokenValid } - = FilteredSearchVisualTokens.getLastVisualToken(); + = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; if (isLastVisualTokenValid) { @@ -72,11 +89,17 @@ class FilteredSearchVisualTokens { } static addSearchVisualToken(searchTerm) { - FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true); + const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) { + lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`; + } else { + FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true); + } } static getLastTokenPartial() { - const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualToken(); + const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (!lastVisualToken) return ''; @@ -90,7 +113,7 @@ class FilteredSearchVisualTokens { } static removeLastTokenPartial() { - const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualToken(); + const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); if (lastVisualToken) { const value = lastVisualToken.querySelector('.value'); @@ -104,6 +127,73 @@ class FilteredSearchVisualTokens { } } } + + static tokenizeInput() { + const input = document.querySelector('.filtered-search'); + const { isLastVisualTokenValid } = + gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + if (input.value) { + if (isLastVisualTokenValid) { + gl.FilteredSearchVisualTokens.addSearchVisualToken(input.value); + } else { + FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement(input.value); + } + + input.value = ''; + } + } + + static editToken(token) { + const input = document.querySelector('.filtered-search'); + + FilteredSearchVisualTokens.tokenizeInput(); + + // Replace token with input field + const tokenContainer = token.parentElement; + const inputLi = input.parentElement; + tokenContainer.replaceChild(inputLi, token); + + const name = token.querySelector('.name'); + const value = token.querySelector('.value'); + + if (token.classList.contains('filtered-search-token')) { + FilteredSearchVisualTokens.addFilterVisualToken(name.innerText); + input.value = value.innerText; + } else { + // token is a search term + input.value = name.innerText; + } + + // Opens dropdown + const inputEvent = new Event('input'); + input.dispatchEvent(inputEvent); + + // Adds cursor to input + input.focus(); + } + + static moveInputToTheRight() { + const input = document.querySelector('.filtered-search'); + const inputLi = input.parentElement; + const tokenContainer = document.querySelector('.tokens-container'); + + if (!tokenContainer.lastElementChild.isEqualNode(inputLi)) { + FilteredSearchVisualTokens.tokenizeInput(); + + const { isLastVisualTokenValid } = + gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + if (!isLastVisualTokenValid) { + const lastPartial = gl.FilteredSearchVisualTokens.getLastTokenPartial(); + gl.FilteredSearchVisualTokens.removeLastTokenPartial(); + gl.FilteredSearchVisualTokens.addSearchVisualToken(lastPartial); + } + + tokenContainer.removeChild(inputLi); + tokenContainer.appendChild(inputLi); + } + } } window.gl = window.gl || {}; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 64e3251a849..d418d172434 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -130,10 +130,8 @@ } .form-control { - left: 30px; position: relative; min-width: 200px; - max-width: calc(100% - 30px); padding-left: 0; padding-right: 25px; border-color: transparent; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 95c9bc7229e..fefe4802fc1 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -13,7 +13,8 @@ .filtered-search-input-container .scroll-container %ul.tokens-container.list-unstyled - %input.form-control.filtered-search{ placeholder: 'Search or filter results...', 'data-id' => 'filtered-search', 'data-project-id' => @project.id, 'data-username-params' => @users.to_json(only: [:id, :username]), 'data-base-endpoint' => namespace_project_path(@project.namespace, @project) } + %li.input-token + %input.form-control.filtered-search{ placeholder: 'Search or filter results...', 'data-id' => 'filtered-search', 'data-project-id' => @project.id, 'data-username-params' => @users.to_json(only: [:id, :username]), 'data-base-endpoint' => namespace_project_path(@project.namespace, @project) } = icon('filter') %button.clear-search.hidden{ type: 'button' } = icon('times') diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb new file mode 100644 index 00000000000..b62a6d7913d --- /dev/null +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -0,0 +1,306 @@ +require 'rails_helper' + +describe 'Visual tokens', js: true, feature: true do + include FilteredSearchHelpers + + let!(:project) { create(:empty_project) } + let!(:user) { create(:user, name: 'administrator', username: 'root') } + let!(:user_rock) { create(:user, name: 'The Rock', username: 'rock') } + let!(:milestone_nine) { create(:milestone, title: '9.0', project: project) } + let!(:milestone_ten) { create(:milestone, title: '10.0', project: project) } + let!(:label) { create(:label, project: project, title: 'abc') } + let!(:cc_label) { create(:label, project: project, title: 'Community Contribution') } + + let(:filtered_search) { find('.filtered-search') } + let(:filter_author_dropdown) { find("#js-dropdown-author .filter-dropdown") } + let(:filter_assignee_dropdown) { find("#js-dropdown-assignee .filter-dropdown") } + let(:filter_milestone_dropdown) { find("#js-dropdown-milestone .filter-dropdown") } + let(:filter_label_dropdown) { find("#js-dropdown-label .filter-dropdown") } + + def is_input_focused + page.evaluate_script("document.activeElement.classList.contains('filtered-search')") + end + + before do + project.add_user(user, :master) + project.add_user(user_rock, :master) + login_as(user) + create(:issue, project: project) + + visit namespace_project_issues_path(project.namespace, project) + end + + describe 'editing author token' do + before do + input_filtered_search('author:@root assignee:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + end + + it 'opens author dropdown' do + expect(page).to have_css('#js-dropdown-author', visible: true) + end + + it 'makes value editable' do + expect_filtered_search_input('@root') + end + + it 'filters value' do + filtered_search.send_keys(:backspace) + + expect(page).to have_css('#js-dropdown-author .filter-dropdown .filter-dropdown-item', count: 1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-author', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-author', visible: false) + end + + describe 'selecting different author from dropdown' do + before do + filter_author_dropdown.find('.filter-dropdown-item .dropdown-light-content', text: "@#{user_rock.username}").click + end + + it 'changes value in visual token' do + expect(first('.tokens-container .filtered-search-token .value').text).to eq("@#{user_rock.username}") + end + + it 'moves input to the right' do + expect(is_input_focused).to eq(true) + end + end + end + + describe 'editing assignee token' do + before do + input_filtered_search('assignee:@root author:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + end + + it 'opens assignee dropdown' do + expect(page).to have_css('#js-dropdown-assignee', visible: true) + end + + it 'makes value editable' do + expect_filtered_search_input('@root') + end + + it 'filters value' do + filtered_search.send_keys(:backspace) + + expect(page).to have_css('#js-dropdown-assignee .filter-dropdown .filter-dropdown-item', count: 1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-assignee', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-assignee', visible: false) + end + + describe 'selecting static option from dropdown' do + before do + find("#js-dropdown-assignee").find('.filter-dropdown-item', text: 'No Assignee').click + end + + it 'changes value in visual token' do + expect(first('.tokens-container .filtered-search-token .value').text).to eq('none') + end + + it 'moves input to the right' do + expect(is_input_focused).to eq(true) + end + end + end + + describe 'editing milestone token' do + before do + input_filtered_search('milestone:%10.0 author:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + first('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item') + end + + it 'opens milestone dropdown' do + expect(filter_milestone_dropdown.find('.filter-dropdown-item', text: milestone_ten.title)).to be_visible + expect(filter_milestone_dropdown.find('.filter-dropdown-item', text: milestone_nine.title)).to be_visible + expect(page).to have_css('#js-dropdown-milestone', visible: true) + end + + it 'selects static option from dropdown' do + find("#js-dropdown-milestone").find('.filter-dropdown-item', text: 'Upcoming').click + + expect(first('.tokens-container .filtered-search-token .value').text).to eq('upcoming') + expect(is_input_focused).to eq(true) + end + + it 'makes value editable' do + expect_filtered_search_input('%10.0') + end + + it 'filters value' do + filtered_search.send_keys(:backspace) + + expect(page).to have_css('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item', count: 1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-milestone', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-milestone', visible: false) + end + end + + describe 'editing label token' do + before do + input_filtered_search("label:~#{label.title} author:none", submit: false) + first('.tokens-container .filtered-search-token').double_click + first('#js-dropdown-label .filter-dropdown .filter-dropdown-item') + end + + it 'opens label dropdown' do + expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible + expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible + expect(page).to have_css('#js-dropdown-label', visible: true) + end + + it 'selects option from dropdown' do + expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible + expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible + + find("#js-dropdown-label").find('.filter-dropdown-item', text: cc_label.title).click + + expect(first('.tokens-container .filtered-search-token .value').text).to eq("~\"#{cc_label.title}\"") + expect(is_input_focused).to eq(true) + end + + it 'makes value editable' do + expect_filtered_search_input("~#{label.title}") + end + + it 'filters value' do + expect(filter_label_dropdown.find('.filter-dropdown-item', text: label.title)).to be_visible + expect(filter_label_dropdown.find('.filter-dropdown-item', text: cc_label.title)).to be_visible + + filtered_search.send_keys(:backspace) + + filter_label_dropdown.find('.filter-dropdown-item') + + expect(page.all('#js-dropdown-label .filter-dropdown .filter-dropdown-item').size).to eq(1) + end + + it 'ends editing mode when document is clicked' do + find('#content-body').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-label', visible: false) + end + + it 'ends editing mode when scroll container is clicked' do + find('.scroll-container').click + + expect_filtered_search_input_empty + expect(page).to have_css('#js-dropdown-label', visible: false) + end + end + + describe 'add new token after editing existing token' do + before do + input_filtered_search('author:@root assignee:none', submit: false) + first('.tokens-container .filtered-search-token').double_click + filtered_search.send_keys(' ') + end + + describe 'opens dropdowns' do + it 'opens hint dropdown' do + expect(page).to have_css('#js-dropdown-hint', visible: true) + end + + it 'opens author dropdown' do + filtered_search.send_keys('author:') + expect(page).to have_css('#js-dropdown-author', visible: true) + end + + it 'opens assignee dropdown' do + filtered_search.send_keys('assignee:') + expect(page).to have_css('#js-dropdown-assignee', visible: true) + end + + it 'opens milestone dropdown' do + filtered_search.send_keys('milestone:') + expect(page).to have_css('#js-dropdown-milestone', visible: true) + end + + it 'opens label dropdown' do + filtered_search.send_keys('label:') + expect(page).to have_css('#js-dropdown-label', visible: true) + end + end + + describe 'creates visual tokens' do + it 'creates author token' do + filtered_search.send_keys('author:@thomas ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Author') + expect(token.find('.value').text).to eq('@thomas') + end + + it 'creates assignee token' do + filtered_search.send_keys('assignee:@thomas ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Assignee') + expect(token.find('.value').text).to eq('@thomas') + end + + it 'creates milestone token' do + filtered_search.send_keys('milestone:none ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Milestone') + expect(token.find('.value').text).to eq('none') + end + + it 'creates label token' do + filtered_search.send_keys('label:~Backend ') + token = page.all('.tokens-container .filtered-search-token')[1] + + expect(token.find('.name').text).to eq('Label') + expect(token.find('.value').text).to eq('~Backend') + end + end + + it 'does not tokenize incomplete token' do + filtered_search.send_keys('author:') + + find('#content-body').click + token = page.all('.tokens-container .js-visual-token')[1] + + expect_filtered_search_input_empty + expect(token.find('.name').text).to eq('Author') + end + end +end diff --git a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 index 7ddc22380a5..a1da3396d7b 100644 --- a/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 +++ b/spec/javascripts/filtered_search/filtered_search_dropdown_manager_spec.js.es6 @@ -15,31 +15,21 @@ require('~/filtered_search/filtered_search_dropdown_manager'); } beforeEach(() => { - // spyOn(gl.FilteredSearchVisualTokens, 'addVisualToken').and.callFake(() => {}); - const div = document.createElement('div'); - // div.classList.add('spec-container'); - - div.innerHTML = ` - - - `; - document.body.appendChild(div); - }); - - afterEach(() => { - document.querySelector('.filtered-search').outerHTML = ''; - document.querySelector('.tokens-container').innerHTML = ''; + setFixtures(` + + `); }); describe('input has no existing value', () => { it('should add just tokenName', () => { gl.FilteredSearchDropdownManager.addWordToInput('milestone'); - const tokensContainer = document.querySelector('.tokens-container'); - const token = tokensContainer.children[0]; + const token = document.querySelector('.tokens-container .js-visual-token'); - expect(tokensContainer.children.length).toBe(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('milestone'); expect(getInputValue()).toBe(''); @@ -48,11 +38,8 @@ require('~/filtered_search/filtered_search_dropdown_manager'); it('should add tokenName and tokenValue', () => { gl.FilteredSearchDropdownManager.addWordToInput('label'); - const tokensContainer = document.querySelector('.tokens-container'); - let token = tokensContainer.children[0]; + let token = document.querySelector('.tokens-container .js-visual-token'); - expect(tokensContainer.children.length).toBe(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); expect(getInputValue()).toBe(''); @@ -60,10 +47,8 @@ require('~/filtered_search/filtered_search_dropdown_manager'); gl.FilteredSearchDropdownManager.addWordToInput('label', 'none'); // We have to get that reference again // Because gl.FilteredSearchDropdownManager deletes the previous token - token = tokensContainer.children[0]; + token = document.querySelector('.tokens-container .js-visual-token'); - expect(tokensContainer.children.length).toBe(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); expect(token.querySelector('.value').innerText).toBe('none'); @@ -76,11 +61,8 @@ require('~/filtered_search/filtered_search_dropdown_manager'); setInputValue('a'); gl.FilteredSearchDropdownManager.addWordToInput('author'); - const tokensContainer = document.querySelector('.tokens-container'); - const token = tokensContainer.children[0]; + const token = document.querySelector('.tokens-container .js-visual-token'); - expect(tokensContainer.children.length).toBe(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('author'); expect(getInputValue()).toBe(''); @@ -92,11 +74,8 @@ require('~/filtered_search/filtered_search_dropdown_manager'); setInputValue('roo'); gl.FilteredSearchDropdownManager.addWordToInput(null, '@root'); - const tokensContainer = document.querySelector('.tokens-container'); - const token = tokensContainer.children[0]; + const token = document.querySelector('.tokens-container .js-visual-token'); - expect(tokensContainer.children.length).toBe(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('author'); expect(token.querySelector('.value').innerText).toBe('@root'); @@ -109,11 +88,8 @@ require('~/filtered_search/filtered_search_dropdown_manager'); setInputValue('"test '); gl.FilteredSearchDropdownManager.addWordToInput('label', '~\'"test me"\''); - const tokensContainer = document.querySelector('.tokens-container'); - const token = tokensContainer.children[0]; + const token = document.querySelector('.tokens-container .js-visual-token'); - expect(tokensContainer.children.length).toBe(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toBe('label'); expect(token.querySelector('.value').innerText).toBe('~\'"test me"\''); 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 8312928a8c5..81c1d81d181 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js.es6 @@ -29,13 +29,16 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper beforeEach(() => { setFixtures(` -
- - - -
+
+
+
    + ${FilteredSearchSpecHelper.createInputHTML(placeholder)} +
+ +
+
`); spyOn(gl.FilteredSearchManager.prototype, 'cleanup').and.callFake(() => {}); @@ -44,44 +47,44 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper spyOn(gl.FilteredSearchDropdownManager.prototype, 'setDropdown').and.callFake(() => {}); spyOn(gl.FilteredSearchDropdownManager.prototype, 'updateDropdownOffset').and.callFake(() => {}); spyOn(gl.utils, 'getParameterByName').and.returnValue(null); + spyOn(gl.FilteredSearchVisualTokens, 'unselectTokens').and.callThrough(); input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); manager = new gl.FilteredSearchManager(); }); - afterEach(() => { - tokensContainer.innerHTML = ''; - }); - describe('search', () => { const defaultParams = '?scope=all&utf8=✓&state=opened'; - it('should search with a single word', () => { + it('should search with a single word', (done) => { input.value = 'searchTerm'; spyOn(gl.utils, 'visitUrl').and.callFake((url) => { expect(url).toEqual(`${defaultParams}&search=searchTerm`); + done(); }); manager.search(); }); - it('should search with multiple words', () => { + it('should search with multiple words', (done) => { input.value = 'awesome search terms'; spyOn(gl.utils, 'visitUrl').and.callFake((url) => { expect(url).toEqual(`${defaultParams}&search=awesome+search+terms`); + done(); }); manager.search(); }); - it('should search with special characters', () => { + it('should search with special characters', (done) => { input.value = '~!@#$%^&*()_+{}:<>,.?/'; spyOn(gl.utils, 'visitUrl').and.callFake((url) => { expect(url).toEqual(`${defaultParams}&search=~!%40%23%24%25%5E%26*()_%2B%7B%7D%3A%3C%3E%2C.%3F%2F`); + done(); }); manager.search(); @@ -103,7 +106,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); it('should not render placeholder when there are tokens and no input', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + ); const event = new Event('input'); input.dispatchEvent(event); @@ -115,7 +120,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('checkForBackspace', () => { describe('tokens and no input', () => { beforeEach(() => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + ); }); it('removes last token', () => { @@ -148,38 +155,42 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); describe('removeSelectedToken', () => { + function getVisualTokens() { + return tokensContainer.querySelectorAll('.js-visual-token'); + } + beforeEach(() => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + ); }); it('removes selected token when the backspace key is pressed', () => { - expect(tokensContainer.children.length).toEqual(1); + expect(getVisualTokens().length).toEqual(1); dispatchBackspaceEvent(document, 'keydown'); - expect(tokensContainer.children.length).toEqual(0); + expect(getVisualTokens().length).toEqual(0); }); it('removes selected token when the delete key is pressed', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true); - - expect(tokensContainer.children.length).toEqual(1); + expect(getVisualTokens().length).toEqual(1); dispatchDeleteEvent(document, 'keydown'); - expect(tokensContainer.children.length).toEqual(0); + expect(getVisualTokens().length).toEqual(0); }); it('updates the input placeholder after removal', () => { manager.handleInputPlaceholder(); expect(input.placeholder).toEqual(''); - expect(tokensContainer.children.length).toEqual(1); + expect(getVisualTokens().length).toEqual(1); dispatchBackspaceEvent(document, 'keydown'); expect(input.placeholder).not.toEqual(''); - expect(tokensContainer.children.length).toEqual(0); + expect(getVisualTokens().length).toEqual(0); }); it('updates the clear button after removal', () => { @@ -188,31 +199,35 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper const clearButton = document.querySelector('.clear-search'); expect(clearButton.classList.contains('hidden')).toEqual(false); - expect(tokensContainer.children.length).toEqual(1); + expect(getVisualTokens().length).toEqual(1); dispatchBackspaceEvent(document, 'keydown'); expect(clearButton.classList.contains('hidden')).toEqual(true); - expect(tokensContainer.children.length).toEqual(0); + expect(getVisualTokens().length).toEqual(0); }); }); describe('unselects token', () => { beforeEach(() => { - tokensContainer.innerHTML = ` + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${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); + expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled(); - input.click(); + // Click directly on input attached to document + // so that the click event will propagate properly + document.querySelector('.filtered-search').click(); + expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled(); expect(selectedToken.classList.contains('selected')).toEqual(false); }); @@ -220,10 +235,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper const selectedToken = tokensContainer.querySelector('.js-visual-token .selected'); expect(selectedToken.classList.contains('selected')).toEqual(true); + expect(gl.FilteredSearchVisualTokens.unselectTokens).not.toHaveBeenCalled(); document.body.click(); expect(selectedToken.classList.contains('selected')).toEqual(false); + expect(gl.FilteredSearchVisualTokens.unselectTokens).toHaveBeenCalled(); }); }); }); 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 4d6eda83e79..5a8617395c1 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 @@ -7,72 +7,106 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper beforeEach(() => { setFixtures(` - + `); tokensContainer = document.querySelector('.tokens-container'); }); - afterEach(() => { - tokensContainer.innerHTML = ''; - }); - - describe('getLastVisualToken', () => { + describe('getLastVisualTokenBeforeInput', () => { it('returns when there are no visual tokens', () => { const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualToken(); + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - expect(lastVisualToken).toEqual(undefined); + expect(lastVisualToken).toEqual(null); expect(isLastVisualTokenValid).toEqual(true); }); - it('returns when there is one visual token', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'); + describe('input is the last item in tokensContainer', () => { + it('returns when there is one visual token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug'), + ); - const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualToken(); + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); - expect(isLastVisualTokenValid).toEqual(true); - }); + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(true); + }); - it('returns when there is an incomplete visual token', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('Author'); + it('returns when there is an incomplete visual token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('Author'), + ); - const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualToken(); + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); - expect(isLastVisualTokenValid).toEqual(false); - }); + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(false); + }); - it('returns when there are multiple visual tokens', () => { - tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} - ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} - `; + it('returns when there are multiple visual tokens', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + `); - const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualToken(); - const items = document.querySelectorAll('.tokens-container li'); + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + const items = document.querySelectorAll('.tokens-container .js-visual-token'); - expect(lastVisualToken).toEqual(items[items.length - 1]); - expect(isLastVisualTokenValid).toEqual(true); + expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); + expect(isLastVisualTokenValid).toEqual(true); + }); + + it('returns when there are multiple visual tokens and an incomplete visual token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee')} + `); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + const items = document.querySelectorAll('.tokens-container .js-visual-token'); + + expect(lastVisualToken.isEqualNode(items[items.length - 1])).toEqual(true); + expect(isLastVisualTokenValid).toEqual(false); + }); }); - it('returns when there are multiple visual tokens and an incomplete visual token', () => { - tokensContainer.innerHTML = ` - ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} - ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} - ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee')} - `; + describe('input is a middle item in tokensContainer', () => { + it('returns last token before input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + `); - const { lastVisualToken, isLastVisualTokenValid } - = gl.FilteredSearchVisualTokens.getLastVisualToken(); - const items = document.querySelectorAll('.tokens-container li'); + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(true); + }); - expect(lastVisualToken).toEqual(items[items.length - 1]); - expect(isLastVisualTokenValid).toEqual(false); + it('returns last partial token before input', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + `); + + const { lastVisualToken, isLastVisualTokenValid } + = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); + + expect(lastVisualToken).toEqual(document.querySelector('.filtered-search-token')); + expect(isLastVisualTokenValid).toEqual(false); + }); }); }); @@ -85,10 +119,10 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); it('removes the selected class from buttons', () => { - tokensContainer.innerHTML = ` + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@author')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '%123', true)} - `; + `); const selected = tokensContainer.querySelector('.js-visual-token .selected'); expect(selected.classList.contains('selected')).toEqual(true); @@ -101,11 +135,11 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('selectToken', () => { beforeEach(() => { - tokensContainer.innerHTML = ` + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~awesome')} - `; + `); }); it('removes the selected class if it has selected class', () => { @@ -140,7 +174,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('removeSelectedToken', () => { it('does not remove when there are no selected tokens', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none'), + ); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); @@ -150,7 +186,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); it('removes selected token', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'none', true), + ); expect(tokensContainer.querySelector('.js-visual-token .selectable')).not.toEqual(null); @@ -160,13 +198,41 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); }); + describe('createVisualTokenElementHTML', () => { + let tokenElement; + + beforeEach(() => { + setFixtures(` +
+ ${gl.FilteredSearchVisualTokens.createVisualTokenElementHTML()} +
+ `); + + tokenElement = document.querySelector('.test-area').firstElementChild; + }); + + it('contains name div', () => { + expect(tokenElement.querySelector('.name')).toEqual(jasmine.anything()); + }); + + it('contains value div', () => { + expect(tokenElement.querySelector('.value')).toEqual(jasmine.anything()); + }); + + it('contains selectable class', () => { + expect(tokenElement.classList.contains('selectable')).toEqual(true); + }); + + it('contains button role', () => { + expect(tokenElement.getAttribute('role')).toEqual('button'); + }); + }); + describe('addVisualTokenElement', () => { it('renders search visual tokens', () => { gl.FilteredSearchVisualTokens.addVisualTokenElement('search term', null, true); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-term')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('search term'); expect(token.querySelector('.value')).toEqual(null); @@ -174,10 +240,8 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper it('renders filter visual token name', () => { gl.FilteredSearchVisualTokens.addVisualTokenElement('milestone'); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); expect(token.querySelector('.value')).toEqual(null); @@ -185,23 +249,75 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper it('renders filter visual token name and value', () => { gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('label'); expect(token.querySelector('.value').innerText).toEqual('Frontend'); }); + + it('inserts visual token before input', () => { + tokensContainer.appendChild(FilteredSearchSpecHelper.createFilterVisualToken('assignee', '@root')); + + gl.FilteredSearchVisualTokens.addVisualTokenElement('label', 'Frontend'); + const tokens = tokensContainer.querySelectorAll('.js-visual-token'); + const labelToken = tokens[0]; + const assigneeToken = tokens[1]; + + expect(labelToken.classList.contains('filtered-search-token')).toEqual(true); + expect(labelToken.querySelector('.name').innerText).toEqual('label'); + expect(labelToken.querySelector('.value').innerText).toEqual('Frontend'); + + expect(assigneeToken.classList.contains('filtered-search-token')).toEqual(true); + expect(assigneeToken.querySelector('.name').innerText).toEqual('assignee'); + expect(assigneeToken.querySelector('.value').innerText).toEqual('@root'); + }); + }); + + describe('addValueToPreviousVisualTokenElement', () => { + it('does not add when previous visual token element has no value', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root'), + ); + + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + + expect(original).toEqual(tokensContainer.innerHTML); + }); + + it('does not add when previous visual token element is a search', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + `); + + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + + expect(original).toEqual(tokensContainer.innerHTML); + }); + + it('adds value to previous visual filter token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label'), + ); + + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement('value'); + const updatedToken = tokensContainer.querySelector('.js-visual-token'); + + expect(updatedToken.querySelector('.name').innerText).toEqual('label'); + expect(updatedToken.querySelector('.value').innerText).toEqual('value'); + expect(original).not.toEqual(tokensContainer.innerHTML); + }); }); describe('addFilterVisualToken', () => { it('creates visual token with just tokenName', () => { gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); expect(token.querySelector('.value')).toEqual(null); @@ -210,10 +326,8 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper it('creates visual token with just tokenValue', () => { gl.FilteredSearchVisualTokens.addFilterVisualToken('milestone'); gl.FilteredSearchVisualTokens.addFilterVisualToken('%8.17'); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('milestone'); expect(token.querySelector('.value').innerText).toEqual('%8.17'); @@ -221,10 +335,8 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper it('creates full visual token', () => { gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', '@john'); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-token')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('assignee'); expect(token.querySelector('.value').innerText).toEqual('@john'); @@ -234,27 +346,42 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('addSearchVisualToken', () => { it('creates search visual token', () => { gl.FilteredSearchVisualTokens.addSearchVisualToken('search term'); - const token = tokensContainer.children[0]; + const token = tokensContainer.querySelector('.js-visual-token'); - expect(tokensContainer.children.length).toEqual(1); - expect(token.classList.contains('js-visual-token')).toEqual(true); expect(token.classList.contains('filtered-search-term')).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('search term'); expect(token.querySelector('.value')).toEqual(null); }); + + it('appends to previous search visual token if previous token was a search token', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('author', '@root')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search term')} + `); + + gl.FilteredSearchVisualTokens.addSearchVisualToken('append this'); + const token = tokensContainer.querySelector('.filtered-search-term'); + + expect(token.querySelector('.name').innerText).toEqual('search term append this'); + expect(token.querySelector('.value')).toEqual(null); + }); }); describe('getLastTokenPartial', () => { it('should get last token value', () => { const value = '~bug'; - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', value); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', value), + ); expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(value); }); it('should get last token name if there is no value', () => { const name = 'assignee'; - tokensContainer.innerHTML = FilteredSearchSpecHelper.createNameFilterVisualTokenHTML(name); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML(name), + ); expect(gl.FilteredSearchVisualTokens.getLastTokenPartial()).toEqual(name); }); @@ -266,7 +393,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('removeLastTokenPartial', () => { it('should remove the last token value if it exists', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~"Community Contribution"'), + ); expect(tokensContainer.querySelector('.js-visual-token .value')).not.toEqual(null); @@ -276,7 +405,9 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper }); it('should remove the last token name if there is no value', () => { - tokensContainer.innerHTML = FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('milestone'); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('milestone'), + ); expect(tokensContainer.querySelector('.js-visual-token .name')).not.toEqual(null); @@ -292,5 +423,167 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper expect(tokensContainer.innerHTML).toEqual(html); }); }); + + describe('tokenizeInput', () => { + it('does not do anything if there is no input', () => { + const original = tokensContainer.innerHTML; + gl.FilteredSearchVisualTokens.tokenizeInput(); + + expect(tokensContainer.innerHTML).toEqual(original); + }); + + it('adds search visual token if previous visual token is valid', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('assignee', 'none'), + ); + + const input = document.querySelector('.filtered-search'); + input.value = 'some value'; + gl.FilteredSearchVisualTokens.tokenizeInput(); + + const newToken = tokensContainer.querySelector('.filtered-search-term'); + + expect(input.value).toEqual(''); + expect(newToken.querySelector('.name').innerText).toEqual('some value'); + expect(newToken.querySelector('.value')).toEqual(null); + }); + + it('adds value to previous visual token element if previous visual token is invalid', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('assignee'), + ); + + const input = document.querySelector('.filtered-search'); + input.value = '@john'; + gl.FilteredSearchVisualTokens.tokenizeInput(); + + const updatedToken = tokensContainer.querySelector('.filtered-search-token'); + + expect(input.value).toEqual(''); + expect(updatedToken.querySelector('.name').innerText).toEqual('assignee'); + expect(updatedToken.querySelector('.value').innerText).toEqual('@john'); + }); + }); + + describe('editToken', () => { + let input; + let token; + + beforeEach(() => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createSearchVisualTokenHTML('search')} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', 'upcoming')} + `); + + input = document.querySelector('.filtered-search'); + token = document.querySelector('.js-visual-token'); + }); + + it('tokenize\'s existing input', () => { + input.value = 'some text'; + spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callThrough(); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(gl.FilteredSearchVisualTokens.tokenizeInput).toHaveBeenCalled(); + expect(input.value).not.toEqual('some text'); + }); + + it('moves input to the token position', () => { + expect(tokensContainer.children[3].querySelector('.filtered-search')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(tokensContainer.children[1].querySelector('.filtered-search')).not.toEqual(null); + expect(tokensContainer.children[3].querySelector('.filtered-search')).toEqual(null); + }); + + it('input contains the visual token value', () => { + gl.FilteredSearchVisualTokens.editToken(token); + + expect(input.value).toEqual('none'); + }); + + describe('selected token is a search term token', () => { + beforeEach(() => { + token = document.querySelector('.filtered-search-term'); + }); + + it('token is removed', () => { + expect(tokensContainer.querySelector('.filtered-search-term')).not.toEqual(null); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(tokensContainer.querySelector('.filtered-search-term')).toEqual(null); + }); + + it('input has the same value as removed token', () => { + expect(input.value).toEqual(''); + + gl.FilteredSearchVisualTokens.editToken(token); + + expect(input.value).toEqual('search'); + }); + }); + }); + + describe('moveInputTotheRight', () => { + it('does nothing if the input is already the right most element', () => { + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none'), + ); + + spyOn(gl.FilteredSearchVisualTokens, 'tokenizeInput').and.callThrough(); + spyOn(gl.FilteredSearchVisualTokens, 'getLastVisualTokenBeforeInput').and.callThrough(); + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + + expect(gl.FilteredSearchVisualTokens.tokenizeInput).not.toHaveBeenCalled(); + expect(gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput).not.toHaveBeenCalled(); + }); + + it('tokenize\'s input', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createNameFilterVisualTokenHTML('label')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + `; + + document.querySelector('.filtered-search').value = 'none'; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + const value = tokensContainer.querySelector('.js-visual-token .value'); + + expect(value.innerText).toEqual('none'); + }); + + it('converts input into search term token if last token is valid', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + `; + + document.querySelector('.filtered-search').value = 'test'; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + const searchValue = tokensContainer.querySelector('.filtered-search-term .name'); + + expect(searchValue.innerText).toEqual('test'); + }); + + it('moves the input to the right most element', () => { + tokensContainer.innerHTML = ` + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', 'none')} + ${FilteredSearchSpecHelper.createInputHTML()} + ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '~bug')} + `; + + gl.FilteredSearchVisualTokens.moveInputToTheRight(); + + expect(tokensContainer.children[2].querySelector('.filtered-search')).not.toEqual(null); + }); + }); }); })(); diff --git a/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 b/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 index 31f4d93f8b8..c891518fce9 100644 --- a/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 +++ b/spec/javascripts/helpers/filtered_search_spec_helper.js.es6 @@ -1,13 +1,20 @@ class FilteredSearchSpecHelper { - static createFilterVisualTokenHTML(name, value, isSelected = false) { - return ` -
  • -
    -
    ${name}
    -
    ${value}
    -
    -
  • + static createFilterVisualTokenHTML(name, value, isSelected) { + return FilteredSearchSpecHelper.createFilterVisualToken(name, value, isSelected).outerHTML; + } + + static createFilterVisualToken(name, value, isSelected = false) { + const li = document.createElement('li'); + li.classList.add('js-visual-token', 'filtered-search-token'); + + li.innerHTML = ` +
    +
    ${name}
    +
    ${value}
    +
    `; + + return li; } static createNameFilterVisualTokenHTML(name) { @@ -25,6 +32,21 @@ class FilteredSearchSpecHelper { `; } + + static createInputHTML(placeholder = '') { + return ` +
  • + +
  • + `; + } + + static createTokensContainerHTML(html, inputPlaceholder) { + return ` + ${html} + ${FilteredSearchSpecHelper.createInputHTML(inputPlaceholder)} + `; + } } module.exports = FilteredSearchSpecHelper; -- cgit v1.2.1