diff --git a/app/assets/javascripts/repository/components/header_area/blob_button_group.vue b/app/assets/javascripts/repository/components/header_area/blob_button_group.vue index c233bd2823da68a981d6d6695e2205df62578206..af7720374e348aaf3c4c74d4cc1d644d9d8f5edd 100644 --- a/app/assets/javascripts/repository/components/header_area/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_button_group.vue @@ -1,34 +1,33 @@ + + diff --git a/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue b/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue index fa6954d011d94e80a40fde56f89de565332097a9..9f34dd7f843e080195a0312062e8ba7ff0a997ca 100644 --- a/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue @@ -2,13 +2,20 @@ import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; import { computed } from 'vue'; import { sprintf, s__, __ } from '~/locale'; +import { createAlert } from '~/alert'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants'; +import { DEFAULT_BLOB_INFO } from '~/repository/constants'; +import getRefMixin from '~/repository/mixins/get_ref'; +import projectInfoQuery from 'ee_else_ce/repository/queries/project_info.query.graphql'; import BlobDefaultActionsGroup from './blob_default_actions_group.vue'; import BlobButtonGroup from './blob_button_group.vue'; +import BlobDeleteFileGroup from './blob_delete_file_group.vue'; export const i18n = { dropdownLabel: __('Actions'), + delete: __('Delete file'), + fetchError: __('An error occurred while fetching lock information, please try again.'), }; export default { @@ -17,71 +24,38 @@ export default { GlDisclosureDropdown, BlobDefaultActionsGroup, BlobButtonGroup, + BlobDeleteFileGroup, }, directives: { GlTooltipDirective, }, - inject: ['canModifyBlob', 'canModifyBlobWithWebIde'], + mixins: [getRefMixin], + inject: { + targetBranch: { + default: '', + }, + originalBranch: { + default: '', + }, + blobInfo: { + default: () => DEFAULT_BLOB_INFO.repository.blobs.nodes[0], + }, + }, provide() { return { - canModifyBlob: computed(() => this.canModifyBlob), - canModifyBlobWithWebIde: computed(() => this.canModifyBlobWithWebIde), + blobInfo: computed(() => this.blobInfo ?? {}), }; }, props: { - name: { - type: String, - required: true, - }, - archived: { - type: Boolean, - required: true, - }, projectPath: { type: String, required: true, }, - path: { - type: String, - required: true, - }, - rawPath: { - type: String, - required: true, - }, - replacePath: { - type: String, - required: true, - }, - webPath: { - type: String, - required: true, - }, - richViewer: { - type: Object, - required: false, - default: () => {}, - }, - simpleViewer: { - type: Object, - required: false, - default: () => {}, - }, isBinary: { type: Boolean, required: false, default: false, }, - environmentName: { - type: String, - required: false, - default: null, - }, - environmentPath: { - type: String, - required: false, - default: null, - }, isEmpty: { type: Boolean, required: false, @@ -97,22 +71,48 @@ export default { required: false, default: false, }, - canCurrentUserPushToBranch: { - type: Boolean, - required: true, - }, isUsingLfs: { type: Boolean, required: false, default: false, }, }, + apollo: { + // eslint-disable-next-line @gitlab/vue-no-undef-apollo-properties + projectInfo: { + query: projectInfoQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update({ project }) { + this.pathLocks = project?.pathLocks || DEFAULT_BLOB_INFO.pathLocks; + this.userPermissions = project?.userPermissions; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, data() { return { + userPermissions: DEFAULT_BLOB_INFO.userPermissions, isLoggedIn: isLoggedIn(), }; }, computed: { + isLoading() { + return this.$apollo?.queries.projectInfo.loading; + }, + deleteFileItem() { + return { + text: this.$options.i18n.delete, + extraAttrs: { + 'data-testid': 'delete', + }, + }; + }, activeViewerType() { if (this.$route?.query?.plain !== '1') { const richViewer = document.querySelector('.blob-viewer[data-type="rich"]'); @@ -123,14 +123,16 @@ export default { return SIMPLE_BLOB_VIEWER; }, viewer() { - return this.activeViewerType === RICH_BLOB_VIEWER ? this.richViewer : this.simpleViewer; + return this.activeViewerType === RICH_BLOB_VIEWER + ? this.blobInfo.richViewer + : this.blobInfo.simpleViewer; }, hasRenderError() { return Boolean(this.viewer.renderError); }, environmentTitle() { return sprintf(s__('BlobViewer|View on %{environmentName}'), { - environmentName: this.environmentName, + environmentName: this.blobInfo.environmentFormattedExternalUrl, }); }, }, @@ -153,28 +155,28 @@ export default { text-sr-only > + diff --git a/app/assets/javascripts/repository/queries/blob_controls.query.graphql b/app/assets/javascripts/repository/queries/blob_controls.query.graphql index 9c50ba6236b9df9f62b71e6c69b93214d2da9ec1..5751e39133668d06b1df2f4d7b77b51d63df6c0b 100644 --- a/app/assets/javascripts/repository/queries/blob_controls.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_controls.query.graphql @@ -26,6 +26,9 @@ query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!, $ref canCurrentUserPushToBranch canModifyBlob canModifyBlobWithWebIde + ideForkAndEditPath + forkAndEditPath + forkAndViewPath simpleViewer { __typename fileType diff --git a/app/assets/javascripts/repository/utils/fork_suggestion_utils.js b/app/assets/javascripts/repository/utils/fork_suggestion_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..2cbc76118f52c8a73bc322fc525cf0be73612328 --- /dev/null +++ b/app/assets/javascripts/repository/utils/fork_suggestion_utils.js @@ -0,0 +1,73 @@ +import { isLoggedIn } from '~/lib/utils/common_utils'; +import { createAlert, VARIANT_INFO } from '~/alert'; +import { __ } from '~/locale'; + +export function showForkSuggestionAlert(forkAndViewPath) { + const i18n = { + forkSuggestion: __( + "You can't edit files directly in this project. Fork this project and submit a merge request with your changes.", + ), + fork: __('Fork'), + cancel: __('Cancel'), + }; + + return createAlert({ + message: i18n.forkSuggestion, + variant: VARIANT_INFO, + primaryButton: { + text: i18n.fork, + link: forkAndViewPath, + }, + secondaryButton: { + text: i18n.cancel, + clickHandler: (alert) => alert.dismiss(), + }, + }); +} + +/** + * Checks if the user can fork the project + * @param {Object} userPermissions - User permissions object + * @param {boolean} isUsingLfs - Whether the project is using LFS + * @returns {boolean} + */ +export const canFork = (userPermissions, isUsingLfs) => { + const { createMergeRequestIn, forkProject } = userPermissions; + return isLoggedIn() && !isUsingLfs && createMergeRequestIn && forkProject; +}; + +/** + * Checks if the fork suggestion should be shown for single file editor + * @param {Object} userPermissions - User permissions object + * @param {boolean} isUsingLfs - Whether the project is using LFS + * @param {boolean} canModifyBlob - Whether the user can modify the blob + * @returns {boolean} + */ +export const showSingleFileEditorForkSuggestion = (userPermissions, isUsingLfs, canModifyBlob) => { + return canFork(userPermissions, isUsingLfs) && !canModifyBlob; +}; + +/** + * Checks if the fork suggestion should be shown for Web IDE + * @param {Object} userPermissions - User permissions object + * @param {boolean} isUsingLfs - Whether the project is using LFS + * @param {boolean} canModifyBlobWithWebIde - Whether the user can modify the blob with Web IDE + * @returns {boolean} + */ +export const showWebIdeForkSuggestion = (userPermissions, isUsingLfs, canModifyBlobWithWebIde) => { + return canFork(userPermissions, isUsingLfs) && !canModifyBlobWithWebIde; +}; + +/** + * Checks if the fork suggestion should be shown + * @param {Object} userPermissions - User permissions object + * @param {boolean} isUsingLfs - Whether the project is using LFS + * @param {Object} blobInfo - blobInfo object, including canModifyBlob and canModifyBlobWithWebIde + * @returns {boolean} + */ +export const showForkSuggestion = (userPermissions, isUsingLfs, blobInfo) => { + return ( + showSingleFileEditorForkSuggestion(userPermissions, isUsingLfs, blobInfo.canModifyBlob) || + showWebIdeForkSuggestion(userPermissions, isUsingLfs, blobInfo.canModifyBlobWithWebIde) + ); +}; diff --git a/ee/spec/frontend/repository/mock_data.js b/ee/spec/frontend/repository/mock_data.js index 19af4775ffa5efb080cc03bfa3a421ecc88e9cc7..15380f309d255dd0a9ef4afff5c34b7fa77e3018 100644 --- a/ee/spec/frontend/repository/mock_data.js +++ b/ee/spec/frontend/repository/mock_data.js @@ -197,3 +197,58 @@ export const lockPathMutationMock = { }, }, }; + +export const blobControlsDataMock = { + __typename: 'Project', + id: '1234', + repository: { + __typename: 'Repository', + empty: false, + blobs: { + __typename: 'RepositoryBlobConnection', + nodes: [ + { + __typename: 'RepositoryBlob', + id: '5678', + name: 'file.js', + blamePath: 'blame/file.js', + permalinkPath: 'permalink/file.js', + path: 'some/file.js', + storedExternally: false, + externalStorage: 'https://external-storage', + environmentFormattedExternalUrl: '', + environmentExternalUrlForRouteMap: '', + rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw', + rawTextBlob: 'Example raw text content', + archived: false, + replacePath: 'some/replace/file.js', + webPath: 'some/file.js', + canCurrentUserPushToBranch: true, + canModifyBlob: true, + canModifyBlobWithWebIde: true, + ideForkAndEditPath: 'ide/fork/edit/path', + forkAndEditPath: 'fork/edit/path', + forkAndViewPath: 'fork/view/path', + simpleViewer: { + __typename: 'BlobViewer', + collapsed: false, + loadingPartialName: 'loading', + renderError: null, + tooLarge: false, + type: 'simple', + fileType: 'rich', + }, + richViewer: { + __typename: 'BlobViewer', + collapsed: false, + loadingPartialName: 'loading', + renderError: 'too big file', + tooLarge: false, + type: 'rich', + fileType: 'rich', + }, + }, + ], + }, + }, +}; diff --git a/locale/gitlab.pot b/locale/gitlab.pot index c85a2c611175a7cba9a9e811722d7257f2cd947b..cffebf914d52ec6981ea87894eb11489779b3ce7 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -19162,6 +19162,9 @@ msgstr "" msgid "Delete epic" msgstr "" +msgid "Delete file" +msgstr "" + msgid "Delete group" msgstr "" @@ -47705,6 +47708,9 @@ msgstr "" msgid "Replace all labels" msgstr "" +msgid "Replace file" +msgstr "" + msgid "Replaced all labels with %{label_references} %{label_text}." msgstr "" @@ -66146,6 +66152,9 @@ msgstr "" msgid "You can't approve because you added one or more commits to this merge request." msgstr "" +msgid "You can't edit files directly in this project. Fork this project and submit a merge request with your changes." +msgstr "" + msgid "You can't follow more than %{limit} users. To follow more users, unfollow some others." msgstr "" diff --git a/spec/frontend/repository/components/header_area/blob_button_group_spec.js b/spec/frontend/repository/components/header_area/blob_button_group_spec.js index 374b55f0b536706f3926fc1e7c031b0099eb7100..8d703e4ec56f46845906384a06df6df9b77641bb 100644 --- a/spec/frontend/repository/components/header_area/blob_button_group_spec.js +++ b/spec/frontend/repository/components/header_area/blob_button_group_spec.js @@ -1,7 +1,7 @@ import { GlDisclosureDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { projectMock } from 'ee_else_ce_jest/repository/mock_data'; +import { projectMock, blobControlsDataMock } from 'ee_else_ce_jest/repository/mock_data'; import projectInfoQuery from 'ee_else_ce/repository/queries/project_info.query.graphql'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; @@ -19,11 +19,6 @@ jest.mock('~/lib/utils/common_utils', () => ({ })); const DEFAULT_PROPS = { - name: 'some name', - path: 'some/path', - replacePath: 'some/replace/path', - deletePath: 'some/delete/path', - canPushToBranch: true, isEmptyRepository: false, projectPath: 'some/project/path', isUsingLfs: true, @@ -32,8 +27,7 @@ const DEFAULT_PROPS = { const DEFAULT_INJECT = { targetBranch: 'master', originalBranch: 'master', - canModifyBlob: true, - canModifyBlobWithWebIde: true, + blobInfo: blobControlsDataMock.repository.blobs.nodes[0], }; describe('BlobButtonGroup component', () => { @@ -105,8 +99,9 @@ describe('BlobButtonGroup component', () => { it('renders component', () => { expect(wrapper.props()).toMatchObject({ - name: 'some name', - path: 'some/path', + isEmptyRepository: false, + isUsingLfs: true, + projectPath: 'some/project/path', }); }); @@ -141,48 +136,70 @@ describe('BlobButtonGroup component', () => { beforeEach(async () => { await createComponent({ props: { isUsingLfs: false }, - inject: { canModifyBlob: false, canModifyBlobWithWebIde: false }, + inject: { + blobInfo: { + ...blobControlsDataMock.repository.blobs.nodes[0], + canModifyBlob: false, + canModifyBlobWithWebIde: false, + }, + }, }); }); it('does not trigger the UploadBlobModal from the replace item', () => { findReplaceItem().vm.$emit('action'); - expect(findReplaceItem().props('item')).toMatchObject({ - extraAttrs: { disabled: true }, - }); - expect(showUploadBlobModalMock).not.toHaveBeenCalled(); - expect(wrapper.emitted().fork).toHaveLength(1); + expect(createAlert).toHaveBeenCalledWith({ + message: + 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.', + primaryButton: { + link: 'fork/view/path', + text: 'Fork', + }, + secondaryButton: { + clickHandler: expect.any(Function), + text: 'Cancel', + }, + variant: 'info', + }); }); it('does not trigger the DeleteBlobModal from the delete item', () => { findDeleteItem().vm.$emit('action'); - expect(findDeleteItem().props('item')).toMatchObject({ - extraAttrs: { disabled: true }, - }); - expect(showDeleteBlobModalMock).not.toHaveBeenCalled(); - expect(wrapper.emitted().fork).toHaveLength(1); + expect(createAlert).toHaveBeenCalledWith({ + message: + 'You can’t edit files directly in this project. Fork this project and submit a merge request with your changes.', + primaryButton: { + link: 'fork/view/path', + text: 'Fork', + }, + secondaryButton: { + clickHandler: expect.any(Function), + text: 'Cancel', + }, + variant: 'info', + }); }); }); }); it('renders UploadBlobModal', () => { expect(findUploadBlobModal().props()).toMatchObject({ - commitMessage: 'Replace some name', + commitMessage: 'Replace file.js', targetBranch: 'master', originalBranch: 'master', canPushCode: true, - path: 'some/path', - replacePath: 'some/replace/path', + path: 'some/file.js', + replacePath: 'some/replace/file.js', }); }); it('renders CommitChangesModal for delete', () => { expect(findDeleteBlobModal().props()).toMatchObject({ - commitMessage: 'Delete some name', + commitMessage: 'Delete file.js', targetBranch: 'master', originalBranch: 'master', canPushCode: true, diff --git a/spec/frontend/repository/components/header_area/blob_controls_spec.js b/spec/frontend/repository/components/header_area/blob_controls_spec.js index ad98ce073cbb6847794c1f7da1e4243c42f8d2a1..14539c594fd8780c84825cc5f4b9db3d335fa675 100644 --- a/spec/frontend/repository/components/header_area/blob_controls_spec.js +++ b/spec/frontend/repository/components/header_area/blob_controls_spec.js @@ -166,33 +166,10 @@ describe('Blob controls component', () => { expect(findOverflowMenu().exists()).toBe(true); expect(findOverflowMenu().props()).toEqual({ - name: 'file.js', projectPath: 'some/project', - path: 'some/file.js', - rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw', isBinary: true, - environmentName: '', - environmentPath: '', isEmpty: false, overrideCopy: true, - archived: false, - replacePath: 'some/replace/file.js', - webPath: 'some/file.js', - canCurrentUserPushToBranch: true, - simpleViewer: { - __typename: 'BlobViewer', - renderError: null, - tooLarge: false, - type: 'simple', - fileType: 'rich', - }, - richViewer: { - __typename: 'BlobViewer', - renderError: 'too big file', - tooLarge: false, - type: 'rich', - fileType: 'rich', - }, isEmptyRepository: false, isUsingLfs: false, }); diff --git a/spec/frontend/repository/components/header_area/blob_default_actions_group_spec.js b/spec/frontend/repository/components/header_area/blob_default_actions_group_spec.js index 54930a587a9cf909267d43b553304a1e94b8c59c..a0ab8e8e8183f8cf3516295e57792659c4accae9 100644 --- a/spec/frontend/repository/components/header_area/blob_default_actions_group_spec.js +++ b/spec/frontend/repository/components/header_area/blob_default_actions_group_spec.js @@ -1,10 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import { GlDisclosureDropdownItem } from '@gitlab/ui'; import BlobDefaultActionsGroup from '~/repository/components/header_area/blob_default_actions_group.vue'; +import { blobControlsDataMock } from '../../mock_data'; const mockBlobHash = 'foo-bar'; const mockEnvironmentName = 'my.testing.environment'; const mockEnvironmentPath = 'https://my.testing.environment'; +const blobInfoMock = blobControlsDataMock.repository.blobs.nodes[0]; describe('Blob Default Actions Group', () => { let wrapper; @@ -12,9 +14,6 @@ describe('Blob Default Actions Group', () => { const createComponent = (props = {}, provide = {}) => { wrapper = shallowMount(BlobDefaultActionsGroup, { propsData: { - name: 'dummy.md', - path: 'foo/bar/dummy.md', - rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw', blobHash: mockBlobHash, activeViewerType: 'simple', hasRenderError: false, @@ -27,6 +26,10 @@ describe('Blob Default Actions Group', () => { provide: { blobHash: mockBlobHash, canDownloadCode: true, + blobInfo: { + ...blobInfoMock, + ...provide.blobInfo, + }, ...provide, }, }); @@ -38,7 +41,8 @@ describe('Blob Default Actions Group', () => { const findCopyFileContentItem = () => findDropdownItemWithText('Copy file contents'); const findViewRawItem = () => findDropdownItemWithText('Open raw'); const findDownloadItem = () => findDropdownItemWithText('Download'); - const findEnvironmentItem = () => findDropdownItemWithText(`View on ${mockEnvironmentName}`); + const findEnvironmentItem = () => + findDropdownItemWithText(`View on ${blobInfoMock.environmentFormattedExternalUrl}`); beforeEach(() => { createComponent(); @@ -106,7 +110,16 @@ describe('Blob Default Actions Group', () => { 'when environmentName is $environmentName and environmentPath is $environmentPath', ({ environmentName, environmentPath, isVisible }) => { it(`${isVisible ? 'renders' : 'does not render'} the button`, () => { - createComponent({ environmentName, environmentPath }); + createComponent( + {}, + { + blobInfo: { + ...blobInfoMock, + environmentFormattedExternalUrl: environmentName, + environmentExternalUrlForRouteMap: environmentPath, + }, + }, + ); expect(findEnvironmentItem()).toEqual(isVisible); }); @@ -114,10 +127,15 @@ describe('Blob Default Actions Group', () => { ); it('renders the correct props', () => { - createComponent({ - environmentName: mockEnvironmentName, - environmentPath: mockEnvironmentPath, - }); + createComponent( + {}, + { + blobInfo: { + environmentFormattedExternalUrl: mockEnvironmentName, + environmentExternalUrlForRouteMap: mockEnvironmentPath, + }, + }, + ); expect(findEnvironmentItem().props('item')).toMatchObject({ text: `View on ${mockEnvironmentName}`, diff --git a/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js b/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js index 6a36820bd735cf26fc8ac30cff3ca511dfef7000..d3d207525b4ffc1721d64aed8ac43c67f1a0f765 100644 --- a/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js +++ b/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js @@ -23,23 +23,11 @@ describe('Blob Overflow Menu', () => { wrapper = shallowMountExtended(BlobOverflowMenu, { router, provide: { - canModifyBlob: true, - canModifyBlobWithWebIde: true, + blobInfo: blobControlsDataMock.repository.blobs.nodes[0], ...provided, }, propsData: { - path: blobControlsDataMock.repository.blobs.nodes[0].path, - rawPath: blobControlsDataMock.repository.blobs.nodes[0].rawPath, projectPath, - richViewer: blobControlsDataMock.repository.blobs.nodes[0].richViewer, - simpleViewer: blobControlsDataMock.repository.blobs.nodes[0].simpleViewer, - name: blobControlsDataMock.repository.blobs.nodes[0].name, - isBinary: blobControlsDataMock.repository.blobs.nodes[0].binary, - archived: blobControlsDataMock.repository.blobs.nodes[0].archived, - replacePath: blobControlsDataMock.repository.blobs.nodes[0].replacePath, - webPath: blobControlsDataMock.repository.blobs.nodes[0].webPath, - canCurrentUserPushToBranch: - blobControlsDataMock.repository.blobs.nodes[0].canCurrentUserPushToBranch, ...propsData, }, stub: { @@ -87,9 +75,15 @@ describe('Blob Overflow Menu', () => { }); it('does not render when blob is archived', () => { - createComponent({ - archived: true, - }); + createComponent( + {}, + { + blobInfo: { + ...blobControlsDataMock.repository.blobs.nodes[0], + archived: true, + }, + }, + ); expect(findBlobButtonGroup().exists()).toBe(false); }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 585c59ec34e37e90d3a1d7998b030dd75e2e9716..dbac72845bd29a722a4ce886b13144896c7258e4 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -106,8 +106,8 @@ export const blobControlsDataMock = { path: 'some/file.js', storedExternally: false, externalStorage: 'https://external-storage', - environmentFormattedExternalUrl: '', - environmentExternalUrlForRouteMap: '', + environmentFormattedExternalUrl: 'my.testing.environment', + environmentExternalUrlForRouteMap: 'https://my.testing.environment', rawPath: 'https://testing.com/flightjs/flight/snippets/51/raw', rawTextBlob: 'Example raw text content', archived: false, @@ -116,6 +116,9 @@ export const blobControlsDataMock = { canCurrentUserPushToBranch: true, canModifyBlob: true, canModifyBlobWithWebIde: true, + ideForkAndEditPath: 'ide/fork/edit/path', + forkAndEditPath: 'fork/edit/path', + forkAndViewPath: 'fork/view/path', simpleViewer: { __typename: 'BlobViewer', collapsed: false, diff --git a/spec/frontend/repository/utils/fork_suggestion_utils_spec.js b/spec/frontend/repository/utils/fork_suggestion_utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..d111f65e078e683f7cf24a67ba314ddd913f839c --- /dev/null +++ b/spec/frontend/repository/utils/fork_suggestion_utils_spec.js @@ -0,0 +1,148 @@ +import { createAlert, VARIANT_INFO } from '~/alert'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { + showForkSuggestionAlert, + canFork, + showSingleFileEditorForkSuggestion, + showWebIdeForkSuggestion, + showForkSuggestion, +} from '~/repository/utils/fork_suggestion_utils'; + +jest.mock('~/alert'); +jest.mock('~/lib/utils/common_utils'); + +describe('forkSuggestionUtils', () => { + let userPermissions; + const createUserPermissions = (createMergeRequestIn = true, forkProject = true) => ({ + createMergeRequestIn, + forkProject, + }); + + beforeEach(() => { + commonUtils.isLoggedIn.mockReturnValue(true); + userPermissions = createUserPermissions(); + }); + + describe('canFork', () => { + it('returns true when all conditions are met', () => { + expect(canFork(userPermissions, false)).toBe(true); + }); + + it('returns false when user is not logged in', () => { + commonUtils.isLoggedIn.mockReturnValue(false); + expect(canFork(userPermissions, false)).toBe(false); + }); + + it('returns false when project is using LFS', () => { + expect(canFork(userPermissions, true)).toBe(false); + }); + + it('returns false when user cannot create merge request', () => { + userPermissions = createUserPermissions(false, true); + expect(canFork(userPermissions, false)).toBe(false); + }); + + it('returns false when user cannot fork project', () => { + userPermissions = createUserPermissions(true, false); + expect(canFork(userPermissions, false)).toBe(false); + }); + }); + + describe('showSingleFileEditorForkSuggestion', () => { + it('returns true when user can fork but cannot modify blob', () => { + expect(showSingleFileEditorForkSuggestion(userPermissions, false, false)).toBe(true); + }); + + it('returns false when user can fork and can modify blob', () => { + expect(showSingleFileEditorForkSuggestion(userPermissions, false, true)).toBe(false); + }); + }); + + describe('showWebIdeForkSuggestion', () => { + it('returns true when user can fork but cannot modify blob with Web IDE', () => { + expect(showWebIdeForkSuggestion(userPermissions, false, false)).toBe(true); + }); + + it('returns false when user can fork and can modify blob with Web IDE', () => { + expect(showWebIdeForkSuggestion(userPermissions, false, true)).toBe(false); + }); + }); + + describe('showForkSuggestion', () => { + it('returns true when single file editor fork suggestion is true', () => { + expect( + showForkSuggestion(userPermissions, false, { + canModifyBlob: false, + canModifyBlobWithWebIde: true, + }), + ).toBe(true); + }); + + it('returns true when Web IDE fork suggestion is true', () => { + expect( + showForkSuggestion(userPermissions, false, { + canModifyBlob: true, + canModifyBlobWithWebIde: false, + }), + ).toBe(true); + }); + + it('returns false when both fork suggestions are false', () => { + expect( + showForkSuggestion(userPermissions, false, { + canModifyBlob: true, + canModifyBlobWithWebIde: true, + }), + ).toBe(false); + }); + + it('returns false when user cannot fork', () => { + commonUtils.isLoggedIn.mockReturnValue(false); + expect( + showForkSuggestion(userPermissions, false, { + canModifyBlob: false, + canModifyBlobWithWebIde: false, + }), + ).toBe(false); + }); + }); + + describe('showForkSuggestionAlert', () => { + const forkAndViewPath = '/path/to/fork'; + + beforeEach(() => { + createAlert.mockClear(); + }); + + it('calls createAlert with correct parameters', () => { + showForkSuggestionAlert(forkAndViewPath); + + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ + message: + "You can't edit files directly in this project. Fork this project and submit a merge request with your changes.", + variant: VARIANT_INFO, + primaryButton: { + text: 'Fork', + link: forkAndViewPath, + }, + secondaryButton: { + text: 'Cancel', + clickHandler: expect.any(Function), + }, + }); + }); + + it('secondary button click handler dismisses the alert', () => { + const mockAlert = { dismiss: jest.fn() }; + createAlert.mockReturnValue(mockAlert); + + showForkSuggestionAlert(forkAndViewPath); + + const secondaryButtonClickHandler = createAlert.mock.calls[0][0].secondaryButton.clickHandler; + secondaryButtonClickHandler(mockAlert); + + expect(mockAlert.dismiss).toHaveBeenCalledTimes(1); + }); + }); +});