Commit b7ce488d authored by Eric Eastwood's avatar Eric Eastwood

Recent search history for issues

Fixes https://gitlab.com/gitlab-org/gitlab-ce/issues/27262
parent 08393eca
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>
`,
};
...@@ -56,7 +56,7 @@ require('./filtered_search_dropdown'); ...@@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() { renderContent() {
const dropdownData = []; 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; const { icon, hint, tag, type } = dropdownMenu.dataset;
if (icon && hint && tag) { if (icon && hint && tag) {
dropdownData.push( dropdownData.push(
......
...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container'; ...@@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
} }
}); });
return values.join(' '); return values
.map(value => value.trim())
.join(' ');
} }
static getSearchInput(filteredSearchInput) { static getSearchInput(filteredSearchInput) {
......
import Vue from 'vue';
export default new Vue();
/* global Flash */
import FilteredSearchContainer from './container'; 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 { class FilteredSearchManager {
constructor(page) { constructor(page) {
this.container = FilteredSearchContainer.container; this.container = FilteredSearchContainer.container;
this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.filteredSearchInputForm = this.filteredSearchInput.form;
this.clearSearchButton = this.container.querySelector('.clear-search'); this.clearSearchButton = this.container.querySelector('.clear-search');
this.tokensContainer = this.container.querySelector('.tokens-container'); this.tokensContainer = this.container.querySelector('.tokens-container');
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; 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) { if (this.filteredSearchInput) {
this.tokenizer = gl.FilteredSearchTokenizer; this.tokenizer = gl.FilteredSearchTokenizer;
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page); 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.bindEvents();
this.loadSearchParamsFromURL(); this.loadSearchParamsFromURL();
this.dropdownManager.setDropdown(); this.dropdownManager.setDropdown();
...@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container'; ...@@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
cleanup() { cleanup() {
this.unbindEvents(); this.unbindEvents();
document.removeEventListener('beforeunload', this.cleanupWrapper); document.removeEventListener('beforeunload', this.cleanupWrapper);
if (this.recentSearchesRoot) {
this.recentSearchesRoot.destroy();
}
} }
bindEvents() { bindEvents() {
...@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container'; ...@@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this); this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this); this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
this.checkForEnterWrapper = this.checkForEnter.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.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this); this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
...@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container'; ...@@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
this.tokenChange = this.tokenChange.bind(this); this.tokenChange = this.tokenChange.bind(this);
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this); this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.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.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper); this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper); this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
...@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container'; ...@@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper); 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', gl.FilteredSearchVisualTokens.unselectTokens);
document.addEventListener('click', this.unselectEditTokensWrapper); document.addEventListener('click', this.unselectEditTokensWrapper);
document.addEventListener('click', this.removeInputContainerFocusWrapper); document.addEventListener('click', this.removeInputContainerFocusWrapper);
document.addEventListener('keydown', this.removeSelectedTokenWrapper); document.addEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
unbindEvents() { unbindEvents() {
...@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container'; ...@@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken); this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper); 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', gl.FilteredSearchVisualTokens.unselectTokens);
document.removeEventListener('click', this.unselectEditTokensWrapper); document.removeEventListener('click', this.unselectEditTokensWrapper);
document.removeEventListener('click', this.removeInputContainerFocusWrapper); document.removeEventListener('click', this.removeInputContainerFocusWrapper);
document.removeEventListener('keydown', this.removeSelectedTokenWrapper); document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
} }
checkForBackspace(e) { checkForBackspace(e) {
...@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container'; ...@@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
} }
addInputContainerFocus() { addInputContainerFocus() {
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container'); const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
if (inputContainer) { if (inputContainer) {
inputContainer.classList.add('focus'); inputContainer.classList.add('focus');
...@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container'; ...@@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
} }
removeInputContainerFocus(e) { 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 isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null; const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
...@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container'; ...@@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
} }
unselectEditTokens(e) { 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 isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null; const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
const isElementTokensContainer = e.target.classList.contains('tokens-container'); const isElementTokensContainer = e.target.classList.contains('tokens-container');
...@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container'; ...@@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
} }
} }
clearSearch(e) { onClearSearch(e) {
e.preventDefault(); e.preventDefault();
this.clearSearch();
}
clearSearch() {
this.filteredSearchInput.value = ''; this.filteredSearchInput.value = '';
const removeElements = []; const removeElements = [];
...@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container'; ...@@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
this.search(); 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() { loadSearchParamsFromURL() {
const params = gl.utils.getUrlParamsArray(); const params = gl.utils.getUrlParamsArray();
const usernameParams = this.getUsernameParams(); const usernameParams = this.getUsernameParams();
...@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container'; ...@@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
} }
}); });
this.saveCurrentSearchQuery();
if (hasFilteredSearch) { if (hasFilteredSearch) {
this.clearSearchButton.classList.remove('hidden'); this.clearSearchButton.classList.remove('hidden');
this.handleInputPlaceholder(); this.handleInputPlaceholder();
...@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container'; ...@@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
search() { search() {
const paths = []; const paths = [];
const searchQuery = gl.DropdownUtils.getSearchQuery();
this.saveCurrentSearchQuery();
const { tokens, searchToken } const { tokens, searchToken }
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery()); = this.tokenizer.processTokens(searchQuery);
const currentState = gl.utils.getParameterByName('state') || 'opened'; const currentState = gl.utils.getParameterByName('state') || 'opened';
paths.push(`state=${currentState}`); paths.push(`state=${currentState}`);
...@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container'; ...@@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
currentDropdownRef.dispatchInputEvent(); currentDropdownRef.dispatchInputEvent();
} }
} }
onrecentSearchesItemSelected(text) {
this.clearSearch();
this.filteredSearchInput.value = text;
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
this.search();
}
} }
window.gl = window.gl || {}; window.gl = window.gl || {};
......
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;
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;
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;
...@@ -177,10 +177,6 @@ ...@@ -177,10 +177,6 @@
border-radius: $border-radius-base; border-radius: $border-radius-base;
box-shadow: 0 2px 4px $dropdown-shadow-color; box-shadow: 0 2px 4px $dropdown-shadow-color;
.filtered-search-input-container & {
max-width: 280px;
}
&.is-loading { &.is-loading {
.dropdown-content { .dropdown-content {
display: none; display: none;
...@@ -467,6 +463,11 @@ ...@@ -467,6 +463,11 @@
overflow-y: auto; overflow-y: auto;
} }
.dropdown-info-note {
color: $gl-text-color-secondary;
text-align: center;
}
.dropdown-footer { .dropdown-footer {
padding-top: 10px; padding-top: 10px;
margin-top: 10px; margin-top: 10px;
......
...@@ -22,7 +22,6 @@ ...@@ -22,7 +22,6 @@
} }
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update { .issues_bulk_update {
.dropdown-menu-toggle { .dropdown-menu-toggle {
width: 132px; width: 132px;
...@@ -56,7 +55,7 @@ ...@@ -56,7 +55,7 @@
} }
} }
.filtered-search-container { .filtered-search-wrapper {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -151,11 +150,13 @@ ...@@ -151,11 +150,13 @@
width: 100%; width: 100%;
} }
.filtered-search-input-container { .filtered-search-box {
position: relative;
flex: 1;
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
position: relative;
width: 100%; width: 100%;
min-width: 0;
border: 1px solid $border-color; border: 1px solid $border-color;
background-color: $white-light; background-color: $white-light;
...@@ -163,14 +164,6 @@ ...@@ -163,14 +164,6 @@
-webkit-flex: 1 1 auto; -webkit-flex: 1 1 auto;
flex: 1 1 auto; flex: 1 1 auto;
margin-bottom: 10px; margin-bottom: 10px;
.dropdown-menu {
width: auto;
left: 0;
right: 0;
max-width: none;
min-width: 100%;
}
} }
&:hover { &:hover {
...@@ -229,6 +222,118 @@ ...@@ -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 { .filter-dropdown-container {
display: -webkit-flex; display: -webkit-flex;
display: flex; display: flex;
...@@ -248,10 +353,8 @@ ...@@ -248,10 +353,8 @@
} }
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) { @media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
.issues-details-filters { .issue-bulk-update-dropdown-toggle {
.dropdown-menu-toggle { width: 100px;
width: 100px;
}
} }
} }
......
module DropdownsHelper module DropdownsHelper
def dropdown_tag(toggle_text, options: {}, &block) 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" } data_attr = { toggle: "dropdown" }
if options.has_key?(:data) if options.has_key?(:data)
...@@ -20,7 +20,7 @@ module DropdownsHelper ...@@ -20,7 +20,7 @@ module DropdownsHelper
output << dropdown_filter(options[:placeholder]) output << dropdown_filter(options[:placeholder])
end 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) capture(&block) if block && !options.has_key?(:footer_content)
end end
......
...@@ -40,21 +40,21 @@ ...@@ -40,21 +40,21 @@
.issues_bulk_update.hide .issues_bulk_update.hide
= form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: 'bulk-update' do
.filter-item.inline .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 %ul
%li %li
%a{ href: "#", data: { id: "reopen" } } Open %a{ href: "#", data: { id: "reopen" } } Open
%li %li
%a{ href: "#", data: {id: "close" } } Closed %a{ href: "#", data: {id: "close" } } Closed
.filter-item.inline .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" } }) 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 .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 .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 } = 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 .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 %ul
%li %li
%a{ href: "#", data: { id: "subscribe" } } Subscribe %a{ href: "#", data: { id: "subscribe" } } Subscribe
......
...@@ -10,85 +10,93 @@ ...@@ -10,85 +10,93 @@
.check-all-holder .check-all-holder
= check_box_tag "check_all_issues", nil, false, = check_box_tag "check_all_issues", nil, false,
class: "check_all_issues left" class: "check_all_issues left"
.issues-other-filters.filtered-search-container .issues-other-filters.filtered-search-wrapper
.filtered-search-input-container .filtered-search-box
.scroll-container = dropdown_tag(content_tag(:i, '', class: 'fa fa-history'),
%ul.tokens-container.list-unstyled options: { wrapper_class: "filtered-search-history-dropdown-wrapper",
%li.input-token toggle_class: "filtered-search-history-dropdown-toggle-button",
%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) } } dropdown_class: "filtered-search-history-dropdown",
= icon('filter') content_class: "filtered-search-history-dropdown-content",
%button.clear-search.hidden{ type: 'button' } title: "Recent searches" }) do
= icon('times') .js-filtered-search-history-dropdown
#js-dropdown-hint.dropdown-menu.hint-dropdown .filtered-search-box-input-container
%ul{ data: { dropdown: true } } .scroll-container
%li.filter-dropdown-item{ data: { action: 'submit' } } %ul.tokens-container.list-unstyled
%button.btn.btn-link %li.input-token
= icon('search') %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) } }
%span = icon('filter')
Press Enter or click to search %button.clear-search.hidden{ type: 'button' }
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } = icon('times')
%li.filter-dropdown-item #js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%button.btn.btn-link %ul{ data: { dropdown: true } }
-# Encapsulate static class name `{{icon}}` inside #{} to bypass %li.filter-dropdown-item{ data: { action: 'submit' } }
-# haml lint's ClassAttributeWithStaticValue %button.btn.btn-link
%i.fa{ class: "#{'{{icon}}'}" } = icon('search')
%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
%span %span
{{name}} Press Enter or click to search
%span.dropdown-light-content %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
@{{username}} %li.filter-dropdown-item
#js-dropdown-assignee.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } } %button.btn.btn-link
%ul{ data: { dropdown: true } } -# Encapsulate static class name `{{icon}}` inside #{} to bypass
%li.filter-dropdown-item{ data: { value: 'none' } } -# haml lint's ClassAttributeWithStaticValue
%button.btn.btn-link %i.fa{ class: "#{'{{icon}}'}" }
No Assignee %span.js-filter-hint
%li.divider {{hint}}
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %span.js-filter-tag.dropdown-light-content
%li.filter-dropdown-item {{tag}}
%button.btn.btn-link.dropdown-user #js-dropdown-author.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'pencil', hint: 'author', tag: '@author' } }
%img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } } %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
.dropdown-user-details %li.filter-dropdown-item
%span %button.btn.btn-link.dropdown-user
{{name}} %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
%span.dropdown-light-content .dropdown-user-details
@{{username}} %span
#js-dropdown-milestone.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } } {{name}}
%ul{ data: { dropdown: true } } %span.dropdown-light-content
%li.filter-dropdown-item{ data: { value: 'none' } } @{{username}}
%button.btn.btn-link #js-dropdown-assignee.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'user', hint: 'assignee', tag: '@assignee' } }
No Milestone %ul{ data: { dropdown: true } }
%li.filter-dropdown-item{ data: { value: 'upcoming' } } %li.filter-dropdown-item{ data: { value: 'none' } }
%button.btn.btn-link %button.btn.btn-link
Upcoming No Assignee
%li.filter-dropdown-item{ 'data-value' => 'started' } %li.divider
%button.btn.btn-link %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
Started %li.filter-dropdown-item
%li.divider %button.btn.btn-link.dropdown-user
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %img.avatar{ alt: '{{name}}\'s avatar', width: '30', data: { src: '{{avatar_url}}' } }
%li.filter-dropdown-item .dropdown-user-details
%button.btn.btn-link.js-data-value %span
{{title}} {{name}}
#js-dropdown-label.dropdown-menu{ data: { icon: 'tag', hint: 'label', tag: '~label', type: 'array' } } %span.dropdown-light-content
%ul{ data: { dropdown: true } } @{{username}}
%li.filter-dropdown-item{ data: { value: 'none' } } #js-dropdown-milestone.filtered-search-input-dropdown-menu.dropdown-menu{ data: { icon: 'clock-o', hint: 'milestone', tag: '%milestone' } }
%button.btn.btn-link %ul{ data: { dropdown: true } }
No Label %li.filter-dropdown-item{ data: { value: 'none' } }
%li.divider %button.btn.btn-link
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } No Milestone
%li.filter-dropdown-item %li.filter-dropdown-item{ data: { value: 'upcoming' } }
%button.btn.btn-link %button.btn.btn-link
%span.dropdown-label-box{ style: 'background: {{color}}' } Upcoming
%span.label-title.js-data-value %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}} {{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 .filter-dropdown-container
- if type == :boards - if type == :boards
- if can?(current_user, :admin_list, @project) - if can?(current_user, :admin_list, @project)
......
---
title: Recent search history for issues
merge_request:
author:
...@@ -590,7 +590,7 @@ describe 'Issue Boards', feature: true, js: true do ...@@ -590,7 +590,7 @@ describe 'Issue Boards', feature: true, js: true do
end end
def click_filter_link(link_text) 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) expect(page).to have_button(link_text)
click_button(link_text) click_button(link_text)
......
...@@ -219,7 +219,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do ...@@ -219,7 +219,7 @@ describe 'Issue Boards add issue modal filtering', :feature, :js do
end end
def click_filter_link(link_text) 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) expect(page).to have_button(link_text)
click_button(link_text) click_button(link_text)
......
...@@ -194,7 +194,7 @@ describe 'Dropdown assignee', :feature, :js do ...@@ -194,7 +194,7 @@ describe 'Dropdown assignee', :feature, :js do
new_user = create(:user) new_user = create(:user)
project.team << [new_user, :master] 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.set('assignee')
filtered_search.send_keys(':') filtered_search.send_keys(':')
......
...@@ -172,7 +172,7 @@ describe 'Dropdown author', js: true, feature: true do ...@@ -172,7 +172,7 @@ describe 'Dropdown author', js: true, feature: true do
new_user = create(:user) new_user = create(:user)
project.team << [new_user, :master] project.team << [new_user, :master]
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.set('author') filtered_search.set('author')
send_keys_to_filtered_search(':') send_keys_to_filtered_search(':')
......
...@@ -33,7 +33,7 @@ describe 'Dropdown label', js: true, feature: true do ...@@ -33,7 +33,7 @@ describe 'Dropdown label', js: true, feature: true do
end end
def clear_search_field def clear_search_field
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
end end
before do before do
......
...@@ -252,7 +252,7 @@ describe 'Dropdown milestone', :feature, :js do ...@@ -252,7 +252,7 @@ describe 'Dropdown milestone', :feature, :js do
expect(initial_size).to be > 0 expect(initial_size).to be > 0
create(:milestone, project: project) create(:milestone, project: project)
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
filtered_search.set('milestone:') filtered_search.set('milestone:')
expect(dropdown_milestone_size).to eq(initial_size) expect(dropdown_milestone_size).to eq(initial_size)
......
...@@ -758,10 +758,10 @@ describe 'Filter issues', js: true, feature: true do ...@@ -758,10 +758,10 @@ describe 'Filter issues', js: true, feature: true do
expect_issues_list_count(2) expect_issues_list_count(2)
sort_toggle = find('.filtered-search-container .dropdown-toggle') sort_toggle = find('.filtered-search-wrapper .dropdown-toggle')
sort_toggle.click 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 wait_for_ajax
expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title) expect(find('.issues-list .issue:first-of-type .issue-title-text a')).to have_content(old_issue.title)
......
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
...@@ -44,7 +44,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -44,7 +44,7 @@ describe 'Search bar', js: true, feature: true do
filtered_search.set(search_text) filtered_search.set(search_text)
expect(filtered_search.value).to eq(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('') expect(filtered_search.value).to eq('')
end end
...@@ -55,7 +55,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -55,7 +55,7 @@ describe 'Search bar', js: true, feature: true do
it 'hides after clicked' do it 'hides after clicked' do
filtered_search.set('a') 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) expect(page).to have_css('.clear-search', visible: false)
end end
...@@ -81,7 +81,7 @@ describe 'Search bar', js: true, feature: true do ...@@ -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) 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 filtered_search.click
expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to eq(original_size) 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 ...@@ -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) 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 filtered_search.click
expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0 expect(page.all('#js-dropdown-hint .filter-dropdown .filter-dropdown-item').size).to be > 0
......
...@@ -14,7 +14,7 @@ feature 'Merge requests filter clear button', feature: true, js: true do ...@@ -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!(:mr2) { create(:merge_request, title: "Bugfix1", source_project: project, target_project: project, source_branch: "Bugfix1") }
let(:merge_request_css) { '.merge-request' } 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 before do
mr2.labels << bug mr2.labels << bug
......
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();
});
});
});
});
...@@ -29,7 +29,7 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper ...@@ -29,7 +29,7 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper
beforeEach(() => { beforeEach(() => {
setFixtures(` setFixtures(`
<div class="filtered-search-input-container"> <div class="filtered-search-box">
<form> <form>
<ul class="tokens-container list-unstyled"> <ul class="tokens-container list-unstyled">
${FilteredSearchSpecHelper.createInputHTML(placeholder)} ${FilteredSearchSpecHelper.createInputHTML(placeholder)}
...@@ -264,12 +264,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper ...@@ -264,12 +264,12 @@ const FilteredSearchSpecHelper = require('../helpers/filtered_search_spec_helper
describe('toggleInputContainerFocus', () => { describe('toggleInputContainerFocus', () => {
it('toggles on focus', () => { it('toggles on focus', () => {
input.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', () => { it('toggles on blur', () => {
input.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);
}); });
}); });
}); });
......
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);
});
});
});
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']);
});
});
});
...@@ -30,7 +30,7 @@ module FilteredSearchHelpers ...@@ -30,7 +30,7 @@ module FilteredSearchHelpers
end end
def clear_search_field def clear_search_field
find('.filtered-search-input-container .clear-search').click find('.filtered-search-box .clear-search').click
end end
def reset_filters def reset_filters
...@@ -51,7 +51,7 @@ module FilteredSearchHelpers ...@@ -51,7 +51,7 @@ module FilteredSearchHelpers
# Iterates through each visual token inside # Iterates through each visual token inside
# .tokens-container to make sure the correct names and values are rendered # .tokens-container to make sure the correct names and values are rendered
def expect_tokens(tokens) 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| page.all(:css, '.tokens-container li').each_with_index do |el, index|
token_name = tokens[index][:name] token_name = tokens[index][:name]
token_value = tokens[index][:value] token_value = tokens[index][:value]
...@@ -71,4 +71,18 @@ module FilteredSearchHelpers ...@@ -71,4 +71,18 @@ module FilteredSearchHelpers
def get_filtered_search_placeholder def get_filtered_search_placeholder
find('.filtered-search')['placeholder'] find('.filtered-search')['placeholder']
end 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 end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment