diff --git a/app/assets/javascripts/repository/file_tree_browser/components/tree_list.vue b/app/assets/javascripts/repository/file_tree_browser/components/tree_list.vue index b3ba4a196a9e6ee9a753f931780754fae228430e..b270ad51d36eeac168ea560c71952118862a1807 100644 --- a/app/assets/javascripts/repository/file_tree_browser/components/tree_list.vue +++ b/app/assets/javascripts/repository/file_tree_browser/components/tree_list.vue @@ -1,11 +1,11 @@ @@ -431,23 +424,24 @@ export default {
- - + > + {{ $options.searchLabel }} + - {{ __('Focus on the filter bar') }} + {{ __('Focus on the search bar') }}
  • ({ joinPaths: jest.fn((...args) => args.join('/').replace(/\/+/g, '/')), })); jest.mock('~/behaviors/shortcuts/shortcuts_toggle'); +jest.mock('~/lib/utils/dom_utils'); describe('Tree List', () => { let wrapper; @@ -71,9 +73,7 @@ describe('Tree List', () => { const findTreeItems = () => wrapper.findAll('[role="treeitem"]'); const findFileRows = () => wrapper.findAllComponents(FileRow); const findFileRowPlaceholders = () => wrapper.findAll('[data-placeholder-item]'); - const findFilterInput = () => wrapper.findComponent(GlFormInput); - const findFilterIcon = () => wrapper.findComponent(GlIcon); - const findNoFilesMessage = () => wrapper.findByText('No files found'); + const findSearchButton = () => wrapper.findByTestId('search-trigger'); const findTooltip = () => wrapper.findComponent(GlTooltip); const { bindInternalEventDocument } = useMockInternalEventsTracking(); @@ -226,110 +226,106 @@ describe('Tree List', () => { expect(mockFocus).toHaveBeenCalled(); }); - - it('can filter with Show more button in the list', async () => { - const filterQuery = '/dir_1/dir_2'; - expect(findFileRows()).toHaveLength(3); // Contains all items before filtering - - findFilterInput().vm.$emit('input', filterQuery); - await nextTick(); - - expect(findFileRows()).toHaveLength(1); // Contains only one item after filtering - expect(findFileRows().at(0).props('file')).toMatchObject({ path: filterQuery }); - }); }); - describe('filtering', () => { - it('renders filter input with icon', () => { - expect(findFilterInput().exists()).toBe(true); - expect(findFilterIcon().exists()).toBe(true); - expect(findFilterIcon().props('name')).toBe('filter'); - expect(findFilterIcon().props('variant')).toBe('subtle'); - expect(findFilterInput().attributes('type')).toBe('search'); + describe('search button', () => { + it('renders search button with correct props', () => { + const button = findSearchButton(); + + expect(button.props('icon')).toBe('search'); + expect(button.attributes('aria-label')).toBe('Search files (*.vue, *.rb...)'); + expect(button.text()).toBe('Search files (*.vue, *.rb...)'); }); - const filterTestCases = [ - { filter: 'file.txt', expectedNames: ['file.txt'] }, - { filter: '*.txt', expectedNames: ['file.txt'] }, - { filter: 'dir_2', expectedNames: ['dir_2'] }, - { filter: '*.nonexistent', expectedNames: [] }, - ]; + it('dispatches global search event when search button is clicked', async () => { + const dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + const mockSearchInput = document.createElement('input'); + mockSearchInput.id = 'search'; + waitForElement.mockResolvedValue(mockSearchInput); - it.each(filterTestCases)('filters correctly with "$filter"', ({ filter, expectedNames }) => { - findFilterInput().vm.$emit('input', filter); - const fileNames = findFileRows().wrappers.map((row) => row.props('file').name); + findSearchButton().vm.$emit('click'); + await nextTick(); - expect(fileNames).toEqual(expect.arrayContaining(expectedNames)); + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'globalSearch:open', + }), + ); }); - it('resets active item when filtered out', async () => { - await createComponent(); - await nextTick(); - - findTree().trigger('keydown', { key: 'ArrowDown' }); - await nextTick(); - const secondItemId = findTreeItems().at(1).attributes('data-item-id'); - expect(wrapper.vm.activeItemId).toBe(secondItemId); + it('sets search input value to "~" after opening global search', async () => { + const mockSearchInput = document.createElement('input'); + mockSearchInput.id = 'search'; + waitForElement.mockResolvedValue(mockSearchInput); - findFilterInput().vm.$emit('input', 'dir_2'); - await nextTick(); + findSearchButton().vm.$emit('click'); + await waitForPromises(); - expect(wrapper.vm.activeItemId).not.toBe(secondItemId); - expect(wrapper.vm.activeItemId).toBe(findTreeItems().at(0).attributes('data-item-id')); + expect(mockSearchInput.value).toBe('~'); }); - }); - describe('empty state', () => { - it('shows no files message when filtered list is empty', async () => { - findFilterInput().vm.$emit('input', '*.nonexistent'); + it('dispatches input event on search input after setting value', async () => { + const mockSearchInput = document.createElement('input'); + mockSearchInput.id = 'search'; + const dispatchEventSpy = jest.spyOn(mockSearchInput, 'dispatchEvent'); + waitForElement.mockResolvedValue(mockSearchInput); + + findSearchButton().vm.$emit('click'); await waitForPromises(); - expect(findNoFilesMessage().exists()).toBe(true); - }); - }); - it('triggers a tracking event when filter bar is click', async () => { - const { trackEventSpy } = bindInternalEventDocument(wrapper.element); + expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event)); + expect(dispatchEventSpy.mock.calls[0][0].type).toBe('input'); + }); - createComponent(); - findFilterInput().vm.$emit('click', '*.nonexistent'); + it('triggers a tracking event when search button is clicked', async () => { + const { trackEventSpy } = bindInternalEventDocument(wrapper.element); - await nextTick(); + findSearchButton().vm.$emit('click'); + await nextTick(); - expect(trackEventSpy).toHaveBeenCalledWith( - 'focus_file_tree_browser_filter_bar_on_repository_page', - { label: 'click' }, - undefined, - ); + expect(trackEventSpy).toHaveBeenCalledWith( + 'focus_file_tree_browser_filter_bar_on_repository_page', + { label: 'click' }, + undefined, + ); + }); }); - describe('handles filter bar focus correctly when shortcuts are enabled', () => { + describe('handles search button focus correctly when shortcuts are enabled', () => { beforeEach(() => { shouldDisableShortcuts.mockReturnValue(false); createComponent(); }); - it('focuses filter input when triggerFocusFilterBar is called', async () => { - const mockFocus = jest.fn(); - findFilterInput().vm.focus = mockFocus; + it('opens global search when shortcut is triggered', async () => { + const dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + const mockSearchInput = document.createElement('input'); + mockSearchInput.id = 'search'; + waitForElement.mockResolvedValue(mockSearchInput); const mousetrapInstance = wrapper.vm.mousetrap; mousetrapInstance.trigger('f'); - await nextTick(); + await waitForPromises(); - expect(mockFocus).toHaveBeenCalled(); + expect(dispatchEventSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'globalSearch:open', + }), + ); + expect(mockSearchInput.value).toBe('~'); }); it('triggers a tracking event when shortcut is used', async () => { const { trackEventSpy } = bindInternalEventDocument(wrapper.element); - - const mockFocus = jest.fn(); - findFilterInput().vm.focus = mockFocus; + const mockSearchInput = document.createElement('input'); + mockSearchInput.id = 'search'; + waitForElement.mockResolvedValue(mockSearchInput); const mousetrapInstance = wrapper.vm.mousetrap; mousetrapInstance.trigger('f'); - await nextTick(); + await waitForPromises(); expect(trackEventSpy).toHaveBeenCalledWith( 'focus_file_tree_browser_filter_bar_on_repository_page', @@ -356,24 +352,30 @@ describe('Tree List', () => { wrapper.destroy(); expect(unbindSpy).toHaveBeenCalledWith(keysFor(FOCUS_FILE_TREE_BROWSER_FILTER_BAR)); }); + + it('sets correct aria-keyshortcuts attribute on search button', () => { + const button = findSearchButton(); + expect(button.attributes('aria-keyshortcuts')).toBe( + keysFor(FOCUS_FILE_TREE_BROWSER_FILTER_BAR)[0], + ); + }); }); - describe('handles filter bar focus correctly when shortcuts are disabled', () => { + describe('handles search button focus correctly when shortcuts are disabled', () => { beforeEach(() => { shouldDisableShortcuts.mockReturnValue(true); createComponent(); }); - it('does not focus when shortcuts are disabled', async () => { - const mockFocus = jest.fn(); - findFilterInput().vm.focus = mockFocus; + it('does not open global search when shortcuts are disabled', async () => { + const dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); const mousetrapInstance = wrapper.vm.mousetrap; mousetrapInstance.trigger('f'); await nextTick(); - expect(mockFocus).not.toHaveBeenCalled(); + expect(dispatchEventSpy).not.toHaveBeenCalled(); }); it('does not display tooltip', () => { @@ -389,6 +391,11 @@ describe('Tree List', () => { wrapper.vm.triggerFocusFilterBar, ); }); + + it('does not set aria-keyshortcuts attribute on search button', () => { + const button = findSearchButton(); + expect(button.attributes('aria-keyshortcuts')).toBeUndefined(); + }); }); describe('deep path navigation with pagination', () => { diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js index b8df970a8289b3b31702cdeb8da35ed1f60d853e..d2753df16aa4273cf8c3528fe1a16a4246e3e21f 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js @@ -15,6 +15,7 @@ import CommandsOverviewDropdown from '~/super_sidebar/components/global_search/c import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue'; import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import { stubComponent } from 'helpers/stub_component'; import { COMMON_HANDLES, COMMAND_HANDLE, @@ -775,4 +776,40 @@ describe('GlobalSearchModal', () => { }); }); }); + + describe('globalSearch:open event', () => { + const modalStub = { show: jest.fn() }; + + beforeEach(() => + createComponent({ stubs: { GlModal: stubComponent(GlModal, { methods: modalStub }) } }), + ); + + it('adds event listener on mount', () => { + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + createComponent({ stubs: { GlModal: stubComponent(GlModal, { methods: modalStub }) } }); + + expect(addEventListenerSpy).toHaveBeenCalledWith('globalSearch:open', expect.any(Function)); + + addEventListenerSpy.mockRestore(); + }); + + it('removes event listener on destroy', () => { + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + wrapper.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'globalSearch:open', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + it('opens modal when event is dispatched', async () => { + document.dispatchEvent(new CustomEvent('globalSearch:open')); + await nextTick(); + + expect(modalStub.show).toHaveBeenCalled(); + }); + }); });