diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index 23b2cb5e684e08379c4defa7ef0d357f953974e7..b701dfb7d97ea59762e167a91106c7a31639af62 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -43,6 +43,8 @@ export default { WebIdeLink: () => import('ee_else_ce/vue_shared/components/web_ide_link.vue'), LockDirectoryButton: () => import('ee_component/repository/components/lock_directory_button.vue'), + HeaderLockIcon: () => + import('ee_component/repository/components/header_area/header_lock_icon.vue'), }, directives: { GlTooltip: GlTooltipDirective, @@ -114,6 +116,13 @@ export default { required: true, }, }, + data() { + return { + directoryLocked: false, + fileLocked: false, + lockAuthor: undefined, + }; + }, computed: { isTreeView() { return this.$route.name !== 'blobPathDecoded'; @@ -132,6 +141,9 @@ export default { fileIconName() { return this.isTreeView ? 'folder-open' : this.directoryName; }, + isLocked() { + return this.isTreeView ? this.directoryLocked : this.fileLocked; + }, getRefType() { return this.$route.query.ref_type; }, @@ -187,6 +199,14 @@ export default { InternalEvents.trackEvent(FIND_FILE_BUTTON_CLICK); Shortcuts.focusSearchFile(); }, + onLockedDirectory({ isLocked, lockAuthor }) { + this.directoryLocked = isLocked; + this.lockAuthor = lockAuthor; + }, + onLockedFile({ isLocked, lockAuthor }) { + this.fileLocked = isLocked; + this.lockAuthor = lockAuthor; + }, }, }; @@ -232,7 +252,7 @@ export default { >

@@ -267,7 +293,12 @@ export default { :new-dir-path="newDirPath" /> - + diff --git a/app/assets/javascripts/repository/components/header_area/blob_controls.vue b/app/assets/javascripts/repository/components/header_area/blob_controls.vue index 1b472516697e93fa39782532b705be59713e4e63..8d28432c9fd11cecee3805b5b57e188f726c4ba9 100644 --- a/app/assets/javascripts/repository/components/header_area/blob_controls.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue @@ -273,6 +273,9 @@ export default { visitUrl(isIdeTarget(target) ? ideEditPath : editBlobPath); } }, + onLockedFile(event) { + this.$emit('lockedFile', event); + }, }, }; @@ -363,6 +366,7 @@ export default { :is-using-lfs="isUsingLfs" @copy="onCopy" @showForkSuggestion="onShowForkSuggestion" + @lockedFile="onLockedFile" /> diff --git a/ee/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue b/ee/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue index dcd8d421ce7d4e3ecf6e7e1e4421144ad13008c0..b4b1c48b81d561cc9c5b09fad9b6b149684655b9 100644 --- a/ee/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue +++ b/ee/app/assets/javascripts/repository/components/header_area/blob_overflow_menu.vue @@ -47,6 +47,12 @@ export default { this.pathLocks = project?.pathLocks || DEFAULT_BLOB_INFO.pathLocks; this.userPermissions = project?.userPermissions; }, + result() { + this.$emit('lockedFile', { + isLocked: this.isLocked, + lockAuthor: this.pathLockedByUser?.name, + }); + }, error() { createAlert({ message: this.$options.i18n.fetchError }); }, @@ -93,6 +99,9 @@ export default { onShowForkSuggestion() { this.$emit('showForkSuggestion'); }, + onLockedFile(isLocked) { + this.$emit('lockedFile', { isLocked, lockAuthor: this.pathLockedByUser?.name }); + }, }, }; @@ -108,5 +117,6 @@ export default { :ee-can-lock="canLock" @copy="onCopy" @showForkSuggestion="onShowForkSuggestion" + @lockedFile="onLockedFile" /> diff --git a/ee/app/assets/javascripts/repository/components/header_area/header_lock_icon.vue b/ee/app/assets/javascripts/repository/components/header_area/header_lock_icon.vue new file mode 100644 index 0000000000000000000000000000000000000000..a05843ced7fcac05471397d0757002786d108c0d --- /dev/null +++ b/ee/app/assets/javascripts/repository/components/header_area/header_lock_icon.vue @@ -0,0 +1,54 @@ + + + diff --git a/ee/app/assets/javascripts/repository/components/lock_directory_button.vue b/ee/app/assets/javascripts/repository/components/lock_directory_button.vue index 838904eaa4b507483b98c53f490892a039beceb2..7357943b670b3cedf4e7967af5a1f05f44bf59a0 100644 --- a/ee/app/assets/javascripts/repository/components/lock_directory_button.vue +++ b/ee/app/assets/javascripts/repository/components/lock_directory_button.vue @@ -62,6 +62,12 @@ export default { ) || {}; this.projectUserPermissions = project.userPermissions; }, + result() { + this.$emit('lockedDirectory', { + isLocked: this.hasPathLocks && this.isLocked, + lockAuthor: this.pathLock.user?.name, + }); + }, error(error) { logError(`Unexpected error while fetching projectInfo query`, error); this.onFetchError(error); diff --git a/ee/app/controllers/ee/projects/blob_controller.rb b/ee/app/controllers/ee/projects/blob_controller.rb index 95dbcf5922747b1f31b2cb0efcd6f73018a3fa53..36587c3e4eecbbf4175c4ae9b529f55c5461930a 100644 --- a/ee/app/controllers/ee/projects/blob_controller.rb +++ b/ee/app/controllers/ee/projects/blob_controller.rb @@ -8,6 +8,7 @@ module BlobController prepended do before_action do push_licensed_feature(:remote_development) + push_frontend_feature_flag(:repository_lock_information, @project) end prepend_around_action :repair_blobs_index, only: [:show] end diff --git a/ee/app/controllers/ee/projects/tree_controller.rb b/ee/app/controllers/ee/projects/tree_controller.rb index e0d15b40d73a8cb0a1e69328113bd441d508c488..b1d889b9da7a1b46c68a11c45c2ce4aa919df596 100644 --- a/ee/app/controllers/ee/projects/tree_controller.rb +++ b/ee/app/controllers/ee/projects/tree_controller.rb @@ -8,6 +8,7 @@ module TreeController prepended do before_action do push_licensed_feature(:remote_development) + push_frontend_feature_flag(:repository_lock_information, @project) end end end diff --git a/ee/app/controllers/ee/projects_controller.rb b/ee/app/controllers/ee/projects_controller.rb index b5e999d6a6199051d01415b5c2761b654690ab5d..09b4d88cdcbd417e2b876be36cf4b44a756b1946 100644 --- a/ee/app/controllers/ee/projects_controller.rb +++ b/ee/app/controllers/ee/projects_controller.rb @@ -19,6 +19,7 @@ module ProjectsController before_action do push_licensed_feature(:remote_development) + push_frontend_feature_flag(:repository_lock_information, @project) end end diff --git a/ee/config/feature_flags/wip/repository_lock_information.yml b/ee/config/feature_flags/wip/repository_lock_information.yml new file mode 100644 index 0000000000000000000000000000000000000000..296af207a96d8749eb2b67d8486bd2320512e426 --- /dev/null +++ b/ee/config/feature_flags/wip/repository_lock_information.yml @@ -0,0 +1,9 @@ +--- +name: repository_lock_information +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/224475 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/186291 +rollout_issue_url: +milestone: '18.0' +group: group::source code +type: wip +default_enabled: false diff --git a/ee/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js b/ee/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js index 1946363d920c85eadc08ebaee1912026479ba794..f685fef2296e29bfef0cfe3cbad8b288e4f1e81a 100644 --- a/ee/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js +++ b/ee/spec/frontend/repository/components/header_area/blob_overflow_menu_spec.js @@ -51,6 +51,21 @@ describe('EE Blob Overflow Menu', () => { const findBlobButtonGroup = () => wrapper.findComponent(BlobButtonGroup); const findBlobDeleteFileGroup = () => wrapper.findComponent(BlobDeleteFileGroup); + it('emits lock information to parent component', async () => { + createComponent({ + projectInfoResolver: jest.fn().mockResolvedValue({ + data: { + project: getProjectMockWithOverrides({ + pathLockNodesOverride: [], + }), + }, + }), + }); + await waitForPromises(); + + expect(wrapper.emitted('lockedFile')).toEqual([[{ isLocked: false, lockAuthor: undefined }]]); + }); + describe('canModifyFile', () => { beforeEach(() => { window.gon.current_user_name = 'root'; diff --git a/ee/spec/frontend/repository/components/header_area/header_lock_icon_spec.js b/ee/spec/frontend/repository/components/header_area/header_lock_icon_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c9cb09bf1937015078dd84ccbb2b70f3531e9e86 --- /dev/null +++ b/ee/spec/frontend/repository/components/header_area/header_lock_icon_spec.js @@ -0,0 +1,110 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import HeaderLockIcon from 'ee_component/repository/components/header_area/header_lock_icon.vue'; + +describe('HeaderLockIcon component', () => { + let wrapper; + + const createComponent = ({ props = {}, provided = {} } = {}) => { + wrapper = shallowMount(HeaderLockIcon, { + provide: { + glFeatures: { + repositoryLockInformation: false, + }, + ...provided, + }, + propsData: { + isTreeView: true, + isLocked: false, + ...props, + }, + }); + }; + + const findButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + createComponent(); + }); + + describe('when repositoryLockInformation feature flag is off', () => { + it('does not render a button with a lock icon', () => { + expect(findButton().exists()).toBe(false); + }); + }); + + describe('when repositoryLockInformation feature flag is on', () => { + it('does not render a button with a lock icon', () => { + createComponent({ + provided: { glFeatures: { repositoryLockInformation: true } }, + }); + + expect(findButton().exists()).toBe(false); + }); + + describe('when a directory is locked', () => { + beforeEach(() => { + createComponent({ + provided: { glFeatures: { repositoryLockInformation: true } }, + props: { isLocked: true }, + }); + }); + + it('renders a button with lock icon', () => { + expect(findButton().exists()).toBe(true); + expect(findButton().props('icon')).toBe('lock'); + }); + + describe('tooltip text', () => { + it('shows "Directory locked" tooltip', () => { + expect(findButton().attributes('title')).toBe('Directory locked'); + expect(findButton().attributes('aria-label')).toBe('Directory locked'); + }); + + it('shows tooltip with author information', () => { + createComponent({ + provided: { glFeatures: { repositoryLockInformation: true } }, + props: { isLocked: true, lockAuthor: 'John Doe' }, + }); + + expect(findButton().attributes('title')).toBe('Directory locked by John Doe'); + }); + }); + }); + + describe('when a file is locked', () => { + beforeEach(() => { + createComponent({ + provided: { glFeatures: { repositoryLockInformation: true } }, + props: { isTreeView: false, isLocked: true }, + }); + }); + + it('renders a button with lock icon', () => { + expect(findButton().exists()).toBe(true); + expect(findButton().props('icon')).toBe('lock'); + }); + + describe('tooltip text', () => { + it('shows "File locked" tooltip', () => { + createComponent({ + provided: { glFeatures: { repositoryLockInformation: true } }, + props: { isTreeView: false, isLocked: true }, + }); + + expect(findButton().attributes('title')).toBe('File locked'); + expect(findButton().attributes('aria-label')).toBe('File locked'); + }); + + it('shows tooltip with author information', () => { + createComponent({ + provided: { glFeatures: { repositoryLockInformation: true } }, + props: { isTreeView: false, isLocked: true, lockAuthor: 'John Doe' }, + }); + + expect(findButton().attributes('title')).toBe('File locked by John Doe'); + }); + }); + }); + }); +}); diff --git a/ee/spec/frontend/repository/components/header_area_spec.js b/ee/spec/frontend/repository/components/header_area_spec.js index cfab7dd79e5ec74a1a797af64c6c5bbd755cab23..0809847bb84d5bf37bdfe3467cdc9d861a4277f2 100644 --- a/ee/spec/frontend/repository/components/header_area_spec.js +++ b/ee/spec/frontend/repository/components/header_area_spec.js @@ -1,8 +1,11 @@ +import { nextTick } from 'vue'; import { RouterLinkStub } from '@vue/test-utils'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import HeaderArea from '~/repository/components/header_area.vue'; +import HeaderLockIcon from 'ee_component/repository/components/header_area/header_lock_icon.vue'; import LockDirectoryButton from 'ee_component/repository/components/lock_directory_button.vue'; import CompactCodeDropdown from 'ee_component/repository/components/code_dropdown/compact_code_dropdown.vue'; +import BlobControls from '~/repository/components/header_area/blob_controls.vue'; import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue'; import CloneCodeDropdown from '~/vue_shared/components/code_dropdown/clone_code_dropdown.vue'; import { headerAppInjected } from 'ee_else_ce_jest/repository/mock_data'; @@ -22,14 +25,16 @@ const defaultMockRoute = { describe('HeaderArea', () => { let wrapper; + const findHeaderLockIcon = () => wrapper.findComponent(HeaderLockIcon); const findLockDirectoryButton = () => wrapper.findComponent(LockDirectoryButton); const findCodeDropdown = () => wrapper.findComponent(CodeDropdown); const findCloneCodeDropdown = () => wrapper.findComponent(CloneCodeDropdown); const findCompactCodeDropdown = () => wrapper.findComponent(CompactCodeDropdown); + const findBlobControls = () => wrapper.findComponent(BlobControls); const createComponent = ({ props = {}, - params = { path: '/directory' }, + route = { name: 'treePathDecoded', params: { path: '/directory' } }, provided = {}, stubs = {}, } = {}) => { @@ -43,17 +48,18 @@ describe('HeaderArea', () => { historyLink: '/history', refType: 'branch', projectId: '123', - refSelectorValue: 'refs/heads/main', + currentRef: 'main', ...props, }, stubs: { RouterLink: RouterLinkStub, + HeaderLockIcon, ...stubs, }, mocks: { $route: { ...defaultMockRoute, - params, + ...route, }, }, }); @@ -64,16 +70,43 @@ describe('HeaderArea', () => { }); describe('when rendered for tree view', () => { + describe('HeaderLockIcon', () => { + it('does not render when on root directory', () => { + wrapper = createComponent({ route: { name: 'treePathDecoded', params: { path: '/' } } }); + expect(findHeaderLockIcon().exists()).toBe(false); + }); + + it('renders HeaderLockIcon component with correct props', () => { + expect(findHeaderLockIcon().exists()).toBe(true); + expect(findHeaderLockIcon().props('isTreeView')).toBe(true); + expect(findHeaderLockIcon().props('isLocked')).toBe(false); + }); + + it('receives lock information from a LockDirectoryButton', async () => { + expect(findHeaderLockIcon().props('isLocked')).toBe(false); + + findLockDirectoryButton().vm.$emit('lockedDirectory', { + isLocked: true, + lockAuthor: 'Admin', + }); + await nextTick(); + + expect(findHeaderLockIcon().props('isLocked')).toBe(true); + expect(findHeaderLockIcon().props('lockAuthor')).toBe('Admin'); + }); + }); + describe('Lock button', () => { it('renders Lock directory button for directories inside the project', () => { expect(findLockDirectoryButton().exists()).toBe(true); }); it('does not render Lock directory button for root directory', () => { - wrapper = createComponent({ params: { path: '/' } }); + wrapper = createComponent({ route: { name: 'treePathDecoded', params: { path: '/' } } }); expect(findLockDirectoryButton().exists()).toBe(false); }); }); + describe('CodeDropdown', () => { describe('when `directory_code_dropdown_updates` flag is false', () => { it('renders CodeDropdown component with correct props for desktop layout', () => { @@ -111,4 +144,30 @@ describe('HeaderArea', () => { }); }); }); + + describe('when rendered for blob view', () => { + beforeEach(() => { + wrapper = createComponent({ + route: { name: 'blobPathDecoded' }, + }); + }); + + describe('HeaderLockIcon', () => { + it('renders HeaderLockIcon component with correct props', () => { + expect(findHeaderLockIcon().exists()).toBe(true); + expect(findHeaderLockIcon().props('isTreeView')).toBe(false); + expect(findHeaderLockIcon().props('isLocked')).toBe(false); + }); + + it('receives lock information from BlobControls', async () => { + expect(findHeaderLockIcon().props('isLocked')).toBe(false); + + findBlobControls().vm.$emit('lockedFile', { isLocked: true, lockAuthor: 'Admin' }); + await nextTick(); + + expect(findHeaderLockIcon().props('isLocked')).toBe(true); + expect(findHeaderLockIcon().props('lockAuthor')).toBe('Admin'); + }); + }); + }); }); diff --git a/ee/spec/frontend/repository/components/lock_directory_button_spec.js b/ee/spec/frontend/repository/components/lock_directory_button_spec.js index 2819e0a18583b315852b4b2bbf66a4b061a66bdf..6637278ca1ecb3cdad1ae1f25cec8a153d1aa9b3 100644 --- a/ee/spec/frontend/repository/components/lock_directory_button_spec.js +++ b/ee/spec/frontend/repository/components/lock_directory_button_spec.js @@ -110,6 +110,13 @@ describe('LockDirectoryButton', () => { it('renders when feature is available and user logged in', () => { expect(findLockDirectoryButton().exists()).toBe(true); + expect(findLockDirectoryButton().text()).toBe('Lock'); + }); + + it('emits lock information to parent component', () => { + expect(wrapper.emitted('lockedDirectory')).toEqual([ + [{ isLocked: false, lockAuthor: undefined }], + ]); }); it('renders with loading state until query fetches projects info', async () => { @@ -235,6 +242,23 @@ describe('LockDirectoryButton', () => { expect(findLockDirectoryButton().props('disabled')).toBe(true); expect(findTooltip().text()).toContain('Locked by User2'); }); + + it('emits lock information to parent component', async () => { + createComponent({ + projectInfoResolver: jest.fn().mockResolvedValue({ + data: { + project: getProjectMockWithOverrides({ + pathLockNodesOverride: [exactDirectoryLock], + }), + }, + }), + }); + await waitForPromises(); + + expect(wrapper.emitted('lockedDirectory')).toEqual([ + [{ isLocked: true, lockAuthor: 'User2' }], + ]); + }); }); describe('when there is an upstream lock', () => { @@ -280,6 +304,23 @@ describe('LockDirectoryButton', () => { expect(findTooltip().text()).toBe(expectedTooltipText); }, ); + + it('emits lock information to parent component', async () => { + createComponent({ + projectInfoResolver: jest.fn().mockResolvedValue({ + data: { + project: getProjectMockWithOverrides({ + pathLockNodesOverride: [upstreamDirectoryLock], + }), + }, + }), + }); + await waitForPromises(); + + expect(wrapper.emitted('lockedDirectory')).toEqual([ + [{ isLocked: true, lockAuthor: 'User2' }], + ]); + }); }); describe('when there is a downstream lock', () => { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2f7bb6069f46b73c357a3d11f7d6f6ccf4c70388..5b9caebfa672c1be08ed9fcf933a15fe5f5a6900 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -22511,6 +22511,9 @@ msgstr "" msgid "Direction" msgstr "" +msgid "Directory locked" +msgstr "" + msgid "Directory name" msgstr "" @@ -26411,6 +26414,9 @@ msgstr "" msgid "File is too big (%{fileSize}MiB). Max filesize: %{maxFileSize}MiB." msgstr "" +msgid "File locked" +msgstr "" + msgid "File mode changed from %{a_mode} to %{b_mode}" msgstr "" diff --git a/spec/frontend/repository/components/header_area/blob_controls_spec.js b/spec/frontend/repository/components/header_area/blob_controls_spec.js index 3caa103c3c555059829cc4657c828aca473acd45..d73d71b6b00c54ef907ddbb23c0410453e16ce73 100644 --- a/spec/frontend/repository/components/header_area/blob_controls_spec.js +++ b/spec/frontend/repository/components/header_area/blob_controls_spec.js @@ -467,6 +467,13 @@ describe('Blob controls component', () => { expect(findForkSuggestionModal().props('visible')).toBe(true); }); + + it('proxy locked-file event', async () => { + findOverflowMenu().vm.$emit('lockedFile', true); + await nextTick(); + + expect(wrapper.emitted('lockedFile')).toEqual([[true]]); + }); }); }); }); diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js index bed30d456a75814a9583d1ba0f35cb167366f52e..fda1fe13dac559ea8f8337c24567b559370cdc56 100644 --- a/spec/frontend/repository/components/header_area_spec.js +++ b/spec/frontend/repository/components/header_area_spec.js @@ -61,7 +61,7 @@ describe('HeaderArea', () => { historyLink: '/history', refType: 'branch', projectId: '123', - refSelectorValue: 'refs/heads/main', + currentRef: 'main', ...props, }, stubs: {