@gitlab/ui 66.32.1 → 66.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/components/experimental/duo/chat/constants.js +2 -1
- package/dist/components/experimental/duo/chat/duo_chat.js +255 -0
- package/dist/components/experimental/duo/chat/mock_data.js +16 -1
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.js +1 -0
- package/dist/tokens/css/tokens.css +1 -1
- package/dist/tokens/css/tokens.dark.css +1 -1
- package/dist/tokens/js/tokens.dark.js +1 -1
- package/dist/tokens/js/tokens.js +1 -1
- package/dist/tokens/scss/_tokens.dark.scss +1 -1
- package/dist/tokens/scss/_tokens.scss +1 -1
- package/dist/utility_classes.css +1 -1
- package/dist/utility_classes.css.map +1 -1
- package/package.json +1 -1
- package/src/components/experimental/duo/chat/constants.js +2 -0
- package/src/components/experimental/duo/chat/duo_chat.md +143 -0
- package/src/components/experimental/duo/chat/duo_chat.scss +50 -0
- package/src/components/experimental/duo/chat/duo_chat.spec.js +391 -0
- package/src/components/experimental/duo/chat/duo_chat.stories.js +216 -0
- package/src/components/experimental/duo/chat/duo_chat.vue +372 -0
- package/src/components/experimental/duo/chat/mock_data.js +19 -0
- package/src/index.js +1 -0
- package/src/scss/components.scss +1 -0
- package/src/scss/utilities.scss +8 -0
- package/src/scss/utility-mixins/z-index.scss +4 -0
package/package.json
CHANGED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
The component represents the complete Duo Chat feature.
|
|
2
|
+
|
|
3
|
+
The component provides a configurable chat UI interface. The primary use is communication with
|
|
4
|
+
GitLab Duo, however, the component is BE-agnostic and can accept information from any source.
|
|
5
|
+
|
|
6
|
+
## Usage
|
|
7
|
+
|
|
8
|
+
To use the component in its simplest form, import it and add it to the `template` part of your
|
|
9
|
+
consumer component.
|
|
10
|
+
|
|
11
|
+
```html
|
|
12
|
+
<gl-duo-chat
|
|
13
|
+
:title="title"
|
|
14
|
+
:messages="messages"
|
|
15
|
+
:error="error"
|
|
16
|
+
:is-loading="isLoading"
|
|
17
|
+
:is-chat-available="isChatAvailable"
|
|
18
|
+
:predefined-prompts="predefinedPrompts"
|
|
19
|
+
:experiment-help-page-url="experimentHelpPageUrl"
|
|
20
|
+
:tool-name="toolName"
|
|
21
|
+
@chat-hidden="onChatHidden"
|
|
22
|
+
@send-chat-prompt="onSendChatPrompt"
|
|
23
|
+
@track-feedback="onTrackFeedback"
|
|
24
|
+
/>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Integration
|
|
28
|
+
|
|
29
|
+
To demonstrate how to connect this component to a backend implementation, let's consider its use
|
|
30
|
+
for GitLab Duo. First, some general notes on the best practices and expectations when using this
|
|
31
|
+
component.
|
|
32
|
+
|
|
33
|
+
### Expected dependency injection
|
|
34
|
+
|
|
35
|
+
To be universal, the component delegates some of its responsibilities to the consumer component.
|
|
36
|
+
|
|
37
|
+
The component expects two function props:
|
|
38
|
+
|
|
39
|
+
- `renderMarkdown`
|
|
40
|
+
- `renderGFM`
|
|
41
|
+
|
|
42
|
+
#### `renderMarkdown`
|
|
43
|
+
|
|
44
|
+
This function prop converts plain Markdown text into HTML markup. To have a better understanding
|
|
45
|
+
of what is expected from this function, take a look at
|
|
46
|
+
[the existing GitLab example](https://gitlab.com/gitlab-org/gitlab/-/blob/774ecc1f2b15a581e8eab6441de33585c9691c82/app/assets/javascripts/notes/utils.js#L22-24).
|
|
47
|
+
|
|
48
|
+
#### `renderGFM`
|
|
49
|
+
|
|
50
|
+
This function prop extends the standard Markdown rendering with support for the
|
|
51
|
+
[GitLab Flavored Markdown (GLFM)](https://docs.gitlab.com/ee/user/markdown.html). To
|
|
52
|
+
have a better understanding of what is expected from this function, take a look at
|
|
53
|
+
[the existing GitLab example](https://gitlab.com/gitlab-org/gitlab/-/blob/774ecc1f2b15a581e8eab6441de33585c9691c82/app/assets/javascripts/behaviors/markdown/render_gfm.js#L18-40).
|
|
54
|
+
|
|
55
|
+
The reason to have two different functions for rendering Markdown is performance. `renderGFM`
|
|
56
|
+
operates on a DOM node and might come with many additional mutations for the node's content.
|
|
57
|
+
Such behavior suits a one-time update. However, Duo Chat also supports streaming of the AI
|
|
58
|
+
responses (check the [Interactive story for this component](?path=/story/experimental-duo-chat-duo-chat--interactive))
|
|
59
|
+
and, in this case, when the message is constantly updating, we rely on a more lightweight
|
|
60
|
+
`renderMarkdown` to render the updated message faster.
|
|
61
|
+
|
|
62
|
+
### Don't use reactivity where unnecessary
|
|
63
|
+
|
|
64
|
+
The `GlDuoChat` component exposes many properties, as seen below. But not all of those should
|
|
65
|
+
be necessarily reactive in the consumer component. The properties that might be static:
|
|
66
|
+
|
|
67
|
+
- `title`. The title is shown in the head of the component.
|
|
68
|
+
- `isChatAvailable`. The flag indicates whether the communication interface should allow follow-up
|
|
69
|
+
questions. Usually, this decision stays the same during the component's lifecycle.
|
|
70
|
+
- `predefinedPrompts`. The `Array` of strings that represents the possible questions to ask when
|
|
71
|
+
there are no messages in the chat.
|
|
72
|
+
- `experimentHelpPageUrl`. The link to an external page explaining the meaning of an "experiment".
|
|
73
|
+
The prop is passed down to the [`GlExperimentBadge` component](?path=/docs/experimental-experiment-badge--docs).
|
|
74
|
+
|
|
75
|
+
### Set up communication with consumer
|
|
76
|
+
|
|
77
|
+
```javascript
|
|
78
|
+
import { GlDuoChat } from '@gitlab/ui';
|
|
79
|
+
|
|
80
|
+
export default {
|
|
81
|
+
...
|
|
82
|
+
data() {
|
|
83
|
+
return {
|
|
84
|
+
messages: [],
|
|
85
|
+
error: null,
|
|
86
|
+
isLoading: false,
|
|
87
|
+
toolName: '',
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
provide: {
|
|
91
|
+
renderMarkdown: (content) => {
|
|
92
|
+
// implementation of the `renderMarkdown` functionality
|
|
93
|
+
},
|
|
94
|
+
renderGFM: (el) => {
|
|
95
|
+
// implementation of the `renderGFM` functionality
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
beforeCreate() {
|
|
99
|
+
// Here, we set up our non-reactive properties if we must change the default values
|
|
100
|
+
this.title = 'Foo Bar';
|
|
101
|
+
this.isChatAvailable = true; // this is just an example. `true` is the default value
|
|
102
|
+
this.predefinedPrompts = ['How to …?', 'Where do I …?'];
|
|
103
|
+
this.experimentHelpPageUrl = 'https://dev.null';
|
|
104
|
+
}
|
|
105
|
+
methods: {
|
|
106
|
+
onChatHidden() {
|
|
107
|
+
...
|
|
108
|
+
},
|
|
109
|
+
onSendChatPrompt(prompt = '') {
|
|
110
|
+
this.isLoading = true;
|
|
111
|
+
this.messages.push(constructUserMessage(prompt));
|
|
112
|
+
...
|
|
113
|
+
},
|
|
114
|
+
onTrackFeedback(feedbackObj = {}) {
|
|
115
|
+
...
|
|
116
|
+
},
|
|
117
|
+
onAiResponse(data) {
|
|
118
|
+
this.messages = data
|
|
119
|
+
…
|
|
120
|
+
this.isLoading = false;
|
|
121
|
+
},
|
|
122
|
+
onAiResponseError(error) {
|
|
123
|
+
this.error = error;
|
|
124
|
+
this.isLoading = false;
|
|
125
|
+
},
|
|
126
|
+
onToolNameChange(toolMessage) {
|
|
127
|
+
this.toolName = toolMessage.content;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
With this template in place, consumer is left with the following things to implement:
|
|
134
|
+
|
|
135
|
+
- Fetch `messages`. For Duo Chat, we rely on GraphQL query to get the cached
|
|
136
|
+
messages and subscription to monitor new messages when:
|
|
137
|
+
|
|
138
|
+
- streaming response
|
|
139
|
+
- listening to chat messages in other tabs/environments
|
|
140
|
+
- listen to updates from different tools to update `toolName`
|
|
141
|
+
|
|
142
|
+
- Send the new user's prompt. For Duo Chat, we rely on GraphQL mutation for this purpose.
|
|
143
|
+
- Send user feedback to the telemetry of your choice when `track-feedback` event arrives.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
.duo-chat {
|
|
2
|
+
z-index: 999;
|
|
3
|
+
|
|
4
|
+
.message-enter-active,
|
|
5
|
+
.message-leave-active {
|
|
6
|
+
transition: all 0.5s ease;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.message-enter,
|
|
10
|
+
.message-leave-to {
|
|
11
|
+
@include gl-opacity-0;
|
|
12
|
+
transform: translateY(10px);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
.loader-enter-active,
|
|
16
|
+
.loader-leave-active {
|
|
17
|
+
transition: opacity 0.5s ease;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.loader-enter,
|
|
21
|
+
.loader-leave-to {
|
|
22
|
+
@include gl-opacity-0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.loader-leave-active {
|
|
26
|
+
@include gl-absolute;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.gl-drawer-body-scrim-on-footer {
|
|
30
|
+
&::before {
|
|
31
|
+
background: linear-gradient(to bottom, rgba($gray-10, 0), $gray-10);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.duo-chat-input {
|
|
37
|
+
@include gl-display-flex;
|
|
38
|
+
@include gl-flex-direction-column;
|
|
39
|
+
|
|
40
|
+
&:focus-within {
|
|
41
|
+
@include gl-focus($color: $gray-900);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.gl-form-textarea.form-control {
|
|
45
|
+
flex: 1;
|
|
46
|
+
resize: none;
|
|
47
|
+
max-height: 240px;
|
|
48
|
+
width: calc(100% - 40px);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { nextTick } from 'vue';
|
|
2
|
+
import { shallowMount } from '@vue/test-utils';
|
|
3
|
+
import GlEmptyState from '../../../regions/empty_state/empty_state.vue';
|
|
4
|
+
import GlExperimentBadge from '../../experiment_badge/experiment_badge.vue';
|
|
5
|
+
import DuoChatLoader from './components/duo_chat_loader/duo_chat_loader.vue';
|
|
6
|
+
import DuoChatPredefinedPrompts from './components/duo_chat_predefined_prompts/duo_chat_predefined_prompts.vue';
|
|
7
|
+
import DuoChatConversation from './components/duo_chat_conversation/duo_chat_conversation.vue';
|
|
8
|
+
import GlDuoChat from './duo_chat.vue';
|
|
9
|
+
|
|
10
|
+
import { MESSAGE_MODEL_ROLES, CHAT_RESET_MESSAGE } from './constants';
|
|
11
|
+
|
|
12
|
+
describe('GlDuoChat', () => {
|
|
13
|
+
let wrapper;
|
|
14
|
+
|
|
15
|
+
const createComponent = ({ propsData = {}, data = {}, slots = {} } = {}) => {
|
|
16
|
+
jest.spyOn(DuoChatLoader.methods, 'computeTransitionWidth').mockImplementation();
|
|
17
|
+
|
|
18
|
+
wrapper = shallowMount(GlDuoChat, {
|
|
19
|
+
propsData,
|
|
20
|
+
data() {
|
|
21
|
+
return {
|
|
22
|
+
...data,
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
slots,
|
|
26
|
+
stubs: {
|
|
27
|
+
DuoChatLoader,
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const findChatComponent = () => wrapper.find('[data-testid="chat-component"]');
|
|
33
|
+
const findCloseButton = () => wrapper.find('[data-testid="chat-close-button"]');
|
|
34
|
+
const findChatConversations = () => wrapper.findAllComponents(DuoChatConversation);
|
|
35
|
+
const findCustomLoader = () => wrapper.findComponent(DuoChatLoader);
|
|
36
|
+
const findError = () => wrapper.find('[data-testid="chat-error"]');
|
|
37
|
+
const findFooter = () => wrapper.find('[data-testid="chat-footer"]');
|
|
38
|
+
const findPromptForm = () => wrapper.find('[data-testid="chat-prompt-form"]');
|
|
39
|
+
const findGeneratedByAI = () => wrapper.find('[data-testid="chat-legal-warning"]');
|
|
40
|
+
const findBadge = () => wrapper.findComponent(GlExperimentBadge);
|
|
41
|
+
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
|
|
42
|
+
const findPredefined = () => wrapper.findComponent(DuoChatPredefinedPrompts);
|
|
43
|
+
const findChatInput = () => wrapper.find('[data-testid="chat-prompt-input"]');
|
|
44
|
+
const findCloseChatButton = () => wrapper.find('[data-testid="chat-close-button"]');
|
|
45
|
+
const findLegalDisclaimer = () => wrapper.find('[data-testid="chat-legal-disclaimer"]');
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
createComponent();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const promptStr = 'foo';
|
|
52
|
+
const messages = [
|
|
53
|
+
{
|
|
54
|
+
role: MESSAGE_MODEL_ROLES.user,
|
|
55
|
+
content: promptStr,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
describe('rendering', () => {
|
|
60
|
+
it('does not fail if no messages are passed', () => {
|
|
61
|
+
createComponent({
|
|
62
|
+
propsData: { messages: null },
|
|
63
|
+
});
|
|
64
|
+
expect(findChatConversations()).toHaveLength(0);
|
|
65
|
+
expect(findEmptyState().exists()).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each`
|
|
69
|
+
desc | component | shouldRender
|
|
70
|
+
${'renders root component'} | ${findChatComponent} | ${true}
|
|
71
|
+
${'renders experimental label'} | ${findBadge} | ${true}
|
|
72
|
+
${'renders empty state'} | ${findEmptyState} | ${true}
|
|
73
|
+
${'renders predefined prompts'} | ${findPredefined} | ${true}
|
|
74
|
+
${'does not render loading skeleton'} | ${findCustomLoader} | ${false}
|
|
75
|
+
${'does not render chat error'} | ${findError} | ${false}
|
|
76
|
+
${'does render chat input'} | ${findChatInput} | ${true}
|
|
77
|
+
${'renders a generated by AI note'} | ${findGeneratedByAI} | ${true}
|
|
78
|
+
`('$desc', ({ component, shouldRender }) => {
|
|
79
|
+
expect(component().exists()).toBe(shouldRender);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('when messages exist', () => {
|
|
83
|
+
it('scrolls to the bottom on load', async () => {
|
|
84
|
+
createComponent({ propsData: { messages } });
|
|
85
|
+
const { element } = findChatComponent();
|
|
86
|
+
jest.spyOn(element, 'scrollHeight', 'get').mockReturnValue(200);
|
|
87
|
+
|
|
88
|
+
await nextTick();
|
|
89
|
+
|
|
90
|
+
expect(element.scrollTop).toEqual(200);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('conversations', () => {
|
|
95
|
+
it('renders one conversation when no reset message is present', () => {
|
|
96
|
+
const newMessages = [
|
|
97
|
+
{
|
|
98
|
+
role: MESSAGE_MODEL_ROLES.user,
|
|
99
|
+
content: 'How are you?',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
role: MESSAGE_MODEL_ROLES.assistant,
|
|
103
|
+
content: 'Great!',
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
createComponent({ propsData: { messages: newMessages } });
|
|
107
|
+
|
|
108
|
+
expect(findChatConversations().length).toEqual(1);
|
|
109
|
+
expect(findChatConversations().at(0).props('showDelimiter')).toEqual(false);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('does not render conversations when no message is present', () => {
|
|
113
|
+
createComponent({ propsData: { messages: [] } });
|
|
114
|
+
|
|
115
|
+
expect(findChatConversations().length).toEqual(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('splits it up into multiple conversations when reset message is present', () => {
|
|
119
|
+
const newMessages = [
|
|
120
|
+
{
|
|
121
|
+
role: MESSAGE_MODEL_ROLES.user,
|
|
122
|
+
content: 'Message 1',
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
role: MESSAGE_MODEL_ROLES.assistant,
|
|
126
|
+
content: 'Great!',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
role: MESSAGE_MODEL_ROLES.user,
|
|
130
|
+
content: CHAT_RESET_MESSAGE,
|
|
131
|
+
},
|
|
132
|
+
];
|
|
133
|
+
createComponent({ propsData: { messages: newMessages } });
|
|
134
|
+
|
|
135
|
+
expect(findChatConversations().length).toEqual(2);
|
|
136
|
+
expect(findChatConversations().at(0).props('showDelimiter')).toEqual(false);
|
|
137
|
+
expect(findChatConversations().at(1).props('showDelimiter')).toEqual(true);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe('slots', () => {
|
|
142
|
+
const slotContent = 'As Gregor Samsa awoke one morning from uneasy dreams';
|
|
143
|
+
|
|
144
|
+
it.each`
|
|
145
|
+
slot | content | isChatAvailable
|
|
146
|
+
${'hero'} | ${slotContent} | ${true}
|
|
147
|
+
${'hero'} | ${slotContent} | ${false}
|
|
148
|
+
${'subheader'} | ${slotContent} | ${false}
|
|
149
|
+
${'subheader'} | ${slotContent} | ${true}
|
|
150
|
+
`(
|
|
151
|
+
'renders the $content passed to the $slot slot when isChatAvailable is $isChatAvailable',
|
|
152
|
+
({ slot, content, isChatAvailable }) => {
|
|
153
|
+
createComponent({
|
|
154
|
+
propsData: { isChatAvailable },
|
|
155
|
+
slots: { [slot]: content },
|
|
156
|
+
});
|
|
157
|
+
expect(wrapper.text()).toContain(content);
|
|
158
|
+
}
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('sets correct props on the Experiment badge', () => {
|
|
163
|
+
const experimentHelpPageUrl = 'https://foo.bar';
|
|
164
|
+
const containerId = 'chat-component';
|
|
165
|
+
createComponent({ propsData: { experimentHelpPageUrl } });
|
|
166
|
+
expect(findBadge().props('experimentHelpPageUrl')).toBe(experimentHelpPageUrl);
|
|
167
|
+
expect(findBadge().attributes('container-id')).toBe(containerId);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('chat', () => {
|
|
172
|
+
const clickSubmit = () =>
|
|
173
|
+
findPromptForm().vm.$emit('submit', {
|
|
174
|
+
preventDefault: jest.fn(),
|
|
175
|
+
stopPropagation: jest.fn(),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('does render the prompt input by default', () => {
|
|
179
|
+
createComponent({ propsData: { messages } });
|
|
180
|
+
expect(findChatInput().exists()).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('does not render the prompt input if `isChatAvailable` prop is `false`', () => {
|
|
184
|
+
createComponent({ propsData: { messages, isChatAvailable: false } });
|
|
185
|
+
expect(findChatInput().exists()).toBe(false);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('renders the legal disclaimer if `isChatAvailable` prop is `true', () => {
|
|
189
|
+
createComponent({ propsData: { messages, isChatAvailable: true } });
|
|
190
|
+
expect(findLegalDisclaimer().exists()).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('submit', () => {
|
|
194
|
+
const ENTER = 'Enter';
|
|
195
|
+
|
|
196
|
+
it.each`
|
|
197
|
+
trigger | event | action | expectEmitted
|
|
198
|
+
${() => clickSubmit()} | ${'Submit button click'} | ${'submit'} | ${[[promptStr]]}
|
|
199
|
+
${() => findChatInput().trigger('keydown.enter', { code: ENTER })} | ${`Clicking ${ENTER}`} | ${'submit'} | ${[[promptStr]]}
|
|
200
|
+
${() => findChatInput().trigger('keydown.enter', { code: ENTER, metaKey: true })} | ${`Clicking ${ENTER} + ⌘`} | ${'not submit'} | ${undefined}
|
|
201
|
+
${() => findChatInput().trigger('keydown.enter', { code: ENTER, altKey: true })} | ${`Clicking ${ENTER} + ⎇`} | ${'not submit'} | ${undefined}
|
|
202
|
+
${() => findChatInput().trigger('keydown.enter', { code: ENTER, shiftKey: true })} | ${`Clicking ${ENTER} + ⬆︎`} | ${'not submit'} | ${undefined}
|
|
203
|
+
${() => findChatInput().trigger('keydown.enter', { code: ENTER, ctrlKey: true })} | ${`Clicking ${ENTER} + CTRL`} | ${'not submit'} | ${undefined}
|
|
204
|
+
`('$event should $action the prompt form', ({ trigger, expectEmitted } = {}) => {
|
|
205
|
+
createComponent({
|
|
206
|
+
propsData: { messages: [], isChatAvailable: true },
|
|
207
|
+
data: { prompt: promptStr },
|
|
208
|
+
});
|
|
209
|
+
trigger();
|
|
210
|
+
if (expectEmitted) {
|
|
211
|
+
expect(wrapper.emitted('send-chat-prompt')).toEqual(expectEmitted);
|
|
212
|
+
} else {
|
|
213
|
+
expect(wrapper.emitted('send-chat-prompt')).toBe(undefined);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('reset', () => {
|
|
219
|
+
it('emits the event with the reset prompt', () => {
|
|
220
|
+
createComponent({
|
|
221
|
+
propsData: { messages, isChatAvailable: true },
|
|
222
|
+
data: { prompt: CHAT_RESET_MESSAGE },
|
|
223
|
+
});
|
|
224
|
+
clickSubmit();
|
|
225
|
+
|
|
226
|
+
expect(wrapper.emitted('send-chat-prompt')).toEqual([[CHAT_RESET_MESSAGE]]);
|
|
227
|
+
expect(findChatConversations().length).toEqual(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('reset does nothing when chat is loading', () => {
|
|
231
|
+
createComponent({
|
|
232
|
+
propsData: { messages, isChatAvailable: true, isLoading: true },
|
|
233
|
+
data: { prompt: CHAT_RESET_MESSAGE },
|
|
234
|
+
});
|
|
235
|
+
clickSubmit();
|
|
236
|
+
|
|
237
|
+
expect(wrapper.emitted('send-chat-prompt')).toBeUndefined();
|
|
238
|
+
expect(findChatConversations().length).toEqual(1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('reset does nothing when there are no messages', () => {
|
|
242
|
+
createComponent({
|
|
243
|
+
propsData: { messages: [], isChatAvailable: true },
|
|
244
|
+
data: { prompt: CHAT_RESET_MESSAGE },
|
|
245
|
+
});
|
|
246
|
+
clickSubmit();
|
|
247
|
+
|
|
248
|
+
expect(wrapper.emitted('send-chat-prompt')).toBeUndefined();
|
|
249
|
+
expect(findChatConversations().length).toEqual(0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('reset does nothing when last message was a reset message', () => {
|
|
253
|
+
const existingMessages = [
|
|
254
|
+
...messages,
|
|
255
|
+
{
|
|
256
|
+
role: MESSAGE_MODEL_ROLES.user,
|
|
257
|
+
content: CHAT_RESET_MESSAGE,
|
|
258
|
+
},
|
|
259
|
+
];
|
|
260
|
+
createComponent({
|
|
261
|
+
propsData: {
|
|
262
|
+
isLoading: false,
|
|
263
|
+
messages: existingMessages,
|
|
264
|
+
isChatAvailable: true,
|
|
265
|
+
},
|
|
266
|
+
data: { prompt: CHAT_RESET_MESSAGE },
|
|
267
|
+
});
|
|
268
|
+
clickSubmit();
|
|
269
|
+
|
|
270
|
+
expect(wrapper.emitted('send-chat-prompt')).toBeUndefined();
|
|
271
|
+
|
|
272
|
+
expect(findChatConversations().length).toEqual(2);
|
|
273
|
+
expect(findChatConversations().at(0).props('messages')).toEqual(messages);
|
|
274
|
+
expect(findChatConversations().at(1).props('messages')).toEqual([]);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('interaction', () => {
|
|
280
|
+
it('is hidden after the header button is clicked', async () => {
|
|
281
|
+
findCloseButton().vm.$emit('click');
|
|
282
|
+
await nextTick();
|
|
283
|
+
expect(findChatComponent().exists()).toBe(false);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('resets the hidden status of the component on loading', async () => {
|
|
287
|
+
createComponent({ data: { isHidden: true } });
|
|
288
|
+
expect(findChatComponent().exists()).toBe(false);
|
|
289
|
+
// setProps is justified here because we are testing the component's
|
|
290
|
+
// reactive behavior which consistutes an exception
|
|
291
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
292
|
+
wrapper.setProps({
|
|
293
|
+
isLoading: true,
|
|
294
|
+
});
|
|
295
|
+
await nextTick();
|
|
296
|
+
expect(findChatComponent().exists()).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('resets the prompt when new messages are added', async () => {
|
|
300
|
+
const prompt = 'foo';
|
|
301
|
+
createComponent({ data: { prompt } });
|
|
302
|
+
expect(findChatInput().props('value')).toBe(prompt);
|
|
303
|
+
// setProps is justified here because we are testing the component's
|
|
304
|
+
// reactive behavior which consistutes an exception
|
|
305
|
+
// See https://docs.gitlab.com/ee/development/fe_guide/style/vue.html#setting-component-state
|
|
306
|
+
wrapper.setProps({
|
|
307
|
+
messages,
|
|
308
|
+
});
|
|
309
|
+
await nextTick();
|
|
310
|
+
expect(findChatInput().props('value')).toBe('');
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('renders custom loader when isLoading', () => {
|
|
314
|
+
createComponent({ propsData: { isLoading: true } });
|
|
315
|
+
expect(findCustomLoader().exists()).toBe(true);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('renders alert if error', () => {
|
|
319
|
+
const errorMessage = 'Something went Wrong';
|
|
320
|
+
createComponent({ propsData: { error: errorMessage } });
|
|
321
|
+
expect(findError().text()).toBe(errorMessage);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('hides the chat on button click and emits an event', async () => {
|
|
325
|
+
createComponent({ propsData: { messages } });
|
|
326
|
+
expect(findChatComponent().exists()).toBe(true);
|
|
327
|
+
findCloseChatButton().vm.$emit('click');
|
|
328
|
+
await nextTick();
|
|
329
|
+
expect(findChatComponent().exists()).toBe(false);
|
|
330
|
+
expect(wrapper.emitted('chat-hidden')).toBeDefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('does not render the empty state when there are messages available', () => {
|
|
334
|
+
createComponent({ propsData: { messages } });
|
|
335
|
+
expect(findEmptyState().exists()).toBe(false);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('scrolling', () => {
|
|
339
|
+
let element;
|
|
340
|
+
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
createComponent({ propsData: { messages, isChatAvailable: true } });
|
|
343
|
+
element = findChatComponent().element;
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('when scrolling to the bottom it removes the scrim class', async () => {
|
|
347
|
+
jest.spyOn(element, 'scrollTop', 'get').mockReturnValue(100);
|
|
348
|
+
jest.spyOn(element, 'offsetHeight', 'get').mockReturnValue(100);
|
|
349
|
+
jest.spyOn(element, 'scrollHeight', 'get').mockReturnValue(200);
|
|
350
|
+
|
|
351
|
+
findChatComponent().trigger('scroll');
|
|
352
|
+
|
|
353
|
+
await nextTick();
|
|
354
|
+
|
|
355
|
+
expect(findFooter().classes()).not.toContain('gl-drawer-body-scrim-on-footer');
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('when scrolling up it adds the scrim class', async () => {
|
|
359
|
+
jest.spyOn(element, 'scrollTop', 'get').mockReturnValue(50);
|
|
360
|
+
jest.spyOn(element, 'offsetHeight', 'get').mockReturnValue(100);
|
|
361
|
+
jest.spyOn(element, 'scrollHeight', 'get').mockReturnValue(200);
|
|
362
|
+
|
|
363
|
+
findChatComponent().trigger('scroll');
|
|
364
|
+
|
|
365
|
+
await nextTick();
|
|
366
|
+
|
|
367
|
+
expect(findFooter().classes()).toContain('gl-drawer-body-scrim-on-footer');
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('predefined prompts', () => {
|
|
372
|
+
const prompts = ['what is a fork'];
|
|
373
|
+
|
|
374
|
+
beforeEach(() => {
|
|
375
|
+
createComponent({ propsData: { predefinedPrompts: prompts } });
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it('passes on predefined prompts', () => {
|
|
379
|
+
expect(findPredefined().props().prompts).toEqual(prompts);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('listens to the click event and sends the predefined prompt', async () => {
|
|
383
|
+
findPredefined().vm.$emit('click', prompts[0]);
|
|
384
|
+
|
|
385
|
+
await nextTick();
|
|
386
|
+
|
|
387
|
+
expect(wrapper.emitted('send-chat-prompt')).toEqual([[prompts[0]]]);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|