diff --git a/app/assets/javascripts/boards/boards_bundle.js b/app/assets/javascripts/boards/boards_bundle.js index 5b2e89e51be798cf8ba3b561787ae06d9e69796d..165b3a9946838a0186c226ce3d292d4f8bf522af 100644 --- a/app/assets/javascripts/boards/boards_bundle.js +++ b/app/assets/javascripts/boards/boards_bundle.js @@ -87,6 +87,7 @@ $(() => { Store.rootPath = this.endpoint; this.filterManager = new FilteredSearchBoards(Store.filter, true, [(this.milestoneTitle ? 'milestone' : null)]); + this.filterManager.setup(); // Listen for updateTokens event eventHub.$on('updateTokens', this.updateTokens); diff --git a/app/assets/javascripts/boards/components/modal/filters.js b/app/assets/javascripts/boards/components/modal/filters.js index b214b5a71994d86528957db4afb9529ec7a065c4..56a0fde5a9143c17b858fd47d0d32bf633ef9011 100644 --- a/app/assets/javascripts/boards/components/modal/filters.js +++ b/app/assets/javascripts/boards/components/modal/filters.js @@ -13,6 +13,7 @@ export default { FilteredSearchContainer.container = this.$el; this.filteredSearch = new FilteredSearchBoards(this.store); + this.filteredSearch.setup(); this.filteredSearch.removeTokens(); this.filteredSearch.handleInputPlaceholder(); this.filteredSearch.toggleClearSearchButton(); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index 5d837134c94921d5d74f57cc280103c00d4ba752..3f083655f950819300ac2b93633d8587ad644f17 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -42,9 +42,7 @@ export default class FilteredSearchBoards extends gl.FilteredSearchManager { this.filteredSearchInput.dispatchEvent(new Event('input')); } - canEdit(token) { - const tokenName = token.querySelector('.name').textContent.trim(); - + canEdit(tokenName) { return this.cantEdit.indexOf(tokenName) === -1; } } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 9139de9c880eb9b5fd46a0126aba7a7e35771e2c..1de8ebdfb39097a12e21cfd8ba78b2bbe991583c 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -130,7 +130,10 @@ import ApproversSelect from './approvers_select'; case 'projects:merge_requests:index': case 'projects:issues:index': if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(page === 'projects:issues:index' ? 'issues' : 'merge_requests'); + const filteredSearchManager = new gl.FilteredSearchManager( + page === 'projects:issues:index' ? 'issues' : 'merge_requests', + ); + filteredSearchManager.setup(); } Issuable.init(); new gl.IssuableBulkActions({ diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 224b6b928cf09ec8bdac4f90cf879126cbce558e..1ed0b2e9896728aec56fa5d2391d0dd476264463 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -6,6 +6,7 @@ import eventHub from './event_hub'; class FilteredSearchManager { constructor(page) { + this.page = page; this.container = FilteredSearchContainer.container; this.filteredSearchInput = this.container.querySelector('.filtered-search'); this.filteredSearchInputForm = this.filteredSearchInput.form; @@ -13,7 +14,7 @@ class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys; - if (page === 'issues' || page === 'boards') { + if (this.page === 'issues' || this.page === 'boards') { this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeysIssuesEE; } @@ -21,16 +22,18 @@ class FilteredSearchManager { isLocalStorageAvailable: RecentSearchesService.isAvailable(), allowedKeys: this.filteredSearchTokenKeys.getKeys(), }); - const searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); - const projectPath = searchHistoryDropdownElement ? - searchHistoryDropdownElement.dataset.projectFullPath : 'project'; + this.searchHistoryDropdownElement = document.querySelector('.js-filtered-search-history-dropdown'); + const projectPath = this.searchHistoryDropdownElement ? + this.searchHistoryDropdownElement.dataset.projectFullPath : 'project'; let recentSearchesPagePrefix = 'issue-recent-searches'; - if (page === 'merge_requests') { + if (this.page === 'merge_requests') { recentSearchesPagePrefix = 'merge-request-recent-searches'; } const recentSearchesKey = `${projectPath}-${recentSearchesPagePrefix}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); + } + setup() { // Fetch recent searches from localStorage this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch() .catch((error) => { @@ -51,12 +54,12 @@ class FilteredSearchManager { if (this.filteredSearchInput) { this.tokenizer = gl.FilteredSearchTokenizer; - this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, page); + this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', this.tokenizer, this.page); this.recentSearchesRoot = new RecentSearchesRoot( this.recentSearchesStore, this.recentSearchesService, - searchHistoryDropdownElement, + this.searchHistoryDropdownElement, ); this.recentSearchesRoot.init(); @@ -145,9 +148,9 @@ class FilteredSearchManager { if (e.keyCode === 8 || e.keyCode === 46) { const { lastVisualToken } = gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); - if (this.filteredSearchInput.value === '' && lastVisualToken) { - if (this.canEdit && !this.canEdit(lastVisualToken)) return; - + const sanitizedTokenName = lastVisualToken && lastVisualToken.querySelector('.name').textContent.trim(); + const canEdit = sanitizedTokenName && this.canEdit && this.canEdit(sanitizedTokenName); + if (this.filteredSearchInput.value === '' && lastVisualToken && canEdit) { this.filteredSearchInput.value = gl.FilteredSearchVisualTokens.getLastTokenPartial(); gl.FilteredSearchVisualTokens.removeLastTokenPartial(); } @@ -246,10 +249,10 @@ class FilteredSearchManager { editToken(e) { const token = e.target.closest('.js-visual-token'); + const sanitizedTokenName = token.querySelector('.name').textContent.trim(); + const canEdit = this.canEdit && this.canEdit(sanitizedTokenName); - if (this.canEdit && !this.canEdit(token)) return; - - if (token) { + if (token && canEdit) { gl.FilteredSearchVisualTokens.editToken(token); this.tokenChange(); } @@ -399,7 +402,12 @@ class FilteredSearchManager { if (condition) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(condition.tokenKey, condition.value); + const canEdit = this.canEdit && this.canEdit(condition.tokenKey); + gl.FilteredSearchVisualTokens.addFilterVisualToken( + condition.tokenKey, + condition.value, + canEdit, + ); } else { // Sanitize value since URL converts spaces into + // Replace before decode so that we know what was originally + versus the encoded + @@ -418,18 +426,27 @@ class FilteredSearchManager { } hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken(sanitizedKey, `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`); + const canEdit = this.canEdit && this.canEdit(sanitizedKey); + gl.FilteredSearchVisualTokens.addFilterVisualToken( + sanitizedKey, + `${symbol}${quotationsToUse}${sanitizedValue}${quotationsToUse}`, + canEdit, + ); } else if (!match && keyParam === 'assignee_id') { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('assignee', `@${usernameParams[id]}`); + const tokenName = 'assignee'; + const canEdit = this.canEdit && this.canEdit(tokenName); + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); } } else if (!match && keyParam === 'author_id') { const id = parseInt(value, 10); if (usernameParams[id]) { hasFilteredSearch = true; - gl.FilteredSearchVisualTokens.addFilterVisualToken('author', `@${usernameParams[id]}`); + const tokenName = 'author'; + const canEdit = this.canEdit && this.canEdit(tokenName); + gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, `@${usernameParams[id]}`, canEdit); } } else if (!match && keyParam === 'search') { hasFilteredSearch = true; @@ -524,6 +541,11 @@ class FilteredSearchManager { this.filteredSearchInput.dispatchEvent(new CustomEvent('input')); this.search(); } + + // eslint-disable-next-line class-methods-use-this + canEdit() { + return true; + } } window.gl = window.gl || {}; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index f3003b86493ea324bd3c218429a7aca1ed6d0a74..bc1226f5879112350387ecf4f0ce6766af836d92 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -36,15 +36,22 @@ class FilteredSearchVisualTokens { } } - static createVisualTokenElementHTML() { + static createVisualTokenElementHTML(canEdit = true) { + let removeTokenMarkup = ''; + if (canEdit) { + removeTokenMarkup = ` + <div class="remove-token" role="button"> + <i class="fa fa-close"></i> + </div> + `; + } + return ` <div class="selectable" role="button"> <div class="name"></div> <div class="value-container"> <div class="value"></div> - <div class="remove-token" role="button"> - <i class="fa fa-close"></i> - </div> + ${removeTokenMarkup} </div> </div> `; @@ -84,13 +91,13 @@ class FilteredSearchVisualTokens { } } - static addVisualTokenElement(name, value, isSearchTerm) { + static addVisualTokenElement(name, value, isSearchTerm, canEdit) { const li = document.createElement('li'); li.classList.add('js-visual-token'); li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token'); if (value) { - li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(); + li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML(canEdit); FilteredSearchVisualTokens.renderVisualTokenValue(li, name, value); } else { li.innerHTML = '<div class="name"></div>'; @@ -114,20 +121,20 @@ class FilteredSearchVisualTokens { } } - static addFilterVisualToken(tokenName, tokenValue) { + static addFilterVisualToken(tokenName, tokenValue, canEdit) { const { lastVisualToken, isLastVisualTokenValid } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement; if (isLastVisualTokenValid) { - addVisualTokenElement(tokenName, tokenValue, false); + addVisualTokenElement(tokenName, tokenValue, false, canEdit); } else { const previousTokenName = lastVisualToken.querySelector('.name').innerText; const tokensContainer = FilteredSearchContainer.container.querySelector('.tokens-container'); tokensContainer.removeChild(lastVisualToken); const value = tokenValue || tokenName; - addVisualTokenElement(previousTokenName, value, false); + addVisualTokenElement(previousTokenName, value, false, canEdit); } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 4d5112d523034b1b6280d9eca18116af7c9cd96f..5d827ebb834371a43a71c41db59703a21dfd6767 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -104,6 +104,22 @@ padding: 2px 7px; } + .name { + background-color: $filter-name-resting-color; + color: $filter-name-text-color; + border-radius: 2px 0 0 2px; + margin-right: 1px; + text-transform: capitalize; + } + + .value-container { + background-color: $white-normal; + color: $filter-value-text-color; + border-radius: 0 2px 2px 0; + margin-right: 5px; + padding-right: 8px; + } + .value { padding-right: 0; } @@ -111,7 +127,7 @@ .remove-token { display: inline-block; padding-left: 4px; - padding-right: 8px; + padding-right: 0; .fa-close { color: $gl-text-color-secondary; @@ -132,21 +148,6 @@ } } - .name { - background-color: $filter-name-resting-color; - color: $filter-name-text-color; - border-radius: 2px 0 0 2px; - margin-right: 1px; - text-transform: capitalize; - } - - .value-container { - background-color: $white-normal; - color: $filter-value-text-color; - border-radius: 0 2px 2px 0; - margin-right: 5px; - } - .selected { .name { background-color: $filter-name-selected-color; diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index f0084bc55574d672a384a9ebb7882617f99ec2dc..a8d44457351b36ce93644e89bce30acff7ea5b22 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -177,7 +177,8 @@ $(document).off('page:restore').on('page:restore', function (event) { if (gl.FilteredSearchManager) { - new gl.FilteredSearchManager(); + const filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); } Issuable.init(); new gl.IssuableBulkActions({ diff --git a/spec/features/boards/boards_spec.rb b/spec/features/boards/boards_spec.rb index 3398882218f36b39b444bcb458af931f4cf61541..025f31190ab32bd5d0156c9fb6ca2229db87d009 100644 --- a/spec/features/boards/boards_spec.rb +++ b/spec/features/boards/boards_spec.rb @@ -4,7 +4,9 @@ include DragTo let(:project) { create(:empty_project, :public) } + let(:milestone) { create(:milestone, title: "v2.2", project: project) } let!(:board) { create(:board, project: project) } + let!(:board_with_milestone) { create(:board, project: project, milestone: milestone) } let(:user) { create(:user) } let!(:user2) { create(:user) } @@ -509,6 +511,22 @@ end end + context 'locked milestone' do + before do + visit namespace_project_board_path(project.namespace, project, board_with_milestone) + wait_for_requests + end + + it 'should not have remove button' do + expect(page).to have_selector('.js-visual-token .remove-token', count: 0) + end + + it 'should not be able to be backspaced' do + find('.input-token .filtered-search').native.send_key(:backspace) + expect(page).to have_selector('.js-visual-token', count: 1) + end + end + context 'keyboard shortcuts' do before do visit namespace_project_boards_path(project.namespace, project) diff --git a/spec/javascripts/filtered_search/filtered_search_manager_spec.js b/spec/javascripts/filtered_search/filtered_search_manager_spec.js index 8688332782dc68e30292327bfe0a18e57209d081..6e59ee96c6b87f0f18f0aa8478436178446934b6 100644 --- a/spec/javascripts/filtered_search/filtered_search_manager_spec.js +++ b/spec/javascripts/filtered_search/filtered_search_manager_spec.js @@ -57,6 +57,7 @@ describe('Filtered Search Manager', () => { input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); manager = new gl.FilteredSearchManager(); + manager.setup(); }); afterEach(() => { @@ -72,6 +73,7 @@ describe('Filtered Search Manager', () => { spyOn(recentSearchesStoreSrc, 'default'); filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); return filteredSearchManager; }); @@ -89,6 +91,7 @@ describe('Filtered Search Manager', () => { spyOn(window, 'Flash'); filteredSearchManager = new gl.FilteredSearchManager(); + filteredSearchManager.setup(); expect(window.Flash).not.toHaveBeenCalled(); });