From 14fbc75f1809c9380d00b0be8cc4b2386a7fa619 Mon Sep 17 00:00:00 2001 From: Elwyn Benson Date: Thu, 29 Aug 2024 19:30:08 +1200 Subject: [PATCH 1/9] feat: add Duo Chat context selection menu - adds new GlDuoChatContextItemMenu component - this allows selecting a context category, then item - supports searching - this component is intended to be used with GlDuoChat, and some functionality (such as keyboard navigation) does not work well as a standalone story --- .../duo_chat_context_item_menu.scss | 14 + .../duo_chat_context_item_menu.spec.js | 289 ++++++++++++++ .../duo_chat_context_item_menu.stories.js | 92 +++++ .../duo_chat_context_item_menu.vue | 369 ++++++++++++++++++ .../duo_chat_context_item_selections.vue | 13 +- .../duo_chat_context/mock_context_data.js | 32 +- .../chat/components/duo_chat_context/utils.js | 8 + .../components/duo_chat_context/utils.spec.js | 56 +++ src/scss/components.scss | 1 + translations.js | 4 + 10 files changed, 867 insertions(+), 11 deletions(-) create mode 100644 src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.scss create mode 100644 src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.spec.js create mode 100644 src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.stories.js create mode 100644 src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu.vue create mode 100644 src/components/experimental/duo/chat/components/duo_chat_context/utils.js create mode 100644 src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js 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 0000000000..600aa3589a --- /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 0000000000..ef07e3d7f8 --- /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,289 @@ +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'; + +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 findAllByTestId = (testId) => wrapper.findAll(`[data-testid="${testId}"]`); + + const findMenu = () => findByTestId('context-item-menu'); + const findContextItemSelections = () => wrapper.findComponent(GlDuoChatContextItemSelections); + const findCategoryItems = () => findAllByTestId('category-item'); + const findCategory = (category) => + findCategoryItems().wrappers.find((item) => item.text().includes(category)); + const findSearchInput = () => findByTestId('context-menu-search-input'); + const findResultItems = () => findAllByTestId('search-result-item'); + const findActiveItem = () => wrapper.find('.active-command'); + const findLoadingIndicator = () => findByTestId('search-results-loading'); + const findLoadingError = () => findByTestId('search-results-error'); + const findEmptyState = () => findByTestId('search-results-empty-state'); + const findActiveItemText = () => findActiveItem().text().trim(); + + // 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', () => { + const items = findCategoryItems().wrappers.map((item) => item.text()); + + expect(items).toEqual(expect.arrayContaining(['Files', 'Issues', 'Merge Requests'])); + }); + + it('cycles through the categories when the arrow keys are pressed', async () => { + expect(findActiveItem().text()).toBe('Files'); + await triggerKeyUp('ArrowDown'); + expect(findActiveItem().text()).toBe('Issues'); + await triggerKeyUp('ArrowDown'); + expect(findActiveItem().text()).toBe('Merge Requests'); + await triggerKeyUp('ArrowUp'); + expect(findActiveItem().text()).toBe('Issues'); + }); + + 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: '', + }, + ]); + }); + + it('selects the category when clicked', async () => { + await findActiveItem().vm.$emit('click'); + + 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', (category) => { + let results; + beforeEach(() => { + const selectedCategory = MOCK_CATEGORIES.find((c) => c.value === category); + results = getMockContextItems() + .filter((item) => item.type === category) + .map((item, index) => ({ + ...item, + isEnabled: index % 2 === 0, + })); + + createComponent({ + results, + }); + + return findCategory(selectedCategory.label).vm.$emit('click'); + }); + + it('shows item search input', () => { + expect(findSearchInput().exists()).toBe(true); + }); + + it('shows search result items', () => { + const items = findResultItems().wrappers.map((item) => item.text().trim()); + + expect(items).toEqual( + expect.arrayContaining( + results.map((result) => expect.stringContaining(result.metadata.name)) + ) + ); + }); + + it('cycles through the items when the arrow keys are pressed', async () => { + await triggerKeyUp('ArrowDown'); + expect(findActiveItemText()).toEqual(expect.stringContaining(results.at(2).metadata.name)); + await triggerKeyUp('ArrowUp'); + expect(findActiveItemText()).toEqual(expect.stringContaining(results.at(0).metadata.name)); + }); + + it('does not cycle to the next item if it is disabled', async () => { + await triggerKeyUp('ArrowDown'); + expect(findActiveItemText()).toEqual(expect.stringContaining(results.at(2).metadata.name)); + await triggerKeyUp('ArrowDown'); + expect(findActiveItemText()).not.toEqual( + expect.stringContaining(results.at(1).metadata.name) // disabled + ); + expect(findActiveItemText()).toEqual(expect.stringContaining(results.at(0).metadata.name)); // cycles back to first result + await triggerKeyUp('ArrowDown'); + expect(findActiveItemText()).toEqual(expect.stringContaining(results.at(2).metadata.name)); + }); + + it('clears category selection when escape is pressed', async () => { + await triggerKeyUp('Escape'); + expect(findCategoryItems()).toHaveLength(3); + expect(findResultItems()).toHaveLength(0); + }); + + 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 findActiveItem().vm.$emit('click'); + expect(wrapper.emitted('select').at(0)).toEqual([results.at(0)]); + }); + + it('emits "close" event when selecting an item', async () => { + await findActiveItem().vm.$emit('click'); + expect(wrapper.emitted('close')).toHaveLength(1); + }); + + it('does not select a disabled item when clicked', async () => { + await findResultItems().at(1).vm.$emit('click'); + expect(wrapper.emitted('select')).toBeUndefined(); + }); + + describe('when searching', () => { + const query = 'e'; + beforeEach(async () => { + await findSearchInput().vm.$emit('input', query); + + await wrapper.setProps({ + loading: true, + }); + }); + + it('emits search event', async () => { + expect(wrapper.emitted('search').at(1)).toEqual([ + { + category, + 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'); + }); + }); + + describe('when there are no results', () => { + beforeEach(async () => { + await wrapper.setProps({ + loading: false, + results: [], + }); + }); + + it('shows empty state', async () => { + expect(findEmptyState().text()).toBe('No results found'); + }); + }); + + 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()).toHaveLength(1); + expect(findActiveItemText()).toContain(matchingResult.metadata.name); + }); + }); + }); + }); + }); +}); 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 0000000000..049ec36764 --- /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,92 @@ +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.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 0000000000..b21d1c8c89 --- /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,369 @@ + + + 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 ee724fcc22..df7bb72af5 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 bd1dc34d03..95d6cb2cec 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 0000000000..0688da3f06 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/utils.js @@ -0,0 +1,8 @@ +export function contextItemsValidator(items) { + return ( + Array.isArray(items) && + items.every((item) => { + return item.id && item.metadata.name && typeof item.isEnabled === 'boolean'; + }) + ); +} 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 0000000000..331dd59757 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/utils.spec.js @@ -0,0 +1,56 @@ +import { contextItemsValidator } from './utils'; +import { MOCK_CONTEXT_ITEM_FILE, MOCK_CONTEXT_ITEM_MERGE_REQUEST } from './mock_context_data'; + +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: {}, isEnabled: true }], + description: 'array with item having empty metadata', + }, + { + value: [{ id: '1', metadata: { name: '' }, isEnabled: true }], + description: 'array with item having empty name', + }, + { + 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 c4ea582029..93d3d646fc 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 e564a8ef95..471d10e74e 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', -- GitLab From 956808c9d3cda705c58c38f0c56c8f50be3d8202 Mon Sep 17 00:00:00 2001 From: Elwyn Benson Date: Mon, 2 Sep 2024 11:09:48 +1200 Subject: [PATCH 2/9] chore: improve prop type to reflect possible null value --- .../duo_chat_context_item_menu/duo_chat_context_item_menu.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b21d1c8c89..dcc5a52cb7 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 @@ -43,7 +43,7 @@ export default { required: true, }, error: { - type: String, + type: [String, null], required: false, default: null, }, -- GitLab From 99c050dcc3fdf25cfa5c67a52f716e8d093b0a49 Mon Sep 17 00:00:00 2001 From: Elwyn Benson Date: Mon, 2 Sep 2024 11:11:03 +1200 Subject: [PATCH 3/9] chore: move disabled results check to computed --- .../duo_chat_context_item_menu.vue | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 dcc5a52cb7..b14a9ad4dd 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 @@ -91,6 +91,9 @@ export default { } ); }, + allResultsAreDisabled() { + return this.results.every((result) => !result.isEnabled); + } }, watch: { open(isOpen) { @@ -202,7 +205,7 @@ export default { if (!this.results.length) { return; } - if (this.results.every((result) => !result.isEnabled)) { + if (this.allResultsAreDisabled) { return; } -- GitLab From 2bba72a7d8bb7c1f633d238ef90b5e224be5f2d7 Mon Sep 17 00:00:00 2001 From: Elwyn Benson Date: Mon, 2 Sep 2024 11:12:23 +1200 Subject: [PATCH 4/9] chore: remove unnecessary template element --- .../duo_chat_context_item_menu.vue | 30 +++++++++---------- 1 file changed, 14 insertions(+), 16 deletions(-) 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 b14a9ad4dd..468571e44c 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 @@ -265,22 +265,20 @@ export default { body-class="!gl-p-2" data-testid="context-item-menu" > - +
    + +
    + + {{ category.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.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 0000000000..b24d31a7a9 --- /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 0000000000..53a65729da --- /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_context_search_items.spec.js b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.spec.js new file mode 100644 index 0000000000..e1ed6bbd80 --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.spec.js @@ -0,0 +1,243 @@ +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 GlDuoChatContextItemMenuContextSearchItemFile from './context_search_items/file.vue'; +import GlDuoChatContextItemMenuContextSearchItemIssue from './context_search_items/issue.vue'; +import GlDuoChatContextItemMenuContextSearchItemMergeRequest from './context_search_items/merge_request.vue'; +import GlDuoChatContextItemMenuContextSearchItems from './duo_chat_context_item_menu_context_search_items.vue'; +import GlDuoChatContextItemMenuContextSearchItemsLoading from './context_search_items/context_search_items_loading.vue'; + +describe('GlDuoChatContextItemMenuContextSearchItems', () => { + 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(GlDuoChatContextItemMenuContextSearchItems, { + 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(GlDuoChatContextItemMenuContextSearchItemsLoading); + 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(GlDuoChatContextItemMenuContextSearchItemFile).props() + ).toEqual( + expect.objectContaining({ + item: matchingResult, + category, + }) + ); + }); + + it('marks the correct item as active', async () => { + expect(findActiveItemDetails().props('item')).toBe(results.at(0)); + + await wrapper.setProps({ + activeIndex: 1, + }); + + expect(findActiveItemDetails().props('item')).toBe(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...', + expectedComponent: GlDuoChatContextItemMenuContextSearchItemFile, + }, + { + testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_ISSUE), + expectedPlaceholder: 'Search issues...', + expectedComponent: GlDuoChatContextItemMenuContextSearchItemIssue, + }, + { + testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_MERGE_REQUEST), + expectedPlaceholder: 'Search merge requests...', + expectedComponent: GlDuoChatContextItemMenuContextSearchItemMergeRequest, + }, + ])( + 'when category is "$testCase.label"', + ({ testCase, expectedPlaceholder, expectedComponent }) => { + beforeEach(() => + createWrapper({ + category: testCase, + }) + ); + + it('renders the expected input placeholder text', () => { + expect(findSearchInput().attributes('placeholder')).toEqual(expectedPlaceholder); + }); + + it('renders the correct category search item component', () => { + expect(wrapper.findComponent(expectedComponent).exists()).toBe(true); + }); + } + ); +}); diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.vue new file mode 100644 index 0000000000..42c767544e --- /dev/null +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.vue @@ -0,0 +1,169 @@ + + 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 61854ec514..8baf8857c3 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 a1f893dca6..4ad71f1522 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 @@ -171,5 +171,5 @@ describe('GlDuoChatContextItemSelections', () => { expect(wrapper.emitted('remove')[0]).toEqual([MOCK_CONTEXT_ITEM_FILE]); }); }); - }) + }); }); 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 0688da3f06..d8baa55712 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,8 +1,29 @@ -export function contextItemsValidator(items) { +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 ( - Array.isArray(items) && - items.every((item) => { - return item.id && item.metadata.name && typeof item.isEnabled === 'boolean'; - }) + 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 index 331dd59757..330cfe1cf1 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,56 +1,173 @@ -import { contextItemsValidator } from './utils'; -import { MOCK_CONTEXT_ITEM_FILE, MOCK_CONTEXT_ITEM_MERGE_REQUEST } from './mock_context_data'; - -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); +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.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: {}, isEnabled: true }], - description: 'array with item having empty metadata', - }, - { - value: [{ id: '1', metadata: { name: '' }, isEnabled: true }], - description: 'array with item having empty name', - }, - { - 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); + 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); + }); }); }); }); -- GitLab From 3a2b2e61f5146d05325eb84d28f2f0582486ad7c Mon Sep 17 00:00:00 2001 From: Elwyn Benson Date: Tue, 3 Sep 2024 11:06:37 +1200 Subject: [PATCH 7/9] chore: remove non-tailwind utility classes, reorder classes --- .../context_search_items/context_search_item.vue | 12 ++++++------ .../duo_chat_context_item_menu_category_items.vue | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/context_search_items/context_search_item.vue b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/context_search_items/context_search_item.vue index abbc393988..ca6c115cc9 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/context_search_items/context_search_item.vue +++ b/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/context_search_items/context_search_item.vue @@ -26,16 +26,16 @@ export default { }; diff --git a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_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 similarity index 79% rename from src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.spec.js rename to src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.spec.js index 496cf11445..e58e4a9cb6 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_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 @@ -5,13 +5,11 @@ import { CONTEXT_ITEM_TYPE_MERGE_REQUEST, CONTEXT_ITEM_TYPE_PROJECT_FILE, } from '../constants'; -import GlDuoChatContextItemMenuContextSearchItemFile from './context_search_items/file.vue'; -import GlDuoChatContextItemMenuContextSearchItemIssue from './context_search_items/issue.vue'; -import GlDuoChatContextItemMenuContextSearchItemMergeRequest from './context_search_items/merge_request.vue'; -import GlDuoChatContextItemMenuContextSearchItems from './duo_chat_context_item_menu_context_search_items.vue'; -import GlDuoChatContextItemMenuContextSearchItemsLoading from './context_search_items/context_search_items_loading.vue'; +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('GlDuoChatContextItemMenuContextSearchItems', () => { +describe('GlDuoChatContextItemMenuSearchItems', () => { let wrapper; let category; let results; @@ -20,7 +18,7 @@ describe('GlDuoChatContextItemMenuContextSearchItems', () => { category = props.category || MOCK_CATEGORIES.at(0); results = props.results || getMockContextItems().filter((item) => item.type === category.value); - wrapper = shallowMount(GlDuoChatContextItemMenuContextSearchItems, { + wrapper = shallowMount(GlDuoChatContextItemMenuSearchItems, { propsData: { activeIndex: 0, searchQuery: '', @@ -38,7 +36,7 @@ describe('GlDuoChatContextItemMenuContextSearchItems', () => { const findSearchInput = () => findByTestId('context-menu-search-input'); const findLoadingIndicator = () => - wrapper.findComponent(GlDuoChatContextItemMenuContextSearchItemsLoading); + wrapper.findComponent(GlDuoChatContextItemMenuSearchItemsLoading); const findLoadingError = () => findByTestId('search-results-error'); const findEmptyState = () => findByTestId('search-results-empty-state'); const findResultItems = () => findAllByTestId('search-result-item'); @@ -151,9 +149,7 @@ describe('GlDuoChatContextItemMenuContextSearchItems', () => { }); expect(findResultItems()).toHaveLength(1); - expect( - wrapper.findComponent(GlDuoChatContextItemMenuContextSearchItemFile).props() - ).toEqual( + expect(wrapper.findComponent(GlDuoChatContextItemMenuSearchItem).props()).toEqual( expect.objectContaining({ item: matchingResult, category, @@ -210,34 +206,24 @@ describe('GlDuoChatContextItemMenuContextSearchItems', () => { { testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_PROJECT_FILE), expectedPlaceholder: 'Search files...', - expectedComponent: GlDuoChatContextItemMenuContextSearchItemFile, }, { testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_ISSUE), expectedPlaceholder: 'Search issues...', - expectedComponent: GlDuoChatContextItemMenuContextSearchItemIssue, }, { testCase: MOCK_CATEGORIES.find((cat) => cat.value === CONTEXT_ITEM_TYPE_MERGE_REQUEST), expectedPlaceholder: 'Search merge requests...', - expectedComponent: GlDuoChatContextItemMenuContextSearchItemMergeRequest, }, - ])( - 'when category is "$testCase.label"', - ({ testCase, expectedPlaceholder, expectedComponent }) => { - beforeEach(() => - createWrapper({ - category: testCase, - }) - ); - - it('renders the expected input placeholder text', () => { - expect(findSearchInput().attributes('placeholder')).toEqual(expectedPlaceholder); - }); - - it('renders the correct category search item component', () => { - expect(wrapper.findComponent(expectedComponent).exists()).toBe(true); - }); - } - ); + ])('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_context_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 similarity index 69% rename from src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_search_items.vue rename to src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_search_items.vue index 42c767544e..b7140700d2 100644 --- a/src/components/experimental/duo/chat/components/duo_chat_context/duo_chat_context_item_menu/duo_chat_context_item_menu_context_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 @@ -1,30 +1,20 @@