diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 566b8c09ca1410415ad90369e48c125a2e18b516..77e6de5b0e21ffa6e671919ab1b76e55450ead94 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -140,6 +140,30 @@ export const highlighter = (li, query) => { return li.replace(regexp, (str, $1, $2, $3) => `> ${$1}${$2}${$3} <`); }; +/** + * Sets up subommands for quickaction for the given + * input with the provided command and the subcommand descriptions. + * + * @param {Object} $input input element + * @param {string} cmd command that triggers subcommand selection + * @param {Record} data object containing names of commands as keys with description and header as values + * + */ +export const setupSubcommands = ($input, cmd, data) => { + $input.filter('[data-supports-quick-actions="true"]').atwho({ + // Always keep the trailing space otherwise the command won't display correctly + at: `/${cmd} `, + alias: cmd, + data: Object.keys(data), + maxLen: 100, + displayTpl({ name }) { + const { header, description } = data[name]; + + return `
  • ${lodashEscape(header)}${lodashEscape(description)}
  • `; + }, + }); +}; + export const defaultAutocompleteConfig = { emojis: true, members: true, @@ -318,18 +342,7 @@ class GfmAutoComplete { }, }; - $input.filter('[data-supports-quick-actions="true"]').atwho({ - // Always keep the trailing space otherwise the command won't display correctly - at: '/submit_review ', - alias: 'submit_review', - data: Object.keys(REVIEW_STATES), - maxLen: 100, - displayTpl({ name }) { - const reviewState = REVIEW_STATES[name]; - - return `
  • ${reviewState.header}${reviewState.description}
  • `; - }, - }); + setupSubcommands($input, 'submit_review', REVIEW_STATES); } setupEmoji($input) { @@ -977,9 +990,7 @@ class GfmAutoComplete { } else if (dataSource) { AjaxCache.retrieve(dataSource, true) .then((data) => { - if (data.some((c) => c.name === 'submit_review')) { - this.setSubmitReviewStates($input); - } + this.loadSubcommands($input, data); this.loadData($input, at, data); }) .catch(() => { @@ -990,6 +1001,12 @@ class GfmAutoComplete { } } + loadSubcommands($input, data) { + if (data.some((c) => c.name === 'submit_review')) { + this.setSubmitReviewStates($input); + } + } + // eslint-disable-next-line max-params loadData($input, at, data, { search } = {}) { this.isLoadingData[at] = false; diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index dc00603c3b29d1a428f02a608617f95c8d44624e..cdf9d562c5b9abe7c0f536b1598cc65e89b20520 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -382,6 +382,7 @@ export default { :autosave-key="autosaveKey" :disabled="isSubmitting" :autocomplete-data-sources="autocompleteDataSources" + :noteable-type="noteableType" supports-quick-actions @keydown.up="editCurrentUserLastNote()" @keydown.shift.meta.enter="handleSave()" diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index d8b33784624e66c68019f710b768188a74b620d5..1469f43200c7f9bce557492e6815377e8d8970a9 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -153,6 +153,9 @@ export default { } return '#'; }, + noteableType() { + return this.getNoteableData.noteableType; + }, diffParams() { if (this.diffFile) { return { @@ -305,7 +308,7 @@ export default { trackSavedUsingEditor( this.$refs.markdownEditor.isContentEditorActive, - `${this.getNoteableData.noteableType}_note`, + `${this.noteableType}_note`, ); this.$emit( @@ -373,6 +376,7 @@ export default { :add-spacing-classes="false" :help-page-path="helpPagePath" :note="discussionNote" + :noteable-type="noteableType" :form-field-props="formFieldProps" :autosave-key="autosaveKey" :autocomplete-data-sources="autocompleteDataSources" diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 0fb156f6fe528328390dc62525ade14bcb500205..fee75832effdf851e2856e6efa1f94f8d8dc20c7 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -119,6 +119,11 @@ export default { required: false, default: () => ({}), }, + noteableType: { + type: String, + required: false, + default: '', + }, restrictedToolBarItems: { type: Array, required: false, @@ -388,6 +393,8 @@ export default { :value="markdown" class="note-textarea js-gfm-input markdown-area" dir="auto" + :data-can-suggest="codeSuggestionsConfig.canSuggest" + :data-noteable-type="noteableType" :data-supports-quick-actions="supportsQuickActions" :data-testid="formFieldProps['data-testid'] || 'markdown-editor-form-field'" :disabled="disabled" diff --git a/ee/app/assets/javascripts/gfm_auto_complete.js b/ee/app/assets/javascripts/gfm_auto_complete.js index 2019d81cc343c818482d8f454f75917f60d415d7..07c54c80e2c2c72ba7c895e1653de06b339bae66 100644 --- a/ee/app/assets/javascripts/gfm_auto_complete.js +++ b/ee/app/assets/javascripts/gfm_auto_complete.js @@ -1,7 +1,9 @@ import $ from 'jquery'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import '~/lib/utils/jquery_at_who'; -import GfmAutoComplete, { showAndHideHelper, escape } from '~/gfm_auto_complete'; +import GfmAutoComplete, { showAndHideHelper, escape, setupSubcommands } from '~/gfm_auto_complete'; +import { s__ } from '~/locale'; +import { MERGE_REQUEST_NOTEABLE_TYPE } from '~/notes/constants'; /** * This is added to keep the export parity with the CE counterpart. @@ -23,6 +25,54 @@ const EPICS_ALIAS = 'epics'; const ITERATIONS_ALIAS = 'iterations'; const VULNERABILITIES_ALIAS = 'vulnerabilities'; +export const Q_ISSUE_SUB_COMMANDS = { + dev: { + header: s__('AmazonQ|dev'), + description: s__('AmazonQ|Create a merge request to incorporate Amazon Q suggestions (Beta)'), + }, + transform: { + header: s__('AmazonQ|transform'), + description: s__('AmazonQ|Upgrade Java Maven application to Java 17 (Beta)'), + }, +}; + +export const Q_MERGE_REQUEST_SUB_COMMANDS = { + dev: { + header: s__('AmazonQ|dev'), + description: s__('AmazonQ|Apply changes to this merge request based on the comments (Beta)'), + }, + fix: { + header: s__('AmazonQ|fix'), + description: s__('AmazonQ|Create fixes for review findings (Beta)'), + }, + review: { + header: s__('AmazonQ|review'), + description: s__('AmazonQ|Review merge request for code quality and security issues (Beta)'), + }, +}; + +export const Q_MERGE_REQUEST_CAN_SUGGEST_SUB_COMMANDS = { + test: { + header: s__('AmazonQ|test'), + description: s__( + 'AmazonQ|Create unit tests for selected lines of code in Java or Python files (Beta)', + ), + }, +}; + +const getQSubCommands = ($input) => { + if ($input.data('noteableType') === MERGE_REQUEST_NOTEABLE_TYPE) { + const canSuggest = $input.data('canSuggest'); + + return { + ...Q_MERGE_REQUEST_SUB_COMMANDS, + ...(canSuggest ? Q_MERGE_REQUEST_CAN_SUGGEST_SUB_COMMANDS : {}), + }; + } + + return Q_ISSUE_SUB_COMMANDS; +}; + GfmAutoComplete.Iterations = { templateFunction({ id, title }) { return `
  • *iteration:${id} ${escape(title)}
  • `; @@ -46,6 +96,14 @@ class GfmAutoCompleteEE extends GfmAutoComplete { super.setupAtWho($input); } + loadSubcommands($input, data) { + if (data.some((c) => c.name === 'q')) { + setupSubcommands($input, 'q', getQSubCommands($input)); + } + + super.loadSubcommands($input, data); + } + // eslint-disable-next-line class-methods-use-this setupAutoCompleteEpics = ($input, defaultCallbacks) => { $input.atwho({ diff --git a/ee/spec/frontend/gfm_auto_complete_spec.js b/ee/spec/frontend/gfm_auto_complete_spec.js index dd107513ec3d2ae0b25590ace8bef37a7b4a4df4..f32e7b35d3f66f693c247c3459105e50af03d022 100644 --- a/ee/spec/frontend/gfm_auto_complete_spec.js +++ b/ee/spec/frontend/gfm_auto_complete_spec.js @@ -1,10 +1,16 @@ import $ from 'jquery'; -import GfmAutoCompleteEE from 'ee/gfm_auto_complete'; +import GfmAutoCompleteEE, { + Q_ISSUE_SUB_COMMANDS, + Q_MERGE_REQUEST_SUB_COMMANDS, + Q_MERGE_REQUEST_CAN_SUGGEST_SUB_COMMANDS, +} from 'ee/gfm_auto_complete'; import { TEST_HOST } from 'helpers/test_constants'; import GfmAutoComplete from '~/gfm_auto_complete'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import waitForPromises from 'helpers/wait_for_promises'; import { iterationsMock } from 'ee_jest/gfm_auto_complete/mock_data'; import { ISSUABLE_EPIC } from '~/work_items/constants'; +import AjaxCache from '~/lib/utils/ajax_cache'; const mockSpriteIcons = '/icons.svg'; @@ -15,18 +21,48 @@ describe('GfmAutoCompleteEE', () => { }; let instance; + let $textarea; + + const triggerDropdown = (text) => { + $textarea + .trigger('focus') + .val($textarea.val() + text) + .caret('pos', -1); + $textarea.trigger('keyup'); + + jest.runOnlyPendingTimers(); + }; + + const getDropdownItems = (id) => { + const dropdown = document.getElementById(id); + + return Array.from(dropdown?.getElementsByTagName('li') || []); + }; + + const getDropdownSubcommands = (id) => + getDropdownItems(id).map((x) => ({ + name: x.querySelector('.name').textContent, + description: x.querySelector('.description').textContent, + })); beforeEach(() => { window.gon = { sprite_icons: mockSpriteIcons }; }); + afterEach(() => { + resetHTMLFixture(); + + $textarea = null; + + instance?.destroy(); + instance = null; + }); + it('should have enableMap', () => { instance = new GfmAutoCompleteEE(dataSources); instance.setup($('')); expect(instance.enableMap).not.toBeNull(); - - instance.destroy(); }); describe('Issues.templateFunction', () => { @@ -59,8 +95,6 @@ describe('GfmAutoCompleteEE', () => { }); describe('Iterations', () => { - let $textarea; - beforeEach(() => { setHTMLFixture(''); $textarea = $('textarea'); @@ -68,24 +102,6 @@ describe('GfmAutoCompleteEE', () => { instance.setup($textarea, { iterations: true }); }); - afterEach(() => { - instance.destroy(); - resetHTMLFixture(); - }); - - const triggerDropdown = (text) => { - $textarea.trigger('focus').val(text).caret('pos', -1); - $textarea.trigger('keyup'); - - jest.runOnlyPendingTimers(); - }; - - const getDropdownItems = () => { - const dropdown = document.getElementById('at-view-iterations'); - const items = dropdown.getElementsByTagName('li'); - return [].map.call(items, (item) => item.textContent.trim()); - }; - it("should list iterations when '/iteration *iteration:' is typed", () => { instance.cachedData['*iteration:'] = [...iterationsMock]; @@ -94,7 +110,9 @@ describe('GfmAutoCompleteEE', () => { triggerDropdown('/iteration *iteration:'); - expect(getDropdownItems()).toEqual(expectedDropdownItems); + expect(getDropdownItems('at-view-iterations').map((x) => x.textContent.trim())).toEqual( + expectedDropdownItems, + ); }); describe('templateFunction', () => { @@ -118,4 +136,73 @@ describe('GfmAutoCompleteEE', () => { }); }); }); + + describe('AmazonQ quick action', () => { + const EXPECTATION_ISSUE_SUB_COMMANDS = [ + { + name: 'dev', + description: Q_ISSUE_SUB_COMMANDS.dev.description, + }, + { + name: 'transform', + description: Q_ISSUE_SUB_COMMANDS.transform.description, + }, + ]; + const EXPECTATION_MR_SUB_COMMANDS = [ + { + name: 'dev', + description: Q_MERGE_REQUEST_SUB_COMMANDS.dev.description, + }, + { + name: 'fix', + description: Q_MERGE_REQUEST_SUB_COMMANDS.fix.description, + }, + { + name: 'review', + description: Q_MERGE_REQUEST_SUB_COMMANDS.review.description, + }, + ]; + const EXPECTATION_MR_DIFF_SUB_COMMANDS = [ + ...EXPECTATION_MR_SUB_COMMANDS, + { + name: 'test', + description: Q_MERGE_REQUEST_CAN_SUGGEST_SUB_COMMANDS.test.description, + }, + ]; + + describe.each` + availableCommand | textareaAttributes | expectation + ${'foo'} | ${''} | ${[]} + ${'q'} | ${''} | ${EXPECTATION_ISSUE_SUB_COMMANDS} + ${'q'} | ${'data-noteable-type="MergeRequest"'} | ${EXPECTATION_MR_SUB_COMMANDS} + ${'q'} | ${'data-noteable-type="MergeRequest" data-can-suggest="true"'} | ${EXPECTATION_MR_DIFF_SUB_COMMANDS} + `( + 'with availableCommands=$availableCommand, textareaAttributes=$textareaAttributes', + ({ availableCommand, textareaAttributes, expectation }) => { + beforeEach(() => { + jest + .spyOn(AjaxCache, 'retrieve') + .mockReturnValue(Promise.resolve([{ name: availableCommand }])); + setHTMLFixture( + ``, + ); + instance = new GfmAutoCompleteEE({ + commands: `${TEST_HOST}/autocomplete_sources/commands`, + }); + $textarea = $('textarea'); + instance.setup($textarea, {}); + }); + + it('renders expected sub commands', async () => { + triggerDropdown('/'); + + await waitForPromises(); + + triggerDropdown('q '); + + expect(getDropdownSubcommands('at-view-q')).toEqual(expectation); + }); + }, + ); + }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4f36ec83756b4214566071e0945ca860f7c3092f..8046d991e48214a10fb1d38b545e34c682cd1db8 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5941,6 +5941,9 @@ msgstr "" msgid "AmazonQ|An unexpected error occurred while submitting the form. Please see the browser console log for more details." msgstr "" +msgid "AmazonQ|Apply changes to this merge request based on the comments (Beta)" +msgstr "" + msgid "AmazonQ|Audience" msgstr "" @@ -5962,9 +5965,18 @@ msgstr "" msgid "AmazonQ|Copy to clipboard" msgstr "" +msgid "AmazonQ|Create a merge request to incorporate Amazon Q suggestions (Beta)" +msgstr "" + msgid "AmazonQ|Create an identity provider for this GitLab instance within AWS using the following values. %{helpStart}Learn more%{helpEnd}." msgstr "" +msgid "AmazonQ|Create fixes for review findings (Beta)" +msgstr "" + +msgid "AmazonQ|Create unit tests for selected lines of code in Java or Python files (Beta)" +msgstr "" + msgid "AmazonQ|Enter the IAM role's ARN." msgstr "" @@ -6007,6 +6019,9 @@ msgstr "" msgid "AmazonQ|Provider type" msgstr "" +msgid "AmazonQ|Review merge request for code quality and security issues (Beta)" +msgstr "" + msgid "AmazonQ|Save changes" msgstr "" @@ -6022,6 +6037,9 @@ msgstr "" msgid "AmazonQ|Status" msgstr "" +msgid "AmazonQ|Upgrade Java Maven application to Java 17 (Beta)" +msgstr "" + msgid "AmazonQ|Use Amazon Q to automate workflows, create a merge request from an issue, upgrade Java, and improve your code with AI-powered reviews." msgstr "" @@ -6037,6 +6055,21 @@ msgstr "" msgid "AmazonQ|Within your AWS account, create an IAM role for Amazon Q and the relevant identity provider. %{helpStart}Learn how to create an IAM role%{helpEnd}." msgstr "" +msgid "AmazonQ|dev" +msgstr "" + +msgid "AmazonQ|fix" +msgstr "" + +msgid "AmazonQ|review" +msgstr "" + +msgid "AmazonQ|test" +msgstr "" + +msgid "AmazonQ|transform" +msgstr "" + msgid "AmbiguousRef|There is a branch and a tag with the same name of %{ref}." msgstr "" diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index 71037750bce838050a7821cc6a7c8ec92e287154..0c6c35c6032dc37c2d932f3f18a313318d355cde 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -348,10 +348,13 @@ describe('issue_comment_form component', () => { expect(findMarkdownEditor().find('textarea').attributes('disabled')).toBeDefined(); }); - it('should support quick actions', () => { + it('should support quick actions and other props', () => { mountComponent({ mountFunction: mountExtended }); - expect(findMarkdownEditor().props('supportsQuickActions')).toBe(true); + expect(findMarkdownEditor().props()).toMatchObject({ + supportsQuickActions: true, + noteableType: noteableDataMock.noteableType, + }); }); it('should link to markdown docs', () => { diff --git a/spec/frontend/notes/components/note_form_spec.js b/spec/frontend/notes/components/note_form_spec.js index ff17dbe3aba6ad3955f4e0fdc40cf541fee8d1c3..6fc9ae78ca7c7cdc51bdca9c0b91ceb727710170 100644 --- a/spec/frontend/notes/components/note_form_spec.js +++ b/spec/frontend/notes/components/note_form_spec.js @@ -118,6 +118,10 @@ describe('issue_note_form component', () => { createComponentWrapper(); }); + it('should render text area with noteable type', () => { + expect(textarea.attributes('data-noteable-type')).toBe(noteableDataMock.noteableType); + }); + it('should render text area with placeholder', () => { expect(textarea.attributes('placeholder')).toBe('Write a comment or drag your files here…'); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index b33712f819f7915d83e0aeef1b89d04d8d397633..4b7c07591ecaf654d8ca95433edfab4454209518 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -363,13 +363,27 @@ describe('vue_shared/component/markdown/markdown_editor', () => { name: formFieldName, placeholder: formFieldPlaceholder, 'aria-label': formFieldAriaLabel, + 'data-noteable-type': '', 'data-supports-quick-actions': 'true', }), ); + expect(findTextarea().attributes('data-can-suggest')).toBeUndefined(); expect(findTextarea().element.value).toBe(value); }); + it('renders data on textarea for noteable type', () => { + buildWrapper({ propsData: { noteableType: 'MergeRequest' } }); + + expect(findTextarea().attributes('data-noteable-type')).toBe('MergeRequest'); + }); + + it('renders data on textarea for can suggest', () => { + buildWrapper({ propsData: { codeSuggestionsConfig: { canSuggest: true } } }); + + expect(findTextarea().attributes('data-can-suggest')).toBe('true'); + }); + it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => { buildWrapper();