diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 3a8375fbee47fa30849201cee92bc73812447912..6e245486182d769da8ae819bbc5a58203d8cf15a 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -29,6 +29,7 @@ import initFileTreeBrowser from '~/repository/file_tree_browser'; import LastCommit from '~/repository/components/last_commit.vue'; import projectPathQuery from '~/repository/queries/project_path.query.graphql'; import refsQuery from '~/repository/queries/ref.query.graphql'; +import { showAlertFromLocalStorage } from '~/repository/local_storage_alert/show_alert_from_local_storage'; import PerformancePlugin from '~/performance/vue_performance_plugin'; @@ -74,6 +75,7 @@ const initLastCommitApp = (router) => { initAmbiguousRefModal(); initFindFileShortcut(); +showAlertFromLocalStorage(); if (viewBlobEl) { const { diff --git a/app/assets/javascripts/repository/local_storage_alert/constants.js b/app/assets/javascripts/repository/local_storage_alert/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..d54f883a3c13f2177ca0263417b6444081b3c41b --- /dev/null +++ b/app/assets/javascripts/repository/local_storage_alert/constants.js @@ -0,0 +1 @@ +export const LOCAL_STORAGE_ALERT_KEY = 'repository_alert'; diff --git a/app/assets/javascripts/repository/local_storage_alert/save_alert_to_local_storage.js b/app/assets/javascripts/repository/local_storage_alert/save_alert_to_local_storage.js new file mode 100644 index 0000000000000000000000000000000000000000..ca7c627459a5a384648a308de9378949a62df059 --- /dev/null +++ b/app/assets/javascripts/repository/local_storage_alert/save_alert_to_local_storage.js @@ -0,0 +1,8 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { LOCAL_STORAGE_ALERT_KEY } from './constants'; + +export const saveAlertToLocalStorage = (alertOptions) => { + if (AccessorUtilities.canUseLocalStorage()) { + localStorage.setItem(LOCAL_STORAGE_ALERT_KEY, JSON.stringify(alertOptions)); + } +}; diff --git a/app/assets/javascripts/repository/local_storage_alert/show_alert_from_local_storage.js b/app/assets/javascripts/repository/local_storage_alert/show_alert_from_local_storage.js new file mode 100644 index 0000000000000000000000000000000000000000..43a4ab881821f09a8187156954dd919e25d5a3e8 --- /dev/null +++ b/app/assets/javascripts/repository/local_storage_alert/show_alert_from_local_storage.js @@ -0,0 +1,19 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { LOCAL_STORAGE_ALERT_KEY } from './constants'; + +export const showAlertFromLocalStorage = async () => { + if (AccessorUtilities.canUseLocalStorage()) { + const alertOptions = localStorage.getItem(LOCAL_STORAGE_ALERT_KEY); + + if (alertOptions) { + try { + const { createAlert } = await import('~/alert'); + createAlert(JSON.parse(alertOptions)); + } catch (error) { + Sentry.captureException(error); + } + } + localStorage.removeItem(LOCAL_STORAGE_ALERT_KEY); + } +}; diff --git a/app/assets/javascripts/repository/pages/blob_edit_header.vue b/app/assets/javascripts/repository/pages/blob_edit_header.vue index 6ac28c7d3395812e6211bd31e51443a7865db416..9f396571fc5b116c54c6b3e024ce1e074fe1a933 100644 --- a/app/assets/javascripts/repository/pages/blob_edit_header.vue +++ b/app/assets/javascripts/repository/pages/blob_edit_header.vue @@ -7,8 +7,11 @@ import PageHeading from '~/vue_shared/components/page_heading.vue'; import CommitChangesModal from '~/repository/components/commit_changes_modal.vue'; import { getParameterByName, visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility'; import { buildApiUrl } from '~/api/api_utils'; +import { VARIANT_INFO } from '~/alert'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { saveAlertToLocalStorage } from '../local_storage_alert/save_alert_to_local_storage'; import getRefMixin from '../mixins/get_ref'; +import { prepareEditFormData, prepareCreateFormData } from '../utils/edit_form_data_utils'; const MR_SOURCE_BRANCH = 'merge_request[source_branch]'; @@ -70,6 +73,25 @@ export default { ? __('An error occurred editing the blob') : __('An error occurred creating the blob'); }, + successMessageForAlert() { + return (isNewBranch, createMergeRequestNotChosen) => { + let message = __( + 'Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully.', + ); + + if (isNewBranch && createMergeRequestNotChosen) { + // Use canPushToBranch to determine if user is working on a fork + const mrMessage = this.canPushToBranch + ? __('You can now submit a merge request to get this change into the original branch.') + : __( + 'You can now submit a merge request to get this change into the original project.', + ); + message += ` ${mrMessage}`; + } + + return message; + }; + }, }, methods: { handleCancelButtonClick() { @@ -87,22 +109,6 @@ export default { this.originalFilePath = this.editor.getOriginalFilePath(); this.$refs[this.updateModalId].show(); }, - prepareFormData(formData) { - formData.append('file', this.fileContent); - formData.append('file_path', this.filePath); - formData.append('last_commit_sha', this.lastCommitSha); - formData.append('from_merge_request_iid', this.fromMergeRequestIid); - - return Object.fromEntries(formData); - }, - prepareControllerFormData(formData) { - formData.append('file', this.fileContent); - formData.append('file_path', this.filePath); - formData.append('last_commit_sha', this.lastCommitSha); - formData.append('from_merge_request_iid', this.fromMergeRequestIid); - - return Object.fromEntries(formData); - }, handleError(message, errorCode = null) { if (!message) return; // Returns generic '403 Forbidden' error message @@ -120,7 +126,19 @@ export default { } }, async handleEditFormSubmit(formData) { - const originalFormData = this.prepareFormData(formData); + const originalFormData = this.glFeatures.blobEditRefactor + ? prepareEditFormData(formData, { + fileContent: this.fileContent, + filePath: this.filePath, + lastCommitSha: this.lastCommitSha, + fromMergeRequestIid: this.fromMergeRequestIid, + }) + : prepareEditFormData(formData, { + fileContent: this.fileContent, + filePath: this.filePath, + lastCommitSha: this.lastCommitSha, + fromMergeRequestIid: this.fromMergeRequestIid, + }); try { const response = this.glFeatures.blobEditRefactor @@ -147,14 +165,10 @@ export default { } }, async handleCreateFormSubmit(formData) { - formData.append('file_name', this.filePath); - formData.append('content', this.fileContent); - - // Object.fromEntries is used here to handle potential line ending mutations in `FormData`. - // `FormData` uses the "multipart/form-data" format (RFC 2388), which follows MIME data stream rules (RFC 2046). - // These specifications require line breaks to be represented as CRLF sequences in the canonical form. - // See https://stackoverflow.com/questions/69835705/formdata-textarea-puts-r-carriage-return-when-sent-with-post for more details. - const originalFormData = Object.fromEntries(formData); + const originalFormData = prepareCreateFormData(formData, { + filePath: this.filePath, + fileContent: this.fileContent, + }); try { const response = await axios({ @@ -218,7 +232,19 @@ export default { ); visitUrl(mrUrl); } else { - visitUrl(this.getUpdatePath(responseData.branch, responseData.file_path)); + const successPath = this.getUpdatePath(responseData.branch, responseData.file_path); + const isNewBranch = this.originalBranch !== responseData.branch; + const createMergeRequestNotChosen = !formData.create_merge_request; + + const message = this.successMessageForAlert(isNewBranch, createMergeRequestNotChosen); + + saveAlertToLocalStorage({ + message, + messageLinks: { changesLink: successPath }, + variant: VARIANT_INFO, + }); + + visitUrl(successPath); } }, handleControllerSuccess(responseData) { diff --git a/app/assets/javascripts/repository/utils/edit_form_data_utils.js b/app/assets/javascripts/repository/utils/edit_form_data_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..f4bbc651247fb8b9f612afe920bb945b33c38fea --- /dev/null +++ b/app/assets/javascripts/repository/utils/edit_form_data_utils.js @@ -0,0 +1,51 @@ +/** + * Prepares form data for blob editing operations by appending required fields + * and converting FormData to a plain object to handle potential line ending mutations. + * + * Object.fromEntries is used here to handle potential line ending mutations in FormData. + * FormData uses the "multipart/form-data" format (RFC 2388), which follows MIME data + * stream rules (RFC 2046). These specifications require line breaks to be represented + * as CRLF sequences in the canonical form. + * See https://stackoverflow.com/questions/69835705/formdata-textarea-puts-r-carriage-return-when-sent-with-post for more details. + * + * @param {FormData} formData - The original form data + * @param {Object} params - Parameters object + * @param {string} params.fileContent - The file content to be committed + * @param {string} params.filePath - The path of the file being edited + * @param {string} params.lastCommitSha - The SHA of the last commit + * @param {string} params.fromMergeRequestIid - The merge request IID if editing from an MR + * @returns {Object} Plain object with form data entries + */ +export const prepareEditFormData = ( + formData, + { fileContent, filePath, lastCommitSha, fromMergeRequestIid }, +) => { + formData.append('file', fileContent); + formData.append('file_path', filePath); + formData.append('last_commit_sha', lastCommitSha); + formData.append('from_merge_request_iid', fromMergeRequestIid); + + return Object.fromEntries(formData); +}; + +/** + * Prepares form data for creating new blobs by appending file name and content. + * + * Object.fromEntries is used here to handle potential line ending mutations in FormData. + * FormData uses the "multipart/form-data" format (RFC 2388), which follows MIME data + * stream rules (RFC 2046). These specifications require line breaks to be represented + * as CRLF sequences in the canonical form. + * See https://stackoverflow.com/questions/69835705/formdata-textarea-puts-r-carriage-return-when-sent-with-post for more details. + * + * @param {FormData} formData - The original form data + * @param {Object} params - Parameters object + * @param {string} params.filePath - The path/name of the new file + * @param {string} params.fileContent - The content of the new file + * @returns {Object} Plain object with form data entries + */ +export const prepareCreateFormData = (formData, { filePath, fileContent }) => { + formData.append('file_name', filePath); + formData.append('content', fileContent); + + return Object.fromEntries(formData); +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 312ec5c5569ff614e6b69583d14eb72fba72091c..68caded23963618b037a8ea6f3a3663224b8569b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -76397,6 +76397,9 @@ msgstr "" msgid "YouTube" msgstr "" +msgid "Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully." +msgstr "" + msgid "Your %{changes_link} have been committed successfully." msgstr "" diff --git a/qa/qa/specs/features/browser_ui/3_create/source_editor/source_editor_toolbar_spec.rb b/qa/qa/specs/features/browser_ui/3_create/source_editor/source_editor_toolbar_spec.rb index 858e063309bee5d749a4b5759b44564ede8ec742..0aab3aeda2953abcfdafac90481a2c1b9f8a61ce 100644 --- a/qa/qa/specs/features/browser_ui/3_create/source_editor/source_editor_toolbar_spec.rb +++ b/qa/qa/specs/features/browser_ui/3_create/source_editor/source_editor_toolbar_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module QA - RSpec.describe 'Create', feature_category: :source_code_management, feature_flag: { - name: :blob_edit_refactor - } do + RSpec.describe 'Create', feature_category: :source_code_management do describe 'Source editor toolbar preview' do let(:project) { create(:project, :with_readme, name: 'empty-project-with-md') } let(:edited_readme_content) { 'Here is the edited content.' } @@ -14,10 +12,6 @@ module QA it 'can preview markdown side-by-side while editing', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/367749' do - # Skip test when blob_edit_refactor feature flag is enabled as it is WIP - # https://gitlab.com/gitlab-org/gitlab/-/issues/509968 - skip 'blob_edit_refactor feature flag is WIP' if Runtime::Feature.enabled?(:blob_edit_refactor) - project.visit! Page::Project::Show.perform do |project| project.click_file('README.md') diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index 38b8aa46aaf5196c084b2945ac0814cd124463b5..051f6f8ab86cee6f02a801d037008216c00c1c0d 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -115,7 +115,6 @@ def has_toolbar_buttons context 'from blob file path' do before do - stub_feature_flags(blob_edit_refactor: false) visit project_blob_path(project, tree_join(branch, file_path)) end diff --git a/spec/features/projects/files/user_edits_files_spec.rb b/spec/features/projects/files/user_edits_files_spec.rb index 5735be774609397ed77b021357293903c56c7554..f0b06d7f622b179b57db09849103150170481f2a 100644 --- a/spec/features/projects/files/user_edits_files_spec.rb +++ b/spec/features/projects/files/user_edits_files_spec.rb @@ -48,7 +48,6 @@ context 'when an user has write access', :js do before do - stub_feature_flags(blob_edit_refactor: false) project.add_maintainer(user) visit(project_tree_path_root_ref) wait_for_requests diff --git a/spec/frontend/repository/local_storage_alert/constants_spec.js b/spec/frontend/repository/local_storage_alert/constants_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cd895c741e78a915dd6c971c07864617121f9ddf --- /dev/null +++ b/spec/frontend/repository/local_storage_alert/constants_spec.js @@ -0,0 +1,7 @@ +import { LOCAL_STORAGE_ALERT_KEY } from '~/repository/local_storage_alert/constants'; + +describe('LOCAL_STORAGE_ALERT_KEY', () => { + it('exports the correct localStorage key', () => { + expect(LOCAL_STORAGE_ALERT_KEY).toBe('repository_alert'); + }); +}); diff --git a/spec/frontend/repository/local_storage_alert/save_alert_to_local_storage_spec.js b/spec/frontend/repository/local_storage_alert/save_alert_to_local_storage_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2d777cb43d38300ff1c0ac858aa7f00807a67459 --- /dev/null +++ b/spec/frontend/repository/local_storage_alert/save_alert_to_local_storage_spec.js @@ -0,0 +1,48 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import { saveAlertToLocalStorage } from '~/repository/local_storage_alert/save_alert_to_local_storage'; +import { LOCAL_STORAGE_ALERT_KEY } from '~/repository/local_storage_alert/constants'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +const mockAlert = { message: 'Message!' }; + +describe('saveAlertToLocalStorage', () => { + useLocalStorageSpy(); + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + }); + + it('saves message to local storage', () => { + saveAlertToLocalStorage(mockAlert); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledWith( + LOCAL_STORAGE_ALERT_KEY, + JSON.stringify(mockAlert), + ); + }); + + it('does not save to local storage when localStorage is not available', () => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); + + saveAlertToLocalStorage(mockAlert); + + expect(localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('saves complex alert options to local storage', () => { + const complexAlert = { + message: 'Your changes have been committed successfully.', + variant: 'success', + renderMessageHTML: true, + }; + + saveAlertToLocalStorage(complexAlert); + + expect(localStorage.setItem).toHaveBeenCalledTimes(1); + expect(localStorage.setItem).toHaveBeenCalledWith( + LOCAL_STORAGE_ALERT_KEY, + JSON.stringify(complexAlert), + ); + }); +}); diff --git a/spec/frontend/repository/local_storage_alert/show_alert_from_local_storage_spec.js b/spec/frontend/repository/local_storage_alert/show_alert_from_local_storage_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..010bfcd71de6f336a96c1adaa94c9cf376a650a1 --- /dev/null +++ b/spec/frontend/repository/local_storage_alert/show_alert_from_local_storage_spec.js @@ -0,0 +1,77 @@ +import AccessorUtilities from '~/lib/utils/accessor'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { showAlertFromLocalStorage } from '~/repository/local_storage_alert/show_alert_from_local_storage'; +import { LOCAL_STORAGE_ALERT_KEY } from '~/repository/local_storage_alert/constants'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { createAlert } from '~/alert'; + +jest.mock('~/alert'); +jest.mock('~/sentry/sentry_browser_wrapper'); + +describe('showAlertFromLocalStorage', () => { + useLocalStorageSpy(); + + beforeEach(() => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(true); + }); + + it('retrieves alert options from local storage and displays them', async () => { + const complexAlert = { + message: 'Your changes have been committed successfully.', + variant: 'success', + renderMessageHTML: true, + }; + + localStorage.getItem.mockReturnValueOnce(JSON.stringify(complexAlert)); + + await showAlertFromLocalStorage(); + + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith(complexAlert); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); + + it.each(['not a json string', null])('does not fail when stored message is %o', async (item) => { + localStorage.getItem.mockReturnValueOnce(item); + + await showAlertFromLocalStorage(); + + expect(createAlert).not.toHaveBeenCalled(); + + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); + + it('does not show alert when localStorage is not available', async () => { + jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); + + await showAlertFromLocalStorage(); + + expect(localStorage.getItem).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); + expect(localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('removes item from localStorage even when no alert is stored', async () => { + localStorage.getItem.mockReturnValueOnce(null); + + await showAlertFromLocalStorage(); + + expect(createAlert).not.toHaveBeenCalled(); + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + }); + + it('handles JSON parsing errors gracefully and logs error to Sentry', async () => { + localStorage.getItem.mockReturnValueOnce('invalid json {'); + + await showAlertFromLocalStorage(); + + expect(createAlert).not.toHaveBeenCalled(); + expect(localStorage.removeItem).toHaveBeenCalledTimes(1); + expect(localStorage.removeItem).toHaveBeenCalledWith(LOCAL_STORAGE_ALERT_KEY); + expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error)); + }); +}); diff --git a/spec/frontend/repository/pages/blob_edit_header_spec.js b/spec/frontend/repository/pages/blob_edit_header_spec.js index c2f8b1a5d8537d76103b46342f8349e959681353..035eee70232f26ccb968d059e559d61a836d98c8 100644 --- a/spec/frontend/repository/pages/blob_edit_header_spec.js +++ b/spec/frontend/repository/pages/blob_edit_header_spec.js @@ -11,11 +11,13 @@ import { import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import CommitChangesModal from '~/repository/components/commit_changes_modal.vue'; import BlobEditHeader from '~/repository/pages/blob_edit_header.vue'; +import { saveAlertToLocalStorage } from '~/repository/local_storage_alert/save_alert_to_local_storage'; import PageHeading from '~/vue_shared/components/page_heading.vue'; import { stubComponent } from 'helpers/stub_component'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; jest.mock('~/alert'); +jest.mock('~/repository/local_storage_alert/save_alert_to_local_storage'); jest.mock('lodash/uniqueId', () => { return jest.fn((input) => `${input}1`); }); @@ -36,7 +38,7 @@ describe('BlobEditHeader', () => { }, }; - const createWrapper = ({ action = 'update', glFeatures = {} } = {}) => { + const createWrapper = ({ action = 'update', glFeatures = {}, provided = {} } = {}) => { return shallowMountExtended(BlobEditHeader, { provide: { action, @@ -54,6 +56,7 @@ describe('BlobEditHeader', () => { projectId: 123, projectPath: 'gitlab-org/gitlab', newMergeRequestPath: 'merge_request/new/123', + ...provided, glFeatures: { blobEditRefactor: true, ...glFeatures }, }, mixins: [glFeatureFlagMixin()], @@ -103,42 +106,45 @@ describe('BlobEditHeader', () => { await axios.waitForAll(); }; - describe('for edit blob', () => { - describe('when blobEditRefactor is enabled', () => { - it('renders title with two buttons', () => { - expect(findTitle().text()).toBe('Edit file'); - const buttons = findButtons(); - expect(buttons).toHaveLength(2); - expect(buttons.at(0).text()).toBe('Cancel'); - expect(buttons.at(1).text()).toBe('Commit changes'); - }); + it('renders title with two buttons', () => { + expect(findTitle().text()).toBe('Edit file'); + const buttons = findButtons(); + expect(buttons).toHaveLength(2); + expect(buttons.at(0).text()).toBe('Cancel'); + expect(buttons.at(1).text()).toBe('Commit changes'); + }); - it('opens commit changes modal with correct props', () => { - clickCommitChangesButton(); - expect(mockEditor.getFileContent).toHaveBeenCalled(); - expect(findCommitChangesModal().props()).toEqual({ - modalId: 'update-modal1', - canPushCode: true, - canPushToBranch: true, - commitMessage: 'Edit test.js', - emptyRepo: false, - isUsingLfs: false, - originalBranch: 'main', - targetBranch: 'feature', - loading: false, - branchAllowsCollaboration: false, - valid: true, - error: null, - }); - }); + it('retrieves edit content, when opening the modal', () => { + clickCommitChangesButton(); + expect(mockEditor.getFileContent).toHaveBeenCalled(); + }); + it('opens commit changes modal with correct props', () => { + expect(findCommitChangesModal().props()).toEqual({ + modalId: 'update-modal1', + canPushCode: true, + canPushToBranch: true, + commitMessage: 'Edit test.js', + emptyRepo: false, + isUsingLfs: false, + originalBranch: 'main', + targetBranch: 'feature', + loading: false, + branchAllowsCollaboration: false, + valid: true, + error: null, + }); + }); + + describe('for edit blob', () => { + describe('when blobEditRefactor is enabled', () => { it('shows confirmation message on cancel button', () => { expect(findCancelButton().attributes('data-confirm')).toBe( 'Leave edit mode? All unsaved changes will be lost.', ); }); - it('on submit, redirects to the updated file', async () => { + it('on submit, saves success message to localStorage and redirects to the updated file', async () => { // First click the commit button to open the modal and set up the file content clickCommitChangesButton(); mock.onPut().replyOnce(HTTP_STATUS_OK, { @@ -147,6 +153,15 @@ describe('BlobEditHeader', () => { }); await submitForm(); + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: + 'Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully. You can now submit a merge request to get this change into the original branch.', + messageLinks: { + changesLink: 'http://test.host/gitlab-org/gitlab/-/blob/feature/test.js', + }, + variant: 'info', + }); + expect(mock.history.put).toHaveLength(1); expect(mock.history.put[0].url).toBe('/api/v4/projects/123/repository/files/test.js'); const putData = JSON.parse(mock.history.put[0].data); @@ -156,13 +171,64 @@ describe('BlobEditHeader', () => { ); }); - describe('error handling', () => { - const errorMessage = 'Custom error message'; + it('on submit to same branch, saves shorter success message to localStorage', async () => { + mock.onPut().replyOnce(HTTP_STATUS_OK, { + branch: 'main', // Same as originalBranch + file_path: 'test.js', + }); + + const formData = new FormData(); + formData.append('commit_message', 'Test commit'); + formData.append('branch_name', 'main'); + formData.append('original_branch', 'main'); + + findCommitChangesModal().vm.$emit('submit-form', formData); + await axios.waitForAll(); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: + 'Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully.', + messageLinks: { + changesLink: 'http://test.host/gitlab-org/gitlab/-/blob/main/test.js', + }, + variant: 'info', + }); + + expect(visitUrlSpy).toHaveBeenCalledWith( + 'http://test.host/gitlab-org/gitlab/-/blob/main/test.js', + ); + }); - beforeEach(() => { - clickCommitChangesButton(); + it('on submit to new branch to a fork repo, saves success message with "original project" text', async () => { + // Create wrapper with canPushToBranch: false to simulate fork scenario + wrapper = createWrapper({ + glFeatures: { blobEditRefactor: true }, + provided: { canPushToBranch: false }, }); + mock.onPut().replyOnce(HTTP_STATUS_OK, { + branch: 'feature', + file_path: 'test.js', + }); + await submitForm(); + + expect(saveAlertToLocalStorage).toHaveBeenCalledWith({ + message: + 'Your %{changesLinkStart}changes%{changesLinkEnd} have been committed successfully. You can now submit a merge request to get this change into the original project.', + messageLinks: { + changesLink: 'http://test.host/gitlab-org/gitlab/-/blob/feature/test.js', + }, + variant: 'info', + }); + + expect(visitUrlSpy).toHaveBeenCalledWith( + 'http://test.host/gitlab-org/gitlab/-/blob/feature/test.js', + ); + }); + + describe('error handling', () => { + const errorMessage = 'Custom error message'; + it('shows error message in modal when response contains error', async () => { mock.onPut().replyOnce(HTTP_STATUS_OK, { error: errorMessage }); await submitForm(); @@ -293,33 +359,6 @@ describe('BlobEditHeader', () => { wrapper = createWrapper({ action: 'create' }); }); - it('renders title with two buttons', () => { - expect(findTitle().text()).toBe('New file'); - const buttons = findButtons(); - expect(buttons).toHaveLength(2); - expect(buttons.at(0).text()).toBe('Cancel'); - expect(buttons.at(1).text()).toBe('Commit changes'); - }); - - it('opens commit changes modal with correct props', () => { - clickCommitChangesButton(); - expect(mockEditor.getFileContent).toHaveBeenCalled(); - expect(findCommitChangesModal().props()).toEqual({ - modalId: 'update-modal1', - canPushCode: true, - canPushToBranch: true, - commitMessage: 'Add new file', - originalBranch: 'main', - targetBranch: 'feature', - isUsingLfs: false, - emptyRepo: false, - branchAllowsCollaboration: false, - loading: false, - valid: true, - error: null, - }); - }); - it('shows confirmation message on cancel button', () => { expect(findCancelButton().attributes('data-confirm')).toBe( 'Leave edit mode? All unsaved changes will be lost.', @@ -341,7 +380,7 @@ describe('BlobEditHeader', () => { describe('validation', () => { it('toggles validation error when filename is empty', () => { mockEditor.filepathFormMediator.$filenameInput.val.mockReturnValue(null); - wrapper = createWrapper(); + createWrapper(); clickCommitChangesButton(); expect(mockEditor.filepathFormMediator.toggleValidationError).toHaveBeenCalledWith(true); diff --git a/spec/frontend/repository/utils/edit_form_data_utils_spec.js b/spec/frontend/repository/utils/edit_form_data_utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..5aec1772658142b819c82a9a0d933ee6f420d124 --- /dev/null +++ b/spec/frontend/repository/utils/edit_form_data_utils_spec.js @@ -0,0 +1,78 @@ +import { + prepareEditFormData, + prepareCreateFormData, +} from '~/repository/utils/edit_form_data_utils'; + +describe('edit_form_data_utils', () => { + let formData; + + beforeEach(() => { + formData = new FormData(); + }); + + describe('prepareEditFormData', () => { + const params = { + fileContent: 'console.log("Hello World");', + filePath: 'src/test.js', + lastCommitSha: 'abc123def456', + fromMergeRequestIid: '42', + }; + + it('appends all required fields to FormData and returns plain object', () => { + const result = prepareEditFormData(formData, params); + + expect(result).toEqual({ + file: params.fileContent, + file_path: params.filePath, + last_commit_sha: params.lastCommitSha, + from_merge_request_iid: params.fromMergeRequestIid, + }); + }); + }); + + describe('prepareCreateFormData', () => { + const params = { + filePath: 'new-file.md', + fileContent: '# New File\n\nThis is a new file.', + }; + + it('appends file_name and content to FormData and returns plain object', () => { + const result = prepareCreateFormData(formData, params); + + expect(result).toEqual({ + file_name: params.filePath, + content: params.fileContent, + }); + }); + + it('handles empty file content', () => { + const emptyParams = { ...params, fileContent: '' }; + const result = prepareCreateFormData(formData, emptyParams); + + expect(result).toEqual({ + file_name: params.filePath, + content: '', + }); + }); + + it('handles file paths with special characters', () => { + const specialParams = { + ...params, + filePath: 'docs/special file (with spaces & symbols).txt', + }; + const result = prepareCreateFormData(formData, specialParams); + + expect(result.file_name).toBe(specialParams.filePath); + }); + + it('handles multiline content with different line endings', () => { + const multilineParams = { + ...params, + fileContent: 'Line 1\nLine 2\r\nLine 3\rLine 4', + }; + const result = prepareCreateFormData(formData, multilineParams); + + expect(result.content).toBe(multilineParams.fileContent); + }); + }); +});