Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • albert.khasanov/gitlab-ui
  • SevenOutman/gitlab-ui
  • ClemMakesApps/gitlab-ui
  • gitlab-org/gitlab-ui
  • gtsiolis/gitlab-ui
  • mark.obradley/gitlab-ui
  • piall/gitlab-ui
  • redreamer/gitlab-ui
  • runrog/gitlab-ui
  • yangchigi/gitlab-ui
  • jayalakshmij/gitlab-ui
  • sonqu/gitlab-ui
  • nnelson/gitlab-ui
  • michel.engelen/gitlab-ui
  • bsradcliffe/gitlab-ui
  • petahbyte/gitlab-ui
  • joe.wollard/gitlab-ui
  • jihye.paik/gitlab-ui
  • Kamikadze4GAME/gitlab-ui
  • Gaslan/gitlab-ui
  • inyee786/gitlab-ui
  • abuuzayr/gitlab-ui
  • NativeUser/gitlab-ui
  • _23phy/gitlab-ui
  • v_hladko/gitlab-ui
  • killbotxd/gitlab-ui
  • yeonyu/gitlab-ui
  • mnzone/gitlab-ui
  • ashishgkwd/gitlab-ui
  • Keimeno/gitlab-ui
  • dcouture/gitlab-ui
  • Rory_Chillmore/gitlab-ui
  • misha28x/gitlab-ui
  • shawchandeshwar61/gitlab-ui
  • aszs/gitlab-ui
  • leetickett/gitlab-ui
  • stalker3343/gitlab-ui
  • davepies/gitlab-ui
  • pravi/gitlab-ui
  • ChasLui/gitlab-ui
  • wangko27/gitlab-ui
  • kaangokdemir/gitlab-ui
  • rajiff/gitlab-ui
  • gitlab-org/frontend/playground/gitlab-ui
  • orozot/gitlab-ui
  • gitlab-renovate-forks/gitlab-ui
  • Meghana-12/gitlab-ui
  • tweichart/gitlab-ui
  • leipert/gitlab-ui
  • wenweicui/gitlab-ui
  • mohanraj.geniebeaver/gitlab-ui
  • imrishabh18/gitlab-ui
  • ma-lihui/gitlab-ui
  • piyushsinghania/gitlab-ui
  • NeetuJain/gitlab-ui
  • waridrox/gitlab-ui
  • ankita.singh.200020/gitlab-ui
  • sercan55344/gitlab-ui
  • pangjian/gitlab-ui
  • 2002newhritik/gitlab-ui
  • rachelvfmurphy/gitlab-ui
  • shridharbhat1998/gitlab-ui
  • paulwvnjohi/gitlab-ui
  • edith007/gitlab-ui
  • IgorPahota/gitlab-ui
  • yashmaheshwari/gitlab-ui
  • chiachenglu/gitlab-ui
  • Dhairya3124/gitlab-ui
  • preetidevsang/gitlab-ui
  • revbp/gitlab-ui
  • khout/gitlab-ui
  • Bajjouayoub/gitlab-ui
  • ali_o_kan/gitlab-ui
  • marcel.feldmann/gitlab-ui
  • serenafang/gitlab-ui
  • jamesliu-gitlab/gitlab-ui
  • wallisaleh87/gitlab-ui
  • ALypovyi/gitlab-ui
  • thutterer/gitlab-ui
  • pikepaule/gitlab-ui
  • splattael/gitlab-ui
  • rettalps/gitlab-ui
  • rajdevelopr/gitlab-ui
  • Mohamadhassan98/gitlab-ui
  • dannyelcf/gitlab-ui
  • vchan14/gitlab-ui
  • 23bytes/gitlab-ui
  • dr.shvets/gitlab-ui
  • crystal.alchemist/gitlab-ui
  • chriscordoba1948/gitlab-ui
  • markrian/gitlab-ui
  • zillemarco/gitlab-ui
  • bhatewarak/gitlab-ui
  • hamare-contrib/gitlab-ui
  • agnieszka.gancarczyk/gitlab-ui
  • khulnasoft/khulnasoft-ui
  • abitrolly/gitlab-ui
  • normatov13/gitlab-ui
  • Brwnknight20/gitlab-ui
  • chekerTlili/medmed-front-test
  • Fcogp90/gitlab-ui
  • Harith_training/gitlab-ui
  • rahulpan_altair/gitlab-ui
  • HelloZJW/gitlab-ui
  • fathead32/gitlab-ui
  • akumar1503/gitlab-ui
  • KhaledElkhoreby/gitlab-ui
  • pierrebelloy/gitlab-ui
  • lxwan/gitlab-ui
  • dpalubin/gitlab-ui
  • gitlab-community/gitlab-ui
  • ubaidisaev/gitlab-ui
  • serenafang/gitlab-ui-serena-test
  • hamzasouelmi/gitlab-ui
  • youngbeomshin/gitlab-ui
  • kimseoha1993/gitlab-ui
  • kevin.rojas/gitlab-ui
  • catinbag/gitlab-ui
  • mathieu.pillar/gitlab-ui
  • qk44077907/gitlab-ui
  • fenyuluoshang/gitlab-ui
  • QingJ/gitlab-ui
  • x--/gitlab-ui
  • nraj0408/gitlab-ui
  • victorelmov/gitlab-ui
  • sollo.nic.c.cc/gitlab-ui
  • sksardar42/gitlab-ui
  • nqdev-fork/gitlab-org/gitlab-ui
  • JeremyWuuuuu/gitlab-ui
  • kara006n/gitlab-ui
  • ndt-contribute/gitlab-ui
  • sahadat-sk/gitlab-ui
  • mdwiltfong/gitlab-ui
  • muntazacloud/gitlab-ui
  • drewcauchi/gitlab-ui
  • liummmm/gitlab-ui
  • ale3oula/gitlab-ui
  • kiran-4444/gitlab-ui
  • DUCKDUCKGODEVELOPER/gitlab-ui
  • g32james/gitlab-ui
  • Saeed178/gitlab-ui
  • nickaldwin/gitlab-ui
  • armbiant/gitlab-gui
  • satyamkale27/gitlab-ui
  • jannik_lehmann/gitlab-ui-mono-tinkering
  • zayminkhant/gitlab-ui
  • aytacyaydem/gitlab-ui
  • initdc/gitlab-ui
  • rungruang1/gitlab-ui
  • dormanshylas1/gitlab-ui
  • armbiant/gitlab-ui
  • Piyush-r-bhaskar/gitlab-ui
  • ollevche/gitlab-ui
  • joefoti178/gitlab-ui
  • william.allen1/gitlab-ui
