diff options
29 files changed, 906 insertions, 132 deletions
diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js new file mode 100644 index 00000000000..9126422b335 --- /dev/null +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js @@ -0,0 +1,87 @@ +import eventHub from '../event_hub'; + +export default { + name: 'RecentSearchesDropdownContent', + + props: { + items: { + type: Array, + required: true, + }, + }, + + computed: { + processedItems() { + return this.items.map((item) => { + const { tokens, searchToken } + = gl.FilteredSearchTokenizer.processTokens(item); + + const resultantTokens = tokens.map(token => ({ + prefix: `${token.key}:`, + suffix: `${token.symbol}${token.value}`, + })); + + return { + text: item, + tokens: resultantTokens, + searchToken, + }; + }); + }, + hasItems() { + return this.items.length > 0; + }, + }, + + methods: { + onItemActivated(text) { + eventHub.$emit('recentSearchesItemSelected', text); + }, + onRequestClearRecentSearches(e) { + // Stop the dropdown from closing + e.stopPropagation(); + + eventHub.$emit('requestClearRecentSearches'); + }, + }, + + template: ` + <div> + <ul v-if="hasItems"> + <li + v-for="(item, index) in processedItems" + :key="index"> + <button + type="button" + class="filtered-search-history-dropdown-item" + @click="onItemActivated(item.text)"> + <span> + <span + v-for="(token, tokenIndex) in item.tokens" + class="filtered-search-history-dropdown-token"> + <span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span> + </span> + </span> + <span class="filtered-search-history-dropdown-search-token"> + {{ item.searchToken }} + </span> + </button> + </li> + <li class="divider"></li> + <li> + <button + type="button" + class="filtered-search-history-clear-button" + @click="onRequestClearRecentSearches($event)"> + Clear recent searches + </button> + </li> + </ul> + <div + v-else + class="dropdown-info-note"> + You don't have any recent searches + </div> + </div> + `, +}; diff --git a/app/assets/javascripts/filtered_search/dropdown_hint.js b/app/assets/javascripts/filtered_search/dropdown_hint.js index 98dcb697af9..64d7153e547 100644 --- a/app/assets/javascripts/filtered_search/dropdown_hint.js +++ b/app/assets/javascripts/filtered_search/dropdown_hint.js @@ -56,7 +56,7 @@ require('./filtered_search_dropdown'); renderContent() { const dropdownData = []; - [].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { + [].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => { const { icon, hint, tag, type } = dropdownMenu.dataset; if (icon && hint && tag) { dropdownData.push( diff --git a/app/assets/javascripts/filtered_search/dropdown_utils.js b/app/assets/javascripts/filtered_search/dropdown_utils.js index 432b0c0dfd2..6c5c20447f7 100644 --- a/app/assets/javascripts/filtered_search/dropdown_utils.js +++ b/app/assets/javascripts/filtered_search/dropdown_utils.js @@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; } }); - return values.join(' '); + return values + .map(value => value.trim()) + .join(' '); } static getSearchInput(filteredSearchInput) { diff --git a/app/assets/javascripts/filtered_search/event_hub.js b/app/assets/javascripts/filtered_search/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/filtered_search/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 22352950452..2a8a6b81b3f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,18 +1,56 @@ +/* global Flash */ + import FilteredSearchContainer from './container'; +import RecentSearchesRoot from './recent_searches_root'; +import RecentSearchesStore from './stores/recent_searches_store'; +import RecentSearchesService from './services/recent_searches_service'; +import eventHub from './event_hub'; (() => { class FilteredSearchManager { constructor(page) { this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); + this.filteredSearchInputForm = this.filteredSearchInput.form; this.clearSearchButton = this.container.querySelector('.clear-search'); this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; + this.recentSearchesStore = new RecentSearchesStore(); + let recentSearchesKey = 'issue-recent-searches'; + if (page === 'merge_requests') { + recentSearchesKey = 'merge-request-recent-searches'; + } + this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + + // Fetch recent searches from localStorage + this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() + .catch(() => { + // eslint-disable-next-line no-new + new Flash('An error occured while parsing recent searches'); + // Gracefully fail to empty array + return []; + }) + .then((searches) => { + // Put any searches that may have come in before + // we fetched the saved searches ahead of the already saved ones + const resultantSearches = this.recentSearchesStore.setRecentSearches( + this.recentSearchesStore.state.recentSearches.concat(searches), + ); + this.recentSearchesService.save(resultantSearches); + }); + if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); + this.recentSearchesRoot = new RecentSearchesRoot( + this.recentSearchesStore, + this.recentSearchesService, + document.querySelector('.js-filtered-search-history-dropdown'), + ); + this.recentSearchesRoot.init(); + this.bindEvents(); this.loadSearchParamsFromURL(); this.dropdownManager.setDropdown(); @@ -25,6 +63,10 @@ import FilteredSearchContainer from './container'; cleanup() { this.unbindEvents(); document.removeEventListener('beforeunload', this.cleanupWrapper); + + if (this.recentSearchesRoot) { + this.recentSearchesRoot.destroy(); + } } bindEvents() { @@ -34,7 +76,7 @@ import FilteredSearchContainer from './container'; this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); this.checkForEnterWrapper = this.checkForEnter.bind(this); - this.clearSearchWrapper = this.clearSearch.bind(this); + this.onClearSearchWrapper = this.onClearSearch.bind(this); this.checkForBackspaceWrapper = this.checkForBackspace.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); @@ -42,8 +84,8 @@ import FilteredSearchContainer from './container'; this.tokenChange = this.tokenChange.bind(this); this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this); + this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this); - this.filteredSearchInputForm = this.filteredSearchInput.form; this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); @@ -56,11 +98,12 @@ import FilteredSearchContainer from './container'; this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.addEventListener('click', this.clearSearchWrapper); + this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper); document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper); + eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); } unbindEvents() { @@ -76,11 +119,12 @@ import FilteredSearchContainer from './container'; this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); - this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper); + this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper); document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens); document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper); + eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper); } checkForBackspace(e) { @@ -131,7 +175,7 @@ import FilteredSearchContainer from './container'; } addInputContainerFocus() { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); if (inputContainer) { inputContainer.classList.add('focus'); @@ -139,7 +183,7 @@ import FilteredSearchContainer from './container'; } removeInputContainerFocus(e) { - const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); + const inputContainer = this.filteredSearchInput.closest('.filtered-search-box'); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; @@ -161,7 +205,7 @@ import FilteredSearchContainer from './container'; } unselectEditTokens(e) { - const inputContainer = this.container.querySelector('.filtered-search-input-container'); + const inputContainer = this.container.querySelector('.filtered-search-box'); const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target); const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementTokensContainer = e.target.classList.contains('tokens-container'); @@ -215,9 +259,12 @@ import FilteredSearchContainer from './container'; } } - clearSearch(e) { + onClearSearch(e) { e.preventDefault(); + this.clearSearch(); + } + clearSearch() { this.filteredSearchInput.value = ''; const removeElements = []; @@ -289,6 +336,17 @@ import FilteredSearchContainer from './container'; this.search(); } + saveCurrentSearchQuery() { + // Don't save before we have fetched the already saved searches + this.fetchingRecentSearchesPromise.then(() => { + const searchQuery = gl.DropdownUtils.getSearchQuery(); + if (searchQuery.length > 0) { + const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery); + this.recentSearchesService.save(resultantSearches); + } + }); + } + loadSearchParamsFromURL() { const params = gl.utils.getUrlParamsArray(); const usernameParams = this.getUsernameParams(); @@ -343,6 +401,8 @@ import FilteredSearchContainer from './container'; } }); + this.saveCurrentSearchQuery(); + if (hasFilteredSearch) { this.clearSearchButton.classList.remove('hidden'); this.handleInputPlaceholder(); @@ -351,8 +411,12 @@ import FilteredSearchContainer from './container'; search() { const paths = []; + const searchQuery = gl.DropdownUtils.getSearchQuery(); + + this.saveCurrentSearchQuery(); + const { tokens, searchToken } - = this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); + = this.tokenizer.processTokens(searchQuery); const currentState = gl.utils.getParameterByName('state') || 'opened'; paths.push(`state=${currentState}`); @@ -416,6 +480,13 @@ import FilteredSearchContainer from './container'; currentDropdownRef.dispatchInputEvent(); } } + + onrecentSearchesItemSelected(text) { + this.clearSearch(); + this.filteredSearchInput.value = text; + this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); + this.search(); + } } window.gl = window.gl || {}; diff --git a/app/assets/javascripts/filtered_search/recent_searches_root.js b/app/assets/javascripts/filtered_search/recent_searches_root.js new file mode 100644 index 00000000000..4e38409e12a --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_root.js @@ -0,0 +1,59 @@ +import Vue from 'vue'; +import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content'; +import eventHub from './event_hub'; + +class RecentSearchesRoot { + constructor( + recentSearchesStore, + recentSearchesService, + wrapperElement, + ) { + this.store = recentSearchesStore; + this.service = recentSearchesService; + this.wrapperElement = wrapperElement; + } + + init() { + this.bindEvents(); + this.render(); + } + + bindEvents() { + this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this); + + eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper); + } + + unbindEvents() { + eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper); + } + + render() { + this.vm = new Vue({ + el: this.wrapperElement, + data: this.store.state, + template: ` + <recent-searches-dropdown-content + :items="recentSearches" /> + `, + components: { + 'recent-searches-dropdown-content': RecentSearchesDropdownContent, + }, + }); + } + + onRequestClearRecentSearches() { + const resultantSearches = this.store.setRecentSearches([]); + this.service.save(resultantSearches); + } + + destroy() { + this.unbindEvents(); + if (this.vm) { + this.vm.$destroy(); + } + } + +} + +export default RecentSearchesRoot; diff --git a/app/assets/javascripts/filtered_search/services/recent_searches_service.js b/app/assets/javascripts/filtered_search/services/recent_searches_service.js new file mode 100644 index 00000000000..3e402d5aed0 --- /dev/null +++ b/app/assets/javascripts/filtered_search/services/recent_searches_service.js @@ -0,0 +1,26 @@ +class RecentSearchesService { + constructor(localStorageKey = 'issuable-recent-searches') { + this.localStorageKey = localStorageKey; + } + + fetch() { + const input = window.localStorage.getItem(this.localStorageKey); + + let searches = []; + if (input && input.length > 0) { + try { + searches = JSON.parse(input); + } catch (err) { + return Promise.reject(err); + } + } + + return Promise.resolve(searches); + } + + save(searches = []) { + window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches)); + } +} + +export default RecentSearchesService; diff --git a/app/assets/javascripts/filtered_search/stores/recent_searches_store.js b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js new file mode 100644 index 00000000000..066be69766a --- /dev/null +++ b/app/assets/javascripts/filtered_search/stores/recent_searches_store.js @@ -0,0 +1,23 @@ +import _ from 'underscore'; + +class RecentSearchesStore { + constructor(initialState = {}) { + this.state = Object.assign({ + recentSearches: [], + }, initialState); + } + + addRecentSearch(newSearch) { + this.setRecentSearches([newSearch].concat(this.state.recentSearches)); + + return this.state.recentSearches; + } + + setRecentSearches(searches = []) { + const trimmedSearches = searches.map(search => search.trim()); + this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5); + return this.state.recentSearches; + } +} + +export default RecentSearchesStore; diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 2ede47e9de6..23cba57f83a 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -177,10 +177,6 @@ border-radius: $border-radius-base; box-shadow: 0 2px 4px $dropdown-shadow-color; - .filtered-search-input-container & { - max-width: 280px; - } - &.is-loading { .dropdown-content { display: none; @@ -467,6 +463,11 @@ overflow-y: auto; } +.dropdown-info-note { + color: $gl-text-color-secondary; + text-align: center; +} + .dropdown-footer { padding-top: 10px; margin-top: 10px; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 51805c5d734..484df6214d3 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -22,7 +22,6 @@ } @media (min-width: $screen-sm-min) { - .issues-filters, .issues_bulk_update { .dropdown-menu-toggle { width: 132px; @@ -56,7 +55,7 @@ } } -.filtered-search-container { +.filtered-search-wrapper { display: -webkit-flex; display: flex; @@ -151,11 +150,13 @@ width: 100%; } -.filtered-search-input-container { +.filtered-search-box { + position: relative; + flex: 1; display: -webkit-flex; display: flex; - position: relative; width: 100%; + min-width: 0; border: 1px solid $border-color; background-color: $white-light; @@ -163,14 +164,6 @@ -webkit-flex: 1 1 auto; flex: 1 1 auto; margin-bottom: 10px; - - .dropdown-menu { - width: auto; - left: 0; - right: 0; - max-width: none; - min-width: 100%; - } } &:hover { @@ -229,6 +222,118 @@ } } +.filtered-search-box-input-container { + flex: 1; + position: relative; + // Fix PhantomJS not supporting `flex: 1;` properly. + // This is important because it can change the expected `e.target` when clicking things in tests. + // See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61 + // - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png + // - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png + width: 100%; + min-width: 0; +} + +.filtered-search-input-dropdown-menu { + max-width: 280px; + + @media (max-width: $screen-xs-min) { + width: auto; + left: 0; + right: 0; + max-width: none; + min-width: 100%; + } +} + +.filtered-search-history-dropdown-toggle-button { + display: flex; + align-items: center; + width: auto; + height: 100%; + padding-top: 0; + padding-left: 0.75em; + padding-bottom: 0; + padding-right: 0.5em; + + background-color: transparent; + border-radius: 0; + border-top: 0; + border-left: 0; + border-bottom: 0; + border-right: 1px solid $border-color; + + color: $gl-text-color-secondary; + + transition: color 0.1s linear; + + &:hover, + &:focus { + color: $gl-text-color; + border-color: $dropdown-input-focus-border; + outline: none; + } + + .dropdown-toggle-text { + color: inherit; + + .fa { + color: inherit; + } + } + + .fa { + position: initial; + } + +} + +.filtered-search-history-dropdown-wrapper { + position: initial; + flex-shrink: 0; +} + +.filtered-search-history-dropdown { + width: 40%; + + @media (max-width: $screen-xs-min) { + left: 0; + right: 0; + max-width: none; + } +} + +.filtered-search-history-dropdown-content { + max-height: none; +} + +.filtered-search-history-dropdown-item, +.filtered-search-history-clear-button { + @include dropdown-link; + + overflow: hidden; + width: 100%; + margin: 0.5em 0; + + background-color: transparent; + border: 0; + text-align: left; + white-space: nowrap; + text-overflow: ellipsis; +} + +.filtered-search-history-dropdown-token { + display: inline; + + &:not(:last-child) { + margin-right: 0.3em; + } + + & > .value { + font-weight: 600; + } +} + .filter-dropdown-container { display: -webkit-flex; display: flex; @@ -248,10 +353,8 @@ } @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { - .issues-details-filters { - .dropdown-menu-toggle { - width: 100px; - } + .issue-bulk-update-dropdown-toggle { + width: 100px; } } diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index 81e0b6bb5ae..8ed99642c7a 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -1,6 +1,6 @@ module DropdownsHelper def dropdown_tag(toggle_text, options: {}, &block) - content_tag :div, class: "dropdown" do + content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do data_attr = { toggle: "dropdown" } if options.has_key?(:data) @@ -20,7 +20,7 @@ module DropdownsHelper output << dropdown_filter(options[:placeholder]) end - output << content_tag(:div, class: "dropdown-content") do + output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do capture(&block) if block && !options.has_key?(:footer_content) end diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 847a86e2e68..c72268473ca 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -40,21 +40,21 @@ .issues_bulk_update.hide = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do .filter-item.inline - = dropdown_tag("Status", options: { toggle_class: "js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do + = dropdown_tag("Status", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-issue-status", title: "Change status", dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: "Status" } } ) do %ul %li %a{ href: "#", data: { id: "reopen" } } Open %li %a{ href: "#", data: {id: "close" } } Closed .filter-item.inline - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + = dropdown_tag("Assignee", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-user-search js-update-assignee js-filter-submit js-filter-bulk-update", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]", default_label: "Assignee" } }) .filter-item.inline - = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) + = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'issue-bulk-update-dropdown-toggle js-milestone-select js-extra-options js-filter-submit js-filter-bulk-update', filter: true, dropdown_class: "dropdown-menu-selectable dropdown-menu-milestone", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", default_label: "Milestone", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), use_id: true } }) .filter-item.inline.labels-filter = render "shared/issuable/label_dropdown", classes: ['js-filter-bulk-update', 'js-multiselect'], dropdown_title: 'Apply a label', show_create: false, show_footer: false, extra_options: false, filter_submit: false, data_options: { persist_when_hide: "true", field_name: "update[label_ids][]", show_no: false, show_any: false, use_id: true } .filter-item.inline - = dropdown_tag("Subscription", options: { toggle_class: "js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do + = dropdown_tag("Subscription", options: { toggle_class: "issue-bulk-update-dropdown-toggle js-subscription-event", title: "Change subscription", dropdown_class: "dropdown-menu-selectable", data: { field_name: "update[subscription_event]", default_label: "Subscription" } } ) do %ul %li %a{ href: "#", data: { id: "subscribe" } } Subscribe diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 330fa8a5b10..9e241c3ea12 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -10,85 +10,93 @@ .check-all-holder = check_box_tag "check_all_issues", nil, false, class: "check_all_issues left" - .issues-other-filters.filtered-search-container - .filtered-search-input-container - .scroll-container - %ul.tokens-container.list-unstyled - %li.input-token - %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } - = icon('filter') - %button.clear-search.hidden{ type: 'button' } - = icon('times') - #js-dropdown-hint.dropdown-menu.hint-dropdown - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { action: 'submit' } } - %button.btn.btn-link - = icon('search') - %span - Press Enter or click to search - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link - -# Encapsulate static class name `{{icon}}` inside #{} to bypass - -# haml lint's ClassAttributeWithStaticValue - %i.fa{ class: "#{'{{icon}}'}" } - %span.js-filter-hint - {{hint}} - %span.js-filter-tag.dropdown-light-content - {{tag}} - #js-dropdown-author.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details + .issues-other-filters.filtered-search-wrapper + .filtered-search-box + = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'), + options: { wrapper_class: "filtered-search-history-dropdown-wrapper", + toggle_class: "filtered-search-history-dropdown-toggle-button", + dropdown_class: "filtered-search-history-dropdown", + content_class: "filtered-search-history-dropdown-content", + title: "Recent searches" }) do + .js-filtered-search-history-dropdown + .filtered-search-box-input-container + .scroll-container + %ul.tokens-container.list-unstyled + %li.input-token + %input.form-control.filtered-search{ placeholder: 'Search or filter results...', data: { id: "filtered-search-#{type.to_s}", 'project-id' => @project.id, 'username-params' => @users.to_json(only: [:id, :username]), 'base-endpoint' => namespace_project_path(@project.namespace, @project) } } + = icon('filter') + %button.clear-search.hidden{ type: 'button' } + = icon('times') + #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { action: 'submit' } } + %button.btn.btn-link + = icon('search') %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-assignee.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link - No Assignee - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.dropdown-user - %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } - .dropdown-user-details - %span - {{name}} - %span.dropdown-light-content - @{{username}} - #js-dropdown-milestone.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link - No Milestone - %li.filter-dropdown-item{ data: { value: 'upcoming' } } - %button.btn.btn-link - Upcoming - %li.filter-dropdown-item{ 'data-value' => 'started' } - %button.btn.btn-link - Started - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link.js-data-value - {{title}} - #js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } - %ul{ data: { dropdown: true } } - %li.filter-dropdown-item{ data: { value: 'none' } } - %button.btn.btn-link - No Label - %li.divider - %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } - %li.filter-dropdown-item - %button.btn.btn-link - %span.dropdown-label-box{ style: 'background: {{color}}' } - %span.label-title.js-data-value + Press Enter or click to search + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + -# Encapsulate static class name `{{icon}}` inside #{} to bypass + -# haml lint's ClassAttributeWithStaticValue + %i.fa{ class: "#{'{{icon}}'}" } + %span.js-filter-hint + {{hint}} + %span.js-filter-tag.dropdown-light-content + {{tag}} + #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } } + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Assignee + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.dropdown-user + %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } + .dropdown-user-details + %span + {{name}} + %span.dropdown-light-content + @{{username}} + #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Milestone + %li.filter-dropdown-item{ data: { value: 'upcoming' } } + %button.btn.btn-link + Upcoming + %li.filter-dropdown-item{ 'data-value' => 'started' } + %button.btn.btn-link + Started + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value {{title}} + #js-dropdown-label.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } + %ul{ data: { dropdown: true } } + %li.filter-dropdown-item{ data: { value: 'none' } } + %button.btn.btn-link + No Label + %li.divider + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link + %span.dropdown-label-box{ style: 'background: {{color}}' } + %span.label-title.js-data-value + {{title}} .filter-dropdown-container - if type == :boards - if can?(current_user, :admin_list, @project) diff --git a/changelogs/unreleased/27262-issue-recent-searches.yml b/changelogs/unreleased/27262-issue-recent-searches.yml new file mode 100644 index 00000000000..4bdec5af31d --- /dev/null +++ b/changelogs/unreleased/27262-issue-recent-searches.yml @@ -0,0 +1,4 @@ +--- +title: Recent search history for issues +merge_request: +author: diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index e168585534d..30ad169e30e 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -590,7 +590,7 @@ describe 'Issue Boards', feature: true, js: true do end def click_filter_link(link_text) - page.within('.filtered-search-input-container') do + page.within('.filtered-search-box') do expect(page).to have_button(link_text) click_button(link_text) diff --git a/spec/features/boards/modal_filter_spec.rb b/spec/features/boards/modal_filter_spec.rb index e2281a7da55..4a4c13e79c8 100644 --- a/spec/features/boards/modal_filter_spec.rb +++ b/spec/features/boards/modal_filter_spec.rb @@ -219,7 +219,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do end def click_filter_link(link_text) - page.within('.add-issues-modal .filtered-search-input-container') do + page.within('.add-issues-modal .filtered-search-box') do expect(page).to have_button(link_text) click_button(link_text) diff --git a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb index 4dcc56a97d1..3d1a9ed1722 100644 --- a/spec/features/issues/filtered_search/dropdown_assignee_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_assignee_spec.rb @@ -194,7 +194,7 @@ describe 'Dropdown assignee', :feature, :js do new_user = create(:user) project.team << [new_user, :master] - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.set('assignee') filtered_search.send_keys(':') diff --git a/spec/features/issues/filtered_search/dropdown_author_spec.rb b/spec/features/issues/filtered_search/dropdown_author_spec.rb index 1772a120045..990e3b3e60c 100644 --- a/spec/features/issues/filtered_search/dropdown_author_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_author_spec.rb @@ -172,7 +172,7 @@ describe 'Dropdown author', js: true, feature: true do new_user = create(:user) project.team << [new_user, :master] - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.set('author') send_keys_to_filtered_search(':') diff --git a/spec/features/issues/filtered_search/dropdown_label_spec.rb b/spec/features/issues/filtered_search/dropdown_label_spec.rb index b192064b693..c8645e08c4b 100644 --- a/spec/features/issues/filtered_search/dropdown_label_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_label_spec.rb @@ -33,7 +33,7 @@ describe 'Dropdown label', js: true, feature: true do end def clear_search_field - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click end before do diff --git a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb index ce96a420699..0a525bc68c9 100644 --- a/spec/features/issues/filtered_search/dropdown_milestone_spec.rb +++ b/spec/features/issues/filtered_search/dropdown_milestone_spec.rb @@ -252,7 +252,7 @@ describe 'Dropdown milestone', :feature, :js do expect(initial_size).to be > 0 create(:milestone, project: project) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.set('milestone:') expect(dropdown_milestone_size).to eq(initial_size) diff --git a/spec/features/issues/filtered_search/filter_issues_spec.rb b/spec/features/issues/filtered_search/filter_issues_spec.rb index 2f880c926e7..6f00066de4d 100644 --- a/spec/features/issues/filtered_search/filter_issues_spec.rb +++ b/spec/features/issues/filtered_search/filter_issues_spec.rb @@ -758,10 +758,10 @@ describe 'Filter issues', js: true, feature: true do expect_issues_list_count(2) - sort_toggle = find('.filtered-search-container .dropdown-toggle') + sort_toggle = find('.filtered-search-wrapper .dropdown-toggle') sort_toggle.click - find('.filtered-search-container .dropdown-menu li a', text: 'Oldest updated').click + find('.filtered-search-wrapper .dropdown-menu li a', text: 'Oldest updated').click wait_for_ajax expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) diff --git a/spec/features/issues/filtered_search/recent_searches_spec.rb b/spec/features/issues/filtered_search/recent_searches_spec.rb new file mode 100644 index 00000000000..f506065a242 --- /dev/null +++ b/spec/features/issues/filtered_search/recent_searches_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe 'Recent searches', js: true, feature: true do + include FilteredSearchHelpers + include WaitForAjax + + let!(:group) { create(:group) } + let!(:project) { create(:project, group: group) } + let!(:user) { create(:user) } + + before do + Capybara.ignore_hidden_elements = false + project.add_master(user) + group.add_developer(user) + create(:issue, project: project) + login_as(user) + + remove_recent_searches + end + + after do + Capybara.ignore_hidden_elements = true + end + + it 'searching adds to recent searches' do + visit namespace_project_issues_path(project.namespace, project) + + input_filtered_search('foo', submit: true) + input_filtered_search('bar', submit: true) + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(2) + expect(items[0].text).to eq('bar') + expect(items[1].text).to eq('foo') + end + + it 'visiting URL with search params adds to recent searches' do + visit namespace_project_issues_path(project.namespace, project, label_name: 'foo', search: 'bar') + visit namespace_project_issues_path(project.namespace, project, label_name: 'qux', search: 'garply') + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(2) + expect(items[0].text).to eq('label:~qux garply') + expect(items[1].text).to eq('label:~foo bar') + end + + it 'saved recent searches are restored last on the list' do + set_recent_searches('["saved1", "saved2"]') + + visit namespace_project_issues_path(project.namespace, project, search: 'foo') + + items = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items.count).to eq(3) + expect(items[0].text).to eq('foo') + expect(items[1].text).to eq('saved1') + expect(items[2].text).to eq('saved2') + end + + it 'clicking item fills search input' do + set_recent_searches('["foo", "bar"]') + visit namespace_project_issues_path(project.namespace, project) + + all('.filtered-search-history-dropdown-item', visible: false)[0].trigger('click') + wait_for_filtered_search('foo') + + expect(find('.filtered-search').value.strip).to eq('foo') + end + + it 'clear recent searches button, clears recent searches' do + set_recent_searches('["foo"]') + visit namespace_project_issues_path(project.namespace, project) + + items_before = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items_before.count).to eq(1) + + find('.filtered-search-history-clear-button', visible: false).trigger('click') + items_after = all('.filtered-search-history-dropdown-item', visible: false) + + expect(items_after.count).to eq(0) + end + + it 'shows flash error when failed to parse saved history' do + set_recent_searches('fail') + visit namespace_project_issues_path(project.namespace, project) + + expect(find('.flash-alert')).to have_text('An error occured while parsing recent searches') + end +end diff --git a/spec/features/issues/filtered_search/search_bar_spec.rb b/spec/features/issues/filtered_search/search_bar_spec.rb index 59244d65eec..48e7af67616 100644 --- a/spec/features/issues/filtered_search/search_bar_spec.rb +++ b/spec/features/issues/filtered_search/search_bar_spec.rb @@ -44,7 +44,7 @@ describe 'Search bar', js: true, feature: true do filtered_search.set(search_text) expect(filtered_search.value).to eq(search_text) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click expect(filtered_search.value).to eq('') end @@ -55,7 +55,7 @@ describe 'Search bar', js: true, feature: true do it 'hides after clicked' do filtered_search.set('a') - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click expect(page).to have_css('.clear-search', visible: false) end @@ -81,7 +81,7 @@ describe 'Search bar', js: true, feature: true do expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(1) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.click expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size) @@ -96,7 +96,7 @@ describe 'Search bar', js: true, feature: true do expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(0) - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click filtered_search.click expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0 diff --git a/spec/features/merge_requests/reset_filters_spec.rb b/spec/features/merge_requests/reset_filters_spec.rb index 14511707af4..df5943f9136 100644 --- a/spec/features/merge_requests/reset_filters_spec.rb +++ b/spec/features/merge_requests/reset_filters_spec.rb @@ -14,7 +14,7 @@ feature 'Merge requests filter clear button', feature: true, js: true do let!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") } let(:merge_request_css) { '.merge-request' } - let(:clear_search_css) { '.filtered-search-input-container .clear-search' } + let(:clear_search_css) { '.filtered-search-box .clear-search' } before do mr2.labels << bug diff --git a/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js new file mode 100644 index 00000000000..2722882375f --- /dev/null +++ b/spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -0,0 +1,166 @@ +import Vue from 'vue'; +import eventHub from '~/filtered_search/event_hub'; +import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content'; + +const createComponent = (propsData) => { + const Component = Vue.extend(RecentSearchesDropdownContent); + + return new Component({ + el: document.createElement('div'), + propsData, + }); +}; + +// Remove all the newlines and whitespace from the formatted markup +const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); + +describe('RecentSearchesDropdownContent', () => { + const propsDataWithoutItems = { + items: [], + }; + const propsDataWithItems = { + items: [ + 'foo', + 'author:@root label:~foo bar', + ], + }; + + let vm; + afterEach(() => { + if (vm) { + vm.$destroy(); + } + }); + + describe('with no items', () => { + let el; + + beforeEach(() => { + vm = createComponent(propsDataWithoutItems); + el = vm.$el; + }); + + it('should render empty state', () => { + expect(el.querySelector('.dropdown-info-note')).toBeDefined(); + + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + expect(items.length).toEqual(propsDataWithoutItems.items.length); + }); + }); + + describe('with items', () => { + let el; + + beforeEach(() => { + vm = createComponent(propsDataWithItems); + el = vm.$el; + }); + + it('should render clear recent searches button', () => { + expect(el.querySelector('.filtered-search-history-clear-button')).toBeDefined(); + }); + + it('should render recent search items', () => { + const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); + expect(items.length).toEqual(propsDataWithItems.items.length); + + expect(trimMarkupWhitespace(items[0].querySelector('.filtered-search-history-dropdown-search-token').textContent)).toEqual('foo'); + + const item1Tokens = items[1].querySelectorAll('.filtered-search-history-dropdown-token'); + expect(item1Tokens.length).toEqual(2); + expect(item1Tokens[0].querySelector('.name').textContent).toEqual('author:'); + expect(item1Tokens[0].querySelector('.value').textContent).toEqual('@root'); + expect(item1Tokens[1].querySelector('.name').textContent).toEqual('label:'); + expect(item1Tokens[1].querySelector('.value').textContent).toEqual('~foo'); + expect(trimMarkupWhitespace(items[1].querySelector('.filtered-search-history-dropdown-search-token').textContent)).toEqual('bar'); + }); + }); + + describe('computed', () => { + describe('processedItems', () => { + it('with items', () => { + vm = createComponent(propsDataWithItems); + const processedItems = vm.processedItems; + + expect(processedItems.length).toEqual(2); + + expect(processedItems[0].text).toEqual(propsDataWithItems.items[0]); + expect(processedItems[0].tokens).toEqual([]); + expect(processedItems[0].searchToken).toEqual('foo'); + + expect(processedItems[1].text).toEqual(propsDataWithItems.items[1]); + expect(processedItems[1].tokens.length).toEqual(2); + expect(processedItems[1].tokens[0].prefix).toEqual('author:'); + expect(processedItems[1].tokens[0].suffix).toEqual('@root'); + expect(processedItems[1].tokens[1].prefix).toEqual('label:'); + expect(processedItems[1].tokens[1].suffix).toEqual('~foo'); + expect(processedItems[1].searchToken).toEqual('bar'); + }); + + it('with no items', () => { + vm = createComponent(propsDataWithoutItems); + const processedItems = vm.processedItems; + + expect(processedItems.length).toEqual(0); + }); + }); + + describe('hasItems', () => { + it('with items', () => { + vm = createComponent(propsDataWithItems); + const hasItems = vm.hasItems; + expect(hasItems).toEqual(true); + }); + + it('with no items', () => { + vm = createComponent(propsDataWithoutItems); + const hasItems = vm.hasItems; + expect(hasItems).toEqual(false); + }); + }); + }); + + describe('methods', () => { + describe('onItemActivated', () => { + let onRecentSearchesItemSelectedSpy; + + beforeEach(() => { + onRecentSearchesItemSelectedSpy = jasmine.createSpy('spy'); + eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); + + vm = createComponent(propsDataWithItems); + }); + + afterEach(() => { + eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy); + }); + + it('emits event', () => { + expect(onRecentSearchesItemSelectedSpy).not.toHaveBeenCalled(); + vm.onItemActivated('something'); + expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('something'); + }); + }); + + describe('onRequestClearRecentSearches', () => { + let onRequestClearRecentSearchesSpy; + + beforeEach(() => { + onRequestClearRecentSearchesSpy = jasmine.createSpy('spy'); + eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy); + + vm = createComponent(propsDataWithItems); + }); + + afterEach(() => { + eventHub.$off('requestClearRecentSearches', onRequestClearRecentSearchesSpy); + }); + + it('emits event', () => { + expect(onRequestClearRecentSearchesSpy).not.toHaveBeenCalled(); + vm.onRequestClearRecentSearches({ stopPropagation: () => {} }); + expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 5f7c05e9014..97af681429b 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -29,7 +29,7 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper beforeEach(() => { setFixtures(` - <div class="filtered-search-input-container"> + <div class="filtered-search-box"> <form> <ul class="tokens-container list-unstyled"> ${FilteredSearchSpecHelper.createInputHTML(placeholder)} @@ -264,12 +264,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper describe('toggleInputContainerFocus', () => { it('toggles on focus', () => { input.focus(); - expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(true); + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(true); }); it('toggles on blur', () => { input.blur(); - expect(document.querySelector('.filtered-search-input-container').classList.contains('focus')).toEqual(false); + expect(document.querySelector('.filtered-search-box').classList.contains('focus')).toEqual(false); }); }); }); diff --git a/spec/javascripts/filtered_search/services/recent_searches_service_spec.js b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js new file mode 100644 index 00000000000..2a58fb3a7df --- /dev/null +++ b/spec/javascripts/filtered_search/services/recent_searches_service_spec.js @@ -0,0 +1,56 @@ +import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; + +describe('RecentSearchesService', () => { + let service; + + beforeEach(() => { + service = new RecentSearchesService(); + window.localStorage.removeItem(service.localStorageKey); + }); + + describe('fetch', () => { + it('should default to empty array', (done) => { + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then((items) => { + expect(items).toEqual([]); + done(); + }) + .catch((err) => { + done.fail('Shouldn\'t reject with empty localStorage key', err); + }); + }); + + it('should reject when unable to parse', (done) => { + window.localStorage.setItem(service.localStorageKey, 'fail'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .catch(() => { + done(); + }); + }); + + it('should return items from localStorage', (done) => { + window.localStorage.setItem(service.localStorageKey, '["foo", "bar"]'); + const fetchItemsPromise = service.fetch(); + + fetchItemsPromise + .then((items) => { + expect(items).toEqual(['foo', 'bar']); + done(); + }); + }); + }); + + describe('setRecentSearches', () => { + it('should save things in localStorage', () => { + const items = ['foo', 'bar']; + service.save(items); + const newLocalStorageValue = + window.localStorage.getItem(service.localStorageKey); + expect(JSON.parse(newLocalStorageValue)).toEqual(items); + }); + }); +}); diff --git a/spec/javascripts/filtered_search/stores/recent_searches_store_spec.js b/spec/javascripts/filtered_search/stores/recent_searches_store_spec.js new file mode 100644 index 00000000000..1eebc6f2367 --- /dev/null +++ b/spec/javascripts/filtered_search/stores/recent_searches_store_spec.js @@ -0,0 +1,59 @@ +import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; + +describe('RecentSearchesStore', () => { + let store; + + beforeEach(() => { + store = new RecentSearchesStore(); + }); + + describe('addRecentSearch', () => { + it('should add to the front of the list', () => { + store.addRecentSearch('foo'); + store.addRecentSearch('bar'); + + expect(store.state.recentSearches).toEqual(['bar', 'foo']); + }); + + it('should deduplicate', () => { + store.addRecentSearch('foo'); + store.addRecentSearch('bar'); + store.addRecentSearch('foo'); + + expect(store.state.recentSearches).toEqual(['foo', 'bar']); + }); + + it('only keeps track of 5 items', () => { + store.addRecentSearch('1'); + store.addRecentSearch('2'); + store.addRecentSearch('3'); + store.addRecentSearch('4'); + store.addRecentSearch('5'); + store.addRecentSearch('6'); + store.addRecentSearch('7'); + + expect(store.state.recentSearches).toEqual(['7', '6', '5', '4', '3']); + }); + }); + + describe('setRecentSearches', () => { + it('should override list', () => { + store.setRecentSearches([ + 'foo', + 'bar', + ]); + store.setRecentSearches([ + 'baz', + 'qux', + ]); + + expect(store.state.recentSearches).toEqual(['baz', 'qux']); + }); + + it('only keeps track of 5 items', () => { + store.setRecentSearches(['1', '2', '3', '4', '5', '6', '7']); + + expect(store.state.recentSearches).toEqual(['1', '2', '3', '4', '5']); + }); + }); +}); diff --git a/spec/support/filtered_search_helpers.rb b/spec/support/filtered_search_helpers.rb index 6b009b132b6..36be0bb6bf8 100644 --- a/spec/support/filtered_search_helpers.rb +++ b/spec/support/filtered_search_helpers.rb @@ -30,7 +30,7 @@ module FilteredSearchHelpers end def clear_search_field - find('.filtered-search-input-container .clear-search').click + find('.filtered-search-box .clear-search').click end def reset_filters @@ -51,7 +51,7 @@ module FilteredSearchHelpers # Iterates through each visual token inside # .tokens-container to make sure the correct names and values are rendered def expect_tokens(tokens) - page.find '.filtered-search-input-container .tokens-container' do + page.find '.filtered-search-box .tokens-container' do page.all(:css, '.tokens-container li').each_with_index do |el, index| token_name = tokens[index][:name] token_value = tokens[index][:value] @@ -71,4 +71,18 @@ module FilteredSearchHelpers def get_filtered_search_placeholder find('.filtered-search')['placeholder'] end + + def remove_recent_searches + execute_script('window.localStorage.removeItem(\'issue-recent-searches\');') + end + + def set_recent_searches(input) + execute_script("window.localStorage.setItem('issue-recent-searches', '#{input}');") + end + + def wait_for_filtered_search(text) + Timeout.timeout(Capybara.default_max_wait_time) do + loop until find('.filtered-search').value.strip == text + end + end end |