diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.spec.js index 5079c3a2d22710afb553010fca2864617726af53..9e3633e5319347e5835ee63c4481e919e8dc955e 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.spec.js +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.spec.js @@ -7,6 +7,7 @@ import { } from '../constants'; import GlDuoChatContextItemSelections from '../duo_chat_context_item_selections/duo_chat_context_item_selections.vue'; import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items.vue'; +import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items.vue'; import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue'; jest.mock('lodash/debounce', () => jest.fn((fn) => fn)); @@ -34,7 +35,7 @@ describe('GlDuoChatContextItemMenu', () => { const findMenu = () => findByTestId('context-item-menu'); const findContextItemSelections = () => wrapper.findComponent(GlDuoChatContextItemSelections); const findCategoryItems = () => wrapper.findComponent(GlDuoChatContextItemMenuCategoryItems); - const findResultItems = () => findByTestId('context-menu-search-items'); + const findSearchItems = () => wrapper.findComponent(GlDuoChatContextItemMenuSearchItems); describe('context item selection', () => { describe('and there are selections', () => { @@ -113,37 +114,52 @@ describe('GlDuoChatContextItemMenu', () => { }); it('shows search result items', () => { - expect(findResultItems().exists()).toBe(true); + expect(findSearchItems().props()).toEqual({ + activeIndex: 0, + category, + error: null, + loading: false, + results, + searchQuery: '', + }); }); it('selects the item when clicked', async () => { - await findResultItems().find('li').trigger('click'); + await findSearchItems().vm.$emit('select', results.at(0)); expect(wrapper.emitted('select').at(0)).toEqual([results.at(0)]); }); it('emits "close" event when selecting an item', async () => { - await findResultItems().find('li').trigger('click'); + await findSearchItems().vm.$emit('select', results.at(0)); expect(wrapper.emitted('close')).toHaveLength(1); }); it('does not select a disabled item when clicked', async () => { - const item = findResultItems() - .findAll('li') - .wrappers.find((i) => i.text().includes(results.at(1).metadata.name)); - await item.trigger('click'); - + await findSearchItems().vm.$emit('select', results.at(1)); expect(wrapper.emitted('select')).toBeUndefined(); }); describe('when searching', () => { + const query = 'e'; beforeEach(async () => { + await findSearchItems().vm.$emit('update:searchQuery', query); + await wrapper.setProps({ loading: true, }); }); + it('emits search event', async () => { + expect(wrapper.emitted('search').at(1)).toEqual([ + { + category: categoryValue, + query, + }, + ]); + }); + it('shows loading state', async () => { - expect(wrapper.text()).toContain('Loading...'); + expect(findSearchItems().props('loading')).toBe(true); }); describe('when there is an error', () => { @@ -155,7 +171,7 @@ describe('GlDuoChatContextItemMenu', () => { }); it('shows error state', async () => { - expect(wrapper.text()).toContain('Error: oh no'); + expect(findSearchItems().props('error')).toBe('oh no'); }); }); @@ -171,8 +187,7 @@ describe('GlDuoChatContextItemMenu', () => { }); it('shows matching results', async () => { - expect(findResultItems().findAll('li')).toHaveLength(1); - expect(findResultItems().text()).toContain(matchingResult.metadata.name); + expect(findSearchItems().props('results')).toEqual([matchingResult]); }); }); }); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue index 197a314b8ccccb21b7479773e8e7424e7b677e4b..e2c4043f3f8b87a7079dfd0155b208881da9466b 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue @@ -1,15 +1,20 @@ + + + + + + {{ error }} + + + {{ $options.i18n.emptyStateMessage }} + + + + + + + {{ item.metadata.name }} {{ item.isEnabled ? '' : '(disabled)' }} + + + + + + + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items_loading.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items_loading.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..44a312902699859641ab3273cf63a7e1fa49d588 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items_loading.spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import GlDuoChatContextItemMenuSearchItemsLoading from './duo_chat_context_item_menu_search_items_loading.vue'; + +describe('GlDuoChatContextItemMenuSearchItemsLoading', () => { + let wrapper; + + const createWrapper = (props = {}) => { + wrapper = shallowMount(GlDuoChatContextItemMenuSearchItemsLoading, { + propsData: { + rows: 3, + ...props, + }, + }); + }; + + const findAllByTestId = (testId) => wrapper.findAll(`[data-testid="${testId}"]`); + const findLoadingRows = () => findAllByTestId('search-results-loading'); + + it('should render the accessible loading text', () => { + createWrapper(); + expect(wrapper.text()).toContain('Loading...'); + }); + + it.each([1, 2, 3, 4, 5])('renders %s rows', (rows) => { + createWrapper({ rows }); + expect(findLoadingRows()).toHaveLength(rows); + }); +}); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items_loading.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items_loading.vue new file mode 100644 index 0000000000000000000000000000000000000000..c44f17ec1d54538da3d1d0ec1d143b2d4dd31279 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items_loading.vue @@ -0,0 +1,34 @@ + + + + + + + + + + + + {{ $options.i18n.loadingMessage }} + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/utils.js b/src/components/experimental/duo/chat/components/duo_chat_context/utils.js index e4c2bb344c8f492d3c211482a56c425172809be5..d8baa557125040a5294792296bc85fe1a5698a3f 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/utils.js +++ b/src/components/experimental/duo/chat/components/duo_chat_context/utils.js @@ -1,4 +1,4 @@ -function categoryValidator(category) { +export function categoryValidator(category) { return Boolean(category && category.value && category.label && category.icon); } @@ -14,7 +14,7 @@ function disabledReasonsValidator(disabledReasons) { ); } -function contextItemValidator(item) { +export function contextItemValidator(item) { return Boolean( item && item.id && diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js index 50a7b0bf0f641b8896cd89e9969fa28d5c1a8288..330cfe1cf17ad3e797b98c40019b773d98eb7213 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js +++ b/src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js @@ -1,11 +1,34 @@ -import { categoriesValidator, contextItemsValidator } from './utils'; +import { + categoriesValidator, + categoryValidator, + contextItemsValidator, + contextItemValidator, +} from './utils'; import { MOCK_CATEGORIES, MOCK_CONTEXT_ITEM_FILE, + MOCK_CONTEXT_ITEM_FILE_DISABLED, MOCK_CONTEXT_ITEM_MERGE_REQUEST, } from './mock_context_data'; describe('duo_chat_context utils', () => { + describe('categoryValidator', () => { + it.each(MOCK_CATEGORIES)('returns true for a valid category', (category) => { + expect(categoryValidator(category)).toBe(true); + }); + + it.each([ + { value: { value: 'test', label: 'Test' }, description: 'missing icon' }, + { value: { value: 'test', icon: 'icon' }, description: 'missing label' }, + { value: { label: 'Test', icon: 'icon' }, description: 'missing value' }, + { value: {}, description: 'empty object' }, + { value: null, description: 'null' }, + { value: undefined, description: 'undefined' }, + ])('returns false for "$description"', ({ value }) => { + expect(categoryValidator(value)).toBe(false); + }); + }); + describe('categoriesValidator', () => { it('returns true for valid categories', () => { expect(categoriesValidator(MOCK_CATEGORIES)).toBe(true); @@ -38,6 +61,70 @@ describe('duo_chat_context utils', () => { }); }); + describe('contextItemValidator', () => { + describe('with a valid item', () => { + it('returns true for a valid file item', () => { + expect(contextItemValidator(MOCK_CONTEXT_ITEM_FILE)).toBe(true); + }); + + it('returns true for a valid merge request item', () => { + expect(contextItemValidator(MOCK_CONTEXT_ITEM_MERGE_REQUEST)).toBe(true); + }); + + it('returns true for a valid disabled item', () => { + expect(contextItemValidator(MOCK_CONTEXT_ITEM_FILE_DISABLED)).toBe(true); + }); + }); + + describe.each([ + { value: null, description: 'null' }, + { value: undefined, description: 'undefined' }, + { value: {}, description: 'empty object' }, + { value: 'not an item', description: 'string instead of object' }, + { value: 42, description: 'number instead of object' }, + { + value: { ...MOCK_CONTEXT_ITEM_FILE, id: undefined }, + description: 'missing id', + }, + { + value: { ...MOCK_CONTEXT_ITEM_FILE, type: undefined }, + description: 'missing type', + }, + { + value: { + ...MOCK_CONTEXT_ITEM_FILE, + isEnabled: undefined, + }, + description: 'missing enabled', + }, + { + value: { + ...MOCK_CONTEXT_ITEM_FILE, + isEnabled: 'true', + }, + description: 'non-boolean enabled', + }, + { + value: { + ...MOCK_CONTEXT_ITEM_FILE, + disabledReasons: 'not an array', + }, + description: 'non-array disabledReasons', + }, + { + value: { + ...MOCK_CONTEXT_ITEM_FILE, + disabledReasons: [42], + }, + description: 'non-string items in disabledReasons array', + }, + ])('with "$description"', ({ value }) => { + it('returns false', () => { + expect(contextItemValidator(value)).toBe(false); + }); + }); + }); + describe('contextItemsValidator', () => { describe.each([ { value: [], description: 'empty array' }, diff --git a/translations.js b/translations.js index 60a0904b009f7be77a68ac55247b7c2b684edeb8..1e235fac6d64ff12d4e31bfb54ce24079d8a4050 100644 --- a/translations.js +++ b/translations.js @@ -8,6 +8,9 @@ export default { 'GlBreadcrumb.showMoreLabel': 'Show more breadcrumbs', 'GlBroadcastMessage.closeButtonTitle': 'Dismiss', 'GlCollapsibleListbox.srOnlyResultsLabel': null, + 'GlDuoChatContextItemMenu.emptyStateMessage': 'No results found', + 'GlDuoChatContextItemMenu.loadingMessage': 'Loading...', + 'GlDuoChatContextItemMenu.searchInputPlaceholder': 'Search %{categoryLabel}...', 'GlDuoChatContextItemMenu.selectedContextItemsTitle': 'Included references', 'GlDuoChatMessage.SelectedContextItemsTitleAssistantMessage': null, 'GlDuoChatMessage.SelectedContextItemsTitleUserMessage': null,