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 new file mode 100644 index 0000000000000000000000000000000000000000..c233bd2823da68a981d6d6695e2205df62578206 --- /dev/null +++ b/app/assets/javascripts/repository/components/header_area/blob_button_group.vue @@ -0,0 +1,207 @@ + + + diff --git a/app/assets/javascripts/repository/components/header_area/blob_controls.vue b/app/assets/javascripts/repository/components/header_area/blob_controls.vue index 8948029cfb570066382e507f5cf4b3f76002f4ac..6d83dd15d72bb45708e433a8d505823b6de6143a 100644 --- a/app/assets/javascripts/repository/components/header_area/blob_controls.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue @@ -1,5 +1,6 @@ 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 6c95b78315bb47de2ca564644d65420550da2d3f..fa6954d011d94e80a40fde56f89de565332097a9 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 @@ -1,9 +1,11 @@ diff --git a/app/assets/javascripts/repository/queries/blob_controls.query.graphql b/app/assets/javascripts/repository/queries/blob_controls.query.graphql index 71470717d1a4c433928fed2a7c3d811c34b0a745..9c50ba6236b9df9f62b71e6c69b93214d2da9ec1 100644 --- a/app/assets/javascripts/repository/queries/blob_controls.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_controls.query.graphql @@ -1,9 +1,14 @@ query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!, $refType: RefType) { project(fullPath: $projectPath) { + __typename id repository { + __typename + empty blobs(paths: [$filePath], ref: $ref, refType: $refType) { + __typename nodes { + __typename id name blamePath @@ -15,13 +20,21 @@ query getBlobControls($projectPath: ID!, $filePath: String!, $ref: String!, $ref path rawPath rawTextBlob + archived + replacePath + webPath + canCurrentUserPushToBranch + canModifyBlob + canModifyBlobWithWebIde simpleViewer { + __typename fileType tooLarge type renderError } richViewer { + __typename fileType tooLarge type diff --git a/ee/app/assets/javascripts/repository/components/header_area/lock_file_dropdown_item.vue b/ee/app/assets/javascripts/repository/components/header_area/lock_file_dropdown_item.vue index ebc8fe7dfaba0d1643bb52dfbb0507453b0b83ef..ca284ea0632bc12c503b3a3f16077044a9c36902 100644 --- a/ee/app/assets/javascripts/repository/components/header_area/lock_file_dropdown_item.vue +++ b/ee/app/assets/javascripts/repository/components/header_area/lock_file_dropdown_item.vue @@ -2,7 +2,6 @@ import { GlDisclosureDropdownItem, GlModal } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { sprintf, __ } from '~/locale'; -import projectInfoQuery from 'ee_else_ce/repository/queries/project_info.query.graphql'; import lockPathMutation from '~/repository/mutations/lock_path.mutation.graphql'; import { DEFAULT_BLOB_INFO } from '~/repository/constants'; @@ -12,7 +11,6 @@ export default { unlock: __('Unlock'), modalTitle: __('Lock file?'), actionCancel: __('Cancel'), - fetchError: __('An error occurred while fetching lock information, please try again.'), mutationError: __('An error occurred while editing lock information, please try again.'), }, components: { @@ -32,23 +30,20 @@ export default { type: String, required: true, }, - }, - 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 }); - }, + pathLocks: { + type: Object, + required: false, + default: () => DEFAULT_BLOB_INFO.pathLocks, + }, + userPermissions: { + type: Object, + required: false, + default: () => DEFAULT_BLOB_INFO.userPermissions, + }, + isLoading: { + type: Boolean, + required: false, + default: false, }, }, data() { @@ -56,14 +51,9 @@ export default { isUpdating: false, isModalVisible: false, locked: false, - pathLocks: DEFAULT_BLOB_INFO.pathLocks, - userPermissions: DEFAULT_BLOB_INFO.userPermissions, }; }, computed: { - isLoading() { - return this.$apollo?.queries.projectInfo.loading; - }, lockButtonTitle() { return this.isLocked ? this.$options.i18n.unlock : this.$options.i18n.lock; }, @@ -112,8 +102,11 @@ export default { }, }, watch: { - isLocked(val) { - this.locked = val; + isLocked: { + immediate: true, + handler(val) { + this.locked = val; + }, }, }, methods: { diff --git a/ee/spec/frontend/repository/components/header_area/lock_file_dropdown_item_spec.js b/ee/spec/frontend/repository/components/header_area/lock_file_dropdown_item_spec.js index 73db421a766cd5a77ea4095c4d7b83151b8152d6..434b60e83ab762387e3769c3fa6d210a59705a02 100644 --- a/ee/spec/frontend/repository/components/header_area/lock_file_dropdown_item_spec.js +++ b/ee/spec/frontend/repository/components/header_area/lock_file_dropdown_item_spec.js @@ -5,7 +5,6 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import lockPathMutation from '~/repository/mutations/lock_path.mutation.graphql'; -import projectInfoQuery from 'ee_component/repository/queries/project_info.query.graphql'; import { projectMock, userPermissionsMock } from 'ee_jest/repository/mock_data'; import LockFileDropdownItem from 'ee_component/repository/components/header_area/lock_file_dropdown_item.vue'; import { createAlert } from '~/alert'; @@ -17,22 +16,11 @@ describe('LockFileDropdownItem component', () => { let wrapper; let fakeApollo; - const projectInfoQueryMockResolver = jest - .fn() - .mockResolvedValue({ data: { project: projectMock } }); - const projectInfoQueryErrorResolver = jest.fn().mockRejectedValue(new Error('Request failed')); + const lockPathMutationResolver = jest.fn(); - const lochPathMutationResolver = jest.fn(); - - const createComponent = ({ - mutationResolver = lochPathMutationResolver, - projectInfoResolver = projectInfoQueryMockResolver, - } = {}) => { + const createComponent = ({ props = {}, mutationResolver = lockPathMutationResolver } = {}) => { window.gon = { current_username: projectMock.pathLocks.nodes[0].user.username }; - fakeApollo = createMockApollo([ - [projectInfoQuery, projectInfoResolver], - [lockPathMutation, mutationResolver], - ]); + fakeApollo = createMockApollo([[lockPathMutation, mutationResolver]]); wrapper = shallowMount(LockFileDropdownItem, { apolloProvider: fakeApollo, @@ -40,6 +28,10 @@ describe('LockFileDropdownItem component', () => { name: 'locked_file.js', path: 'some/path/locked_file.js', projectPath: 'some/project/path', + isLoading: false, + userPermissions: userPermissionsMock, + pathLocks: projectMock.pathLocks, + ...props, }, }); }; @@ -60,19 +52,13 @@ describe('LockFileDropdownItem component', () => { }); it('renders disabled the lock dropdown item if user can not lock a file', async () => { - const projectWithNoPushPermission = { - data: { - project: { - ...projectMock, - userPermissions: { - ...userPermissionsMock, - pushCode: false, - }, + createComponent({ + props: { + userPermissions: { + ...userPermissionsMock, + pushCode: false, }, }, - }; - createComponent({ - projectInfoResolver: jest.fn().mockResolvedValue(projectWithNoPushPermission), }); await waitForPromises(); @@ -82,12 +68,11 @@ describe('LockFileDropdownItem component', () => { }); it('renders disabled until query fetches projects info', async () => { - const projectInfoQueryLoading = jest.fn().mockResolvedValue(new Promise(() => {})); createComponent({ - projectInfoResolver: projectInfoQueryLoading, + props: { isLoading: true }, }); await waitForPromises(); - expect(projectInfoQueryLoading).toHaveBeenCalled(); + expect(findLockFileDropdownItem().props('item')).toMatchObject({ extraAttrs: { disabled: true }, }); @@ -110,17 +95,8 @@ describe('LockFileDropdownItem component', () => { }); it('renders the Lock dropdown item label, when file is not locked', async () => { - const projectWithNoLocks = { - data: { - project: { - ...projectMock, - pathLocks: { __typename: 'PathLockConnection', nodes: [] }, - }, - }, - }; - createComponent({ - projectInfoResolver: jest.fn().mockResolvedValue(projectWithNoLocks), + props: { pathLocks: { __typename: 'PathLockConnection', nodes: [] } }, }); await waitForPromises(); @@ -137,15 +113,6 @@ describe('LockFileDropdownItem component', () => { }); }); - it('creates an alert with the correct message, when projectInfo query fails', async () => { - createComponent({ projectInfoResolver: projectInfoQueryErrorResolver }); - await waitForPromises(); - - expect(createAlert).toHaveBeenCalledWith({ - message: 'An error occurred while fetching lock information, please try again.', - }); - }); - describe('Modal', () => { it('displays a confirm modal when the lock dropdown item is clicked', () => { findLockFileDropdownItem().vm.$emit('action'); @@ -195,7 +162,7 @@ describe('LockFileDropdownItem component', () => { findLockFileDropdownItem().vm.$emit('action'); clickSubmit(); - expect(lochPathMutationResolver).toHaveBeenCalledWith({ + expect(lockPathMutationResolver).toHaveBeenCalledWith({ filePath: 'some/path/locked_file.js', lock: false, projectPath: 'some/project/path', @@ -205,7 +172,7 @@ describe('LockFileDropdownItem component', () => { it('does not execute a lock mutation if lock not confirmed', () => { findLockFileDropdownItem().vm.$emit('action'); - expect(lochPathMutationResolver).not.toHaveBeenCalled(); + expect(lockPathMutationResolver).not.toHaveBeenCalled(); }); }); }); 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 new file mode 100644 index 0000000000000000000000000000000000000000..374b55f0b536706f3926fc1e7c031b0099eb7100 --- /dev/null +++ b/spec/frontend/repository/components/header_area/blob_button_group_spec.js @@ -0,0 +1,193 @@ +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 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'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import BlobButtonGroup from '~/repository/components/header_area/blob_button_group.vue'; +import CommitChangesModal from '~/repository/components/commit_changes_modal.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import { createAlert } from '~/alert'; + +Vue.use(VueApollo); +jest.mock('~/alert'); +jest.mock('~/lib/utils/common_utils', () => ({ + isLoggedIn: jest.fn().mockReturnValue(true), +})); + +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, +}; + +const DEFAULT_INJECT = { + targetBranch: 'master', + originalBranch: 'master', + canModifyBlob: true, + canModifyBlobWithWebIde: true, +}; + +describe('BlobButtonGroup component', () => { + let wrapper; + let fakeApollo; + + let showUploadBlobModalMock; + let showDeleteBlobModalMock; + + const projectInfoQueryMockResolver = jest + .fn() + .mockResolvedValue({ data: { project: projectMock } }); + const projectInfoQueryErrorResolver = jest.fn().mockRejectedValue(new Error('Request failed')); + + const createComponent = async ({ + props = {}, + projectInfoResolver = projectInfoQueryMockResolver, + inject = {}, + } = {}) => { + showUploadBlobModalMock = jest.fn(); + showDeleteBlobModalMock = jest.fn(); + + const UploadBlobModalStub = stubComponent(UploadBlobModal, { + methods: { + show: showUploadBlobModalMock, + }, + }); + const DeleteBlobModalStub = stubComponent(CommitChangesModal, { + methods: { + show: showDeleteBlobModalMock, + }, + }); + + fakeApollo = createMockApollo([[projectInfoQuery, projectInfoResolver]]); + + wrapper = mountExtended(BlobButtonGroup, { + apolloProvider: fakeApollo, + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + provide: { + ...DEFAULT_INJECT, + ...inject, + }, + stubs: { + UploadBlobModal: UploadBlobModalStub, + CommitChangesModal: DeleteBlobModalStub, + }, + }); + await waitForPromises(); + }; + + const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findDropdownItemWithText = (text) => + findDropdownItems().wrappers.find((x) => x.props('item').text === text); + const findDeleteItem = () => findDropdownItemWithText('Delete'); + const findReplaceItem = () => findDropdownItemWithText('Replace'); + const findDeleteBlobModal = () => wrapper.findComponent(CommitChangesModal); + const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); + + beforeEach(async () => { + await createComponent(); + }); + + afterEach(() => { + fakeApollo = null; + }); + + it('renders component', () => { + expect(wrapper.props()).toMatchObject({ + name: 'some name', + path: 'some/path', + }); + }); + + it('creates an alert with the correct message, when projectInfo query fails', async () => { + await createComponent({ projectInfoResolver: projectInfoQueryErrorResolver }); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'An error occurred while fetching lock information, please try again.', + }); + }); + + describe('dropdown items', () => { + it('renders both the replace and delete item', () => { + expect(wrapper.findAllComponents(GlDisclosureDropdownItem)).toHaveLength(2); + expect(findReplaceItem().exists()).toBe(true); + expect(findDeleteItem().exists()).toBe(true); + }); + + it('triggers the UploadBlobModal from the replace item', () => { + findReplaceItem().vm.$emit('action'); + + expect(showUploadBlobModalMock).toHaveBeenCalled(); + }); + + it('triggers the CommitChangesModal from the delete item', () => { + findDeleteItem().vm.$emit('action'); + + expect(showDeleteBlobModalMock).toHaveBeenCalled(); + }); + + describe('when user cannot modify blob', () => { + beforeEach(async () => { + await createComponent({ + props: { isUsingLfs: false }, + inject: { 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); + }); + + 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); + }); + }); + }); + + it('renders UploadBlobModal', () => { + expect(findUploadBlobModal().props()).toMatchObject({ + commitMessage: 'Replace some name', + targetBranch: 'master', + originalBranch: 'master', + canPushCode: true, + path: 'some/path', + replacePath: 'some/replace/path', + }); + }); + + it('renders CommitChangesModal for delete', () => { + expect(findDeleteBlobModal().props()).toMatchObject({ + commitMessage: 'Delete some name', + targetBranch: 'master', + originalBranch: 'master', + canPushCode: true, + emptyRepo: false, + isUsingLfs: 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 95b9f9f1f2038a7beef8c331bdc085fb950dba47..ad98ce073cbb6847794c1f7da1e4243c42f8d2a1 100644 --- a/spec/frontend/repository/components/header_area/blob_controls_spec.js +++ b/spec/frontend/repository/components/header_area/blob_controls_spec.js @@ -38,9 +38,13 @@ const createComponent = async ( mockResolver = jest.fn().mockResolvedValue({ data: { project: { + __typename: 'Project', id: '1234', repository: { + __typename: 'Repository', + empty: blobControlsDataMock.repository.empty, blobs: { + __typename: 'RepositoryBlobConnection', nodes: [{ ...blobControlsDataMock.repository.blobs.nodes[0], ...blobInfoOverrides }], }, }, @@ -55,6 +59,8 @@ const createComponent = async ( apolloProvider: createMockApollo([[blobControlsQuery, mockResolver]]), provide: { glFeatures, + canModifyBlob: true, + canModifyBlobWithWebIde: true, }, propsData: { projectPath, @@ -112,7 +118,7 @@ describe('Blob controls component', () => { }); it('does not render blame button when blobInfo.externalStorage is "lfs"', async () => { - await createComponent({}, { externalStorage: 'lfs' }); + await createComponent({}, { storedExternally: true, externalStorage: 'lfs' }); expect(findBlameButton().exists()).toBe(false); }); @@ -169,18 +175,26 @@ describe('Blob controls component', () => { 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_overflow_menu_spec.js b/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js index e9aaccf36f61d8593027f81a93bb00f382b1c3b8..6a36820bd735cf26fc8ac30cff3ca511dfef7000 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 @@ -1,10 +1,16 @@ import { GlDisclosureDropdown } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { isLoggedIn } from '~/lib/utils/common_utils'; import BlobOverflowMenu from '~/repository/components/header_area/blob_overflow_menu.vue'; import BlobDefaultActionsGroup from '~/repository/components/header_area/blob_default_actions_group.vue'; +import BlobButtonGroup from '~/repository/components/header_area/blob_button_group.vue'; import createRouter from '~/repository/router'; import { blobControlsDataMock, refMock } from '../../mock_data'; +jest.mock('~/lib/utils/common_utils', () => ({ + isLoggedIn: jest.fn().mockReturnValue(true), +})); + describe('Blob Overflow Menu', () => { let wrapper; @@ -17,6 +23,8 @@ describe('Blob Overflow Menu', () => { wrapper = shallowMountExtended(BlobOverflowMenu, { router, provide: { + canModifyBlob: true, + canModifyBlobWithWebIde: true, ...provided, }, propsData: { @@ -27,6 +35,11 @@ describe('Blob Overflow Menu', () => { 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: { @@ -35,18 +48,14 @@ describe('Blob Overflow Menu', () => { }); } - const findDefaultBlobActions = () => wrapper.findByTestId('default-actions-container'); const findBlobDefaultActionsGroup = () => wrapper.findComponent(BlobDefaultActionsGroup); + const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); beforeEach(() => { createComponent(); }); describe('Default blob actions', () => { - it('renders component', () => { - expect(findDefaultBlobActions().exists()).toBe(true); - }); - it('renders BlobDefaultActionsGroup component', () => { expect(findBlobDefaultActionsGroup().exists()).toBe(true); }); @@ -71,4 +80,25 @@ describe('Blob Overflow Menu', () => { }); }); }); + + describe('Blob Button Group', () => { + it('renders component', () => { + expect(findBlobButtonGroup().exists()).toBe(true); + }); + + it('does not render when blob is archived', () => { + createComponent({ + archived: true, + }); + + expect(findBlobButtonGroup().exists()).toBe(false); + }); + + it('does not render when user is not logged in', () => { + isLoggedIn.mockImplementationOnce(() => false); + createComponent(); + + expect(findBlobButtonGroup().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/repository/mock_data.js b/spec/frontend/repository/mock_data.js index 422fabdc82495a8b88c747a5f01f060a92a512bc..7c2682cc9f396e1016e72e058655a212e57901b7 100644 --- a/spec/frontend/repository/mock_data.js +++ b/spec/frontend/repository/mock_data.js @@ -89,11 +89,16 @@ export const refWithSpecialCharMock = 'feat/selected-#-ref-#'; export const encodedRefWithSpecialCharMock = 'feat/selected-%23-ref-%23'; 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', @@ -105,7 +110,14 @@ export const blobControlsDataMock = { 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, simpleViewer: { + __typename: 'BlobViewer', collapsed: false, loadingPartialName: 'loading', renderError: null, @@ -114,6 +126,7 @@ export const blobControlsDataMock = { fileType: 'rich', }, richViewer: { + __typename: 'BlobViewer', collapsed: false, loadingPartialName: 'loading', renderError: 'too big file',