diff --git a/app/assets/javascripts/merge_requests/generated_content.js b/app/assets/javascripts/merge_requests/generated_content.js
new file mode 100644
index 0000000000000000000000000000000000000000..0184801ce8073c9b0d7cecb2b56d4baa2cdd63cd
--- /dev/null
+++ b/app/assets/javascripts/merge_requests/generated_content.js
@@ -0,0 +1,64 @@
+export class MergeRequestGeneratedContent {
+ constructor({ editor } = {}) {
+ this.warningElement = document.querySelector('.js-ai-description-warning');
+ this.markdownEditor = editor;
+ this.generatedContent = null;
+
+ this.connectToDOM();
+ }
+
+ get hasEditor() {
+ return Boolean(this.markdownEditor);
+ }
+ get hasWarning() {
+ return Boolean(this.warningElement);
+ }
+ get canReplaceContent() {
+ return this.hasEditor && Boolean(this.generatedContent);
+ }
+
+ connectToDOM() {
+ let close;
+ let cancel;
+ let approve;
+
+ if (this.hasWarning) {
+ approve = this.warningElement.querySelector('.js-ai-override-description');
+ cancel = this.warningElement.querySelector('.js-cancel-btn');
+ close = this.warningElement.querySelector('.js-close-btn');
+
+ approve.addEventListener('click', () => {
+ this.replaceDescription();
+ this.hideWarning();
+ });
+
+ cancel.addEventListener('click', () => this.hideWarning());
+ close.addEventListener('click', () => this.hideWarning());
+ }
+ }
+
+ setEditor(markdownEditor) {
+ this.markdownEditor = markdownEditor;
+ }
+ setGeneratedContent(newContent) {
+ this.generatedContent = newContent;
+ }
+ clearGeneratedContent() {
+ this.generatedContent = null;
+ }
+
+ showWarning() {
+ if (this.canReplaceContent) {
+ this.warningElement?.classList.remove('hidden');
+ }
+ }
+ hideWarning() {
+ this.warningElement?.classList.add('hidden');
+ }
+ replaceDescription() {
+ if (this.canReplaceContent) {
+ this.markdownEditor.setValue(this.generatedContent);
+ this.clearGeneratedContent();
+ }
+ }
+}
diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue
index 0c6a9c2fa98565ebd188b360a4c769710e0ad63e..64aa5742e977eef545d6effb21d897fe9051d8b6 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue
@@ -39,6 +39,7 @@ export default {
default: null,
},
editorAiActions: { default: () => [] },
+ mrGeneratedContent: { default: null },
},
props: {
previewMarkdown: {
@@ -208,19 +209,13 @@ export default {
replaceTextarea(text) {
const { description, descriptionForSha } = this.$options.i18n;
const headSha = document.getElementById('merge_request_diff_head_sha').value;
- const textArea = this.$el.closest('.md-area')?.querySelector('textarea');
const addendum = headSha
? sprintf(descriptionForSha, { revision: truncateSha(headSha) })
: description;
- if (textArea) {
- textArea.value = '';
- updateText({
- textArea,
- tag: `${text}\n\n---\n\n_${addendum}_`,
- cursorOffset: 0,
- wrap: false,
- });
+ if (this.mrGeneratedContent) {
+ this.mrGeneratedContent.setGeneratedContent(`${text}\n\n---\n\n_${addendum}_`);
+ this.mrGeneratedContent.showWarning();
}
},
switchPreview() {
diff --git a/app/views/shared/form_elements/_apply_generated_description_warning.haml b/app/views/shared/form_elements/_apply_generated_description_warning.haml
new file mode 100644
index 0000000000000000000000000000000000000000..b695ae4f29298579f34f30ac9a7dd05a060dea23
--- /dev/null
+++ b/app/views/shared/form_elements/_apply_generated_description_warning.haml
@@ -0,0 +1,13 @@
+.form-group.row.js-ai-description-warning.hidden.js-issuable-ai-description-warning
+ .col-sm-12
+ .warning_message.mb-0{ role: 'alert' }
+ %btn.js-close-btn.js-dismiss-btn.close{ type: "button", "aria-hidden": "true", "aria-label": _("Close") }
+ = sprite_icon("close")
+
+ %p
+ = _("AI|Replace the existing description with an AI-generated description? Any changes you have made will be lost.")
+
+ = render Pajamas::ButtonComponent.new(variant: :confirm, button_options: { class: 'gl-mr-3 js-ai-override-description' }) do
+ = _("AI|Apply AI-generated description")
+ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-cancel-btn' }) do
+ = _("Cancel")
diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml
index 415849672b68f4a5ebb4d42f10021fa7c6fa626d..62da7d994fd60da4a95fb704abe9bcf9d11d9495 100644
--- a/app/views/shared/form_elements/_description.html.haml
+++ b/app/views/shared/form_elements/_description.html.haml
@@ -15,6 +15,8 @@
= render 'shared/issuable/form/template_selector', issuable: model
= render 'shared/form_elements/apply_template_warning', issuable: model
+ - if model.is_a?(MergeRequest)
+ = render 'shared/form_elements/apply_generated_description_warning', issuable: model
.js-markdown-editor{ data: { render_markdown_path: preview_url,
markdown_docs_path: help_page_path('user/markdown'),
diff --git a/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js b/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
index a045cfbead91177e72ed33f66bfe0a37dfe4fda0..3d6d44ec8ec10b8fba058d2cd31689628b6900d9 100644
--- a/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
+++ b/ee/app/assets/javascripts/vue_shared/components/markdown/mount_markdown_editor.js
@@ -4,11 +4,15 @@ import { convertToGraphQLId } from '~/graphql_shared/utils';
import { generateDescriptionAction } from 'ee/ai/editor_actions/generate_description';
import aiFillDescriptionMutation from 'ee/ai/graphql/fill_mr_description.mutation.graphql';
import { mountMarkdownEditor as mountCEMarkdownEditor } from '~/vue_shared/components/markdown/mount_markdown_editor';
+import { MergeRequestGeneratedContent } from '~/merge_requests/generated_content';
export function mountMarkdownEditor() {
const provideEEAiActions = [];
+ let mrGeneratedContent;
if (window.gon?.features?.fillInMrTemplate) {
+ mrGeneratedContent = new MergeRequestGeneratedContent();
+
provideEEAiActions.push({
title: __('Fill in merge request template'),
description: __('Replace current template with filled in placeholders'),
@@ -46,9 +50,15 @@ export function mountMarkdownEditor() {
provideEEAiActions.push(generateDescriptionAction());
}
- return mountCEMarkdownEditor({
+ const editor = mountCEMarkdownEditor({
+ useApollo: true,
provide: {
editorAiActions: provideEEAiActions,
+ mrGeneratedContent,
},
});
+
+ mrGeneratedContent?.setEditor(editor);
+
+ return editor;
}
diff --git a/ee/spec/frontend/vue_shared/components/markdown/header_spec.js b/ee/spec/frontend/vue_shared/components/markdown/header_spec.js
index abfff48881438750cc6b3a8b3a8c7c106c9dc2b0..8b6cbaa73d5f29583deadf5568950313406324ce 100644
--- a/ee/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/ee/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -1,8 +1,12 @@
import { GlTabs, GlDisclosureDropdown, GlListboxItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+
+import { MergeRequestGeneratedContent } from '~/merge_requests/generated_content';
import HeaderComponent from '~/vue_shared/components/markdown/header.vue';
import AiActionsDropdown from 'ee/ai/components/ai_actions_dropdown.vue';
-import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+
+jest.mock('~/merge_requests/generated_content');
describe('Markdown field header component', () => {
document.execCommand = jest.fn();
@@ -36,43 +40,44 @@ describe('Markdown field header component', () => {
},
);
- describe('generated text responses', () => {
+ describe('when AI features are enabled and a generated content class is provided to the component', () => {
const sha = 'abc123';
const addendum = `
---
_This description was generated for revision ${sha} using AI_`;
+ let gen;
beforeEach(() => {
+ gen = new MergeRequestGeneratedContent({ editor: {} });
+
setHTMLFixture(`
`);
- });
-
- afterEach(() => {
- resetHTMLFixture();
- });
-
- it('replaces the text content when the AI actions dropdown reports a `replace` event', () => {
- const text = document.querySelector('textarea');
-
- text.value = 'test';
createWrapper({
attachTo: '#root',
provide: {
editorAiActions: [{ value: 'myAction', title: 'myAction' }],
+ mrGeneratedContent: gen,
},
});
+ });
- expect(text.value).toBe('test');
+ afterEach(() => {
+ resetHTMLFixture();
+ });
- findAiActionsButton().vm.$emit('replace', 'other text');
+ describe('and the AI Actions Dropdown reports a `replace` event', () => {
+ it('calls the MergeRequestGeneratedContent instance with the correct value and shows the warning', () => {
+ findAiActionsButton().vm.$emit('replace', 'other text');
- expect(text.value).toBe(`other text${addendum}`);
+ expect(gen.setGeneratedContent).toHaveBeenCalledWith(`other text${addendum}`);
+ expect(gen.showWarning).toHaveBeenCalled();
+ });
});
});
});
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 858153947bd04edddd48e9c2f2733542cf52748e..bf5e50e4f8bad44bc0b8bb2cb9ec17b35ffb6663 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1916,6 +1916,9 @@ msgstr ""
msgid "AI|AI generated explanations will appear here."
msgstr ""
+msgid "AI|Apply AI-generated description"
+msgstr ""
+
msgid "AI|Ask a question"
msgstr ""
@@ -1973,6 +1976,9 @@ msgstr ""
msgid "AI|Populate issue description"
msgstr ""
+msgid "AI|Replace the existing description with an AI-generated description? Any changes you have made will be lost."
+msgstr ""
+
msgid "AI|Responses generated by AI"
msgstr ""
diff --git a/spec/frontend/merge_requests/generated_content_spec.js b/spec/frontend/merge_requests/generated_content_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..f56a67ec466920141fb9e3d2cc703340f3b4ee7a
--- /dev/null
+++ b/spec/frontend/merge_requests/generated_content_spec.js
@@ -0,0 +1,310 @@
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+
+import { MergeRequestGeneratedContent } from '~/merge_requests/generated_content';
+
+function findWarningElement() {
+ return document.querySelector('.js-ai-description-warning');
+}
+
+function findCloseButton() {
+ return findWarningElement()?.querySelector('.js-close-btn');
+}
+
+function findApprovalButton() {
+ return findWarningElement()?.querySelector('.js-ai-override-description');
+}
+
+function findCancelButton() {
+ return findWarningElement()?.querySelector('.js-cancel-btn');
+}
+
+function clickButton(button) {
+ button.dispatchEvent(new Event('click'));
+}
+
+describe('MergeRequestGeneratedContent', () => {
+ const warningDOM = `
+
+
+
+
+
+
+
+`;
+
+ describe('class basics', () => {
+ let gen;
+
+ beforeEach(() => {
+ gen = new MergeRequestGeneratedContent();
+ });
+
+ it.each`
+ description | property
+ ${'with no editor'} | ${'hasEditor'}
+ ${'with no warning'} | ${'hasWarning'}
+ ${'unable to replace the content'} | ${'canReplaceContent'}
+ `('begins $description', ({ property }) => {
+ expect(gen[property]).toBe(false);
+ });
+ });
+
+ describe('the internal editor representation', () => {
+ let gen;
+
+ it('accepts an editor during construction', () => {
+ gen = new MergeRequestGeneratedContent({ editor: {} });
+
+ expect(gen.hasEditor).toBe(true);
+ });
+
+ it('allows adding an editor through a public API after construction', () => {
+ gen = new MergeRequestGeneratedContent();
+
+ expect(gen.hasEditor).toBe(false);
+
+ gen.setEditor({});
+
+ expect(gen.hasEditor).toBe(true);
+ });
+ });
+
+ describe('generated content', () => {
+ let gen;
+
+ beforeEach(() => {
+ gen = new MergeRequestGeneratedContent();
+ });
+
+ it('can be provided to the instance through a public API', () => {
+ expect(gen.generatedContent).toBe(null);
+
+ gen.setGeneratedContent('generated content');
+
+ expect(gen.generatedContent).toBe('generated content');
+ });
+
+ it('can be cleared from the instance through a public API', () => {
+ gen.setGeneratedContent('generated content');
+
+ expect(gen.generatedContent).toBe('generated content');
+
+ gen.clearGeneratedContent();
+
+ expect(gen.generatedContent).toBe(null);
+ });
+ });
+
+ describe('warning element', () => {
+ let gen;
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ it.each`
+ presence | withFixture
+ ${'is'} | ${true}
+ ${'is not'} | ${false}
+ `('`.hasWarning` is $withFixture when the element $presence in the DOM', ({ withFixture }) => {
+ if (withFixture) {
+ setHTMLFixture(warningDOM);
+ }
+
+ gen = new MergeRequestGeneratedContent();
+
+ expect(gen.hasWarning).toBe(withFixture);
+ });
+ });
+
+ describe('special cases', () => {
+ it.each`
+ description | value | props
+ ${'there is no internal editor representation, and no generated content'} | ${false} | ${{}}
+ ${'there is an internal editor representation, but no generated content'} | ${false} | ${{ editor: {} }}
+ ${'there is no internal editor representation, but there is generated content'} | ${false} | ${{ content: 'generated content' }}
+ ${'there is an internal editor representation, and there is generated content'} | ${true} | ${{ editor: {}, content: 'generated content' }}
+ `('`.canReplaceContent` is $value when $description', ({ value, props }) => {
+ const gen = new MergeRequestGeneratedContent();
+
+ if (props.editor) {
+ gen.setEditor(props.editor);
+ }
+ if (props.content) {
+ gen.setGeneratedContent(props.content);
+ }
+
+ expect(gen.canReplaceContent).toBe(value);
+ });
+ });
+
+ describe('behaviors', () => {
+ describe('UI', () => {
+ describe('warning element', () => {
+ let gen;
+
+ beforeEach(() => {
+ setHTMLFixture(warningDOM);
+ gen = new MergeRequestGeneratedContent({ editor: {} });
+
+ gen.setGeneratedContent('generated content');
+ });
+
+ describe('#showWarning', () => {
+ it("shows the warning if it exists in the DOM and if it's possible to replace the description", () => {
+ gen.showWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(false);
+ });
+
+ it("does nothing if the warning doesn't exist or if it's not possible to replace the description", () => {
+ gen.setEditor(null);
+
+ gen.showWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(true);
+
+ gen.setEditor({});
+ gen.setGeneratedContent(null);
+
+ gen.showWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(true);
+
+ resetHTMLFixture();
+ gen = new MergeRequestGeneratedContent({ editor: {} });
+ gen.setGeneratedContent('generated content');
+
+ expect(() => gen.showWarning()).not.toThrow();
+ expect(findWarningElement()).toBe(null);
+ });
+ });
+
+ describe('#hideWarning', () => {
+ it('hides the warning', () => {
+ findWarningElement().classList.remove('hidden');
+
+ gen.hideWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(true);
+ });
+
+ it("does nothing if there's no warning element", () => {
+ resetHTMLFixture();
+ gen = new MergeRequestGeneratedContent();
+
+ expect(() => gen.hideWarning()).not.toThrow();
+ expect(findWarningElement()).toBe(null);
+ });
+ });
+ });
+ });
+
+ describe('content', () => {
+ const editor = {};
+ let gen;
+
+ beforeEach(() => {
+ editor.setValue = jest.fn();
+ gen = new MergeRequestGeneratedContent({ editor });
+ });
+
+ describe('#replaceDescription', () => {
+ it("sets the instance's generated content value to the internal representation of the editor", () => {
+ gen.setGeneratedContent('generated content');
+
+ gen.replaceDescription();
+
+ expect(editor.setValue).toHaveBeenCalledWith('generated content');
+ });
+
+ it("does nothing if there's no editor or no generated content", () => {
+ // Starts with editor, but no content
+ gen.replaceDescription();
+
+ expect(editor.setValue).not.toHaveBeenCalled();
+
+ gen.setGeneratedContent('generated content');
+ gen.setEditor(null);
+
+ gen.replaceDescription();
+
+ expect(editor.setValue).not.toHaveBeenCalled();
+ });
+
+ it("clears the generated content so the warning can't be re-shown with stale content", () => {
+ gen.setGeneratedContent('generated content');
+
+ gen.replaceDescription();
+
+ expect(editor.setValue).toHaveBeenCalledWith('generated content');
+ expect(gen.hasEditor).toBe(true);
+ expect(gen.canReplaceContent).toBe(false);
+ expect(gen.generatedContent).toBe(null);
+ });
+ });
+ });
+ });
+
+ describe('events', () => {
+ describe('UI clicks', () => {
+ const editor = {};
+ let gen;
+
+ beforeEach(() => {
+ setHTMLFixture(warningDOM);
+ editor.setValue = jest.fn();
+ gen = new MergeRequestGeneratedContent({ editor });
+
+ gen.setGeneratedContent('generated content');
+ });
+
+ describe('banner close button', () => {
+ it('hides the warning element', () => {
+ const close = findCloseButton();
+
+ gen.showWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(false);
+
+ clickButton(close);
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(true);
+ });
+ });
+
+ describe('banner approval button', () => {
+ it('sends the generated content to the editor, clears the internal generated content, and hides the warning', () => {
+ const approve = findApprovalButton();
+
+ gen.showWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(false);
+ expect(gen.generatedContent).toBe('generated content');
+ expect(editor.setValue).not.toHaveBeenCalled();
+
+ clickButton(approve);
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(true);
+ expect(gen.generatedContent).toBe(null);
+ expect(editor.setValue).toHaveBeenCalledWith('generated content');
+ });
+ });
+
+ describe('banner cancel button', () => {
+ it('hides the warning element', () => {
+ const cancel = findCancelButton();
+
+ gen.showWarning();
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(false);
+
+ clickButton(cancel);
+
+ expect(findWarningElement().classList.contains('hidden')).toBe(true);
+ });
+ });
+ });
+ });
+});