Commit c2fdb38c authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '333773-blob-header-delete-modal' into 'master'

Blob refactor: add blob delete modal

See merge request gitlab-org/gitlab!65293
parents 50eee8e0 d4b5335a
...@@ -3,6 +3,7 @@ import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import getRefMixin from '../mixins/get_ref'; import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue';
export default { export default {
...@@ -15,6 +16,7 @@ export default { ...@@ -15,6 +16,7 @@ export default {
GlButtonGroup, GlButtonGroup,
GlButton, GlButton,
UploadBlobModal, UploadBlobModal,
DeleteBlobModal,
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
...@@ -41,10 +43,18 @@ export default { ...@@ -41,10 +43,18 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
deletePath: {
type: String,
required: true,
},
canPushCode: { canPushCode: {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
emptyRepo: {
type: Boolean,
required: true,
},
}, },
computed: { computed: {
replaceModalId() { replaceModalId() {
...@@ -53,6 +63,12 @@ export default { ...@@ -53,6 +63,12 @@ export default {
replaceModalTitle() { replaceModalTitle() {
return sprintf(__('Replace %{name}'), { name: this.name }); return sprintf(__('Replace %{name}'), { name: this.name });
}, },
deleteModalId() {
return uniqueId('delete-modal');
},
deleteModalTitle() {
return sprintf(__('Delete %{name}'), { name: this.name });
},
}, },
}; };
</script> </script>
...@@ -63,7 +79,9 @@ export default { ...@@ -63,7 +79,9 @@ export default {
<gl-button v-gl-modal="replaceModalId"> <gl-button v-gl-modal="replaceModalId">
{{ $options.i18n.replace }} {{ $options.i18n.replace }}
</gl-button> </gl-button>
<gl-button>{{ $options.i18n.delete }}</gl-button> <gl-button v-gl-modal="deleteModalId">
{{ $options.i18n.delete }}
</gl-button>
</gl-button-group> </gl-button-group>
<upload-blob-modal <upload-blob-modal
:modal-id="replaceModalId" :modal-id="replaceModalId"
...@@ -76,5 +94,15 @@ export default { ...@@ -76,5 +94,15 @@ export default {
:replace-path="replacePath" :replace-path="replacePath"
:primary-btn-text="$options.i18n.replacePrimaryBtnText" :primary-btn-text="$options.i18n.replacePrimaryBtnText"
/> />
<delete-blob-modal
:modal-id="deleteModalId"
:modal-title="deleteModalTitle"
:delete-path="deletePath"
:commit-message="deleteModalTitle"
:target-branch="targetBranch || ref"
:original-branch="originalBranch || ref"
:can-push-code="canPushCode"
:empty-repo="emptyRepo"
/>
</div> </div>
</template> </template>
...@@ -69,6 +69,7 @@ export default { ...@@ -69,6 +69,7 @@ export default {
pushCode: false, pushCode: false,
}, },
repository: { repository: {
empty: true,
blobs: { blobs: {
nodes: [ nodes: [
{ {
...@@ -92,6 +93,7 @@ export default { ...@@ -92,6 +93,7 @@ export default {
forkPath: '', forkPath: '',
simpleViewer: {}, simpleViewer: {},
richViewer: null, richViewer: null,
webPath: '',
}, },
], ],
}, },
...@@ -174,7 +176,9 @@ export default { ...@@ -174,7 +176,9 @@ export default {
:path="path" :path="path"
:name="blobInfo.name" :name="blobInfo.name"
:replace-path="blobInfo.replacePath" :replace-path="blobInfo.replacePath"
:delete-path="blobInfo.webPath"
:can-push-code="project.userPermissions.pushCode" :can-push-code="project.userPermissions.pushCode"
:empty-repo="project.repository.empty"
/> />
</template> </template>
</blob-header> </blob-header>
......
<script>
import { GlModal, GlFormGroup, GlFormInput, GlFormTextarea, GlToggle } from '@gitlab/ui';
import csrf from '~/lib/utils/csrf';
import { __ } from '~/locale';
import {
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
} from '../constants';
export default {
csrf,
components: {
GlModal,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlToggle,
},
i18n: {
PRIMARY_OPTIONS_TEXT: __('Delete file'),
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
},
props: {
modalId: {
type: String,
required: true,
},
modalTitle: {
type: String,
required: true,
},
deletePath: {
type: String,
required: true,
},
commitMessage: {
type: String,
required: true,
},
targetBranch: {
type: String,
required: true,
},
originalBranch: {
type: String,
required: true,
},
canPushCode: {
type: Boolean,
required: true,
},
emptyRepo: {
type: Boolean,
required: true,
},
},
data() {
return {
loading: false,
commit: this.commitMessage,
target: this.targetBranch,
createNewMr: true,
error: '',
};
},
computed: {
primaryOptions() {
return {
text: this.$options.i18n.PRIMARY_OPTIONS_TEXT,
attributes: [
{
variant: 'danger',
loading: this.loading,
disabled: !this.formCompleted || this.loading,
},
],
};
},
cancelOptions() {
return {
text: this.$options.i18n.SECONDARY_OPTIONS_TEXT,
attributes: [
{
disabled: this.loading,
},
],
};
},
showCreateNewMrToggle() {
return this.canPushCode && this.target !== this.originalBranch;
},
formCompleted() {
return this.commit && this.target;
},
},
methods: {
submitForm(e) {
e.preventDefault(); // Prevent modal from closing
this.loading = true;
this.$refs.form.submit();
},
},
};
</script>
<template>
<gl-modal
:modal-id="modalId"
:title="modalTitle"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
@primary="submitForm"
>
<form ref="form" :action="deletePath" method="post">
<input type="hidden" name="_method" value="delete" />
<input :value="$options.csrf.token" type="hidden" name="authenticity_token" />
<template v-if="emptyRepo">
<!-- Once "empty_repo_upload_experiment" is made available, will need to add class 'js-branch-name'
Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335721 -->
<input type="hidden" name="branch_name" :value="originalBranch" />
</template>
<template v-else>
<input type="hidden" name="original_branch" :value="originalBranch" />
<!-- Once "push to branch" permission is made available, will need to add to conditional
Follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/335462 -->
<input v-if="createNewMr" type="hidden" name="create_merge_request" value="1" />
<gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
<gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
</gl-form-group>
<gl-form-group
v-if="canPushCode"
:label="$options.i18n.TARGET_BRANCH_LABEL"
label-for="branch_name"
>
<gl-form-input v-model="target" :disabled="loading" name="branch_name" />
</gl-form-group>
<gl-toggle
v-if="showCreateNewMrToggle"
v-model="createNewMr"
:disabled="loading"
:label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
/>
</template>
</form>
</gl-modal>
</template>
import { __ } from '~/locale';
export const TREE_PAGE_LIMIT = 1000; // the maximum amount of items per page 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_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 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');
...@@ -4,6 +4,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) { ...@@ -4,6 +4,7 @@ query getBlobInfo($projectPath: ID!, $filePath: String!) {
pushCode pushCode
} }
repository { repository {
empty
blobs(paths: [$filePath]) { blobs(paths: [$filePath]) {
nodes { nodes {
webPath webPath
......
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import BlobButtonGroup from '~/repository/components/blob_button_group.vue'; 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'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
const DEFAULT_PROPS = { const DEFAULT_PROPS = {
...@@ -10,6 +10,8 @@ const DEFAULT_PROPS = { ...@@ -10,6 +10,8 @@ const DEFAULT_PROPS = {
path: 'some/path', path: 'some/path',
canPushCode: true, canPushCode: true,
replacePath: 'some/replace/path', replacePath: 'some/replace/path',
deletePath: 'some/delete/path',
emptyRepo: false,
}; };
const DEFAULT_INJECT = { const DEFAULT_INJECT = {
...@@ -39,6 +41,7 @@ describe('BlobButtonGroup component', () => { ...@@ -39,6 +41,7 @@ describe('BlobButtonGroup component', () => {
wrapper.destroy(); wrapper.destroy();
}); });
const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal);
const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal);
const findReplaceButton = () => wrapper.findAll(GlButton).at(0); const findReplaceButton = () => wrapper.findAll(GlButton).at(0);
...@@ -93,4 +96,22 @@ describe('BlobButtonGroup component', () => { ...@@ -93,4 +96,22 @@ describe('BlobButtonGroup component', () => {
primaryBtnText: 'Replace file', 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,
});
});
}); });
...@@ -58,23 +58,36 @@ const richMockData = { ...@@ -58,23 +58,36 @@ const richMockData = {
renderError: null, renderError: null,
}, },
}; };
const userPermissionsMockData = {
const projectMockData = {
userPermissions: { userPermissions: {
pushCode: true, pushCode: true,
}, },
repository: {
empty: false,
},
}; };
const localVue = createLocalVue(); const localVue = createLocalVue();
const mockAxios = new MockAdapter(axios); const mockAxios = new MockAdapter(axios);
const createComponentWithApollo = (mockData, mockPermissionData = true) => { const createComponentWithApollo = (mockData = {}) => {
localVue.use(VueApollo); 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({ const mockResolver = jest.fn().mockResolvedValue({
data: { data: {
project: { project: {
userPermissions: { pushCode: mockPermissionData }, userPermissions: { pushCode: canPushCode },
repository: { blobs: { nodes: [mockData] } }, repository: {
empty: emptyRepo,
blobs: {
nodes: [blobs],
},
},
}, },
}, },
}); });
...@@ -209,14 +222,14 @@ describe('Blob content viewer component', () => { ...@@ -209,14 +222,14 @@ describe('Blob content viewer component', () => {
describe('legacy viewers', () => { describe('legacy viewers', () => {
it('does not load a legacy viewer when a rich viewer is not available', async () => { it('does not load a legacy viewer when a rich viewer is not available', async () => {
createComponentWithApollo(simpleMockData); createComponentWithApollo({ blobs: simpleMockData });
await waitForPromises(); await waitForPromises();
expect(mockAxios.history.get).toHaveLength(0); expect(mockAxios.history.get).toHaveLength(0);
}); });
it('loads a legacy viewer when a rich viewer is available', async () => { it('loads a legacy viewer when a rich viewer is available', async () => {
createComponentWithApollo(richMockData); createComponentWithApollo({ blobs: richMockData });
await waitForPromises(); await waitForPromises();
expect(mockAxios.history.get).toHaveLength(1); expect(mockAxios.history.get).toHaveLength(1);
...@@ -320,16 +333,20 @@ describe('Blob content viewer component', () => { ...@@ -320,16 +333,20 @@ describe('Blob content viewer component', () => {
}); });
describe('BlobButtonGroup', () => { describe('BlobButtonGroup', () => {
const { name, path, replacePath } = simpleMockData; const { name, path, replacePath, webPath } = simpleMockData;
const { const {
userPermissions: { pushCode }, userPermissions: { pushCode },
} = userPermissionsMockData; repository: { empty },
} = projectMockData;
it('renders component', async () => { it('renders component', async () => {
window.gon.current_user_id = 1; window.gon.current_user_id = 1;
fullFactory({ fullFactory({
mockData: { blobInfo: simpleMockData, project: userPermissionsMockData }, mockData: {
blobInfo: simpleMockData,
project: { userPermissions: { pushCode }, repository: { empty } },
},
stubs: { stubs: {
BlobContent: true, BlobContent: true,
BlobButtonGroup: true, BlobButtonGroup: true,
...@@ -342,7 +359,9 @@ describe('Blob content viewer component', () => { ...@@ -342,7 +359,9 @@ describe('Blob content viewer component', () => {
name, name,
path, path,
replacePath, replacePath,
deletePath: webPath,
canPushCode: pushCode, canPushCode: pushCode,
emptyRepo: empty,
}); });
}); });
......
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);
},
);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment