summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.js87
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_hint.js2
-rw-r--r--app/assets/javascripts/filtered_search/dropdown_utils.js4
-rw-r--r--app/assets/javascripts/filtered_search/event_hub.js3
-rw-r--r--app/assets/javascripts/filtered_search/filtered_search_manager.js89
-rw-r--r--app/assets/javascripts/filtered_search/recent_searches_root.js59
-rw-r--r--app/assets/javascripts/filtered_search/services/recent_searches_service.js26
-rw-r--r--app/assets/javascripts/filtered_search/stores/recent_searches_store.js23
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss9
-rw-r--r--app/assets/stylesheets/framework/filters.scss135
-rw-r--r--app/helpers/dropdowns_helper.rb4
-rw-r--r--app/views/shared/issuable/_filter.html.haml8
-rw-r--r--app/views/shared/issuable/_search_bar.html.haml162
-rw-r--r--changelogs/unreleased/27262-issue-recent-searches.yml4
-rw-r--r--spec/features/boards/boards_spec.rb2
-rw-r--r--spec/features/boards/modal_filter_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_assignee_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_author_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_label_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/dropdown_milestone_spec.rb2
-rw-r--r--spec/features/issues/filtered_search/filter_issues_spec.rb4
-rw-r--r--spec/features/issues/filtered_search/recent_searches_spec.rb92
-rw-r--r--spec/features/issues/filtered_search/search_bar_spec.rb8
-rw-r--r--spec/features/merge_requests/reset_filters_spec.rb2
-rw-r--r--spec/javascripts/filtered_search/components/recent_searches_dropdown_content_spec.js166
-rw-r--r--spec/javascripts/filtered_search/filtered_search_manager_spec.js6
-rw-r--r--spec/javascripts/filtered_search/services/recent_searches_service_spec.js56
-rw-r--r--spec/javascripts/filtered_search/stores/recent_searches_store_spec.js59
-rw-r--r--spec/support/filtered_search_helpers.rb18
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