From d4b5335a5641edbe2268d77da8574100779512aa Mon Sep 17 00:00:00 2001 From: Samantha Ming Date: Wed, 7 Jul 2021 06:50:53 -0700 Subject: [PATCH] Add blob delete modal Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/333773 --- .../components/blob_button_group.vue | 30 +++- .../components/blob_content_viewer.vue | 4 + .../components/delete_blob_modal.vue | 151 ++++++++++++++++++ .../javascripts/repository/constants.js | 7 + .../queries/blob_info.query.graphql | 1 + .../components/blob_button_group_spec.js | 23 ++- .../components/blob_content_viewer_spec.js | 37 +++-- .../components/delete_blob_modal_spec.js | 130 +++++++++++++++ 8 files changed, 372 insertions(+), 11 deletions(-) create mode 100644 app/assets/javascripts/repository/components/delete_blob_modal.vue create mode 100644 spec/frontend/repository/components/delete_blob_modal_spec.js diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 424dc4529ffacd..273825b996a3d6 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -3,6 +3,7 @@ import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; import { sprintf, __ } from '~/locale'; import getRefMixin from '../mixins/get_ref'; +import DeleteBlobModal from './delete_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue'; export default { @@ -15,6 +16,7 @@ export default { GlButtonGroup, GlButton, UploadBlobModal, + DeleteBlobModal, }, directives: { GlModal: GlModalDirective, @@ -41,10 +43,18 @@ export default { type: String, required: true, }, + deletePath: { + type: String, + required: true, + }, canPushCode: { type: Boolean, required: true, }, + emptyRepo: { + type: Boolean, + required: true, + }, }, computed: { replaceModalId() { @@ -53,6 +63,12 @@ export default { replaceModalTitle() { return sprintf(__('Replace %{name}'), { name: this.name }); }, + deleteModalId() { + return uniqueId('delete-modal'); + }, + deleteModalTitle() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, }, }; @@ -63,7 +79,9 @@ export default { {{ $options.i18n.replace }} - {{ $options.i18n.delete }} + + {{ $options.i18n.delete }} + + diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index c3876a77ec4ccf..09ac60c94c7e19 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -69,6 +69,7 @@ export default { pushCode: false, }, repository: { + empty: true, blobs: { nodes: [ { @@ -92,6 +93,7 @@ export default { forkPath: '', simpleViewer: {}, richViewer: null, + webPath: '', }, ], }, @@ -174,7 +176,9 @@ export default { :path="path" :name="blobInfo.name" :replace-path="blobInfo.replacePath" + :delete-path="blobInfo.webPath" :can-push-code="project.userPermissions.pushCode" + :empty-repo="project.repository.empty" /> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue new file mode 100644 index 00000000000000..6599d99d7bdae9 --- /dev/null +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -0,0 +1,151 @@ + + + diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 22349261d3cfff..2d2faa8d9f397a 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -1,3 +1,10 @@ +import { __ } from '~/locale'; + export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page export const TREE_PAGE_SIZE = 100; // the amount of items to be fetched per (batch) request export const TREE_INITIAL_FETCH_COUNT = TREE_PAGE_LIMIT / TREE_PAGE_SIZE; // the amount of (batch) requests to make + +export const SECONDARY_OPTIONS_TEXT = __('Cancel'); +export const COMMIT_LABEL = __('Commit message'); +export const TARGET_BRANCH_LABEL = __('Target branch'); +export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 1889f2269f5938..a8f263941e2971 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -4,6 +4,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { pushCode } repository { + empty blobs(paths: [$filePath]) { nodes { webPath diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index b9a11dd1270270..a449fd6f06c934 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -1,8 +1,8 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; - import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; +import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; const DEFAULT_PROPS = { @@ -10,6 +10,8 @@ const DEFAULT_PROPS = { path: 'some/path', canPushCode: true, replacePath: 'some/replace/path', + deletePath: 'some/delete/path', + emptyRepo: false, }; const DEFAULT_INJECT = { @@ -39,6 +41,7 @@ describe('BlobButtonGroup component', () => { wrapper.destroy(); }); + const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); const findReplaceButton = () => wrapper.findAll(GlButton).at(0); @@ -93,4 +96,22 @@ describe('BlobButtonGroup component', () => { primaryBtnText: 'Replace file', }); }); + + it('renders DeleteBlobModel', () => { + createComponent(); + + const { targetBranch, originalBranch } = DEFAULT_INJECT; + const { name, canPushCode, deletePath, emptyRepo } = DEFAULT_PROPS; + const title = `Delete ${name}`; + + expect(findDeleteBlobModal().props()).toMatchObject({ + modalTitle: title, + commitMessage: title, + targetBranch, + originalBranch, + canPushCode, + deletePath, + emptyRepo, + }); + }); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index e1ac46171bb106..a83d0a607f2f41 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -58,23 +58,36 @@ const richMockData = { renderError: null, }, }; -const userPermissionsMockData = { + +const projectMockData = { userPermissions: { pushCode: true, }, + repository: { + empty: false, + }, }; const localVue = createLocalVue(); const mockAxios = new MockAdapter(axios); -const createComponentWithApollo = (mockData, mockPermissionData = true) => { +const createComponentWithApollo = (mockData = {}) => { localVue.use(VueApollo); + const defaultPushCode = projectMockData.userPermissions.pushCode; + const defaultEmptyRepo = projectMockData.repository.empty; + const { blobs, emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode } = mockData; + const mockResolver = jest.fn().mockResolvedValue({ data: { project: { - userPermissions: { pushCode: mockPermissionData }, - repository: { blobs: { nodes: [mockData] } }, + userPermissions: { pushCode: canPushCode }, + repository: { + empty: emptyRepo, + blobs: { + nodes: [blobs], + }, + }, }, }, }); @@ -209,14 +222,14 @@ describe('Blob content viewer component', () => { describe('legacy viewers', () => { it('does not load a legacy viewer when a rich viewer is not available', async () => { - createComponentWithApollo(simpleMockData); + createComponentWithApollo({ blobs: simpleMockData }); await waitForPromises(); expect(mockAxios.history.get).toHaveLength(0); }); it('loads a legacy viewer when a rich viewer is available', async () => { - createComponentWithApollo(richMockData); + createComponentWithApollo({ blobs: richMockData }); await waitForPromises(); expect(mockAxios.history.get).toHaveLength(1); @@ -320,16 +333,20 @@ describe('Blob content viewer component', () => { }); describe('BlobButtonGroup', () => { - const { name, path, replacePath } = simpleMockData; + const { name, path, replacePath, webPath } = simpleMockData; const { userPermissions: { pushCode }, - } = userPermissionsMockData; + repository: { empty }, + } = projectMockData; it('renders component', async () => { window.gon.current_user_id = 1; fullFactory({ - mockData: { blobInfo: simpleMockData, project: userPermissionsMockData }, + mockData: { + blobInfo: simpleMockData, + project: { userPermissions: { pushCode }, repository: { empty } }, + }, stubs: { BlobContent: true, BlobButtonGroup: true, @@ -342,7 +359,9 @@ describe('Blob content viewer component', () => { name, path, replacePath, + deletePath: webPath, canPushCode: pushCode, + emptyRepo: empty, }); }); diff --git a/spec/frontend/repository/components/delete_blob_modal_spec.js b/spec/frontend/repository/components/delete_blob_modal_spec.js new file mode 100644 index 00000000000000..a74e3e6d325064 --- /dev/null +++ b/spec/frontend/repository/components/delete_blob_modal_spec.js @@ -0,0 +1,130 @@ +import { GlFormTextarea, GlModal, GlFormInput, GlToggle } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); + +const initialProps = { + modalId: 'Delete-blob', + modalTitle: 'Delete File', + deletePath: 'some/path', + commitMessage: 'Delete File', + targetBranch: 'some-target-branch', + originalBranch: 'main', + canPushCode: true, + emptyRepo: false, +}; + +describe('DeleteBlobModal', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(DeleteBlobModal, { + propsData: { + ...initialProps, + ...props, + }, + }); + }; + + const findModal = () => wrapper.findComponent(GlModal); + const findForm = () => wrapper.findComponent({ ref: 'form' }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders Modal component', () => { + createComponent(); + + const { modalTitle: title } = initialProps; + + expect(findModal().props()).toMatchObject({ + title, + size: 'md', + actionPrimary: { + text: 'Delete file', + }, + actionCancel: { + text: 'Cancel', + }, + }); + }); + + describe('form', () => { + it('gets passed the path for action attribute', () => { + createComponent(); + expect(findForm().attributes('action')).toBe(initialProps.deletePath); + }); + + it('submits the form', async () => { + createComponent(); + + const submitSpy = jest.spyOn(findForm().element, 'submit'); + findModal().vm.$emit('primary', { preventDefault: () => {} }); + await nextTick(); + + expect(submitSpy).toHaveBeenCalled(); + submitSpy.mockRestore(); + }); + + it.each` + component | defaultValue | canPushCode | targetBranch | originalBranch | exist + ${GlFormTextarea} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${GlFormInput} | ${initialProps.targetBranch} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${GlFormInput} | ${undefined} | ${false} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false} + ${GlToggle} | ${'true'} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true} + ${GlToggle} | ${undefined} | ${true} | ${'same-branch'} | ${'same-branch'} | ${false} + `( + 'has the correct form fields ', + ({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => { + createComponent({ + canPushCode, + targetBranch, + originalBranch, + }); + const formField = wrapper.findComponent(component); + + if (!exist) { + expect(formField.exists()).toBe(false); + return; + } + + expect(formField.exists()).toBe(true); + expect(formField.attributes('value')).toBe(defaultValue); + }, + ); + + it.each` + input | value | emptyRepo | canPushCode | exist + ${'authenticity_token'} | ${'mock-csrf-token'} | ${false} | ${true} | ${true} + ${'authenticity_token'} | ${'mock-csrf-token'} | ${true} | ${false} | ${true} + ${'_method'} | ${'delete'} | ${false} | ${true} | ${true} + ${'_method'} | ${'delete'} | ${true} | ${false} | ${true} + ${'original_branch'} | ${initialProps.originalBranch} | ${false} | ${true} | ${true} + ${'original_branch'} | ${undefined} | ${true} | ${true} | ${false} + ${'create_merge_request'} | ${'1'} | ${false} | ${false} | ${true} + ${'create_merge_request'} | ${'1'} | ${false} | ${true} | ${true} + ${'create_merge_request'} | ${undefined} | ${true} | ${false} | ${false} + `( + 'passes $input as a hidden input with the correct value', + ({ input, value, emptyRepo, canPushCode, exist }) => { + createComponent({ + emptyRepo, + canPushCode, + }); + + const inputMethod = findForm().find(`input[name="${input}"]`); + + if (!exist) { + expect(inputMethod.exists()).toBe(false); + return; + } + + expect(inputMethod.attributes('type')).toBe('hidden'); + expect(inputMethod.attributes('value')).toBe(value); + }, + ); + }); +}); -- GitLab