Commit e4d3923c authored by Savas Vedova's avatar Savas Vedova

Merge branch '330406-add-lock-button' into 'master'

Blob refactor: Add ability to lock/unlock a blob

See merge request gitlab-org/gitlab!67408
parents b146ddec d9b95acb
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui'; import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../mixins/get_ref'; import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue'; import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue';
...@@ -17,11 +18,12 @@ export default { ...@@ -17,11 +18,12 @@ export default {
GlButton, GlButton,
UploadBlobModal, UploadBlobModal,
DeleteBlobModal, DeleteBlobModal,
LockButton: () => import('ee_component/repository/components/lock_button.vue'),
}, },
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
mixins: [getRefMixin], mixins: [getRefMixin, glFeatureFlagMixin()],
inject: { inject: {
targetBranch: { targetBranch: {
default: '', default: '',
...@@ -55,6 +57,18 @@ export default { ...@@ -55,6 +57,18 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
projectPath: {
type: String,
required: true,
},
isLocked: {
type: Boolean,
required: true,
},
canLock: {
type: Boolean,
required: true,
},
}, },
computed: { computed: {
replaceModalId() { replaceModalId() {
...@@ -76,10 +90,19 @@ export default { ...@@ -76,10 +90,19 @@ export default {
<template> <template>
<div class="gl-mr-3"> <div class="gl-mr-3">
<gl-button-group> <gl-button-group>
<gl-button v-gl-modal="replaceModalId"> <lock-button
v-if="glFeatures.fileLocks"
:name="name"
:path="path"
:project-path="projectPath"
:is-locked="isLocked"
:can-lock="canLock"
data-testid="lock"
/>
<gl-button v-gl-modal="replaceModalId" data-testid="replace">
{{ $options.i18n.replace }} {{ $options.i18n.replace }}
</gl-button> </gl-button>
<gl-button v-gl-modal="deleteModalId"> <gl-button v-gl-modal="deleteModalId" data-testid="delete">
{{ $options.i18n.delete }} {{ $options.i18n.delete }}
</gl-button> </gl-button>
</gl-button-group> </gl-button-group>
......
...@@ -75,6 +75,10 @@ export default { ...@@ -75,6 +75,10 @@ export default {
project: { project: {
userPermissions: { userPermissions: {
pushCode: false, pushCode: false,
downloadCode: false,
},
pathLocks: {
nodes: [],
}, },
repository: { repository: {
empty: true, empty: true,
...@@ -95,9 +99,6 @@ export default { ...@@ -95,9 +99,6 @@ export default {
externalStorageUrl: '', externalStorageUrl: '',
replacePath: '', replacePath: '',
deletePath: '', deletePath: '',
canLock: false,
isLocked: false,
lockLink: '',
forkPath: '', forkPath: '',
simpleViewer: {}, simpleViewer: {},
richViewer: null, richViewer: null,
...@@ -120,7 +121,7 @@ export default { ...@@ -120,7 +121,7 @@ export default {
return this.isBinary || this.viewer.fileType === 'download'; return this.isBinary || this.viewer.fileType === 'download';
}, },
blobInfo() { blobInfo() {
const nodes = this.project?.repository?.blobs?.nodes; const nodes = this.project?.repository?.blobs?.nodes || [];
return nodes[0] || {}; return nodes[0] || {};
}, },
...@@ -142,6 +143,14 @@ export default { ...@@ -142,6 +143,14 @@ export default {
const { fileType } = this.viewer; const { fileType } = this.viewer;
return viewerProps(fileType, this.blobInfo); return viewerProps(fileType, this.blobInfo);
}, },
canLock() {
const { pushCode, downloadCode } = this.project.userPermissions;
return pushCode && downloadCode;
},
isLocked() {
return this.project.pathLocks.nodes.some((node) => node.path === this.path);
},
}, },
methods: { methods: {
loadLegacyViewer() { loadLegacyViewer() {
...@@ -191,6 +200,9 @@ export default { ...@@ -191,6 +200,9 @@ export default {
:delete-path="blobInfo.webPath" :delete-path="blobInfo.webPath"
:can-push-code="project.userPermissions.pushCode" :can-push-code="project.userPermissions.pushCode"
:empty-repo="project.repository.empty" :empty-repo="project.repository.empty"
:project-path="projectPath"
:is-locked="isLocked"
:can-lock="canLock"
/> />
</template> </template>
</blob-header> </blob-header>
......
...@@ -170,6 +170,7 @@ export default { ...@@ -170,6 +170,7 @@ export default {
this.apolloQuery(blobInfoQuery, { this.apolloQuery(blobInfoQuery, {
projectPath: this.projectPath, projectPath: this.projectPath,
filePath: this.path, filePath: this.path,
ref: this.ref,
}); });
}, },
apolloQuery(query, variables) { apolloQuery(query, variables) {
......
mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) { mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) {
projectSetLocked(input: { projectPath: $projectPath, filePath: $filePath, lock: $lock }) { projectSetLocked(input: { projectPath: $projectPath, filePath: $filePath, lock: $lock }) {
project { project {
id
pathLocks { pathLocks {
nodes { nodes {
path path
......
query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
id
userPermissions { userPermissions {
pushCode pushCode
downloadCode
}
pathLocks {
nodes {
path
}
} }
repository { repository {
empty empty
......
...@@ -44,6 +44,7 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -44,6 +44,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml) push_frontend_feature_flag(:refactor_blob_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml) push_frontend_feature_flag(:consolidated_edit_button, @project, default_enabled: :yaml)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end end
def new def new
......
<script>
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import { sprintf, __ } from '~/locale';
import lockPathMutation from '~/repository/mutations/lock_path.mutation.graphql';
export default {
i18n: {
lock: __('Lock'),
unlock: __('Unlock'),
},
components: {
GlButton,
},
props: {
name: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
projectPath: {
type: String,
required: true,
},
isLocked: {
type: Boolean,
required: true,
},
canLock: {
type: Boolean,
required: true,
},
},
data() {
return {
lockLoading: false,
};
},
computed: {
lockButtonTitle() {
return this.isLocked ? this.$options.i18n.unlock : this.$options.i18n.lock;
},
lockConfirmText() {
return sprintf(__('Are you sure you want to %{action} %{name}?'), {
action: this.lockButtonTitle.toLowerCase(),
name: this.name,
});
},
},
methods: {
onLockToggle() {
// eslint-disable-next-line no-alert
if (window.confirm(this.lockConfirmText)) {
this.toggleLock();
}
},
toggleLock() {
this.lockLoading = true;
this.$apollo
.mutate({
mutation: lockPathMutation,
variables: {
filePath: this.path,
projectPath: this.projectPath,
lock: !this.isLocked,
},
})
.catch((error) => {
createFlash({ message: error, captureError: true, error });
})
.finally(() => {
this.lockLoading = false;
});
},
},
};
</script>
<template>
<gl-button v-if="canLock" :loading="lockLoading" @click="onLockToggle">
{{ lockButtonTitle }}
</gl-button>
</template>
import { GlButton } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import LockButton from 'ee_component/repository/components/lock_button.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import lockPathMutation from '~/repository/mutations/lock_path.mutation.graphql';
const DEFAULT_PROPS = {
name: 'some_file.js',
path: 'some/path',
projectPath: 'some/project/path',
isLocked: false,
canLock: true,
};
describe('LockButton component', () => {
const localVue = createLocalVue();
let wrapper;
const createMockApolloProvider = (resolverMock) => {
localVue.use(VueApollo);
return createMockApollo([[lockPathMutation, resolverMock]]);
};
const createComponent = (props = {}, lockMutation = jest.fn()) => {
wrapper = shallowMount(LockButton, {
localVue,
apolloProvider: createMockApolloProvider(lockMutation),
propsData: {
...DEFAULT_PROPS,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe('lock button', () => {
let confirmSpy;
let lockMutationMock;
const findLockButton = () => wrapper.find(GlButton);
beforeEach(() => {
confirmSpy = jest.spyOn(window, 'confirm');
confirmSpy.mockImplementation(jest.fn());
lockMutationMock = jest.fn();
});
afterEach(() => confirmSpy.mockRestore());
it('does not render if canLock is set to false', () => {
createComponent({ canLock: false });
expect(findLockButton().exists()).toBe(false);
});
it.each`
isLocked | label
${false} | ${'Lock'}
${true} | ${'Unlock'}
`('renders the correct button labels', ({ isLocked, label }) => {
createComponent({ isLocked });
expect(findLockButton().text()).toBe(label);
});
it('passes the correct prop if lockLoading is set to true', async () => {
createComponent();
wrapper.setData({ lockLoading: true });
await nextTick();
expect(findLockButton().props('loading')).toBe(true);
});
it('displays a confirm dialog when the lock button is clicked', () => {
createComponent();
findLockButton().vm.$emit('click');
expect(confirmSpy).toHaveBeenCalledWith('Are you sure you want to lock some_file.js?');
});
it('executes a lock mutation once lock is confirmed', () => {
confirmSpy.mockReturnValue(true);
createComponent({}, lockMutationMock);
findLockButton().vm.$emit('click');
expect(lockMutationMock).toHaveBeenCalledWith({
filePath: 'some/path',
lock: true,
projectPath: 'some/project/path',
});
});
it('does not execute a lock mutation if lock not confirmed', () => {
confirmSpy.mockReturnValue(false);
createComponent({}, lockMutationMock);
findLockButton().vm.$emit('click');
expect(lockMutationMock).not.toHaveBeenCalled();
});
});
});
...@@ -4323,6 +4323,9 @@ msgstr "" ...@@ -4323,6 +4323,9 @@ msgstr ""
msgid "Are you sure that you want to unarchive this project?" msgid "Are you sure that you want to unarchive this project?"
msgstr "" msgstr ""
msgid "Are you sure you want to %{action} %{name}?"
msgstr ""
msgid "Are you sure you want to cancel editing this comment?" msgid "Are you sure you want to cancel editing this comment?"
msgstr "" msgstr ""
......
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import LockButton from 'ee_component/repository/components/lock_button.vue';
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 DeleteBlobModal from '~/repository/components/delete_blob_modal.vue';
...@@ -12,9 +13,13 @@ const DEFAULT_PROPS = { ...@@ -12,9 +13,13 @@ const DEFAULT_PROPS = {
replacePath: 'some/replace/path', replacePath: 'some/replace/path',
deletePath: 'some/delete/path', deletePath: 'some/delete/path',
emptyRepo: false, emptyRepo: false,
projectPath: 'some/project/path',
isLocked: false,
canLock: true,
}; };
const DEFAULT_INJECT = { const DEFAULT_INJECT = {
glFeatures: { fileLocks: true },
targetBranch: 'master', targetBranch: 'master',
originalBranch: 'master', originalBranch: 'master',
}; };
...@@ -43,7 +48,8 @@ describe('BlobButtonGroup component', () => { ...@@ -43,7 +48,8 @@ describe('BlobButtonGroup component', () => {
const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal); 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.find('[data-testid="replace"]');
const findLockButton = () => wrapper.findComponent(LockButton);
it('renders component', () => { it('renders component', () => {
createComponent(); createComponent();
...@@ -61,6 +67,18 @@ describe('BlobButtonGroup component', () => { ...@@ -61,6 +67,18 @@ describe('BlobButtonGroup component', () => {
createComponent(); createComponent();
}); });
it('renders the lock button', () => {
expect(findLockButton().exists()).toBe(true);
expect(findLockButton().props()).toMatchObject({
canLock: true,
isLocked: false,
name: 'some name',
path: 'some/path',
projectPath: 'some/project/path',
});
});
it('renders both the replace and delete button', () => { it('renders both the replace and delete button', () => {
expect(wrapper.findAll(GlButton)).toHaveLength(2); expect(wrapper.findAll(GlButton)).toHaveLength(2);
}); });
......
...@@ -39,9 +39,6 @@ const simpleMockData = { ...@@ -39,9 +39,6 @@ const simpleMockData = {
externalStorageUrl: 'some_file.js', externalStorageUrl: 'some_file.js',
replacePath: 'some_file.js/replace', replacePath: 'some_file.js/replace',
deletePath: 'some_file.js/delete', deletePath: 'some_file.js/delete',
canLock: true,
isLocked: false,
lockLink: 'some_file.js/lock',
forkPath: 'some_file.js/fork', forkPath: 'some_file.js/fork',
simpleViewer: { simpleViewer: {
fileType: 'text', fileType: 'text',
...@@ -64,6 +61,7 @@ const richMockData = { ...@@ -64,6 +61,7 @@ const richMockData = {
const projectMockData = { const projectMockData = {
userPermissions: { userPermissions: {
pushCode: true, pushCode: true,
downloadCode: true,
}, },
repository: { repository: {
empty: false, empty: false,
...@@ -77,13 +75,24 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => { ...@@ -77,13 +75,24 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => {
localVue.use(VueApollo); localVue.use(VueApollo);
const defaultPushCode = projectMockData.userPermissions.pushCode; const defaultPushCode = projectMockData.userPermissions.pushCode;
const defaultDownloadCode = projectMockData.userPermissions.downloadCode;
const defaultEmptyRepo = projectMockData.repository.empty; const defaultEmptyRepo = projectMockData.repository.empty;
const { blobs, emptyRepo = defaultEmptyRepo, canPushCode = defaultPushCode } = mockData; const {
blobs,
emptyRepo = defaultEmptyRepo,
canPushCode = defaultPushCode,
canDownloadCode = defaultDownloadCode,
pathLocks = [],
} = mockData;
mockResolver = jest.fn().mockResolvedValue({ mockResolver = jest.fn().mockResolvedValue({
data: { data: {
project: { project: {
userPermissions: { pushCode: canPushCode }, id: '1234',
userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode },
pathLocks: {
nodes: pathLocks,
},
repository: { repository: {
empty: emptyRepo, empty: emptyRepo,
blobs: { blobs: {
...@@ -371,7 +380,7 @@ describe('Blob content viewer component', () => { ...@@ -371,7 +380,7 @@ describe('Blob content viewer component', () => {
describe('BlobButtonGroup', () => { describe('BlobButtonGroup', () => {
const { name, path, replacePath, webPath } = simpleMockData; const { name, path, replacePath, webPath } = simpleMockData;
const { const {
userPermissions: { pushCode }, userPermissions: { pushCode, downloadCode },
repository: { empty }, repository: { empty },
} = projectMockData; } = projectMockData;
...@@ -381,7 +390,7 @@ describe('Blob content viewer component', () => { ...@@ -381,7 +390,7 @@ describe('Blob content viewer component', () => {
fullFactory({ fullFactory({
mockData: { mockData: {
blobInfo: simpleMockData, blobInfo: simpleMockData,
project: { userPermissions: { pushCode }, repository: { empty } }, project: { userPermissions: { pushCode, downloadCode }, repository: { empty } },
}, },
stubs: { stubs: {
BlobContent: true, BlobContent: true,
...@@ -397,10 +406,37 @@ describe('Blob content viewer component', () => { ...@@ -397,10 +406,37 @@ describe('Blob content viewer component', () => {
replacePath, replacePath,
deletePath: webPath, deletePath: webPath,
canPushCode: pushCode, canPushCode: pushCode,
canLock: true,
isLocked: false,
emptyRepo: empty, emptyRepo: empty,
}); });
}); });
it.each`
canPushCode | canDownloadCode | canLock
${true} | ${true} | ${true}
${false} | ${true} | ${false}
${true} | ${false} | ${false}
`('passes the correct lock states', async ({ canPushCode, canDownloadCode, canLock }) => {
fullFactory({
mockData: {
blobInfo: simpleMockData,
project: {
userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode },
repository: { empty },
},
},
stubs: {
BlobContent: true,
BlobButtonGroup: true,
},
});
await nextTick();
expect(findBlobButtonGroup().props('canLock')).toBe(canLock);
});
it('does not render if not logged in', async () => { it('does not render if not logged in', async () => {
window.gon.current_user_id = null; window.gon.current_user_id = null;
......
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