Commit b635f8f0 authored by James Becker's avatar James Becker

Support single- or multiple-token deletion via backspace keyboard combinations in search inputs

parent 4de027be
...@@ -14,7 +14,13 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer'; ...@@ -14,7 +14,13 @@ import FilteredSearchTokenizer from './filtered_search_tokenizer';
import FilteredSearchDropdownManager from './filtered_search_dropdown_manager'; import FilteredSearchDropdownManager from './filtered_search_dropdown_manager';
import FilteredSearchVisualTokens from './filtered_search_visual_tokens'; import FilteredSearchVisualTokens from './filtered_search_visual_tokens';
import DropdownUtils from './dropdown_utils'; import DropdownUtils from './dropdown_utils';
import { BACKSPACE_KEY_CODE } from '~/lib/utils/keycodes'; import {
ENTER_KEY_CODE,
BACKSPACE_KEY_CODE,
DELETE_KEY_CODE,
UP_KEY_CODE,
DOWN_KEY_CODE,
} from '~/lib/utils/keycodes';
import { __ } from '~/locale'; import { __ } from '~/locale';
export default class FilteredSearchManager { export default class FilteredSearchManager {
...@@ -176,6 +182,8 @@ export default class FilteredSearchManager { ...@@ -176,6 +182,8 @@ export default class FilteredSearchManager {
this.checkForEnterWrapper = this.checkForEnter.bind(this); this.checkForEnterWrapper = this.checkForEnter.bind(this);
this.onClearSearchWrapper = this.onClearSearch.bind(this); this.onClearSearchWrapper = this.onClearSearch.bind(this);
this.checkForBackspaceWrapper = this.checkForBackspace.call(this); this.checkForBackspaceWrapper = this.checkForBackspace.call(this);
this.checkForMetaBackspaceWrapper = this.checkForMetaBackspace.bind(this);
this.checkForAltOrCtrlBackspaceWrapper = this.checkForAltOrCtrlBackspace.bind(this);
this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this); this.removeSelectedTokenKeydownWrapper = this.removeSelectedTokenKeydown.bind(this);
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this); this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
this.editTokenWrapper = this.editToken.bind(this); this.editTokenWrapper = this.editToken.bind(this);
...@@ -192,6 +200,9 @@ export default class FilteredSearchManager { ...@@ -192,6 +200,9 @@ export default class FilteredSearchManager {
this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.addEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.addEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.addEventListener('keyup', this.checkForBackspaceWrapper);
// e.metaKey only works with keydown, not keyup
this.filteredSearchInput.addEventListener('keydown', this.checkForMetaBackspaceWrapper);
this.filteredSearchInput.addEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper);
this.filteredSearchInput.addEventListener('click', this.tokenChange); this.filteredSearchInput.addEventListener('click', this.tokenChange);
this.filteredSearchInput.addEventListener('keyup', this.tokenChange); this.filteredSearchInput.addEventListener('keyup', this.tokenChange);
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper); this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
...@@ -213,6 +224,8 @@ export default class FilteredSearchManager { ...@@ -213,6 +224,8 @@ export default class FilteredSearchManager {
this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper); this.filteredSearchInput.removeEventListener('input', this.handleInputPlaceholderWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper); this.filteredSearchInput.removeEventListener('keyup', this.handleInputVisualTokenWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper); this.filteredSearchInput.removeEventListener('keydown', this.checkForEnterWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForMetaBackspaceWrapper);
this.filteredSearchInput.removeEventListener('keydown', this.checkForAltOrCtrlBackspaceWrapper);
this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper); this.filteredSearchInput.removeEventListener('keyup', this.checkForBackspaceWrapper);
this.filteredSearchInput.removeEventListener('click', this.tokenChange); this.filteredSearchInput.removeEventListener('click', this.tokenChange);
this.filteredSearchInput.removeEventListener('keyup', this.tokenChange); this.filteredSearchInput.removeEventListener('keyup', this.tokenChange);
...@@ -235,7 +248,11 @@ export default class FilteredSearchManager { ...@@ -235,7 +248,11 @@ export default class FilteredSearchManager {
return e => { return e => {
// 8 = Backspace Key // 8 = Backspace Key
// 46 = Delete Key // 46 = Delete Key
if (e.keyCode === 8 || e.keyCode === 46) { // Handled by respective backspace-combination check functions
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput(); const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken); const { tokenName, tokenValue } = DropdownUtils.getVisualTokenValues(lastVisualToken);
const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue); const canEdit = tokenName && this.canEdit && this.canEdit(tokenName, tokenValue);
...@@ -258,15 +275,31 @@ export default class FilteredSearchManager { ...@@ -258,15 +275,31 @@ export default class FilteredSearchManager {
}; };
} }
checkForAltOrCtrlBackspace(e) {
if ((e.altKey || e.ctrlKey) && e.keyCode === BACKSPACE_KEY_CODE) {
// Default to native OS behavior if input value present
if (this.filteredSearchInput.value === '') {
FilteredSearchVisualTokens.removeLastTokenPartial();
}
}
}
checkForMetaBackspace(e) {
const onlyMeta = e.metaKey && !e.altKey && !e.ctrlKey && !e.shiftKey;
if (onlyMeta && e.keyCode === BACKSPACE_KEY_CODE) {
this.clearSearch();
}
}
checkForEnter(e) { checkForEnter(e) {
if (e.keyCode === 38 || e.keyCode === 40) { if (e.keyCode === UP_KEY_CODE || e.keyCode === DOWN_KEY_CODE) {
const { selectionStart } = this.filteredSearchInput; const { selectionStart } = this.filteredSearchInput;
e.preventDefault(); e.preventDefault();
this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart); this.filteredSearchInput.setSelectionRange(selectionStart, selectionStart);
} }
if (e.keyCode === 13) { if (e.keyCode === ENTER_KEY_CODE) {
const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown]; const dropdown = this.dropdownManager.mapping[this.dropdownManager.currentDropdown];
const dropdownEl = dropdown.element; const dropdownEl = dropdown.element;
const activeElements = dropdownEl.querySelectorAll('.droplab-item-active'); const activeElements = dropdownEl.querySelectorAll('.droplab-item-active');
...@@ -375,7 +408,7 @@ export default class FilteredSearchManager { ...@@ -375,7 +408,7 @@ export default class FilteredSearchManager {
removeSelectedTokenKeydown(e) { removeSelectedTokenKeydown(e) {
// 8 = Backspace Key // 8 = Backspace Key
// 46 = Delete Key // 46 = Delete Key
if (e.keyCode === 8 || e.keyCode === 46) { if (e.keyCode === BACKSPACE_KEY_CODE || e.keyCode === DELETE_KEY_CODE) {
this.removeSelectedToken(); this.removeSelectedToken();
} }
} }
......
export const UP_KEY_CODE = 38; export const BACKSPACE_KEY_CODE = 8;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13; export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27; export const ESC_KEY_CODE = 27;
export const BACKSPACE_KEY_CODE = 8; export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const DELETE_KEY_CODE = 46;
---
title: Added support for single-token deletion via option/ctrl-backspace or search-filter clearing via command-backspace in filtered search
merge_request: 28295
author: James Becker
type: added
...@@ -98,7 +98,9 @@ You can view recent searches by clicking on the little arrow-clock icon, which i ...@@ -98,7 +98,9 @@ You can view recent searches by clicking on the little arrow-clock icon, which i
## Removing search filters ## Removing search filters
Individual filters can be removed by clicking on the filter's (x) button or backspacing. The entire search filter can be cleared by clicking on the search box's (x) button. Individual filters can be removed by clicking on the filter's (x) button or backspacing. The entire search filter can be cleared by clicking on the search box's (x) button or via <kbd></kbd> (Mac) + <kbd></kbd>.
To delete filter tokens one at a time, the <kbd></kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd></kbd> keyboard combination can be used.
## Filtering with multiple filters of the same type ## Filtering with multiple filters of the same type
......
...@@ -127,6 +127,15 @@ This shortcut is available when viewing a [wiki page](project/wiki/index.md): ...@@ -127,6 +127,15 @@ This shortcut is available when viewing a [wiki page](project/wiki/index.md):
| ----------------- | ----------- | | ----------------- | ----------- |
| <kbd>e</kbd> | Edit wiki page. | | <kbd>e</kbd> | Edit wiki page. |
### Filtered Search
These shortcuts are available when using a [filtered search input](search/index.md):
| Keyboard Shortcut | Description |
| ----------------------------------------------------- | ----------- |
| <kbd></kbd> (Mac) + <kbd></kbd> | Clear entire search filter. |
| <kbd></kbd> (Mac) / <kbd>Ctrl</kbd> + <kbd></kbd> | Clear one token at a time. |
## Epics **(ULTIMATE)** ## Epics **(ULTIMATE)**
These shortcuts are available when viewing [Epics](group/epics/index.md): These shortcuts are available when viewing [Epics](group/epics/index.md):
......
...@@ -8,6 +8,7 @@ import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual ...@@ -8,6 +8,7 @@ import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual
import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager'; import FilteredSearchDropdownManager from '~/filtered_search/filtered_search_dropdown_manager';
import FilteredSearchManager from '~/filtered_search/filtered_search_manager'; import FilteredSearchManager from '~/filtered_search/filtered_search_manager';
import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper';
import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes';
describe('Filtered Search Manager', function() { describe('Filtered Search Manager', function() {
let input; let input;
...@@ -17,16 +18,35 @@ describe('Filtered Search Manager', function() { ...@@ -17,16 +18,35 @@ describe('Filtered Search Manager', function() {
const placeholder = 'Search or filter results...'; const placeholder = 'Search or filter results...';
function dispatchBackspaceEvent(element, eventType) { function dispatchBackspaceEvent(element, eventType) {
const backspaceKey = 8;
const event = new Event(eventType); const event = new Event(eventType);
event.keyCode = backspaceKey; event.keyCode = BACKSPACE_KEY_CODE;
element.dispatchEvent(event); element.dispatchEvent(event);
} }
function dispatchDeleteEvent(element, eventType) { function dispatchDeleteEvent(element, eventType) {
const deleteKey = 46;
const event = new Event(eventType); const event = new Event(eventType);
event.keyCode = deleteKey; event.keyCode = DELETE_KEY_CODE;
element.dispatchEvent(event);
}
function dispatchAltBackspaceEvent(element, eventType) {
const event = new Event(eventType);
event.altKey = true;
event.keyCode = BACKSPACE_KEY_CODE;
element.dispatchEvent(event);
}
function dispatchCtrlBackspaceEvent(element, eventType) {
const event = new Event(eventType);
event.ctrlKey = true;
event.keyCode = BACKSPACE_KEY_CODE;
element.dispatchEvent(event);
}
function dispatchMetaBackspaceEvent(element, eventType) {
const event = new Event(eventType);
event.metaKey = true;
event.keyCode = BACKSPACE_KEY_CODE;
element.dispatchEvent(event); element.dispatchEvent(event);
} }
...@@ -299,6 +319,80 @@ describe('Filtered Search Manager', function() { ...@@ -299,6 +319,80 @@ describe('Filtered Search Manager', function() {
}); });
}); });
describe('checkForAltOrCtrlBackspace', () => {
beforeEach(() => {
initializeManager();
spyOn(FilteredSearchVisualTokens, 'removeLastTokenPartial').and.callThrough();
});
describe('tokens and no input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
});
it('removes last token via alt-backspace', () => {
dispatchAltBackspaceEvent(input, 'keydown');
expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
});
it('removes last token via ctrl-backspace', () => {
dispatchCtrlBackspaceEvent(input, 'keydown');
expect(FilteredSearchVisualTokens.removeLastTokenPartial).toHaveBeenCalled();
});
});
describe('tokens and input', () => {
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
});
it('does not remove token or change input via alt-backspace when there is existing input', () => {
input = manager.filteredSearchInput;
input.value = 'text';
dispatchAltBackspaceEvent(input, 'keydown');
expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
expect(input.value).toEqual('text');
});
it('does not remove token or change input via ctrl-backspace when there is existing input', () => {
input = manager.filteredSearchInput;
input.value = 'text';
dispatchCtrlBackspaceEvent(input, 'keydown');
expect(FilteredSearchVisualTokens.removeLastTokenPartial).not.toHaveBeenCalled();
expect(input.value).toEqual('text');
});
});
});
describe('checkForMetaBackspace', () => {
beforeEach(() => {
initializeManager();
});
beforeEach(() => {
tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(
FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug'),
);
});
it('removes all tokens and input', () => {
spyOn(FilteredSearchManager.prototype, 'clearSearch').and.callThrough();
dispatchMetaBackspaceEvent(input, 'keydown');
expect(manager.clearSearch).toHaveBeenCalled();
expect(manager.filteredSearchInput.value).toEqual('');
expect(DropdownUtils.getSearchQuery()).toEqual('');
});
});
describe('removeToken', () => { describe('removeToken', () => {
beforeEach(() => { beforeEach(() => {
initializeManager(); initializeManager();
......
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