From 5939342ac0960915f493d0b95fce605c13245102 Mon Sep 17 00:00:00 2001 From: Thomas Randolph Date: Thu, 29 Jun 2023 21:03:34 -0600 Subject: [PATCH 1/5] Add warning when replacing MR description with generated content Changelog: changed EE: true --- .../merge_requests/generated_content.js | 64 +++++++++++++++++++ .../vue_shared/components/markdown/header.vue | 13 ++-- .../_apply_generated_description_warning.haml | 13 ++++ .../form_elements/_description.html.haml | 2 + .../markdown/mount_markdown_editor.js | 12 +++- locale/gitlab.pot | 6 ++ 6 files changed, 100 insertions(+), 10 deletions(-) create mode 100644 app/assets/javascripts/merge_requests/generated_content.js create mode 100644 app/views/shared/form_elements/_apply_generated_description_warning.haml 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 00000000000000..0184801ce8073c --- /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 0c6a9c2fa98565..64aa5742e977ee 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 00000000000000..ea1dea5c9c3f80 --- /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 + = _("Applying a description generated by AI will replace the existing 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 + = _("Apply AI Generated Description") + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close-btn 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 415849672b68f4..62da7d994fd60d 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 a045cfbead9117..3d6d44ec8ec10b 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/locale/gitlab.pot b/locale/gitlab.pot index 858153947bd04e..aa5184cdf49831 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5721,6 +5721,9 @@ msgid_plural "Apply %d suggestions" msgstr[0] "" msgstr[1] "" +msgid "Apply AI Generated Description" +msgstr "" + msgid "Apply a label" msgstr "" @@ -5739,6 +5742,9 @@ msgstr "" msgid "Applying" msgstr "" +msgid "Applying a description generated by AI will replace the existing description. Any changes you have made will be lost." +msgstr "" + msgid "Applying a template will replace the existing issue description. Any changes you have made will be lost." msgstr "" -- GitLab From d7361c2ab7cf1f1804ded3b472c68803e290e66f Mon Sep 17 00:00:00 2001 From: Thomas Randolph Date: Mon, 10 Jul 2023 16:10:31 -0600 Subject: [PATCH 2/5] Test HAML/DOM/Vue bridge class MergeRequestGeneratedContent This class is similar to the IssuableTemplateSelector class. Both bind to static HTML (from Rails) and manage the DOM interactions with other libraries. In the case of IssuableTemplateSelector, that's jQuery and the API. In the case of MergeRequestGeneratedContent, that's purely the DOM, BUT it can receive input and commands from a Vue component that is arbitrating GraphQL responses and feature flags. --- .../merge_requests/generated_content_spec.js | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 spec/frontend/merge_requests/generated_content_spec.js 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 00000000000000..f56a67ec466920 --- /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); + }); + }); + }); + }); +}); -- GitLab From f9893c34d5a831e25e367543a8876a5f75cbaecd Mon Sep 17 00:00:00 2001 From: Thomas Randolph Date: Mon, 10 Jul 2023 16:42:03 -0600 Subject: [PATCH 3/5] Add a test to the markdown header component This test confirms that when the generator features are enabled, the header component properly defers to the MergeRequestGeneratedContent class instance for updating the editor. --- .../components/markdown/header_spec.js | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) 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 abfff488814387..8b6cbaa73d5f29 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(); + }); }); }); }); -- GitLab From 834e06782e4e0ac9852aef4903e7355b0e46f6b6 Mon Sep 17 00:00:00 2001 From: Amy Qualls Date: Wed, 12 Jul 2023 16:42:18 +0000 Subject: [PATCH 4/5] (Review) Update text with TW suggestions --- .../_apply_generated_description_warning.haml | 4 ++-- locale/gitlab.pot | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/shared/form_elements/_apply_generated_description_warning.haml b/app/views/shared/form_elements/_apply_generated_description_warning.haml index ea1dea5c9c3f80..0f86be15f70db4 100644 --- a/app/views/shared/form_elements/_apply_generated_description_warning.haml +++ b/app/views/shared/form_elements/_apply_generated_description_warning.haml @@ -5,9 +5,9 @@ = sprite_icon("close") %p - = _("Applying a description generated by AI will replace the existing description. Any changes you have made will be lost.") + = _("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 - = _("Apply AI Generated Description") + = _("Apply AI-generated description") = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close-btn js-cancel-btn' }) do = _("Cancel") diff --git a/locale/gitlab.pot b/locale/gitlab.pot index aa5184cdf49831..dae9aa573302f7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5721,7 +5721,7 @@ msgid_plural "Apply %d suggestions" msgstr[0] "" msgstr[1] "" -msgid "Apply AI Generated Description" +msgid "Apply AI-generated description" msgstr "" msgid "Apply a label" @@ -5742,9 +5742,6 @@ msgstr "" msgid "Applying" msgstr "" -msgid "Applying a description generated by AI will replace the existing description. Any changes you have made will be lost." -msgstr "" - msgid "Applying a template will replace the existing issue description. Any changes you have made will be lost." msgstr "" @@ -38584,6 +38581,9 @@ msgstr "" msgid "Replace image" msgstr "" +msgid "Replace the existing description with an AI-generated description? Any changes you have made will be lost." +msgstr "" + msgid "Replace video" msgstr "" -- GitLab From fe75df82cb7ec4c13af622309a7d1bf99c3a0c23 Mon Sep 17 00:00:00 2001 From: Thomas Randolph Date: Mon, 17 Jul 2023 07:42:37 -0600 Subject: [PATCH 5/5] (Review) Namespace text and remove a class --- .../_apply_generated_description_warning.haml | 6 +++--- locale/gitlab.pot | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/views/shared/form_elements/_apply_generated_description_warning.haml b/app/views/shared/form_elements/_apply_generated_description_warning.haml index 0f86be15f70db4..b695ae4f292985 100644 --- a/app/views/shared/form_elements/_apply_generated_description_warning.haml +++ b/app/views/shared/form_elements/_apply_generated_description_warning.haml @@ -5,9 +5,9 @@ = sprite_icon("close") %p - = _("Replace the existing description with an AI-generated description? Any changes you have made will be lost.") + = _("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 - = _("Apply AI-generated description") - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-close-btn js-cancel-btn' }) do + = _("AI|Apply AI-generated description") + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-cancel-btn' }) do = _("Cancel") diff --git a/locale/gitlab.pot b/locale/gitlab.pot index dae9aa573302f7..bf5e50e4f8bad4 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 "" @@ -5721,9 +5727,6 @@ msgid_plural "Apply %d suggestions" msgstr[0] "" msgstr[1] "" -msgid "Apply AI-generated description" -msgstr "" - msgid "Apply a label" msgstr "" @@ -38581,9 +38584,6 @@ msgstr "" msgid "Replace image" msgstr "" -msgid "Replace the existing description with an AI-generated description? Any changes you have made will be lost." -msgstr "" - msgid "Replace video" msgstr "" -- GitLab