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); + }); + }); + }); + }); +});