155 results
Show changes
Commits on Source (12)
Showing
with 69 additions and 2077 deletions
......@@ -16,12 +16,6 @@ module.exports = {
message:
'Import components and directives directly rather than via the top-level barrel file.',
},
{
group: ['**/markdown_renderer'],
importNames: ['renderDuoChatMarkdownPreview'],
message:
'Importing `renderDuoChatMarkdownPreview` outside of the Duo chat components is a no-go. If you want other components to be able to render markdown, please open an issue.\n',
},
],
paths: [
{
......@@ -36,11 +30,6 @@ module.exports = {
name: 'lodash/isFinite',
message: 'Prefer native Number.isFinite method.',
},
{
name: 'marked',
message:
'Importing `marked` outside of the Duo chat components is a no-go. If you want other components to be able to render markdown, please open an issue.\n',
},
],
},
],
......
......@@ -13,8 +13,5 @@ doc/ @rdickenson
[UX]
/tests/__image_snapshots__/ @gitlab-com/gitlab-ux/designers
[Duo Chat]
src/components/experimental/duo @dmishunov @nicolasdular
[Design Tokens]
src/tokens @gitlab-org/foundations/design-system
## [105.0.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v105.0.0...v105.0.1) (2024-12-05)
### Bug Fixes
* **GlFormCheckbox:** checked state with non-boolean value ([eb28dd3](https://gitlab.com/gitlab-org/gitlab-ui/commit/eb28dd37fcfb934a9e1c820b4ce09fd51d781d49)), closes [/gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4863#note_2236852320](https://gitlab.com//gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4863/issues/note_2236852320)
# [105.0.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v104.2.0...v105.0.0) (2024-12-05)
### chore
* remove deprecated Duo components in favor of Duo-UI ([8e8d394](https://gitlab.com/gitlab-org/gitlab-ui/commit/8e8d394d0a6f299a87ea3614a0282d8fb1634cdd))
### BREAKING CHANGES
* Refactored code to fully remove deprecated
duo components, Updated references and imports to Duo-UI for consistency
# [104.2.0](https://gitlab.com/gitlab-org/gitlab-ui/compare/v104.1.2...v104.2.0) (2024-12-05)
### Features
* **Bootstrap:** Update link color and link hover color ([6812034](https://gitlab.com/gitlab-org/gitlab-ui/commit/6812034cdf0d44e1277e95addd45da526dc8e688))
## [104.1.2](https://gitlab.com/gitlab-org/gitlab-ui/compare/v104.1.1...v104.1.2) (2024-12-04)
### Bug Fixes
* **Tailwind:** fix gl-shadow-inner-b-border class shadow ([951cd36](https://gitlab.com/gitlab-org/gitlab-ui/commit/951cd36c1dc319192f6cd6c2fcbf7b2f14e0a336))
## [104.1.1](https://gitlab.com/gitlab-org/gitlab-ui/compare/v104.1.0...v104.1.1) (2024-12-03)
......
{
"name": "@gitlab/ui",
"version": "104.1.1",
"version": "105.0.1",
"description": "GitLab UI Components",
"license": "MIT",
"main": "dist/index.js",
......@@ -80,8 +80,6 @@
"echarts": "^5.3.2",
"iframe-resizer": "^4.3.2",
"lodash": "^4.17.20",
"marked": "^12.0.0",
"marked-bidi": "^1.0.8",
"popper.js": "^1.16.1",
"portal-vue": "^2.1.7",
"vue-functional-data-merge": "^3.1.0",
......
import { mount } from '@vue/test-utils';
import GlFormCheckbox from './form_checkbox.vue';
describe('GlFormCheckbox', () => {
let wrapper;
const createComponent = (options) => {
wrapper = mount(GlFormCheckbox, options);
};
it('can start checked', async () => {
createComponent({
propsData: {
checked: 'checked_value',
value: 'checked_value',
name: 'foo',
},
});
expect(wrapper.find('input[name="foo"]').element.checked).toBe(true);
});
});
......@@ -46,6 +46,18 @@ const Template = (args, { argTypes }) => ({
export const Default = Template.bind({});
const Single = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components,
template: `
<div>
<gl-form-checkbox value="checked-option" checked="checked-option">Checked option</gl-form-checkbox>
</div>
`,
});
export const SingleCheckbox = Single.bind({});
export default {
title: 'base/form/form checkbox',
component: GlFormCheckbox,
......
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
export const CONTEXT_ITEM_CATEGORY_ISSUE = 'issue';
export const CONTEXT_ITEM_CATEGORY_MERGE_REQUEST = 'merge_request';
export const CONTEXT_ITEM_CATEGORY_FILE = 'file';
export const CONTEXT_ITEM_CATEGORY_LOCAL_GIT = 'local_git';
export const CONTEXT_ITEM_CATEGORY_DEPENDENCY = 'dependency';
export const CONTEXT_ITEM_LOCAL_GIT_COMMIT = 'commit';
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';
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
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_DEPENDENCY, 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 GlDuoChatContextItemDetailsModal from './duo_chat_context_item_details_modal.vue';
describe('GlDuoChatContextItemDetailsModal', () => {
let wrapper;
let renderGFM;
const createComponent = (propsData = {}) => {
renderGFM = jest.fn();
wrapper = shallowMount(GlDuoChatContextItemDetailsModal, {
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');
const findContentError = () => findByTestId('content-error-alert');
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',
actionCancel: null,
actionPrimary: 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('does not emit "close" event when modal becomes visible', () => {
createComponent({ contextItem: MOCK_CONTEXT_ITEM_FILE });
findModal().vm.$emit('change', true);
expect(wrapper.emitted('close')).toBeUndefined();
});
});
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', () => {
describe('for "file" items', () => {
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 of 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);
});
});
});
describe('for "dependencies" items', () => {
describe('when the content cannot be parsed', () => {
beforeEach(async () => {
wrapper.setProps({
contextItem: { ...MOCK_CONTEXT_ITEM_DEPENDENCY, content: 'i-am<not>-valid{JSON!' },
});
await nextTick();
});
it('should not show the loading state', () => {
expect(findLoadingState().exists()).toBe(false);
});
it('should show the content error', () => {
expect(findContentError().exists()).toBe(true);
});
it('should not render any content', () => {
expect(findContent().exists()).toBe(false);
});
});
describe('when the content is valid', () => {
beforeEach(async () => {
wrapper.setProps({ contextItem: MOCK_CONTEXT_ITEM_DEPENDENCY });
await nextTick();
});
it('should not show the loading state', () => {
expect(findLoadingState().exists()).toBe(false);
});
it('should not show the content error', () => {
expect(findContentError().exists()).toBe(false);
});
it('should not call the "renderGFM" function', () => {
expect(renderGFM).not.toHaveBeenCalled();
});
it('should render content summary', () => {
expect(findContent().text()).toContain('Project dependencies from package.json');
});
it('should render dependencies content', () => {
const text = findContent().text();
expect(text).toContain('javascript');
expect(text).toContain('@types/node@16.11.7');
expect(text).toContain('@vue/compiler-sfc@3.2.37');
expect(text).toContain('typescript@4.5.5');
expect(text).toContain('vue@3.2.37');
});
});
});
});
});
});
<script>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
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 { translate } from '../../../../../../../utils/i18n';
import {
CONTEXT_ITEM_CATEGORY_DEPENDENCY,
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
LANGUAGE_IDENTIFIER_DIFF,
LANGUAGE_IDENTIFIER_PLAINTEXT,
LANGUAGE_IDENTIFIER_PREFIX,
} from '../constants';
import GlAlert from '../../../../../../base/alert/alert.vue';
export default {
name: 'GlDuoChatContextItemDetailsModal',
components: {
GlAlert,
GlSkeletonLoader,
GlModal,
},
directives: {
SafeHtml,
},
inject: {
renderGFM: {
from: 'renderGFM',
default: () => (element) => {
element.classList.add('duo-chat-markdown', 'duo-chat-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,
},
},
data() {
return {
contentErrorIsVisible: false,
};
},
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('GlDuoChatContextItemDetailsModal.title', 'Preview')
);
},
isDependencies() {
return this.contextItem.category === CONTEXT_ITEM_CATEGORY_DEPENDENCY;
},
},
watch: {
contextItem: {
async handler(newVal, oldVal) {
// Dependency items contain structured data as content, not code/markdown.
// So skip running this content through GFM, we'll parse and render it here in the component.
if (newVal.category === CONTEXT_ITEM_CATEGORY_DEPENDENCY) {
return;
}
const isUnchangedOrEmptyContent = !newVal?.content || newVal?.content === oldVal?.content;
if (isUnchangedOrEmptyContent) {
return;
}
await nextTick();
await this.hydrateContentWithGFM();
},
immediate: true,
},
},
methods: {
async hydrateContentWithGFM() {
await nextTick();
if (this.$refs.content) {
this.renderGFM(this.$refs.content);
}
},
parseDependencies() {
if (this.contextItem.category !== CONTEXT_ITEM_CATEGORY_DEPENDENCY) {
return null;
}
if (!this.contextItem.content) {
return null;
}
try {
return JSON.parse(this.contextItem.content);
} catch (error) {
this.contentErrorIsVisible = true;
return {};
}
},
onModalVisibilityChange(isVisible) {
if (!isVisible) {
this.$emit('close');
}
},
},
CONTENT_ERROR_MESSAGE: translate(
'GlDuoChatContextItemDetailsModal.contentErrorMessage',
'Item content could not be displayed.'
),
};
</script>
<template>
<gl-modal
modal-id="context-item-details-modal"
:title="title"
:visible="true"
:scrollable="true"
hide-footer
size="lg"
@change="onModalVisibilityChange"
>
<gl-skeleton-loader v-if="isLoadingContent" />
<gl-alert
v-else-if="contentErrorIsVisible"
variant="danger"
:dismissible="false"
data-testid="content-error-alert"
>
{{ $options.CONTENT_ERROR_MESSAGE }}
</gl-alert>
<div v-else-if="isDependencies" data-testid="context-item-content">
<p>Project dependencies from {{ contextItem.metadata.secondaryText }}</p>
<div v-for="(matches, index) in parseDependencies()" :key="index">
<div v-for="(dependencies, language) in matches" :key="language">
<h3 class="gl-heading-4 gl-mb-2">{{ language }}</h3>
<ul class="gl-pl-6">
<li v-for="dependency in dependencies" :key="dependency" class="">
{{ dependency }}
</li>
</ul>
</div>
</div>
</div>
<div v-else ref="content" data-testid="context-item-content">
<pre
v-safe-html="contextItem.content"
class="code js-syntax-highlight gl-p-3"
:class="languageIdentifierClass"
></pre>
</div>
</gl-modal>
</template>
<!--
This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
Please use the corresponding component in Duo-UI going forward.
All future development and maintenance for Duo components should take place in Duo-UI.
For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
-->
Allows selecting and removing context items for the conversation.
**Note:**
Keyboard events don't work properly in this story (independently of the main GlDuoChat
component)- test in the main `GlDuoChat` interactive story with the /include command.
## AIContextItem type
The component expects items with specific display properties:
```typescript
export type AIContextItem = {
id: string;
category: 'file' | 'snippet' | 'issue' | 'merge_request' | 'dependency';
content?: string; // some categories allow loading/displaying content in the details-modal
metadata: {
icon: string; // should be a valid gitlab-ui icon name
title: string;
secondaryText: string;
subTypeLabel: string;
// Additional properties some categories have to help differentiate results
project?: string;
repositoryName?: string;
// items may be disabled, e.g. if they belong to a non-Duo-enabled project
enabled: boolean;
disabledReasons?: string[];
};
};
```
For the editor extensions, these types are defined [in the language server](https://gitlab.com/gitlab-org/editor-extensions/gitlab-lsp/blob/main/src/common/ai_context_management/index.ts)
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { getMockCategory, getMockContextItems, MOCK_CATEGORIES } from '../mock_context_data';
import {
CONTEXT_ITEM_CATEGORY_ISSUE,
CONTEXT_ITEM_CATEGORY_MERGE_REQUEST,
CONTEXT_ITEM_CATEGORY_FILE,
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
} from '../constants';
import GlDuoChatContextItemSelections from '../duo_chat_context_item_selections/duo_chat_context_item_selections.vue';
import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items.vue';
import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items.vue';
import GlDuoChatContextItemMenu from './duo_chat_context_item_menu.vue';
jest.mock('lodash/debounce', () => jest.fn((fn) => fn));
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]);
});
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', () => {
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_CATEGORY_ISSUE,
CONTEXT_ITEM_CATEGORY_MERGE_REQUEST,
CONTEXT_ITEM_CATEGORY_FILE,
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
])('when a "%s" category has been selected', (categoryValue) => {
let category;
let results;
beforeEach(() => {
category = getMockCategory(categoryValue);
results = getMockContextItems()
.filter((item) => item.category === categoryValue)
.map((item, index) => ({
...item,
metadata: {
...item.metadata,
enabled: index % 2 === 0, // disable odd indexed items
},
}))
.slice(0, 3); // ensure consistent number of items for testing wrapping/cycling logic
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('refocuses on parent prompt when clearing category selection', async () => {
await triggerKeyUp('Escape');
expect(wrapper.emitted('focus-prompt')).toHaveLength(1);
});
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]);
});
it('initially marks the first enabled item as active', async () => {
const firstEnabledIndex = 2;
await wrapper.setProps({
results: results.map((result, index) => ({
...result,
metadata: {
...result.metadata,
enabled: index === firstEnabledIndex,
},
})),
});
await nextTick();
expect(findResultItems().props('activeIndex')).toEqual(firstEnabledIndex);
});
});
});
});
});
});
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import { makeContainer } from '../../../../../../../utils/story_decorators/container';
import { setStoryTimeout } from '../../../../../../../utils/test_utils';
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 readme from './duo_chat_context_item_menu.md';
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: readme,
},
},
},
};
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);
}
},
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: `
<div class="gl-h-full gl-flex gl-flex-col gl-justify-end">
<div class="gl-relative gl-max-w-full">
<gl-duo-chat-context-item-menu
:open="isOpen"
:selections="selectedItems"
:categories="categories"
:loading="isLoading"
:error="errorMessage"
:results="searchResults"
@search="handleContextItemsSearch"
@select="handleContextItemSelect"
@remove="handleContextItemRemove"
@get-context-item-content="handleGetContent"
@close="isOpen = false"
/>
</div>
<button @click="isOpen = !isOpen">Toggle Context Menu</button>
</div>
`,
});
export const Default = Template.bind({});
Default.args = {
open: false,
loading: false,
error: null,
categories: sampleCategories,
results: [],
selections: [],
};
<script>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import debounce from 'lodash/debounce';
import { translate } from '../../../../../../../utils/i18n';
import GlCard from '../../../../../../base/card/card.vue';
import GlDuoChatContextItemSelections from '../duo_chat_context_item_selections/duo_chat_context_item_selections.vue';
import { categoriesValidator, contextItemsValidator, wrapIndex } from '../utils';
import GlDuoChatContextItemMenuCategoryItems from './duo_chat_context_item_menu_category_items.vue';
import GlDuoChatContextItemMenuSearchItems from './duo_chat_context_item_menu_search_items.vue';
const SEARCH_DEBOUNCE_MS = 30;
export default {
name: 'GlDuoChatContextItemMenu',
components: {
GlCard,
GlDuoChatContextItemMenuCategoryItems,
GlDuoChatContextItemMenuSearchItems,
GlDuoChatContextItemSelections,
},
props: {
/**
* Whether the menu is open.
*/
open: {
type: Boolean,
required: true,
},
/**
* Array of selected context items.
*/
selections: {
type: Array,
required: true,
validator: contextItemsValidator,
},
/**
* Whether the menu is in a loading state.
*/
loading: {
type: Boolean,
required: true,
},
/**
* Error message to display, if any.
*/
error: {
type: [String, null],
required: false,
default: null,
},
/**
* Array of available categories for context items.
*/
categories: {
type: Array,
required: true,
validator: categoriesValidator,
},
/**
* Array of search results for context items.
*/
results: {
type: Array,
required: true,
validator: contextItemsValidator,
},
},
data() {
return {
selectedCategory: null,
searchQuery: '',
activeIndex: 0,
};
},
computed: {
showCategorySelection() {
return this.open && !this.selectedCategory;
},
allResultsAreDisabled() {
return this.results.every((result) => !result.metadata.enabled);
},
},
watch: {
open(isOpen) {
if (!isOpen) {
this.resetSelection();
}
},
searchQuery(query) {
this.debouncedSearch(query);
},
results(newResults) {
const firstEnabledIndex = newResults.findIndex((result) => result.metadata.enabled);
this.activeIndex = firstEnabledIndex >= 0 ? firstEnabledIndex : 0;
},
},
methods: {
selectCategory(category) {
this.searchQuery = '';
this.selectedCategory = category;
this.$emit('search', {
category: category.value,
query: '',
});
},
debouncedSearch: debounce(function search(query) {
/**
* Emitted when a search should be performed.
* @property {Object} filter
* @property {string} filter.category - The value of the selected category
* @property {string} filter.query - The search query
*/
this.$emit('search', {
category: this.selectedCategory.value,
query,
});
}, SEARCH_DEBOUNCE_MS),
selectItem(item) {
if (!item.metadata.enabled) {
return;
}
/**
* Emitted when a context item is selected.
* @property {Object} item - The selected context item
*/
this.$emit(
'select',
this.results.find((result) => result.id === item.id)
);
/**
* Emitted when the menu should be closed.
*/
this.$emit('close');
this.resetSelection();
},
removeItem(item) {
/**
* Emitted when a context item should be removed.
* @property {Object} item - The context item to be removed
*/
this.$emit('remove', item);
},
resetSelection() {
this.selectedCategory = null;
this.searchQuery = '';
this.activeIndex = 0;
},
async scrollActiveItemIntoView() {
await this.$nextTick();
const activeItem = document.getElementById(`dropdown-item-${this.activeIndex}`);
if (activeItem) {
activeItem.scrollIntoView({ block: 'nearest', inline: 'start' });
}
},
handleKeyUp(e) {
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault();
this.moveActiveIndex(e.key === 'ArrowDown' ? 1 : -1);
this.scrollActiveItemIntoView();
break;
case 'Enter':
e.preventDefault();
if (this.showCategorySelection) {
this.selectCategory(this.categories[this.activeIndex]);
return;
}
if (!this.results.length) {
return;
}
this.selectItem(this.results[this.activeIndex]);
break;
case 'Escape':
e.preventDefault();
if (this.showCategorySelection) {
this.$emit('close');
return;
}
this.selectedCategory = null;
/**
* Emitted when the parent GlDuoChat component should refocus on the main prompt input
*/
this.$emit('focus-prompt');
break;
default:
break;
}
},
moveActiveIndex(step) {
if (this.showCategorySelection) {
// Categories cannot be disabled, so just loop to the next/prev one
this.activeIndex = wrapIndex(this.activeIndex, step, this.categories.length);
return;
}
// Return early if there are no results or all results are disabled
if (!this.results.length || this.allResultsAreDisabled) {
return;
}
// contextItems CAN be disabled, so loop to next/prev but ensure we don't land on a disabled one
let newIndex = this.activeIndex;
do {
newIndex = wrapIndex(newIndex, step, this.results.length);
if (newIndex === this.activeIndex) {
// If we've looped through all items and found no enabled ones, keep the current index
return;
}
} while (!this.results[newIndex].metadata.enabled);
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: {
selectedContextItemsTitle: translate(
'GlDuoChatContextItemMenu.selectedContextItemsTitle',
'Included references'
),
},
};
</script>
<template>
<div>
<gl-duo-chat-context-item-selections
v-if="selections.length"
:selections="selections"
:categories="categories"
:removable="true"
:title="$options.i18n.selectedContextItemsTitle"
:default-collapsed="false"
class="gl-mb-3"
@remove="removeItem"
@get-content="onGetContextItemContent"
/>
<gl-card
v-if="open"
class="slash-commands !gl-absolute gl-bottom-0 gl-w-full gl-pl-0 gl-shadow-md"
body-class="!gl-p-2"
data-testid="context-item-menu"
>
<gl-duo-chat-context-item-menu-category-items
v-if="showCategorySelection"
:active-index="activeIndex"
:categories="categories"
@select="selectCategory"
@active-index-change="activeIndex = $event"
/>
<gl-duo-chat-context-item-menu-search-items
v-else
v-model="searchQuery"
:active-index="activeIndex"
:category="selectedCategory"
:loading="loading"
:error="error"
:results="results"
@select="selectItem"
@keyup="handleKeyUp"
@active-index-change="activeIndex = $event"
/>
</gl-card>
</div>
</template>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
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);
});
});
<script>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import GlDropdownItem from '../../../../../../base/dropdown/dropdown_item.vue';
import GlIcon from '../../../../../../base/icon/icon.vue';
import { categoriesValidator } from '../utils';
export default {
name: 'GlDuoChatContextItemMenuCategoryItems',
components: { GlIcon, GlDropdownItem },
props: {
categories: {
type: Array,
required: true,
validator: categoriesValidator,
},
activeIndex: {
type: Number,
required: true,
},
},
methods: {
selectCategory(category) {
this.$emit('select', category);
},
setActiveIndex(index) {
this.$emit('active-index-change', index);
},
},
};
</script>
<template>
<ul class="gl-mb-0 gl-list-none gl-pl-0">
<gl-dropdown-item
v-for="(category, index) in categories"
:key="category.value"
:class="{ 'active-command': index === activeIndex }"
data-testid="category-item"
@click="selectCategory(category)"
>
<div class="gl-flex gl-items-center" @mouseenter="setActiveIndex(index)">
<gl-icon :name="category.icon" class="gl-mr-2" />
{{ category.label }}
</div>
</gl-dropdown-item>
</ul>
</template>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import { shallowMount } from '@vue/test-utils';
import GlTruncate from '../../../../../../utilities/truncate/truncate.vue';
import {
getMockCategory,
MOCK_CONTEXT_ITEM_DEPENDENCY,
MOCK_CONTEXT_ITEM_FILE,
MOCK_CONTEXT_ITEM_GIT_COMMIT,
MOCK_CONTEXT_ITEM_GIT_DIFF,
MOCK_CONTEXT_ITEM_ISSUE,
MOCK_CONTEXT_ITEM_MERGE_REQUEST,
} from '../mock_context_data';
import {
CONTEXT_ITEM_CATEGORY_ISSUE,
CONTEXT_ITEM_CATEGORY_MERGE_REQUEST,
CONTEXT_ITEM_CATEGORY_FILE,
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
CONTEXT_ITEM_CATEGORY_DEPENDENCY,
} 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,
},
stubs: {
GlTruncate,
},
});
};
const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findCategoryIcon = () => findByTestId('category-icon');
const findContextItemPopover = () => wrapper.findComponent(GlDuoChatContextItemPopover);
const findItemTitle = () => findByTestId('item-title');
const findItemSecondaryText = () => findByTestId('item-secondary-text');
const findItemSource = () => findByTestId('context-item-source');
describe.each([
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_FILE),
contextItem: MOCK_CONTEXT_ITEM_FILE,
expectedIcon: 'document',
expectedSecondaryText: `src/plants/strawberry.ts`,
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_ISSUE),
contextItem: MOCK_CONTEXT_ITEM_ISSUE,
expectedIcon: 'issues',
expectedSecondaryText: `example/garden#1234`,
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_MERGE_REQUEST),
contextItem: MOCK_CONTEXT_ITEM_MERGE_REQUEST,
expectedIcon: 'merge-request',
expectedSecondaryText: `example/garden!1122`,
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_LOCAL_GIT),
contextItem: MOCK_CONTEXT_ITEM_GIT_COMMIT,
expectedIcon: 'commit',
expectedSecondaryText: `20f8caf94cb8f5e5f9dbd1a9ac32702321de201b`,
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_LOCAL_GIT),
contextItem: MOCK_CONTEXT_ITEM_GIT_DIFF,
expectedIcon: 'comparison',
expectedSecondaryText: `main`,
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_DEPENDENCY),
contextItem: MOCK_CONTEXT_ITEM_DEPENDENCY,
expectedIcon: 'package',
expectedSecondaryText: `package.json`,
},
])(
'for category "$contextItem.category" and type "$contextItem.metadata.gitType"',
({ category, contextItem, expectedIcon, expectedSecondaryText }) => {
beforeEach(() => createWrapper({ category, contextItem }));
it('renders the expected icon', () => {
expect(findCategoryIcon().props('name')).toBe(expectedIcon);
});
it('renders the item title', () => {
const title = findItemTitle();
expect(title.props('text')).toEqual(contextItem.metadata.title);
});
it('renders the context item popover', () => {
expect(findContextItemPopover().props()).toEqual(
expect.objectContaining({
contextItem,
target: `info-icon-${contextItem.id}`,
})
);
});
it('renders the default context item title', () => {
expect(wrapper.text()).toContain(contextItem.metadata.title);
});
it('renders expected secondary text', () => {
const secondaryText = findItemSecondaryText();
const truncated = secondaryText.findComponent(GlTruncate);
expect(truncated.props('text')).toEqual(expectedSecondaryText);
});
}
);
describe.each([
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_ISSUE),
contextItem: MOCK_CONTEXT_ITEM_ISSUE,
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_MERGE_REQUEST),
contextItem: MOCK_CONTEXT_ITEM_MERGE_REQUEST,
},
])('for $contextItem.category', ({ category, contextItem }) => {
beforeEach(() => {
createWrapper({ category, contextItem });
});
it('does not render item source badge', () => {
expect(findItemSource().exists()).toBe(false);
});
});
describe.each([
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_FILE),
contextItem: MOCK_CONTEXT_ITEM_FILE,
expectedSource: 'example/garden',
},
{
category: getMockCategory(CONTEXT_ITEM_CATEGORY_LOCAL_GIT),
contextItem: MOCK_CONTEXT_ITEM_GIT_COMMIT,
expectedSource: 'example/garden',
},
])('for $contextItem.category', ({ category, contextItem, expectedSource }) => {
beforeEach(() => {
createWrapper({ category, contextItem });
});
it('renders item source badge', () => {
expect(findItemSource().text()).toEqual(expectedSource);
});
});
});
<script>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import GlDuoChatContextItemPopover from '../duo_chat_context_item_popover/duo_chat_context_item_popover.vue';
import GlTruncate from '../../../../../../utilities/truncate/truncate.vue';
import GlIcon from '../../../../../../base/icon/icon.vue';
import {
categoryValidator,
contextItemValidator,
getContextItemIcon,
getContextItemSecondaryText,
getContextItemSource,
} from '../utils';
import GlBadge from '../../../../../../base/badge/badge.vue';
export default {
name: 'GlDuoChatContextItemMenuSearchItem',
components: { GlBadge, GlTruncate, GlIcon, GlDuoChatContextItemPopover },
props: {
category: {
type: Object,
required: true,
validator: categoryValidator,
},
contextItem: {
type: Object,
required: true,
validator: contextItemValidator,
},
},
computed: {
title() {
return this.contextItem.metadata?.title || '';
},
secondaryText() {
return getContextItemSecondaryText(this.contextItem);
},
icon() {
return getContextItemIcon(this.contextItem, this.category);
},
itemSource() {
return getContextItemSource(this.contextItem);
},
},
};
</script>
<template>
<div class="gl-flex gl-flex-col">
<div class="gl-flex gl-items-center">
<gl-icon :name="icon" class="gl-mr-2 gl-shrink-0" data-testid="category-icon" />
<gl-truncate :text="title" class="gl-min-w-0" data-testid="item-title" />
<gl-icon
:id="`info-icon-${contextItem.id}`"
name="information-o"
class="gl-ml-2 gl-shrink-0 gl-cursor-pointer gl-text-secondary"
:size="12"
/>
<gl-duo-chat-context-item-popover
:context-item="contextItem"
:target="`info-icon-${contextItem.id}`"
placement="left"
/>
</div>
<div
v-if="secondaryText"
class="gl-align-items-center gl-mt-1 gl-flex gl-shrink-0 gl-whitespace-nowrap gl-text-secondary"
data-testid="item-secondary-text"
>
<gl-badge
v-if="itemSource"
variant="neutral"
class="gl-mr-1"
data-testid="context-item-source"
>{{ itemSource }}</gl-badge
>
<gl-truncate class="gl-min-w-0" position="middle" :text="secondaryText" />
</div>
</div>
</template>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { MOCK_CATEGORIES, getMockContextItems, getMockCategory } from '../mock_context_data';
import {
CONTEXT_ITEM_CATEGORY_ISSUE,
CONTEXT_ITEM_CATEGORY_MERGE_REQUEST,
CONTEXT_ITEM_CATEGORY_FILE,
CONTEXT_ITEM_CATEGORY_LOCAL_GIT,
} 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.category === 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('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]);
});
describe('when loading', () => {
it('shows loading state', async () => {
expect(findLoadingIndicator().exists()).toBe(true);
});
describe.each([
{ numResults: 0, expectedRows: 1 },
{ numResults: 1, expectedRows: 1 },
{ numResults: 2, expectedRows: 2 },
{ numResults: 3, expectedRows: 3 },
{ numResults: 4, expectedRows: 4 },
{ numResults: 5, expectedRows: 5 },
])('when there are $numResults results', ({ numResults, expectedRows }) => {
beforeEach(async () => {
await wrapper.setProps({
loading: true,
results: getMockContextItems().slice(0, numResults),
});
});
it(`shows ${expectedRows} loading rows next time when searching`, () => {
expect(findLoadingIndicator().props('rows')).toBe(expectedRows);
});
});
});
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({
contextItem: matchingResult,
category,
})
);
});
it('marks the correct item as active when the index changes', async () => {
expect(findActiveItemDetails().props('contextItem')).toEqual(results.at(0));
await wrapper.setProps({
activeIndex: 1,
});
expect(findActiveItemDetails().props('contextItem')).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,
metadata: {
...result.metadata,
enabled: 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('gl-cursor-not-allowed');
});
it('does not mark any item as active if all items are disabled', async () => {
wrapper.setProps({
results: getMockContextItems().map((result) => ({
...result,
metadata: {
...result.metadata,
enabled: false,
},
})),
});
await nextTick();
expect(findActiveItem().exists()).toBe(false);
});
});
});
});
describe.each([
{
testCase: getMockCategory(CONTEXT_ITEM_CATEGORY_FILE),
expectedPlaceholder: 'Search files...',
},
{
testCase: getMockCategory(CONTEXT_ITEM_CATEGORY_ISSUE),
expectedPlaceholder: 'Search issues...',
},
{
testCase: getMockCategory(CONTEXT_ITEM_CATEGORY_MERGE_REQUEST),
expectedPlaceholder: 'Search merge requests...',
},
{
testCase: getMockCategory(CONTEXT_ITEM_CATEGORY_LOCAL_GIT),
expectedPlaceholder: 'Search local git...',
},
])('when category is "$testCase.label"', ({ testCase, expectedPlaceholder }) => {
beforeEach(() =>
createWrapper({
category: testCase,
})
);
it('renders the expected input placeholder text', () => {
expect(findSearchInput().attributes('placeholder')).toEqual(expectedPlaceholder);
});
});
});
<script>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import GlDropdownItem from '../../../../../../base/dropdown/dropdown_item.vue';
import GlFormInput from '../../../../../../base/form/form_input/form_input.vue';
import GlAlert from '../../../../../../base/alert/alert.vue';
import { sprintf, translate } from '../../../../../../../utils/i18n';
import { categoryValidator, contextItemsValidator } from '../utils';
import GlDuoChatContextItemMenuSearchItemsLoading from './duo_chat_context_item_menu_search_items_loading.vue';
import GlDuoChatContextItemMenuSearchItem from './duo_chat_context_item_menu_search_item.vue';
export default {
name: 'GlDuoChatContextItemMenuSearchItems',
components: {
GlAlert,
GlDropdownItem,
GlDuoChatContextItemMenuSearchItem,
GlDuoChatContextItemMenuSearchItemsLoading,
GlFormInput,
},
model: {
prop: 'searchQuery',
event: 'update:searchQuery',
},
props: {
activeIndex: {
type: Number,
required: true,
},
searchQuery: {
type: String,
required: true,
},
category: {
type: Object,
required: true,
validator: categoryValidator,
},
loading: {
type: Boolean,
required: true,
},
error: {
type: [String, null],
required: false,
default: null,
},
results: {
type: Array,
required: true,
validator: contextItemsValidator,
},
},
data() {
return {
userInitiatedSearch: false,
numLoadingItems: 3,
};
},
computed: {
showEmptyState() {
return Boolean(
this.userInitiatedSearch && !this.loading && !this.error && !this.results.length
);
},
searchInputPlaceholder() {
return sprintf(
translate('GlDuoChatContextItemMenu.searchInputPlaceholder', 'Search %{categoryLabel}...'),
{
categoryLabel: this.category.label.toLowerCase(),
}
);
},
},
watch: {
searchQuery() {
this.userInitiatedSearch = true;
},
results(results) {
this.numLoadingItems = Math.max(1, results.length);
},
},
methods: {
selectItem(contextItem) {
this.$emit('select', contextItem);
this.userInitiatedSearch = false;
},
handleKeyUp(e) {
this.$emit('keyup', e);
},
setActiveIndex(index) {
if (this.results[index]?.metadata.enabled) {
this.$emit('active-index-change', index);
}
},
isActiveItem(contextItem, index) {
return index === this.activeIndex && contextItem.metadata.enabled;
},
},
i18n: {
emptyStateMessage: translate('GlDuoChatContextItemMenu.emptyStateMessage', 'No results found'),
},
};
</script>
<template>
<div>
<div class="gl-max-h-31 gl-overflow-y-scroll">
<gl-duo-chat-context-item-menu-search-items-loading v-if="loading" :rows="numLoadingItems" />
<gl-alert
v-else-if="error"
variant="danger"
:dismissible="false"
class="gl-m-3"
data-testid="search-results-error"
>
{{ error }}
</gl-alert>
<div
v-else-if="showEmptyState"
class="gl-rounded-base gl-p-3 gl-text-center gl-text-secondary"
data-testid="search-results-empty-state"
>
{{ $options.i18n.emptyStateMessage }}
</div>
<ul v-else class="gl-mb-1 gl-list-none gl-flex-row gl-pl-0">
<gl-dropdown-item
v-for="(contextItem, index) in results"
:id="`dropdown-item-${index}`"
:key="contextItem.id"
:class="{
'active-command': isActiveItem(contextItem, index),
'gl-cursor-not-allowed [&>button]:focus-within:!gl-shadow-none':
!contextItem.metadata.enabled,
}"
:tabindex="!contextItem.metadata.enabled ? -1 : undefined"
class="duo-chat-context-search-result-item"
data-testid="search-result-item"
@click="selectItem(contextItem)"
>
<div @mouseenter="setActiveIndex(index)">
<gl-duo-chat-context-item-menu-search-item
:context-item="contextItem"
:category="category"
:class="{ 'gl-text-secondary': !contextItem.metadata.enabled }"
data-testid="search-result-item-details"
/>
</div>
</gl-dropdown-item>
</ul>
</div>
<gl-form-input
ref="contextMenuSearchInput"
:value="searchQuery"
:placeholder="searchInputPlaceholder"
autofocus
data-testid="context-menu-search-input"
@input="$emit('update:searchQuery', $event)"
@keyup="handleKeyUp"
/>
</div>
</template>
/**
* This component has been migrated to the Duo-UI library (https://gitlab.com/gitlab-org/duo-ui).
*
* Please use the corresponding component in Duo-UI going forward.
* All future development and maintenance for Duo components should take place in Duo-UI.
*
* For more details, see the migration epic: https://gitlab.com/groups/gitlab-org/-/epics/15344 or reach out to the Duo-Chat team in #g_duo_chat.
*/
import { shallowMount } from '@vue/test-utils';
import GlDuoChatContextItemMenuSearchItemsLoading from './duo_chat_context_item_menu_search_items_loading.vue';
describe('GlDuoChatContextItemMenuSearchItemsLoading', () => {
let wrapper;
const createWrapper = (props = {}) => {
wrapper = shallowMount(GlDuoChatContextItemMenuSearchItemsLoading, {
propsData: {
rows: 3,
...props,
},
});
};
const findAllByTestId = (testId) => wrapper.findAll(`[data-testid="${testId}"]`);
const findLoadingRows = () => findAllByTestId('search-results-loading');
it('should render the accessible loading text', () => {
createWrapper();
expect(wrapper.text()).toContain('Loading...');
});
it.each([1, 2, 3, 4, 5])('renders %s rows', (rows) => {
createWrapper({ rows });
expect(findLoadingRows()).toHaveLength(rows);
});
});