diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.scss b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.scss new file mode 100644 index 0000000000000000000000000000000000000000..600aa3589a8cde6a8b39f71d6ed9b343165d6735 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.scss @@ -0,0 +1,14 @@ +.duo-chat-context-search-result-item.disabled { + > button { + @apply gl-cursor-not-allowed gl-text-secondary #{!important}; + + &:hover { + @apply gl-text-secondary #{!important}; + } + + &:active, + &:focus { + @apply gl-shadow-none #{!important}; + } + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..9b00f21425ba6b655b7e87cdd15d6c0506311b88 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.spec.js @@ -0,0 +1,255 @@ +import { shallowMount } from '@vue/test-utils'; +import { getMockContextItems, MOCK_CATEGORIES } from '../mock_context_data'; +import { + CONTEXT_ITEM_TYPE_ISSUE, + CONTEXT_ITEM_TYPE_MERGE_REQUEST, + CONTEXT_ITEM_TYPE_PROJECT_FILE, +} from '../constants'; +import GlDuoChatContextItemSelections from '../duo_chat_context_item_selections/duo_chat_context_item_selections.vue'; +import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue'; +import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items.vue'; +import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items.vue'; + +jest.mock('lodash/debounce', () => jest.fn((fn) => fn)); + +describe('GlDuoChatContextItemMenu', () => { + let wrapper; + + const createComponent = (props = {}, options = {}) => { + wrapper = shallowMount(GlDuoChatContextItemMenu, { + propsData: { + open: true, + categories: MOCK_CATEGORIES, + selections: [], + loading: false, + error: null, + results: [], + ...props, + }, + ...options, + }); + }; + + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + + const findMenu = () => findByTestId('context-item-menu'); + const findContextItemSelections = () => wrapper.findComponent(GlDuoChatContextItemSelections); + const findCategoryItems = () => wrapper.findComponent(GlDuoChatContextItemMenuCategoryItems); + const findResultItems = () => wrapper.findComponent(GlDuoChatContextItemMenuSearchItems); + + // Keyboard events are passed by $ref from the parent GlDuoChat component, simulate that here + const triggerKeyUp = async (key) => wrapper.vm.handleKeyUp({ key, preventDefault: jest.fn() }); + + describe('context item selection', () => { + describe('and there are selections', () => { + it('renders context item selections', () => { + const selections = getMockContextItems().slice(0, 2); + createComponent({ open: false, selections }); + + expect(findContextItemSelections().props('removable')).toBe(true); + expect(findContextItemSelections().props('defaultCollapsed')).toBe(false); + expect(findContextItemSelections().props('title')).toBe('Included references'); + }); + + it('emits "remove" event when an item is removed', () => { + const selections = getMockContextItems().slice(0, 2); + createComponent({ open: false, selections }); + + const removed = selections.at(0); + findContextItemSelections().vm.$emit('remove', removed); + + expect(wrapper.emitted('remove').at(0)).toEqual([removed]); + }); + }); + + describe('and there are no selections', () => { + it('does not render selections', () => { + createComponent({ open: false, selections: [] }); + + expect(findContextItemSelections().exists()).toBe(false); + }); + }); + }); + + describe('when the menu is closed', () => { + it('does not render any menu', () => { + createComponent({ open: false }); + + expect(findMenu().exists()).toBe(false); + }); + }); + + describe('when the menu is open', () => { + describe('when a category has not been selected', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows categories', () => { + expect(findCategoryItems().props()).toEqual({ + activeIndex: 0, + categories: MOCK_CATEGORIES, + }); + }); + + it('cycles through the categories when the arrow keys are pressed', async () => { + expect(findCategoryItems().props('activeIndex')).toBe(0); + await triggerKeyUp('ArrowDown'); + expect(findCategoryItems().props('activeIndex')).toBe(1); + await triggerKeyUp('ArrowDown'); + expect(findCategoryItems().props('activeIndex')).toBe(2); + await triggerKeyUp('ArrowUp'); + expect(findCategoryItems().props('activeIndex')).toBe(1); + }); + + it('emits "close" event when escape is pressed', async () => { + await triggerKeyUp('Escape'); + + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('selects the category when enter is pressed', async () => { + await triggerKeyUp('Enter'); + + expect(wrapper.emitted('search').at(0)).toEqual([ + { + category: MOCK_CATEGORIES[0].value, + query: '', + }, + ]); + }); + }); + + describe.each([ + CONTEXT_ITEM_TYPE_ISSUE, + CONTEXT_ITEM_TYPE_MERGE_REQUEST, + CONTEXT_ITEM_TYPE_PROJECT_FILE, + ])('when a "%s" category has been selected', (categoryValue) => { + let category; + let results; + beforeEach(() => { + category = MOCK_CATEGORIES.find((cat) => cat.value === categoryValue); + results = getMockContextItems() + .filter((item) => item.type === categoryValue) + .map((item, index) => ({ + ...item, + isEnabled: index % 2 === 0, // disable odd indexed items + })); + + createComponent({ + results, + }); + + return findCategoryItems().vm.$emit('select', category); + }); + + it('shows search result items', () => { + expect(findResultItems().props()).toEqual({ + activeIndex: 0, + category, + error: null, + loading: false, + results, + searchQuery: '', + }); + }); + + it('cycles through the items when the arrow keys are pressed', async () => { + expect(findResultItems().props('activeIndex')).toBe(0); + await triggerKeyUp('ArrowDown'); + expect(findResultItems().props('activeIndex')).toBe(2); + await triggerKeyUp('ArrowUp'); + expect(findResultItems().props('activeIndex')).toBe(0); + }); + + it('does not cycle to the next item if it is disabled', async () => { + await triggerKeyUp('ArrowDown'); + expect(findResultItems().props('activeIndex')).toBe(2); + await triggerKeyUp('ArrowDown'); + expect(findResultItems().props('activeIndex')).not.toBe(1); // odd indexes disabled + expect(findResultItems().props('activeIndex')).toBe(0); // cycles back to first result + await triggerKeyUp('ArrowDown'); + expect(findResultItems().props('activeIndex')).toBe(2); + }); + + it('clears category selection when escape is pressed', async () => { + await triggerKeyUp('Escape'); + expect(findCategoryItems().exists()).toBe(true); + expect(findResultItems().exists()).toBe(false); + }); + + it('selects the item when enter is pressed', async () => { + await triggerKeyUp('Enter'); + expect(wrapper.emitted('select').at(0)).toEqual([results.at(0)]); + }); + + it('selects the item when clicked', async () => { + await findResultItems().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().vm.$emit('select', results.at(0)); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('does not select a disabled item when clicked', async () => { + await findResultItems().vm.$emit('select', results.at(1)); + expect(wrapper.emitted('select')).toBeUndefined(); + }); + + describe('when searching', () => { + const query = 'e'; + beforeEach(async () => { + await findResultItems().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(findResultItems().props('loading')).toBe(true); + }); + + describe('when there is an error', () => { + beforeEach(async () => { + await wrapper.setProps({ + loading: false, + error: 'oh no', + }); + }); + + it('shows error state', async () => { + expect(findResultItems().props('error')).toBe('oh no'); + }); + }); + + describe('when there are results', () => { + let matchingResult; + + beforeEach(async () => { + matchingResult = results.at(0); + await wrapper.setProps({ + loading: false, + results: [matchingResult], + }); + }); + + it('shows matching results', async () => { + expect(findResultItems().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.stories.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.stories.js new file mode 100644 index 0000000000000000000000000000000000000000..0c2d3180207dfc767af1481a375aa467b4f3d859 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.stories.js @@ -0,0 +1,93 @@ +import { makeContainer } from '../../../../../../../utils/story_decorators/container'; +import { setStoryTimeout } from '../../../../../../../utils/test_utils'; +import { getMockContextItems, MOCK_CATEGORIES } from '../mock_context_data'; +import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue'; + +const sampleCategories = MOCK_CATEGORIES; +const sampleContextItems = getMockContextItems(); + +export default { + title: 'experimental/duo/chat/components/duo-chat-context/duo-chat-context-item-menu', + component: GlDuoChatContextItemMenu, + decorators: [makeContainer({ height: '300px' })], + tags: ['skip-visual-test'], + parameters: { + docs: { + description: { + component: `Allows selecting and removing context items for the conversation. + +Note that keyboard events don't work properly in this story (independently of the main GlDuoChat component), this will soon be available in the GlDuoChat interactive story with the /include command.`, + }, + }, + }, +}; + +const Template = (args, { argTypes }) => ({ + components: { GlDuoChatContextItemMenu }, + props: Object.keys(argTypes), + data() { + return { + isOpen: this.open, + isLoading: this.loading, + errorMessage: this.error, + searchResults: this.results, + selectedItems: this.selections, + }; + }, + methods: { + handleContextItemsSearch({ category, query }) { + this.isLoading = true; + this.errorMessage = null; + setStoryTimeout(() => { + this.isLoading = false; + // this.errorMessage = 'OH NO SOME ERROR'; + this.searchResults = sampleContextItems + .filter((item) => item.type === category) + .filter( + (item) => !query || item.metadata.name.toLowerCase().includes(query.toLowerCase()) + ) + .filter((item) => !this.selectedItems.some((contextItem) => contextItem.id === item.id)); + }, 300); + }, + handleContextItemSelect(item) { + if (!this.selectedItems.some((i) => i.id === item.id)) { + this.selectedItems.push(item); + } + }, + handleContextItemRemove(item) { + const index = this.selectedItems.findIndex((i) => i.id === item.id); + if (index !== -1) { + this.selectedItems.splice(index, 1); + } + }, + }, + template: ` +
+
+ +
+ +
+ `, +}); + +export const Default = Template.bind({}); +Default.args = { + open: false, + loading: false, + error: null, + categories: sampleCategories, + results: [], + selections: [], +}; 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 new file mode 100644 index 0000000000000000000000000000000000000000..c2eb12f2730125c818618432ba84885378ab6f07 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_category_items.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_category_items.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b24d31a7a9cb5dd602548a2145099a786cd906e4 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_category_items.spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import GlDropdownItem from '../../../../../../base/dropdown/dropdown_item.vue'; +import GlIcon from '../../../../../../base/icon/icon.vue'; +import { MOCK_CATEGORIES } from '../mock_context_data'; +import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items.vue'; + +describe('GlDuoChatContextItemMenuCategoryItems', () => { + let wrapper; + + const createWrapper = () => { + wrapper = shallowMount(GlDuoChatContextItemMenuCategoryItems, { + propsData: { + categories: MOCK_CATEGORIES, + activeIndex: 0, + }, + }); + }; + + const findActiveItem = () => wrapper.find('.active-command'); + const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); + + beforeEach(() => createWrapper()); + + it('selects the category when clicked', async () => { + await findActiveItem().vm.$emit('click'); + + expect(wrapper.emitted('select').at(0)).toEqual([MOCK_CATEGORIES.at(0)]); + }); + + it('updates active index when hovering over item', () => { + findDropdownItems().wrappers.at(1).find('div').trigger('mouseenter'); + + expect(wrapper.emitted('active-index-change').at(0)).toEqual([1]); + }); + + it('renders the category details', () => { + const dropdownItems = findDropdownItems(); + + expect(dropdownItems).toHaveLength(MOCK_CATEGORIES.length); + + dropdownItems.wrappers.forEach((dropdownItem, index) => { + const expectedCategory = MOCK_CATEGORIES.at(index); + expect(dropdownItem.text()).toEqual(expectedCategory.label); + expect(dropdownItem.findComponent(GlIcon).props('name')).toEqual(expectedCategory.icon); + }); + }); + + it('marks the correct category as active', async () => { + expect(findActiveItem().text()).toBe(MOCK_CATEGORIES.at(0).label); + + await wrapper.setProps({ activeIndex: 1 }); + expect(findActiveItem().text()).toBe(MOCK_CATEGORIES.at(1).label); + + await wrapper.setProps({ activeIndex: 2 }); + expect(findActiveItem().text()).toBe(MOCK_CATEGORIES.at(2).label); + }); +}); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_category_items.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_category_items.vue new file mode 100644 index 0000000000000000000000000000000000000000..d9fa44966481b8b944bcd68a993e6be0ae509454 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_category_items.vue @@ -0,0 +1,45 @@ + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_item.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_item.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1516efc56cdde92ca0f9a7eccf57aaac651d7078 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_item.spec.js @@ -0,0 +1,64 @@ +import { shallowMount } from '@vue/test-utils'; +import { + MOCK_CATEGORIES, + MOCK_CONTEXT_ITEM_FILE, + MOCK_CONTEXT_ITEM_ISSUE, + MOCK_CONTEXT_ITEM_MERGE_REQUEST, +} from '../mock_context_data'; +import { + CONTEXT_ITEM_TYPE_ISSUE, + CONTEXT_ITEM_TYPE_MERGE_REQUEST, + CONTEXT_ITEM_TYPE_PROJECT_FILE, +} from '../constants'; +import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue'; +import GlDuoChatContextItemMenuSearchItem from './duo_chat_context_item_menu_search_item.vue'; + +describe('GlDuoChatContextItemMenuContextSearchItem', () => { + let wrapper; + + const createWrapper = (props) => { + wrapper = shallowMount(GlDuoChatContextItemMenuSearchItem, { + propsData: { + ...props, + }, + }); + }; + + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + const findCategoryIcon = () => findByTestId('category-icon'); + const findContextItemPopover = () => wrapper.findComponent(GlDuoChatContextItemPopover); + + describe.each([ + { + category: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_PROJECT_FILE), + item: MOCK_CONTEXT_ITEM_FILE, + }, + { + category: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_ISSUE), + item: MOCK_CONTEXT_ITEM_ISSUE, + }, + { + category: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_MERGE_REQUEST), + item: MOCK_CONTEXT_ITEM_MERGE_REQUEST, + }, + ])('for "$category"', ({ category, item }) => { + beforeEach(() => createWrapper({ category, item })); + + it('renders the category icon', () => { + expect(findCategoryIcon().props('name')).toBe(category.icon); + }); + + it('renders the context item popover', () => { + expect(findContextItemPopover().props()).toEqual( + expect.objectContaining({ + item, + target: `info-icon-${item.id}`, + }) + ); + }); + + it('renders the default context item title', () => { + expect(wrapper.text()).toContain(item.metadata.name); + }); + }); +}); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_item.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_item.vue new file mode 100644 index 0000000000000000000000000000000000000000..b94dbfe350e717f702e0455482d623257cb792c8 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_item.vue @@ -0,0 +1,68 @@ + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e58e4a9cb67e1710fb08e5c98e31c80b845b97db --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.spec.js @@ -0,0 +1,229 @@ +import { shallowMount } from '@vue/test-utils'; +import { MOCK_CATEGORIES, getMockContextItems } from '../mock_context_data'; +import { + CONTEXT_ITEM_TYPE_ISSUE, + CONTEXT_ITEM_TYPE_MERGE_REQUEST, + CONTEXT_ITEM_TYPE_PROJECT_FILE, +} from '../constants'; +import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items.vue'; +import GlDuoChatContextItemMenuSearchItemsLoading from './duo_chat_context_item_menu_search_items_loading.vue'; +import GlDuoChatContextItemMenuSearchItem from './duo_chat_context_item_menu_search_item.vue'; + +describe('GlDuoChatContextItemMenuSearchItems', () => { + let wrapper; + let category; + let results; + + const createWrapper = (props = {}) => { + category = props.category || MOCK_CATEGORIES.at(0); + results = props.results || getMockContextItems().filter((item) => item.type === category.value); + + wrapper = shallowMount(GlDuoChatContextItemMenuSearchItems, { + propsData: { + activeIndex: 0, + searchQuery: '', + category, + loading: false, + error: null, + results, + ...props, + }, + }); + }; + + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + const findAllByTestId = (testId) => wrapper.findAll(`[data-testid="${testId}"]`); + + const findSearchInput = () => findByTestId('context-menu-search-input'); + const findLoadingIndicator = () => + wrapper.findComponent(GlDuoChatContextItemMenuSearchItemsLoading); + const findLoadingError = () => findByTestId('search-results-error'); + const findEmptyState = () => findByTestId('search-results-empty-state'); + const findResultItems = () => findAllByTestId('search-result-item'); + const findActiveItem = () => wrapper.find('.active-command'); + const findActiveItemDetails = () => + findActiveItem().find('[data-testid="search-result-item-details"]'); + + describe('default rendering', () => { + beforeEach(() => createWrapper()); + + it('renders the search input', () => { + expect(findSearchInput().exists()).toBe(true); + }); + + it('emits "keyup" event', async () => { + const event = new Event('keyup'); + + findSearchInput().vm.$emit('keyup', event); + + expect(wrapper.emitted('keyup').at(0)).toEqual([event]); + }); + + it('does not render the loading state', () => { + expect(findLoadingIndicator().exists()).toBe(false); + }); + + it('does not render the error state', () => { + expect(findLoadingError().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when searching', () => { + const query = 'e'; + beforeEach(async () => { + createWrapper(); + await findSearchInput().vm.$emit('input', query); + + await wrapper.setProps({ + loading: true, + searchQuery: query, + }); + }); + + it('emits query input', async () => { + expect(wrapper.emitted('update:searchQuery').at(0)).toEqual([query]); + }); + + it('shows loading state', async () => { + expect(findLoadingIndicator().exists()).toBe(true); + }); + + describe('when there is an error', () => { + beforeEach(async () => { + await wrapper.setProps({ + loading: false, + error: 'oh no', + }); + }); + + it('shows error state', async () => { + expect(findLoadingError().text()).toBe('oh no'); + }); + + it('does not render the loading state', () => { + expect(findLoadingIndicator().exists()).toBe(false); + }); + + it('does not render the empty state', () => { + expect(findEmptyState().exists()).toBe(false); + }); + }); + + describe('when there are no results', () => { + beforeEach(async () => { + await wrapper.setProps({ + loading: false, + error: null, + results: [], + }); + }); + + it('shows empty state', async () => { + expect(findEmptyState().text()).toBe('No results found'); + }); + + it('does not render the loading state', () => { + expect(findLoadingIndicator().exists()).toBe(false); + }); + + it('does not render the error state', () => { + expect(findLoadingError().exists()).toBe(false); + }); + }); + + describe('when there are results', () => { + beforeEach(() => + wrapper.setProps({ + loading: false, + }) + ); + + it('shows matching results', async () => { + const matchingResult = results.at(0); + await wrapper.setProps({ + results: [matchingResult], + }); + + expect(findResultItems()).toHaveLength(1); + expect(wrapper.findComponent(GlDuoChatContextItemMenuSearchItem).props()).toEqual( + expect.objectContaining({ + item: matchingResult, + category, + }) + ); + }); + + it('marks the correct item as active', async () => { + expect(findActiveItemDetails().props('item')).toEqual(results.at(0)); + + await wrapper.setProps({ + activeIndex: 1, + }); + + expect(findActiveItemDetails().props('item')).toEqual(results.at(1)); + }); + + it('emits "active-index-change" event when hovering over an item', async () => { + const index = 1; + await findResultItems().wrappers.at(index).find('div').trigger('mouseenter'); + + expect(wrapper.emitted('active-index-change').at(0)).toEqual([index]); + }); + + describe('when there are disabled results', () => { + let disabledItem; + beforeEach(async () => { + const disabledIndex = 2; + await wrapper.setProps({ + results: results.map((result, index) => ({ + ...result, + isEnabled: index !== disabledIndex, + })), + }); + + disabledItem = findResultItems().at(disabledIndex); + }); + + it('does not emit "active-index-change" event when hovering over a disabled item', async () => { + await disabledItem.find('div').trigger('mouseenter'); + + expect(wrapper.emitted('active-index-change')).toBeUndefined(); + }); + + it('disables the item', () => { + expect(disabledItem.attributes('tabindex')).toBe('-1'); + expect(disabledItem.classes()).toContain('disabled'); + }); + }); + }); + }); + + describe.each([ + { + testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_PROJECT_FILE), + expectedPlaceholder: 'Search files...', + }, + { + testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_ISSUE), + expectedPlaceholder: 'Search issues...', + }, + { + testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_MERGE_REQUEST), + expectedPlaceholder: 'Search merge requests...', + }, + ])('when category is "$testCase.label"', ({ testCase, expectedPlaceholder }) => { + beforeEach(() => + createWrapper({ + category: testCase, + }) + ); + + it('renders the expected input placeholder text', () => { + expect(findSearchInput().attributes('placeholder')).toEqual(expectedPlaceholder); + }); + }); +}); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7140700d2e08fdba7323a5abf236f223a85cb93 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.vue @@ -0,0 +1,146 @@ + + 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..eab8445547e00ca0da163c4a09067540c8445755 --- /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,15 @@ +import { shallowMount } from '@vue/test-utils'; +import GlDuoChatContextItemMenuSearchItemsLoading from './duo_chat_context_item_menu_search_items_loading.vue'; + +describe('GlDuoChatContextItemMenuSearchItemsLoading', () => { + let wrapper; + + const createWrapper = () => { + wrapper = shallowMount(GlDuoChatContextItemMenuSearchItemsLoading); + }; + + it('should render the accessible loading text', () => { + createWrapper(); + expect(wrapper.text()).toContain('Loading...'); + }); +}); 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..5da1a5be4e00201e660ceda4c740fe9f0ca5bbc6 --- /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,28 @@ + + + diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue index 61854ec514239eb074ae243bc4e46dfb58aa043e..8baf8857c3bc97cd73447469f8e1e22a3ab99fef 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_popover/duo_chat_context_item_popover.vue @@ -6,6 +6,7 @@ import { CONTEXT_ITEM_TYPE_MERGE_REQUEST, CONTEXT_ITEM_TYPE_PROJECT_FILE, } from '../constants'; +import { contextItemValidator } from '../utils'; const ID_PREFIXES = { issue: '#', @@ -21,6 +22,7 @@ export default { item: { type: Object, required: true, + validator: contextItemValidator, }, target: { type: String, diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js index 0d8800f360628f7fd990fbd14dc7af3aa5499840..4ad71f15223419a7ffebb7285202736aa05c0a3a 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.spec.js @@ -144,4 +144,32 @@ describe('GlDuoChatContextItemSelections', () => { expect(popover.props('placement')).toBe('bottom'); }); }); + + describe('removable items', () => { + describe('when items cannot be removed', () => { + beforeEach(() => createComponent({ removable: false })); + + it('renders view-only tokens', () => { + findTokens().wrappers.forEach((token) => { + expect(token.props('viewOnly')).toBe(true); + }); + }); + }); + + describe('when items can be removed', () => { + beforeEach(() => createComponent({ removable: true })); + + it('renders removable tokens', () => { + findTokens().wrappers.forEach((token) => { + expect(token.props('viewOnly')).toBe(false); + }); + }); + + it('emits remove event when token is closed', async () => { + await findTokens().at(0).vm.$emit('close'); + expect(wrapper.emitted('remove')).toHaveLength(1); + expect(wrapper.emitted('remove')[0]).toEqual([MOCK_CONTEXT_ITEM_FILE]); + }); + }); + }); }); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue index ee724fcc22d3bb89b2fb195aa93bceeef2484b0c..df7bb72af5209eabc5efdf3e9731d52e2ec99c68 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_selections/duo_chat_context_item_selections.vue @@ -8,6 +8,7 @@ import { CONTEXT_ITEM_TYPE_MERGE_REQUEST, CONTEXT_ITEM_TYPE_PROJECT_FILE, } from '../constants'; +import { contextItemsValidator } from '../utils'; export default { name: 'GlDuoChatContextItemSelections', @@ -20,6 +21,7 @@ export default { selections: { type: Array, required: true, + validator: contextItemsValidator, }, title: { type: String, @@ -29,6 +31,11 @@ export default { type: Boolean, required: true, }, + removable: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -53,6 +60,9 @@ export default { toggleCollapse() { this.isCollapsed = !this.isCollapsed; }, + onRemoveItem(item) { + this.$emit('remove', item); + }, }, }; @@ -75,9 +85,10 @@ export default {
diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js b/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js index bd1dc34d03381ff3a8ed1505fc3c995ca0c81f29..95d6cb2cec1c11788f930a949669577e3509fcd3 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js +++ b/src/components/experimental/duo/chat/components/duo_chat_context/mock_context_data.js @@ -1,3 +1,15 @@ +import { + CONTEXT_ITEM_TYPE_ISSUE, + CONTEXT_ITEM_TYPE_MERGE_REQUEST, + CONTEXT_ITEM_TYPE_PROJECT_FILE, +} from './constants'; + +export const MOCK_CATEGORIES = [ + { label: 'Files', value: CONTEXT_ITEM_TYPE_PROJECT_FILE, icon: 'document' }, + { label: 'Issues', value: CONTEXT_ITEM_TYPE_ISSUE, icon: 'issues' }, + { label: 'Merge Requests', value: CONTEXT_ITEM_TYPE_MERGE_REQUEST, icon: 'merge-request' }, +]; + export const MOCK_CONTEXT_ITEM_FILE = { id: '123e4567-e89b-12d3-a456-426614174000', isEnabled: true, @@ -8,7 +20,7 @@ export const MOCK_CONTEXT_ITEM_FILE = { relFilePath: 'src/plants/strawberry.ts', }, }, - type: 'project_file', + type: CONTEXT_ITEM_TYPE_PROJECT_FILE, }; export const MOCK_CONTEXT_ITEM_FILE_DISABLED = { @@ -21,7 +33,7 @@ export const MOCK_CONTEXT_ITEM_FILE_DISABLED = { relFilePath: '/src/VehicleFoo/motorbike.cs', }, }, - type: 'project_file', + type: CONTEXT_ITEM_TYPE_PROJECT_FILE, }; const mockFiles = [ MOCK_CONTEXT_ITEM_FILE, @@ -35,7 +47,7 @@ const mockFiles = [ relFilePath: '/src/plants/potato.ts', }, }, - type: 'project_file', + type: CONTEXT_ITEM_TYPE_PROJECT_FILE, }, MOCK_CONTEXT_ITEM_FILE_DISABLED, ]; @@ -50,20 +62,20 @@ export const MOCK_CONTEXT_ITEM_ISSUE = { iid: 1234, }, }, - type: 'issue', + type: CONTEXT_ITEM_TYPE_ISSUE, }; export const MOCK_CONTEXT_ITEM_ISSUE_DISABLED = { id: 'c463fb31-2a4c-4f8e-a609-97230ac48ae5', isEnabled: false, metadata: { - name: 'Fix vehicle colours', + name: `Fix vehicle colours and make them look real nice and colourful won't that be wonderful wow this issue title is really long I sure hope it's gonna wrap OK`, info: { project: 'example/vehicle', iid: 91011, }, }, disabledReasons: ['This foo is not available to bar', 'Lorem something something wow?'], - type: 'issue', + type: CONTEXT_ITEM_TYPE_ISSUE, }; const mockIssues = [ @@ -78,7 +90,7 @@ const mockIssues = [ iid: 5678, }, }, - type: 'issue', + type: CONTEXT_ITEM_TYPE_ISSUE, }, MOCK_CONTEXT_ITEM_ISSUE_DISABLED, ]; @@ -93,7 +105,7 @@ export const MOCK_CONTEXT_ITEM_MERGE_REQUEST = { iid: 1122, }, }, - type: 'merge_request', + type: CONTEXT_ITEM_TYPE_MERGE_REQUEST, }; export const MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED = { id: '4eb665fc-e5e1-49b0-9789-2a16964e461a', @@ -106,7 +118,7 @@ export const MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED = { }, }, disabledReasons: ['This foo is not available to bar', 'Lorem something something wow?'], - type: 'merge_request', + type: CONTEXT_ITEM_TYPE_MERGE_REQUEST, }; const mockMergeRequests = [ @@ -122,7 +134,7 @@ const mockMergeRequests = [ }, }, disabledReasons: ['This foo is not available to bar', 'Lorem something something wow?'], - type: 'merge_request', + type: CONTEXT_ITEM_TYPE_MERGE_REQUEST, }, MOCK_CONTEXT_ITEM_MERGE_REQUEST_DISABLED, ]; 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 new file mode 100644 index 0000000000000000000000000000000000000000..d8baa557125040a5294792296bc85fe1a5698a3f --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/utils.js @@ -0,0 +1,29 @@ +export function categoryValidator(category) { + return Boolean(category && category.value && category.label && category.icon); +} + +export function categoriesValidator(categories) { + return Array.isArray(categories) && categories.every((category) => categoryValidator(category)); +} + +function disabledReasonsValidator(disabledReasons) { + return ( + disabledReasons === undefined || + (Array.isArray(disabledReasons) && + disabledReasons.every((reason) => typeof reason === 'string')) + ); +} + +export function contextItemValidator(item) { + return Boolean( + item && + item.id && + item.type && + typeof item.isEnabled === 'boolean' && + disabledReasonsValidator(item.disabledReasons) + ); +} + +export function contextItemsValidator(items) { + return Array.isArray(items) && items.every((item) => contextItemValidator(item)); +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..330cfe1cf17ad3e797b98c40019b773d98eb7213 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js @@ -0,0 +1,173 @@ +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); + }); + + it.each([ + { value: null, description: 'null' }, + { value: undefined, description: 'undefined' }, + { value: {}, description: 'object instead of array' }, + { value: 'not an array', description: 'string instead of array' }, + { value: 42, description: 'number instead of array' }, + { + value: [{ value: 'test', label: 'Test' }], + description: 'array with invalid category (missing icon)', + }, + { + value: [{ value: 'test', icon: 'icon' }], + description: 'array with invalid category (missing label)', + }, + { + value: [{ label: 'Test', icon: 'icon' }], + description: 'array with invalid category (missing value)', + }, + { + value: [MOCK_CATEGORIES.at(0), MOCK_CATEGORIES.at(1), { label: 'Test' }], + description: 'array with mix of valid and invalid categories', + }, + ])('returns false for "$description', ({ value }) => { + expect(categoriesValidator(value)).toBe(false); + }); + }); + + 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' }, + { + value: [MOCK_CONTEXT_ITEM_FILE], + description: 'one valid item', + }, + { + value: [MOCK_CONTEXT_ITEM_FILE, MOCK_CONTEXT_ITEM_MERGE_REQUEST], + description: 'multiple valid items', + }, + ])('with "$description"', ({ value }) => { + it('returns true', () => { + expect(contextItemsValidator(value)).toBe(true); + }); + }); + + describe.each([ + { value: null, description: 'null' }, + { value: undefined, description: 'undefined' }, + { value: {}, description: 'object instead of array' }, + { value: 'not an array', description: 'string instead of array' }, + { value: 42, description: 'number instead of array' }, + { + value: [{ id: '1', metadata: { name: 'Item 1' } }], + description: 'array with item missing isEnabled', + }, + { + value: [{ metadata: { name: 'Item 1' }, isEnabled: true }], + description: 'array with item missing id', + }, + { + value: [{ id: '1', metadata: { name: 'Item 1' }, isEnabled: 'true' }], + description: 'array with item having non-boolean isEnabled', + }, + { + value: [MOCK_CONTEXT_ITEM_FILE, { metadata: { name: 'Item 2' }, isEnabled: false }], + description: 'array with one valid and one invalid item', + }, + ])('with "$description"', ({ value }) => { + it('returns false', () => { + expect(contextItemsValidator(value)).toBe(false); + }); + }); + }); +}); diff --git a/src/scss/components.scss b/src/scss/components.scss index c4ea582029a2a2fd08afcea7efd2400b3e63071e..93d3d646fc4549c81045ad7857a5cd9625e1d855 100644 --- a/src/scss/components.scss +++ b/src/scss/components.scss @@ -6,6 +6,7 @@ @import '../components/experimental/duo/chat/duo_chat'; @import '../components/experimental/duo/chat/components/duo_chat_message/duo_chat_message'; @import '../components/experimental/duo/chat/components/duo_chat_loader/duo_chat_loader'; +@import '../components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu'; @import '../components/base/new_dropdowns/disclosure/disclosure_dropdown'; @import '../components/base/keyset_pagination/keyset_pagination'; @import '../components/charts/gauge/gauge'; diff --git a/translations.js b/translations.js index e564a8ef953d151b0f61b42a50a9fb0df6569d03..471d10e74e9926c9ff57aa4f8e209cfc9a9036d3 100644 --- a/translations.js +++ b/translations.js @@ -4,6 +4,10 @@ export default { 'DuoChatContextItemPopover.DisabledReason': 'This item is disabled', 'GlBreadcrumb.showMoreLabel': 'Show more breadcrumbs', '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, 'GlDuoWorkflowPanel.collapseButtonTitle': 'Collapse',