Skip to content
Snippets Groups Projects
Verified Commit 63909dd7 authored by Elwyn Benson's avatar Elwyn Benson :red_circle:
Browse files

feat(GlDuoChat): view context item content/details

- adds support for view context item content/details
- clicking selected context item of type file/git will
emit event to load additional data, and displays in new
details modal
- adds hook for syntax highlighting the content from consumer
applications, in the same manner the chat highlighting is done
parent 127691f7
No related branches found
No related tags found
1 merge request!4648feat(GlDuoChat): view context item content/details
Showing
with 539 additions and 6 deletions
...@@ -5,3 +5,7 @@ export const CONTEXT_ITEM_CATEGORY_LOCAL_GIT = 'local_git'; ...@@ -5,3 +5,7 @@ export const CONTEXT_ITEM_CATEGORY_LOCAL_GIT = 'local_git';
export const CONTEXT_ITEM_LOCAL_GIT_COMMIT = 'commit'; export const CONTEXT_ITEM_LOCAL_GIT_COMMIT = 'commit';
export const CONTEXT_ITEM_LOCAL_GIT_DIFF = 'diff'; export const CONTEXT_ITEM_LOCAL_GIT_DIFF = 'diff';
export const LANGUAGE_IDENTIFIER_PREFIX = 'language-';
export const LANGUAGE_IDENTIFIER_DIFF = 'language-diff';
export const LANGUAGE_IDENTIFIER_PLAINTEXT = 'language-plaintext';
<script>
import { nextTick } from 'vue';
import { contextItemValidator } from '../utils';
import GlModal from '../../../../../../base/modal/modal.vue';
import { SafeHtmlDirective as SafeHtml } from '../../../../../../../directives/safe_html/safe_html';
import GlSkeletonLoader from '../../../../../../base/skeleton_loader/skeleton_loader.vue';
import {
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
LANGUAGE_IDENTIFIER_DIFF,
LANGUAGE_IDENTIFIER_PLAINTEXT,
LANGUAGE_IDENTIFIER_PREFIX,
} from '../constants';
import { translate } from '../../../../../../../utils/i18n';
export default {
name: 'GlDuoChatContextItemDetails',
components: {
GlSkeletonLoader,
GlModal,
},
directives: {
SafeHtml,
},
inject: {
renderGFM: {
from: 'renderGFM',
default: () => (element) => {
element.classList.add('gl-markdown', 'gl-compact-markdown');
},
},
},
props: {
/**
* Context items to preview. If it has no `content`, the loading state will be displayed.
*/
contextItem: {
type: Object,
required: true,
validator: contextItemValidator,
},
},
computed: {
isLoadingContent() {
return this.contextItem.content === undefined;
},
languageIdentifierClass() {
if (this.contextItem.category === CONTEXT_ITEM_CATEGORY_LOCAL_GIT) {
return LANGUAGE_IDENTIFIER_DIFF;
}
const fileExtension = this.contextItem.metadata?.relativePath?.split('.').at(-1);
if (fileExtension && fileExtension !== this.contextItem.metadata?.relativePath) {
return `${LANGUAGE_IDENTIFIER_PREFIX}${fileExtension}`;
}
return LANGUAGE_IDENTIFIER_PLAINTEXT;
},
title() {
return (
this.contextItem.metadata?.title ||
this.contextItem.metadata?.relativePath ||
translate('GlDuoChatContextItemDetails.title', 'Preview')
);
},
},
watch: {
contextItem: {
async handler(newVal, oldVal) {
const shouldFormat = newVal?.content !== oldVal?.content && newVal?.content;
if (shouldFormat) {
await nextTick();
await this.hydrateContentWithGFM();
}
},
immediate: true,
},
},
methods: {
async hydrateContentWithGFM() {
await nextTick();
if (this.$refs.content) {
this.renderGFM(this.$refs.content);
}
},
},
ACTION_PRIMARY: { text: translate('GlDuoChatContextItemDetails.close', 'Close') },
};
</script>
<template>
<gl-modal
modal-id="context-item-details-modal"
:title="title"
:visible="true"
:action-primary="$options.ACTION_PRIMARY"
:scrollable="true"
size="lg"
@change="$emit('close')"
@primary="$emit('close')"
>
<gl-skeleton-loader v-if="isLoadingContent" />
<div v-else ref="content" data-testid="context-item-content">
<pre
v-safe-html="contextItem.content"
class="code js-syntax-highlight p-3"
:class="languageIdentifierClass"
></pre>
</div>
</gl-modal>
</template>
import { nextTick } from 'vue';
import { shallowMount } from '@vue/test-utils';
import GlSkeletonLoader from '../../../../../../base/skeleton_loader/skeleton_loader.vue';
import GlModal from '../../../../../../base/modal/modal.vue';
import { MOCK_CONTEXT_ITEM_FILE } from '../mock_context_data';
import {
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
LANGUAGE_IDENTIFIER_DIFF,
LANGUAGE_IDENTIFIER_PLAINTEXT,
LANGUAGE_IDENTIFIER_PREFIX,
} from '../constants';
import GlDuoChatContextItemDetails from './duo_chat_content_item_details.vue';
describe('GlDuoChatContextItemDetails', () => {
let wrapper;
let renderGFM;
const createComponent = (propsData = {}) => {
renderGFM = jest.fn();
wrapper = shallowMount(GlDuoChatContextItemDetails, {
propsData,
provide: { renderGFM },
});
};
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findModal = () => wrapper.findComponent(GlModal);
const findLoadingState = () => wrapper.findComponent(GlSkeletonLoader);
const findContent = () => findByTestId('context-item-content');
function expectLanguageIdentifierClass(cls) {
const [el] = renderGFM.mock.calls.at(0);
const elMarkup = el.innerHTML.toString();
expect(elMarkup).toContain(cls);
}
describe('default behaviour', () => {
it('renders an open modal', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
expect(findModal().props()).toEqual(
expect.objectContaining({
dismissLabel: 'Close',
actionPrimary: {
text: 'Close',
},
actionCancel: null,
actionSecondary: null,
size: 'lg',
visible: true,
})
);
});
it.each([
{ metadata: { title: 'WOW' }, expected: 'WOW' },
{ metadata: { title: undefined }, expected: 'Preview' },
])('sets modal title to contextItem value or fallbacks', ({ metadata, expected }) => {
const contextItem = {
...MOCK_CONTEXT_ITEM_FILE,
metadata: {
enabled: true,
...metadata,
},
};
createComponent({ contextItem });
expect(findModal().props('title')).toEqual(expected);
});
it('emits "close" event when modal closes', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
findModal().vm.$emit('change', false);
expect(wrapper.emitted('close')).toHaveLength(1);
});
it('emits "close" event when modal primary action is triggered', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
findModal().vm.$emit('primary');
expect(wrapper.emitted('close')).toHaveLength(1);
});
});
describe('when context item does not have content', () => {
beforeEach(() => {
const contextItem = {
...MOCK_CONTEXT_ITEM_FILE,
content: undefined,
};
createComponent({ contextItem });
});
it('should display the loading state', () => {
expect(findLoadingState().exists()).toBe(true);
});
it('should not display content', () => {
expect(findContent().exists()).toBe(false);
});
describe('when context item content finishes loading', () => {
beforeEach(async () => {
const hydratedContextItem = {
...MOCK_CONTEXT_ITEM_FILE,
content: 'water',
};
wrapper.setProps({ contextItem: hydratedContextItem });
await nextTick();
});
it('should not show the loading state', () => {
expect(findLoadingState().exists()).toBe(false);
});
it('should format content with provided "renderGFM" function', async () => {
expect(renderGFM).toHaveBeenCalledTimes(1);
const [el] = renderGFM.mock.calls.at(0);
const elMarkup = el.innerHTML.toString();
expect(elMarkup).toContain('water');
});
it('should apply necessary class for external highlight-js to perform syntax highlighting', () => {
expect(renderGFM).toHaveBeenCalledTimes(1);
const [el] = renderGFM.mock.calls.at(0);
const elMarkup = el.innerHTML.toString();
expect(elMarkup).toContain('js-syntax-highlight');
});
describe('language identifier classes', () => {
it('should apply "language-diff" for a contextItem fo category "git"', async () => {
wrapper.setProps({
contextItem: {
...MOCK_CONTEXT_ITEM_FILE,
category: CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
content: 'ding',
},
});
await nextTick();
expectLanguageIdentifierClass(LANGUAGE_IDENTIFIER_DIFF);
});
it.each(['ts', 'js', 'md', 'sh'])(
'should apply "language-%s" when file extension is "%s"',
async (extension) => {
wrapper.setProps({
contextItem: {
...MOCK_CONTEXT_ITEM_FILE,
metadata: {
...MOCK_CONTEXT_ITEM_FILE.metadata,
relativePath: `wow/so/cool.${extension}`,
},
content: 'ding',
},
});
await nextTick();
expectLanguageIdentifierClass(`${LANGUAGE_IDENTIFIER_PREFIX}${extension}`);
}
);
it('should apply "language-plaintext" when file type is unknown', async () => {
wrapper.setProps({
contextItem: {
...MOCK_CONTEXT_ITEM_FILE,
metadata: {
...MOCK_CONTEXT_ITEM_FILE.metadata,
relativePath: `this/file/has/no/extension/ohno`,
},
content: 'ding',
},
});
await nextTick();
expectLanguageIdentifierClass(LANGUAGE_IDENTIFIER_PLAINTEXT);
});
});
});
});
});
...@@ -62,6 +62,18 @@ describe('GlDuoChatContextItemMenu', () => { ...@@ -62,6 +62,18 @@ describe('GlDuoChatContextItemMenu', () => {
expect(wrapper.emitted('remove').at(0)).toEqual([removed]); expect(wrapper.emitted('remove').at(0)).toEqual([removed]);
}); });
it('emits "get-context-item-content" event when an item is selected', () => {
const selections = getMockContextItems().slice(0, 2);
createComponent({ open: false, selections });
const selected = selections.at(0);
findContextItemSelections().vm.$emit('get-content', selected);
expect(wrapper.emitted('get-context-item-content').at(0)).toEqual([
{ contextItem: selected },
]);
});
}); });
describe('and there are no selections', () => { describe('and there are no selections', () => {
......
import { makeContainer } from '../../../../../../../utils/story_decorators/container'; import { makeContainer } from '../../../../../../../utils/story_decorators/container';
import { setStoryTimeout } from '../../../../../../../utils/test_utils'; import { setStoryTimeout } from '../../../../../../../utils/test_utils';
import { getMockContextItems, MOCK_CATEGORIES } from '../mock_context_data'; import {
getMockContextItems,
MOCK_CATEGORIES,
MOCK_CONTEXT_FILE_CONTENT,
MOCK_CONTEXT_FILE_DIFF_CONTENT,
} from '../mock_context_data';
import { CONTEXT_ITEM_CATEGORY_LOCAL_GIT } from '../constants';
import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue'; import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue';
const sampleCategories = MOCK_CATEGORIES; const sampleCategories = MOCK_CATEGORIES;
...@@ -59,6 +65,20 @@ const Template = (args, { argTypes }) => ({ ...@@ -59,6 +65,20 @@ const Template = (args, { argTypes }) => ({
this.selectedItems.splice(index, 1); this.selectedItems.splice(index, 1);
} }
}, },
handleGetContent(contextItem) {
this.selectedItems = this.selectedItems.map((item) => {
if (item.id === contextItem.id) {
return {
...contextItem,
content:
contextItem.category === CONTEXT_ITEM_CATEGORY_LOCAL_GIT
? MOCK_CONTEXT_FILE_DIFF_CONTENT
: MOCK_CONTEXT_FILE_CONTENT,
};
}
return item;
});
},
}, },
template: ` template: `
<div class="gl-h-full gl-flex gl-flex-col gl-justify-end"> <div class="gl-h-full gl-flex gl-flex-col gl-justify-end">
...@@ -73,6 +93,7 @@ const Template = (args, { argTypes }) => ({ ...@@ -73,6 +93,7 @@ const Template = (args, { argTypes }) => ({
@search="handleContextItemsSearch" @search="handleContextItemsSearch"
@select="handleContextItemSelect" @select="handleContextItemSelect"
@remove="handleContextItemRemove" @remove="handleContextItemRemove"
@get-context-item-content="handleGetContent"
@close="isOpen = false" @close="isOpen = false"
/> />
</div> </div>
......
...@@ -218,6 +218,14 @@ export default { ...@@ -218,6 +218,14 @@ export default {
this.activeIndex = newIndex; this.activeIndex = newIndex;
}, },
onGetContextItemContent(contextItem) {
/**
* Emit get-context-item-content event that tells clients to load the full file content for a selected context item.
* The fully hydrated context item should be updated in the context item selections.
* @param {*} event An event containing the context item to hydrate
*/
this.$emit('get-context-item-content', { contextItem });
},
}, },
i18n: { i18n: {
selectedContextItemsTitle: translate( selectedContextItemsTitle: translate(
...@@ -233,11 +241,13 @@ export default { ...@@ -233,11 +241,13 @@ export default {
<gl-duo-chat-context-item-selections <gl-duo-chat-context-item-selections
v-if="selections.length" v-if="selections.length"
:selections="selections" :selections="selections"
:categories="categories"
:removable="true" :removable="true"
:title="$options.i18n.selectedContextItemsTitle" :title="$options.i18n.selectedContextItemsTitle"
:default-collapsed="false" :default-collapsed="false"
class="gl-mb-3" class="gl-mb-3"
@remove="removeItem" @remove="removeItem"
@get-content="onGetContextItemContent"
/> />
<gl-card <gl-card
v-if="open" v-if="open"
......
import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import GlIcon from '../../../../../../base/icon/icon.vue'; import GlIcon from '../../../../../../base/icon/icon.vue';
import GlToken from '../../../../../../base/token/token.vue'; import GlToken from '../../../../../../base/token/token.vue';
import GlDuoChatContextItemDetails from '../duo_chat_content_item_details/duo_chat_content_item_details.vue';
import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue'; import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue';
import { import {
getMockContextItems, getMockContextItems,
MOCK_CONTEXT_ITEM_FILE, MOCK_CONTEXT_ITEM_FILE,
...@@ -16,9 +19,9 @@ describe('GlDuoChatContextItemSelections', () => { ...@@ -16,9 +19,9 @@ describe('GlDuoChatContextItemSelections', () => {
let wrapper; let wrapper;
let mockSelections; let mockSelections;
const createComponent = (props = {}) => { const createComponent = (props = {}, mountFn = shallowMount) => {
mockSelections = getMockContextItems().slice(0, 3); mockSelections = getMockContextItems().slice(0, 3);
wrapper = shallowMount(GlDuoChatContextItemSelections, { wrapper = mountFn(GlDuoChatContextItemSelections, {
propsData: { propsData: {
selections: mockSelections, selections: mockSelections,
title: 'Test Title', title: 'Test Title',
...@@ -26,6 +29,9 @@ describe('GlDuoChatContextItemSelections', () => { ...@@ -26,6 +29,9 @@ describe('GlDuoChatContextItemSelections', () => {
showClose: true, showClose: true,
...props, ...props,
}, },
stubs: {
GlSkeletonLoader: { name: 'GlSkeletonLoaderStub', template: '<div></div>' },
},
}); });
}; };
...@@ -37,6 +43,7 @@ describe('GlDuoChatContextItemSelections', () => { ...@@ -37,6 +43,7 @@ describe('GlDuoChatContextItemSelections', () => {
const findTokensIcons = () => findTokensWrapper().findAllComponents(GlIcon); const findTokensIcons = () => findTokensWrapper().findAllComponents(GlIcon);
const findPopovers = () => wrapper.findAllComponents(GlDuoChatContextItemPopover); const findPopovers = () => wrapper.findAllComponents(GlDuoChatContextItemPopover);
const findCollapseIcon = () => findByTestId('chat-context-collapse-icon'); const findCollapseIcon = () => findByTestId('chat-context-collapse-icon');
const findItemDetailsModal = () => wrapper.findComponent(GlDuoChatContextItemDetails);
describe('component rendering', () => { describe('component rendering', () => {
it('renders the component when selections are provided', () => { it('renders the component when selections are provided', () => {
...@@ -185,5 +192,55 @@ describe('GlDuoChatContextItemSelections', () => { ...@@ -185,5 +192,55 @@ describe('GlDuoChatContextItemSelections', () => {
expect(wrapper.emitted('remove')[0]).toEqual([MOCK_CONTEXT_ITEM_FILE]); expect(wrapper.emitted('remove')[0]).toEqual([MOCK_CONTEXT_ITEM_FILE]);
}); });
}); });
describe('when opening context items', () => {
describe.each([{ item: MOCK_CONTEXT_ITEM_FILE }, { item: MOCK_CONTEXT_ITEM_GIT_DIFF }])(
'and the item is a "$item.category"',
({ item }) => {
beforeEach(() => createComponent({ selections: [item] }, mount));
describe.each(['click', 'keydown.enter', 'keydown.space'])(
'when opening by "$eventType"',
(eventType) => {
beforeEach(() => findTokens().at(0).trigger(eventType));
it('should display the details view', () => {
expect(findItemDetailsModal().props('contextItem')).toEqual(item);
});
it('should emit a "get-content" event to hydrate the item', () => {
expect(wrapper.emitted('get-content')).toHaveLength(1);
expect(wrapper.emitted('get-content').at(0)).toEqual([item]);
});
it('should close the details view when modal emits "close" event', async () => {
findItemDetailsModal().vm.$emit('close');
await nextTick();
expect(findItemDetailsModal().exists()).toBe(false);
});
}
);
}
);
describe.each([{ item: MOCK_CONTEXT_ITEM_MERGE_REQUEST }, { item: MOCK_CONTEXT_ITEM_ISSUE }])(
'and the item is a "$item.category"',
({ item }) => {
beforeEach(() => {
createComponent({ selections: [item] });
return findTokens().at(0).vm.$emit('click');
});
it('should not display any details view', () => {
expect(findItemDetailsModal().exists()).toBe(false);
});
it('should not emit any "get-content" event', () => {
expect(wrapper.emitted('get-content')).toBe(undefined);
});
}
);
});
}); });
}); });
...@@ -4,16 +4,27 @@ import GlIcon from '../../../../../../base/icon/icon.vue'; ...@@ -4,16 +4,27 @@ import GlIcon from '../../../../../../base/icon/icon.vue';
import GlToken from '../../../../../../base/token/token.vue'; import GlToken from '../../../../../../base/token/token.vue';
import GlTruncate from '../../../../../../utilities/truncate/truncate.vue'; import GlTruncate from '../../../../../../utilities/truncate/truncate.vue';
import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue'; import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue';
import { CONTEXT_ITEM_CATEGORY_FILE, CONTEXT_ITEM_CATEGORY_LOCAL_GIT } from '../constants';
import GlDuoChatContextItemDetails from '../duo_chat_content_item_details/duo_chat_content_item_details.vue';
import { contextItemsValidator, getContextItemIcon } from '../utils'; import { contextItemsValidator, getContextItemIcon } from '../utils';
export default { export default {
name: 'GlDuoChatContextItemSelections', name: 'GlDuoChatContextItemSelections',
components: { components: {
GlTruncate, GlTruncate,
GlDuoChatContextItemDetails,
GlIcon, GlIcon,
GlDuoChatContextItemPopover, GlDuoChatContextItemPopover,
GlToken, GlToken,
}, },
inject: {
renderGFM: {
from: 'renderGFM',
default: () => (element) => {
element.classList.add('gl-markdown', 'gl-compact-markdown');
},
},
},
props: { props: {
/** /**
* Array of selected context items. * Array of selected context items.
...@@ -55,6 +66,7 @@ export default { ...@@ -55,6 +66,7 @@ export default {
return { return {
isCollapsed: this.defaultCollapsed, isCollapsed: this.defaultCollapsed,
selectionsId: uniqueId(), selectionsId: uniqueId(),
previewContextItemId: null,
}; };
}, },
computed: { computed: {
...@@ -73,6 +85,13 @@ export default { ...@@ -73,6 +85,13 @@ export default {
} }
return ''; return '';
}, },
contextItemPreview() {
if (!this.previewContextItemId) {
return undefined;
}
return this.selections.find((item) => item.id === this.previewContextItemId);
},
}, },
methods: { methods: {
getContextItemIcon, getContextItemIcon,
...@@ -86,6 +105,23 @@ export default { ...@@ -86,6 +105,23 @@ export default {
*/ */
this.$emit('remove', contextItem); this.$emit('remove', contextItem);
}, },
onOpenItem(contextItem) {
if (!this.canOpen(contextItem)) {
return;
}
if (!contextItem.content) {
this.$emit('get-content', contextItem);
}
this.previewContextItemId = contextItem.id;
},
canOpen(contextItem) {
return [CONTEXT_ITEM_CATEGORY_LOCAL_GIT, CONTEXT_ITEM_CATEGORY_FILE].includes(
contextItem.category
);
},
onClosePreview() {
this.previewContextItemId = null;
},
}, },
}; };
</script> </script>
...@@ -113,7 +149,12 @@ export default { ...@@ -113,7 +149,12 @@ export default {
:view-only="!removable" :view-only="!removable"
variant="default" variant="default"
class="gl-mb-2 gl-mr-2 gl-max-w-full" class="gl-mb-2 gl-mr-2 gl-max-w-full"
:class="tokenVariantClasses" :class="[tokenVariantClasses, canOpen(item) ? 'gl-cursor-pointer' : '']"
:tabindex="canOpen(item) ? 0 : -1"
role="button"
@click="onOpenItem(item)"
@keydown.enter="onOpenItem(item)"
@keydown.space.prevent="onOpenItem(item)"
@close="onRemoveItem(item)" @close="onRemoveItem(item)"
> >
<div <div
...@@ -132,8 +173,14 @@ export default { ...@@ -132,8 +173,14 @@ export default {
:context-item="item" :context-item="item"
:target="`context-item-${item.id}-${selectionsId}-token`" :target="`context-item-${item.id}-${selectionsId}-token`"
placement="bottom" placement="bottom"
@show-git-diff="onOpenItem(item)"
/> />
</gl-token> </gl-token>
</div> </div>
<gl-duo-chat-context-item-details
v-if="contextItemPreview"
:context-item="contextItemPreview"
@close="onClosePreview"
/>
</div> </div>
</template> </template>
...@@ -16,6 +16,21 @@ export function getMockCategory(categoryValue) { ...@@ -16,6 +16,21 @@ export function getMockCategory(categoryValue) {
return MOCK_CATEGORIES.find((cat) => cat.value === categoryValue); return MOCK_CATEGORIES.find((cat) => cat.value === categoryValue);
} }
export const MOCK_CONTEXT_FILE_CONTENT = `export function waterPlants() {
console.log('sprinkle');
}`;
export const MOCK_CONTEXT_FILE_DIFF_CONTENT = `diff --git a/src/plants/strawberry.ts b/src/plants/strawberry.ts
index 1234567..8901234 100644
--- a/src/plants/strawberry.ts
+++ b/src/plants/strawberry.ts
@@ -1,4 +1,4 @@
export const strawberry = {
name: 'Strawberry',
- waterNeeds: 'moderate',
+ waterNeeds: 'high',
};`;
export const MOCK_CONTEXT_ITEM_FILE = { export const MOCK_CONTEXT_ITEM_FILE = {
id: '123e4567-e89b-12d3-a456-426614174000', id: '123e4567-e89b-12d3-a456-426614174000',
category: CONTEXT_ITEM_CATEGORY_FILE, category: CONTEXT_ITEM_CATEGORY_FILE,
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { MOCK_USER_PROMPT_MESSAGE, MOCK_RESPONSE_MESSAGE } from '../../mock_data'; import { MOCK_USER_PROMPT_MESSAGE, MOCK_RESPONSE_MESSAGE } from '../../mock_data';
import { MOCK_CONTEXT_ITEM_FILE } from '../duo_chat_context/mock_context_data';
import GlDuoChatMessage from '../duo_chat_message/duo_chat_message.vue'; import GlDuoChatMessage from '../duo_chat_message/duo_chat_message.vue';
import GlDuoChatConversation from './duo_chat_conversation.vue'; import GlDuoChatConversation from './duo_chat_conversation.vue';
...@@ -78,5 +79,15 @@ describe('GlDuoChatConversation', () => { ...@@ -78,5 +79,15 @@ describe('GlDuoChatConversation', () => {
const chatMessages = findChatMessages(); const chatMessages = findChatMessages();
expect(chatMessages.at(0).props('isCancelled')).toBe(false); expect(chatMessages.at(0).props('isCancelled')).toBe(false);
}); });
it('emits "get-context-item-content" when a message requests hydrated context item', () => {
const contextItem = MOCK_CONTEXT_ITEM_FILE;
createComponent();
const message = findChatMessages().at(0);
message.vm.$emit('get-context-item-content', contextItem);
expect(wrapper.emitted('get-context-item-content')).toHaveLength(1);
expect(wrapper.emitted('get-context-item-content').at(0)).toEqual([contextItem]);
});
}); });
}); });
...@@ -57,6 +57,9 @@ export default { ...@@ -57,6 +57,9 @@ export default {
onInsertCodeSnippet(e) { onInsertCodeSnippet(e) {
this.$emit('insert-code-snippet', e); this.$emit('insert-code-snippet', e);
}, },
onGetContextItemContent(e) {
this.$emit('get-context-item-content', e);
},
}, },
i18n, i18n,
}; };
...@@ -81,6 +84,7 @@ export default { ...@@ -81,6 +84,7 @@ export default {
:is-cancelled="canceledRequestIds.includes(msg.requestId)" :is-cancelled="canceledRequestIds.includes(msg.requestId)"
@track-feedback="onTrackFeedback" @track-feedback="onTrackFeedback"
@insert-code-snippet="onInsertCodeSnippet" @insert-code-snippet="onInsertCodeSnippet"
@get-context-item-content="onGetContextItemContent"
/> />
</div> </div>
</template> </template>
...@@ -14,6 +14,10 @@ const generateProps = ({ message = MOCK_RESPONSE_MESSAGE } = {}) => ({ ...@@ -14,6 +14,10 @@ const generateProps = ({ message = MOCK_RESPONSE_MESSAGE } = {}) => ({
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
alert(`Insert code snippet triggered:\n${event.detail.code}`); alert(`Insert code snippet triggered:\n${event.detail.code}`);
}, },
onGetContextItemContent: (event) => {
// eslint-disable-next-line no-alert
alert(`Get context item content triggered:\n${JSON.stringify(event.contextItem, null, 4)}`);
},
}); });
const Template = (args, { argTypes }) => ({ const Template = (args, { argTypes }) => ({
...@@ -24,7 +28,7 @@ const Template = (args, { argTypes }) => ({ ...@@ -24,7 +28,7 @@ const Template = (args, { argTypes }) => ({
renderGFM, renderGFM,
}, },
template: ` template: `
<gl-duo-chat-message :message="message" :is-cancelled="false" @insert-code-snippet="onInsertCode" /> <gl-duo-chat-message :message="message" :is-cancelled="false" @insert-code-snippet="onInsertCode" @get-context-item-content="onGetContextItemContent" />
`, `,
}); });
......
...@@ -234,6 +234,12 @@ export default { ...@@ -234,6 +234,12 @@ export default {
onInsertCodeSnippet(e) { onInsertCodeSnippet(e) {
this.$emit('insert-code-snippet', e); this.$emit('insert-code-snippet', e);
}, },
onGetContextItemContent(contextItem) {
this.$emit('get-context-item-content', {
messageId: this.message.id,
contextItem,
});
},
}, },
}; };
</script> </script>
...@@ -264,6 +270,7 @@ export default { ...@@ -264,6 +270,7 @@ export default {
:title="selectedContextItemsTitle" :title="selectedContextItemsTitle"
:default-collapsed="selectedContextItemsDefaultCollapsed" :default-collapsed="selectedContextItemsDefaultCollapsed"
variant="assistant" variant="assistant"
@get-content="onGetContextItemContent"
/> />
<div <div
v-if="error" v-if="error"
...@@ -309,6 +316,7 @@ export default { ...@@ -309,6 +316,7 @@ export default {
:title="selectedContextItemsTitle" :title="selectedContextItemsTitle"
:default-collapsed="selectedContextItemsDefaultCollapsed" :default-collapsed="selectedContextItemsDefaultCollapsed"
variant="user" variant="user"
@get-content="onGetContextItemContent"
/> />
</div> </div>
</div> </div>
......
...@@ -6,6 +6,8 @@ import GlDuoChatContextItemMenu from './components/duo_chat_context/duo_chat_con ...@@ -6,6 +6,8 @@ import GlDuoChatContextItemMenu from './components/duo_chat_context/duo_chat_con
import { import {
getMockContextItems, getMockContextItems,
MOCK_CATEGORIES, MOCK_CATEGORIES,
MOCK_CONTEXT_FILE_CONTENT,
MOCK_CONTEXT_FILE_DIFF_CONTENT,
} from './components/duo_chat_context/mock_context_data'; } from './components/duo_chat_context/mock_context_data';
import GlDuoChat from './duo_chat.vue'; import GlDuoChat from './duo_chat.vue';
import readme from './duo_chat.md'; import readme from './duo_chat.md';
...@@ -18,6 +20,7 @@ import { ...@@ -18,6 +20,7 @@ import {
generateMockResponseChunks, generateMockResponseChunks,
renderGFM, renderGFM,
} from './mock_data'; } from './mock_data';
import { CONTEXT_ITEM_CATEGORY_LOCAL_GIT } from './components/duo_chat_context/constants';
const sampleContextItems = getMockContextItems(); const sampleContextItems = getMockContextItems();
...@@ -204,6 +207,36 @@ export const Interactive = (args, { argTypes }) => ({ ...@@ -204,6 +207,36 @@ export const Interactive = (args, { argTypes }) => ({
this.contextItems.splice(index, 1); this.contextItems.splice(index, 1);
} }
}, },
handleGetContextItemContent({ messageId, contextItem }) {
const hydratedItem = {
...contextItem,
content:
contextItem.category === CONTEXT_ITEM_CATEGORY_LOCAL_GIT
? MOCK_CONTEXT_FILE_DIFF_CONTENT
: MOCK_CONTEXT_FILE_CONTENT,
};
if (messageId === undefined) {
const index = this.contextItems.findIndex((item) => item.id === hydratedItem.id);
if (index !== -1) {
this.$set(this.contextItems, index, hydratedItem);
}
return;
}
const messageIndex = this.msgs.findIndex((msg) => msg.id === messageId);
if (messageIndex !== -1) {
const message = this.msgs[messageIndex];
if (message.extras && Array.isArray(message.extras.contextItems)) {
const contextItemIndex = message.extras.contextItems.findIndex(
(item) => item.id === contextItem.id
);
if (contextItemIndex !== -1) {
this.$set(message.extras.contextItems, contextItemIndex, hydratedItem);
}
}
}
},
}, },
template: ` template: `
<div style="height: 800px"> <div style="height: 800px">
...@@ -237,6 +270,7 @@ export const Interactive = (args, { argTypes }) => ({ ...@@ -237,6 +270,7 @@ export const Interactive = (args, { argTypes }) => ({
@chat-hidden="onChatHidden" @chat-hidden="onChatHidden"
@chat-cancel="onChatCancel" @chat-cancel="onChatCancel"
@insert-code-snippet="onInsertCodeSnippet" @insert-code-snippet="onInsertCodeSnippet"
@get-context-item-content="handleGetContextItemContent"
> >
<template #context-items-menu="{ isOpen, onClose, setRef, focusPrompt }"> <template #context-items-menu="{ isOpen, onClose, setRef, focusPrompt }">
<gl-duo-chat-context-item-menu <gl-duo-chat-context-item-menu
...@@ -252,6 +286,7 @@ export const Interactive = (args, { argTypes }) => ({ ...@@ -252,6 +286,7 @@ export const Interactive = (args, { argTypes }) => ({
@remove="handleContextItemRemove" @remove="handleContextItemRemove"
@close="onClose" @close="onClose"
@focus-prompt="focusPrompt" @focus-prompt="focusPrompt"
@get-context-item-content="handleGetContextItemContent"
/> />
</template> </template>
</gl-duo-chat> </gl-duo-chat>
......
...@@ -518,6 +518,14 @@ export default { ...@@ -518,6 +518,14 @@ export default {
*/ */
this.$emit('insert-code-snippet', e); this.$emit('insert-code-snippet', e);
}, },
onGetContextItemContent(event) {
/**
* Emit get-context-item-content event that tells clients to load the full file content for a selected context item.
* The fully hydrated context item should be updated in the chat message context item.
* @param {*} event An event containing the message ID and context item to hydrate
*/
this.$emit('get-context-item-content', event);
},
closeContextItemsMenuOpen() { closeContextItemsMenuOpen() {
this.contextItemsMenuIsOpen = false; this.contextItemsMenuIsOpen = false;
this.setPromptAndFocus(); this.setPromptAndFocus();
...@@ -603,6 +611,7 @@ export default { ...@@ -603,6 +611,7 @@ export default {
:show-delimiter="index > 0" :show-delimiter="index > 0"
@track-feedback="onTrackFeedback" @track-feedback="onTrackFeedback"
@insert-code-snippet="onInsertCodeSnippet" @insert-code-snippet="onInsertCodeSnippet"
@get-context-item-content="onGetContextItemContent"
/> />
<template v-if="!hasMessages && !isLoading"> <template v-if="!hasMessages && !isLoading">
<gl-empty-state <gl-empty-state
......
...@@ -27,6 +27,8 @@ export default { ...@@ -27,6 +27,8 @@ export default {
'GlDuoChat.chatPromptPlaceholderDefault': 'GitLab Duo Chat', 'GlDuoChat.chatPromptPlaceholderDefault': 'GitLab Duo Chat',
'GlDuoChat.chatPromptPlaceholderWithCommands': 'Type "/" for slash commands', 'GlDuoChat.chatPromptPlaceholderWithCommands': 'Type "/" for slash commands',
'GlDuoChat.chatSubmitLabel': 'Send chat message.', 'GlDuoChat.chatSubmitLabel': 'Send chat message.',
'GlDuoChatContextItemDetails.close': 'Close',
'GlDuoChatContextItemDetails.title': 'Preview',
'GlDuoChatContextItemMenu.emptyStateMessage': 'No results found', 'GlDuoChatContextItemMenu.emptyStateMessage': 'No results found',
'GlDuoChatContextItemMenu.loadingMessage': 'Loading...', 'GlDuoChatContextItemMenu.loadingMessage': 'Loading...',
'GlDuoChatContextItemMenu.searchInputPlaceholder': 'Search %{categoryLabel}...', 'GlDuoChatContextItemMenu.searchInputPlaceholder': 'Search %{categoryLabel}...',
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment