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 @@
import { GlButtonGroup, GlButton, GlModalDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { sprintf, __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getRefMixin from '../mixins/get_ref';
import DeleteBlobModal from './delete_blob_modal.vue';
import UploadBlobModal from './upload_blob_modal.vue';
......@@ -17,11 +18,12 @@ export default {
GlButton,
UploadBlobModal,
DeleteBlobModal,
LockButton: () => import('ee_component/repository/components/lock_button.vue'),
},
directives: {
GlModal: GlModalDirective,
},
mixins: [getRefMixin],
mixins: [getRefMixin, glFeatureFlagMixin()],
inject: {
targetBranch: {
default: '',
......@@ -55,6 +57,18 @@ export default {
type: Boolean,
required: true,
},
projectPath: {
type: String,
required: true,
},
isLocked: {
type: Boolean,
required: true,
},
canLock: {
type: Boolean,
required: true,
},
},
computed: {
replaceModalId() {
......@@ -76,10 +90,19 @@ export default {
<template>
<div class="gl-mr-3">
<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 }}
</gl-button>
<gl-button v-gl-modal="deleteModalId">
<gl-button v-gl-modal="deleteModalId" data-testid="delete">
{{ $options.i18n.delete }}
</gl-button>
</gl-button-group>
......
......@@ -75,6 +75,10 @@ export default {
project: {
userPermissions: {
pushCode: false,
downloadCode: false,
},
pathLocks: {
nodes: [],
},
repository: {
empty: true,
......@@ -95,9 +99,6 @@ export default {
externalStorageUrl: '',
replacePath: '',
deletePath: '',
canLock: false,
isLocked: false,
lockLink: '',
forkPath: '',
simpleViewer: {},
richViewer: null,
......@@ -120,7 +121,7 @@ export default {
return this.isBinary || this.viewer.fileType === 'download';
},
blobInfo() {
const nodes = this.project?.repository?.blobs?.nodes;
const nodes = this.project?.repository?.blobs?.nodes || [];
return nodes[0] || {};
},
......@@ -142,6 +143,14 @@ export default {
const { fileType } = this.viewer;
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: {
loadLegacyViewer() {
......@@ -191,6 +200,9 @@ export default {
:delete-path="blobInfo.webPath"
:can-push-code="project.userPermissions.pushCode"
:empty-repo="project.repository.empty"
:project-path="projectPath"
:is-locked="isLocked"
:can-lock="canLock"
/>
</template>
</blob-header>
......
......@@ -170,6 +170,7 @@ export default {
this.apolloQuery(blobInfoQuery, {
projectPath: this.projectPath,
filePath: this.path,
ref: this.ref,
});
},
apolloQuery(query, variables) {
......
mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) {
projectSetLocked(input: { projectPath: $projectPath, filePath: $filePath, lock: $lock }) {
project {
id
pathLocks {
nodes {
path
......
query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
project(fullPath: $projectPath) {
id
userPermissions {
pushCode
downloadCode
}
pathLocks {
nodes {
path
}
}
repository {
empty
......
......@@ -44,6 +44,7 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:refactor_blob_viewer, @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
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 ""
msgid "Are you sure that you want to unarchive this project?"
msgstr ""
msgid "Are you sure you want to %{action} %{name}?"
msgstr ""
msgid "Are you sure you want to cancel editing this comment?"
msgstr ""
......
import { GlButton } from '@gitlab/ui';
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 BlobButtonGroup from '~/repository/components/blob_button_group.vue';
import DeleteBlobModal from '~/repository/components/delete_blob_modal.vue';
......@@ -12,9 +13,13 @@ const DEFAULT_PROPS = {
replacePath: 'some/replace/path',
deletePath: 'some/delete/path',
emptyRepo: false,
projectPath: 'some/project/path',
isLocked: false,
canLock: true,
};
const DEFAULT_INJECT = {
glFeatures: { fileLocks: true },
targetBranch: 'master',
originalBranch: 'master',
};
......@@ -43,7 +48,8 @@ describe('BlobButtonGroup component', () => {
const findDeleteBlobModal = () => wrapper.findComponent(DeleteBlobModal);
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', () => {
createComponent();
......@@ -61,6 +67,18 @@ describe('BlobButtonGroup component', () => {
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', () => {
expect(wrapper.findAll(GlButton)).toHaveLength(2);
});
......
......@@ -39,9 +39,6 @@ const simpleMockData = {
externalStorageUrl: 'some_file.js',
replacePath: 'some_file.js/replace',
deletePath: 'some_file.js/delete',
canLock: true,
isLocked: false,
lockLink: 'some_file.js/lock',
forkPath: 'some_file.js/fork',
simpleViewer: {
fileType: 'text',
......@@ -64,6 +61,7 @@ const richMockData = {
const projectMockData = {
userPermissions: {
pushCode: true,
downloadCode: true,
},
repository: {
empty: false,
......@@ -77,13 +75,24 @@ const createComponentWithApollo = (mockData = {}, inject = {}) => {
localVue.use(VueApollo);
const defaultPushCode = projectMockData.userPermissions.pushCode;
const defaultDownloadCode = projectMockData.userPermissions.downloadCode;
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({
data: {
project: {
userPermissions: { pushCode: canPushCode },
id: '1234',
userPermissions: { pushCode: canPushCode, downloadCode: canDownloadCode },
pathLocks: {
nodes: pathLocks,
},
repository: {
empty: emptyRepo,
blobs: {
......@@ -371,7 +380,7 @@ describe('Blob content viewer component', () => {
describe('BlobButtonGroup', () => {
const { name, path, replacePath, webPath } = simpleMockData;
const {
userPermissions: { pushCode },
userPermissions: { pushCode, downloadCode },
repository: { empty },
} = projectMockData;
......@@ -381,7 +390,7 @@ describe('Blob content viewer component', () => {
fullFactory({
mockData: {
blobInfo: simpleMockData,
project: { userPermissions: { pushCode }, repository: { empty } },
project: { userPermissions: { pushCode, downloadCode }, repository: { empty } },
},
stubs: {
BlobContent: true,
......@@ -397,10 +406,37 @@ describe('Blob content viewer component', () => {
replacePath,
deletePath: webPath,
canPushCode: pushCode,
canLock: true,
isLocked: false,
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 () => {
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