From d9b95acbe34cb398f3c815c222f155d3fd13080d Mon Sep 17 00:00:00 2001 From: Jacques Erasmus Date: Mon, 2 Aug 2021 11:27:33 +0000 Subject: [PATCH] Add lock button Added lock button to refactored viewer --- .../components/blob_button_group.vue | 29 ++++- .../components/blob_content_viewer.vue | 20 +++- .../repository/components/table/row.vue | 1 + .../mutations/lock_path.mutation.graphql | 1 + .../queries/blob_info.query.graphql | 7 ++ app/controllers/projects/blob_controller.rb | 1 + .../repository/components/lock_button.vue | 86 ++++++++++++++ .../repository/components/lock_button_spec.js | 106 ++++++++++++++++++ locale/gitlab.pot | 3 + .../components/blob_button_group_spec.js | 20 +++- .../components/blob_content_viewer_spec.js | 50 +++++++-- 11 files changed, 309 insertions(+), 15 deletions(-) create mode 100644 ee/app/assets/javascripts/repository/components/lock_button.vue create mode 100644 ee/spec/frontend/repository/components/lock_button_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 273825b996a3d6..4e7ca7b17e44d9 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -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 { diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 82c18d13a6a79e..fa358a75cc19db 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -170,6 +170,7 @@ export default { this.apolloQuery(blobInfoQuery, { projectPath: this.projectPath, filePath: this.path, + ref: this.ref, }); }, apolloQuery(query, variables) { diff --git a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql index 68ff22566e3fbe..eaebc4ddf17caa 100644 --- a/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql +++ b/app/assets/javascripts/repository/mutations/lock_path.mutation.graphql @@ -1,6 +1,7 @@ mutation toggleLock($projectPath: ID!, $filePath: String!, $lock: Boolean!) { projectSetLocked(input: { projectPath: $projectPath, filePath: $filePath, lock: $lock }) { project { + id pathLocks { nodes { path diff --git a/app/assets/javascripts/repository/queries/blob_info.query.graphql b/app/assets/javascripts/repository/queries/blob_info.query.graphql index 4dba6869194080..45f07f7dc58568 100644 --- a/app/assets/javascripts/repository/queries/blob_info.query.graphql +++ b/app/assets/javascripts/repository/queries/blob_info.query.graphql @@ -1,7 +1,14 @@ query getBlobInfo($projectPath: ID!, $filePath: String!, $ref: String!) { project(fullPath: $projectPath) { + id userPermissions { pushCode + downloadCode + } + pathLocks { + nodes { + path + } } repository { empty diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 08066acb45c7f2..acf6b6116b8663 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -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 diff --git a/ee/app/assets/javascripts/repository/components/lock_button.vue b/ee/app/assets/javascripts/repository/components/lock_button.vue new file mode 100644 index 00000000000000..0cc7a06841ad5f --- /dev/null +++ b/ee/app/assets/javascripts/repository/components/lock_button.vue @@ -0,0 +1,86 @@ + + + diff --git a/ee/spec/frontend/repository/components/lock_button_spec.js b/ee/spec/frontend/repository/components/lock_button_spec.js new file mode 100644 index 00000000000000..dc6b4c235ddf08 --- /dev/null +++ b/ee/spec/frontend/repository/components/lock_button_spec.js @@ -0,0 +1,106 @@ +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(); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3f985b32a1efd6..886218e228dcec 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/frontend/repository/components/blob_button_group_spec.js b/spec/frontend/repository/components/blob_button_group_spec.js index a449fd6f06c934..f2e546533336a7 100644 --- a/spec/frontend/repository/components/blob_button_group_spec.js +++ b/spec/frontend/repository/components/blob_button_group_spec.js @@ -1,5 +1,6 @@ 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); }); diff --git a/spec/frontend/repository/components/blob_content_viewer_spec.js b/spec/frontend/repository/components/blob_content_viewer_spec.js index e8d6e866248505..d462995328baed 100644 --- a/spec/frontend/repository/components/blob_content_viewer_spec.js +++ b/spec/frontend/repository/components/blob_content_viewer_spec.js @@ -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; -- GitLab