Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorJacob Schatz <jschatz@gitlab.com>2017-04-06 19:32:09 +0300
committerJacob Schatz <jschatz@gitlab.com>2017-04-06 19:32:09 +0300
commit3f60fe1a6045bac777357aa0d962e1a11552468c (patch)
tree49c91eca87d5ab814938d4ae4e3b37fd88dd16ff /app
parentdf10507f3eb43bda9cc6adbebf8c2d1736c3463f (diff)
parentb7ce488df57ad2c0e2e509c906747cc31c5bef1f (diff)
Merge branch '27262-issue-recent-searches' into 'master'
Recent search history for issues Closes #27262 See merge request !10324
Diffstat (limited to 'app')
-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
13 files changed, 497 insertions, 114 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)