From 90e8a036a11a0f1a0707770a783f630c7d75bd68 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Fri, 28 Mar 2025 16:58:40 +0100 Subject: [PATCH 1/6] Add repository_lock_information feature flag --- app/controllers/projects/blob_controller.rb | 1 + app/controllers/projects/tree_controller.rb | 1 + app/controllers/projects_controller.rb | 1 + .../feature_flags/wip/repository_lock_information.yml | 9 +++++++++ 4 files changed, 12 insertions(+) create mode 100644 ee/config/feature_flags/wip/repository_lock_information.yml diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index d8910f744757fa..1d5462e0b4ad37 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -51,6 +51,7 @@ class Projects::BlobController < Projects::ApplicationController push_frontend_feature_flag(:filter_blob_path, current_user) push_frontend_feature_flag(:blob_repository_vue_header_app, @project) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) + push_frontend_feature_flag(:repository_lock_information, @project) push_frontend_feature_flag(:directory_code_dropdown_updates, current_user) push_frontend_feature_flag(:ci_pipeline_status_realtime, @project) end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 29e4e4a5b0a655..cdd7d69d3429c5 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -23,6 +23,7 @@ class Projects::TreeController < Projects::ApplicationController push_frontend_feature_flag(:blob_overflow_menu, current_user) push_frontend_feature_flag(:filter_blob_path, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) + push_frontend_feature_flag(:repository_lock_information, @project) push_frontend_feature_flag(:directory_code_dropdown_updates, current_user) push_frontend_feature_flag(:ci_pipeline_status_realtime, @project) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index ad557f3694fffb..2837725b630863 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -46,6 +46,7 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:blob_overflow_menu, current_user) push_frontend_feature_flag(:filter_blob_path, current_user) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) + push_frontend_feature_flag(:repository_lock_information, @project) push_frontend_feature_flag(:directory_code_dropdown_updates, current_user) push_frontend_feature_flag(:ci_pipeline_status_realtime, @project) 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 00000000000000..6d48daa1f143f1 --- /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: '17.11' +group: group::source code +type: wip +default_enabled: false -- GitLab From 698e7ea35437b98b0f7b5213b2fd92b9369e52fd Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Fri, 28 Mar 2025 17:41:25 +0100 Subject: [PATCH 2/6] Add a lock indicator for directory or file name --- .../repository/components/header_area.vue | 7 ++- .../header_area/header_lock_icon.vue | 42 ++++++++++++++++++ .../header_area/header_lock_icon_spec.js | 43 +++++++++++++++++++ locale/gitlab.pot | 6 +++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 ee/app/assets/javascripts/repository/components/header_area/header_lock_icon.vue create mode 100644 ee/spec/frontend/repository/components/header_area/header_lock_icon_spec.js diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index 23b2cb5e684e08..3d8bcbd706ab90 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, @@ -232,7 +234,7 @@ export default { >

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 00000000000000..a54b7948b92388 --- /dev/null +++ b/ee/app/assets/javascripts/repository/components/header_area/header_lock_icon.vue @@ -0,0 +1,42 @@ + + + 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 00000000000000..8e1354407f1b90 --- /dev/null +++ b/ee/spec/frontend/repository/components/header_area/header_lock_icon_spec.js @@ -0,0 +1,43 @@ +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 = {}) => { + wrapper = shallowMount(HeaderLockIcon, { + propsData: { + isTreeView: true, + ...props, + }, + }); + }; + + const findLockButton = () => wrapper.findComponent(GlButton); + + beforeEach(() => { + createComponent(); + }); + + it('renders a button with lock icon', () => { + expect(findLockButton().exists()).toBe(true); + expect(findLockButton().props('icon')).toBe('lock'); + }); + + describe('tooltip text', () => { + it('shows "Locked directory" tooltip when isTreeView is true', () => { + createComponent({ isTreeView: true }); + + expect(findLockButton().attributes('title')).toBe('Locked directory'); + expect(findLockButton().attributes('aria-label')).toBe('Locked directory'); + }); + + it('shows "Locked file" tooltip when isTreeView is false', () => { + createComponent({ isTreeView: false }); + + expect(findLockButton().attributes('title')).toBe('Locked file'); + expect(findLockButton().attributes('aria-label')).toBe('Locked file'); + }); + }); +}); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2f7bb6069f46b7..0ede22d5f5f75f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -36240,6 +36240,12 @@ msgstr "" msgid "Locked by %{locker}. You do not have permission to unlock this" msgstr "" +msgid "Locked directory" +msgstr "" + +msgid "Locked file" +msgstr "" + msgid "Locked files" msgstr "" -- GitLab From 896ac143890245e5e575eba5b7a4aa9aa170c9da Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Wed, 9 Apr 2025 11:51:06 +0200 Subject: [PATCH 3/6] Pass lock information to HeaderLockIcon When information about a directory or file lock status is fetched, emit an event with the status. The event is proxied to HeaderArea and passed to HeaderLockIcon to decide wether we render the icon or not. Emitting the event allows us to keep the logic for lock calculation in their original components, while avoiding additional query. EE: true --- .../repository/components/header_area.vue | 25 +++++++- .../components/header_area/blob_controls.vue | 4 ++ .../header_area/blob_overflow_menu.vue | 7 +++ .../header_area/header_lock_icon.vue | 12 ++-- .../components/lock_directory_button.vue | 3 + .../header_area/blob_overflow_menu_spec.js | 15 +++++ .../header_area/header_lock_icon_spec.js | 33 ++++++---- .../repository/components/header_area_spec.js | 62 +++++++++++++++++-- .../components/lock_directory_button_spec.js | 35 +++++++++++ .../header_area/blob_controls_spec.js | 7 +++ .../repository/components/header_area_spec.js | 2 +- 11 files changed, 180 insertions(+), 25 deletions(-) diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index 3d8bcbd706ab90..6b15207fccbcfa 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -116,6 +116,12 @@ export default { required: true, }, }, + data() { + return { + directoryLocked: false, + fileLocked: false, + }; + }, computed: { isTreeView() { return this.$route.name !== 'blobPathDecoded'; @@ -134,6 +140,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; }, @@ -189,6 +198,12 @@ export default { InternalEvents.trackEvent(FIND_FILE_BUTTON_CLICK); Shortcuts.focusSearchFile(); }, + onLockedDirectory(isLocked) { + this.directoryLocked = isLocked; + }, + onLockedFile(isLocked) { + this.fileLocked = isLocked; + }, }, }; @@ -245,7 +260,7 @@ export default { class="gl-inline-flex" :class="{ 'gl-text-subtle': isTreeView }" />{{ directoryName }} - + @@ -270,7 +285,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 1b472516697e93..7f0ac3e04f419a 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(isLocked) { + this.$emit('lockedFile', isLocked); + }, }, }; @@ -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 dcd8d421ce7d4e..32f97a198509ca 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,9 @@ export default { this.pathLocks = project?.pathLocks || DEFAULT_BLOB_INFO.pathLocks; this.userPermissions = project?.userPermissions; }, + result() { + this.$emit('lockedFile', this.isLocked); + }, error() { createAlert({ message: this.$options.i18n.fetchError }); }, @@ -93,6 +96,9 @@ export default { onShowForkSuggestion() { this.$emit('showForkSuggestion'); }, + onLockedFile(isLocked) { + this.$emit('lockedFile', Boolean(isLocked)); + }, }, }; @@ -108,5 +114,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 index a54b7948b92388..11550532f5b186 100644 --- 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 @@ -12,14 +12,14 @@ export default { props: { isTreeView: { type: Boolean, - required: false, + required: true, default: true, }, - }, - data() { - return { - isLocked: true, - }; + isLocked: { + type: Boolean, + required: true, + default: false, + }, }, computed: { lockIconTooltip() { 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 838904eaa4b507..38cf3e9153911a 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,9 @@ export default { ) || {}; this.projectUserPermissions = project.userPermissions; }, + result() { + this.$emit('lockedDirectory', this.hasPathLocks && this.isLocked); + }, error(error) { logError(`Unexpected error while fetching projectInfo query`, error); this.onFetchError(error); 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 1946363d920c85..61d171867c1415 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([[false]]); + }); + 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 index 8e1354407f1b90..ace9c15ecf703e 100644 --- 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 @@ -9,6 +9,7 @@ describe('HeaderLockIcon component', () => { wrapper = shallowMount(HeaderLockIcon, { propsData: { isTreeView: true, + isLocked: false, ...props, }, }); @@ -20,24 +21,32 @@ describe('HeaderLockIcon component', () => { createComponent(); }); - it('renders a button with lock icon', () => { - expect(findLockButton().exists()).toBe(true); - expect(findLockButton().props('icon')).toBe('lock'); + it('does not render a button with a lock icon', () => { + expect(findLockButton().exists()).toBe(false); }); - describe('tooltip text', () => { - it('shows "Locked directory" tooltip when isTreeView is true', () => { - createComponent({ isTreeView: true }); + describe('when a directory or file is locked', () => { + beforeEach(() => { + createComponent({ isLocked: true }); + }); - expect(findLockButton().attributes('title')).toBe('Locked directory'); - expect(findLockButton().attributes('aria-label')).toBe('Locked directory'); + it('renders a button with lock icon', () => { + expect(findLockButton().exists()).toBe(true); + expect(findLockButton().props('icon')).toBe('lock'); }); - it('shows "Locked file" tooltip when isTreeView is false', () => { - createComponent({ isTreeView: false }); + describe('tooltip text', () => { + it('shows "Locked directory" tooltip when isTreeView is true', () => { + expect(findLockButton().attributes('title')).toBe('Locked directory'); + expect(findLockButton().attributes('aria-label')).toBe('Locked directory'); + }); + + it('shows "Locked file" tooltip when isTreeView is false', () => { + createComponent({ isTreeView: false, isLocked: true }); - expect(findLockButton().attributes('title')).toBe('Locked file'); - expect(findLockButton().attributes('aria-label')).toBe('Locked file'); + expect(findLockButton().attributes('title')).toBe('Locked file'); + expect(findLockButton().attributes('aria-label')).toBe('Locked file'); + }); }); }); }); diff --git a/ee/spec/frontend/repository/components/header_area_spec.js b/ee/spec/frontend/repository/components/header_area_spec.js index cfab7dd79e5ec7..4a721e5a7a6cb1 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,39 @@ 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', true); + await nextTick(); + + expect(findHeaderLockIcon().props('isLocked')).toBe(true); + }); + }); + 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 +140,29 @@ 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', true); + await nextTick(); + + expect(findHeaderLockIcon().props('isLocked')).toBe(true); + }); + }); + }); }); 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 2819e0a18583b3..68db34de0520a4 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,11 @@ 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([[false]]); }); it('renders with loading state until query fetches projects info', async () => { @@ -235,6 +240,21 @@ 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([[true]]); + }); }); describe('when there is an upstream lock', () => { @@ -280,6 +300,21 @@ 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([[true]]); + }); }); describe('when there is a downstream lock', () => { 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 3caa103c3c5550..d73d71b6b00c54 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 bed30d456a7581..fda1fe13dac559 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: { -- GitLab From 61fc746c8f8c9dcf0eec5af4e7794f34a30c2460 Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 10 Apr 2025 15:40:43 +0200 Subject: [PATCH 4/6] Include lock author name in lock icon tooltip EE: true --- .../repository/components/header_area.vue | 14 +++++-- .../components/header_area/blob_controls.vue | 4 +- .../header_area/blob_overflow_menu.vue | 7 +++- .../header_area/header_lock_icon.vue | 12 +++++- .../components/lock_directory_button.vue | 5 ++- .../header_area/blob_overflow_menu_spec.js | 2 +- .../header_area/header_lock_icon_spec.js | 39 +++++++++++++++---- .../repository/components/header_area_spec.js | 9 ++++- .../components/lock_directory_button_spec.js | 12 ++++-- locale/gitlab.pot | 12 +++--- 10 files changed, 88 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index 6b15207fccbcfa..b701dfb7d97ea5 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -120,6 +120,7 @@ export default { return { directoryLocked: false, fileLocked: false, + lockAuthor: undefined, }; }, computed: { @@ -198,11 +199,13 @@ export default { InternalEvents.trackEvent(FIND_FILE_BUTTON_CLICK); Shortcuts.focusSearchFile(); }, - onLockedDirectory(isLocked) { + onLockedDirectory({ isLocked, lockAuthor }) { this.directoryLocked = isLocked; + this.lockAuthor = lockAuthor; }, - onLockedFile(isLocked) { + onLockedFile({ isLocked, lockAuthor }) { this.fileLocked = isLocked; + this.lockAuthor = lockAuthor; }, }, }; @@ -260,7 +263,12 @@ export default { class="gl-inline-flex" :class="{ 'gl-text-subtle': isTreeView }" />{{ directoryName }} - + 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 7f0ac3e04f419a..8d28432c9fd11c 100644 --- a/app/assets/javascripts/repository/components/header_area/blob_controls.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue @@ -273,8 +273,8 @@ export default { visitUrl(isIdeTarget(target) ? ideEditPath : editBlobPath); } }, - onLockedFile(isLocked) { - this.$emit('lockedFile', isLocked); + onLockedFile(event) { + this.$emit('lockedFile', event); }, }, }; 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 32f97a198509ca..b4b1c48b81d561 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 @@ -48,7 +48,10 @@ export default { this.userPermissions = project?.userPermissions; }, result() { - this.$emit('lockedFile', this.isLocked); + this.$emit('lockedFile', { + isLocked: this.isLocked, + lockAuthor: this.pathLockedByUser?.name, + }); }, error() { createAlert({ message: this.$options.i18n.fetchError }); @@ -97,7 +100,7 @@ export default { this.$emit('showForkSuggestion'); }, onLockedFile(isLocked) { - this.$emit('lockedFile', Boolean(isLocked)); + this.$emit('lockedFile', { isLocked, lockAuthor: this.pathLockedByUser?.name }); }, }, }; 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 index 11550532f5b186..9cb57966834df8 100644 --- 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 @@ -20,10 +20,20 @@ export default { required: true, default: false, }, + lockAuthor: { + type: String, + required: false, + default: undefined, + }, }, computed: { + lockTypeText() { + return this.isTreeView ? __('Directory locked') : __('File locked'); + }, lockIconTooltip() { - return this.isTreeView ? __('Locked directory') : __('Locked file'); + return this.lockAuthor + ? `${this.lockTypeText} ${__('by')} ${this.lockAuthor}` + : this.lockTypeText; }, }, }; 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 38cf3e9153911a..7357943b670b3c 100644 --- a/ee/app/assets/javascripts/repository/components/lock_directory_button.vue +++ b/ee/app/assets/javascripts/repository/components/lock_directory_button.vue @@ -63,7 +63,10 @@ export default { this.projectUserPermissions = project.userPermissions; }, result() { - this.$emit('lockedDirectory', this.hasPathLocks && this.isLocked); + this.$emit('lockedDirectory', { + isLocked: this.hasPathLocks && this.isLocked, + lockAuthor: this.pathLock.user?.name, + }); }, error(error) { logError(`Unexpected error while fetching projectInfo query`, error); 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 61d171867c1415..f685fef2296e29 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 @@ -63,7 +63,7 @@ describe('EE Blob Overflow Menu', () => { }); await waitForPromises(); - expect(wrapper.emitted('lockedFile')).toEqual([[false]]); + expect(wrapper.emitted('lockedFile')).toEqual([[{ isLocked: false, lockAuthor: undefined }]]); }); describe('canModifyFile', () => { 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 index ace9c15ecf703e..be0b2edcc7cc5b 100644 --- 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 @@ -25,7 +25,7 @@ describe('HeaderLockIcon component', () => { expect(findLockButton().exists()).toBe(false); }); - describe('when a directory or file is locked', () => { + describe('when a directory is locked', () => { beforeEach(() => { createComponent({ isLocked: true }); }); @@ -36,16 +36,41 @@ describe('HeaderLockIcon component', () => { }); describe('tooltip text', () => { - it('shows "Locked directory" tooltip when isTreeView is true', () => { - expect(findLockButton().attributes('title')).toBe('Locked directory'); - expect(findLockButton().attributes('aria-label')).toBe('Locked directory'); + it('shows "Directory locked" tooltip', () => { + expect(findLockButton().attributes('title')).toBe('Directory locked'); + expect(findLockButton().attributes('aria-label')).toBe('Directory locked'); }); - it('shows "Locked file" tooltip when isTreeView is false', () => { + it('shows tooltip with author information', () => { + createComponent({ isLocked: true, lockAuthor: 'John Doe' }); + + expect(findLockButton().attributes('title')).toBe('Directory locked by John Doe'); + }); + }); + }); + + describe('when a file is locked', () => { + beforeEach(() => { + createComponent({ isTreeView: false, isLocked: true }); + }); + + it('renders a button with lock icon', () => { + expect(findLockButton().exists()).toBe(true); + expect(findLockButton().props('icon')).toBe('lock'); + }); + + describe('tooltip text', () => { + it('shows "File locked" tooltip', () => { createComponent({ isTreeView: false, isLocked: true }); - expect(findLockButton().attributes('title')).toBe('Locked file'); - expect(findLockButton().attributes('aria-label')).toBe('Locked file'); + expect(findLockButton().attributes('title')).toBe('File locked'); + expect(findLockButton().attributes('aria-label')).toBe('File locked'); + }); + + it('shows tooltip with author information', () => { + createComponent({ isTreeView: false, isLocked: true, lockAuthor: 'John Doe' }); + + expect(findLockButton().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 4a721e5a7a6cb1..0809847bb84d5b 100644 --- a/ee/spec/frontend/repository/components/header_area_spec.js +++ b/ee/spec/frontend/repository/components/header_area_spec.js @@ -85,10 +85,14 @@ describe('HeaderArea', () => { it('receives lock information from a LockDirectoryButton', async () => { expect(findHeaderLockIcon().props('isLocked')).toBe(false); - findLockDirectoryButton().vm.$emit('lockedDirectory', true); + findLockDirectoryButton().vm.$emit('lockedDirectory', { + isLocked: true, + lockAuthor: 'Admin', + }); await nextTick(); expect(findHeaderLockIcon().props('isLocked')).toBe(true); + expect(findHeaderLockIcon().props('lockAuthor')).toBe('Admin'); }); }); @@ -158,10 +162,11 @@ describe('HeaderArea', () => { it('receives lock information from BlobControls', async () => { expect(findHeaderLockIcon().props('isLocked')).toBe(false); - findBlobControls().vm.$emit('lockedFile', true); + 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 68db34de0520a4..6637278ca1ecb3 100644 --- a/ee/spec/frontend/repository/components/lock_directory_button_spec.js +++ b/ee/spec/frontend/repository/components/lock_directory_button_spec.js @@ -114,7 +114,9 @@ describe('LockDirectoryButton', () => { }); it('emits lock information to parent component', () => { - expect(wrapper.emitted('lockedDirectory')).toEqual([[false]]); + expect(wrapper.emitted('lockedDirectory')).toEqual([ + [{ isLocked: false, lockAuthor: undefined }], + ]); }); it('renders with loading state until query fetches projects info', async () => { @@ -253,7 +255,9 @@ describe('LockDirectoryButton', () => { }); await waitForPromises(); - expect(wrapper.emitted('lockedDirectory')).toEqual([[true]]); + expect(wrapper.emitted('lockedDirectory')).toEqual([ + [{ isLocked: true, lockAuthor: 'User2' }], + ]); }); }); @@ -313,7 +317,9 @@ describe('LockDirectoryButton', () => { }); await waitForPromises(); - expect(wrapper.emitted('lockedDirectory')).toEqual([[true]]); + expect(wrapper.emitted('lockedDirectory')).toEqual([ + [{ isLocked: true, lockAuthor: 'User2' }], + ]); }); }); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0ede22d5f5f75f..5b9caebfa672c1 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 "" @@ -36240,12 +36246,6 @@ msgstr "" msgid "Locked by %{locker}. You do not have permission to unlock this" msgstr "" -msgid "Locked directory" -msgstr "" - -msgid "Locked file" -msgstr "" - msgid "Locked files" msgstr "" -- GitLab From c0ec18f6ff9687c69367c6aa32e5e37e1940b3fb Mon Sep 17 00:00:00 2001 From: psjakubowska Date: Thu, 10 Apr 2025 17:52:08 +0200 Subject: [PATCH 5/6] Include feature flag in HeaderLockIcon EE: true --- app/controllers/projects/blob_controller.rb | 1 - app/controllers/projects/tree_controller.rb | 1 - app/controllers/projects_controller.rb | 1 - .../header_area/header_lock_icon.vue | 4 +- .../ee/projects/blob_controller.rb | 1 + .../ee/projects/tree_controller.rb | 1 + ee/app/controllers/ee/projects_controller.rb | 1 + .../header_area/header_lock_icon_spec.js | 105 ++++++++++++------ 8 files changed, 75 insertions(+), 40 deletions(-) diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 1d5462e0b4ad37..d8910f744757fa 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -51,7 +51,6 @@ class Projects::BlobController < Projects::ApplicationController push_frontend_feature_flag(:filter_blob_path, current_user) push_frontend_feature_flag(:blob_repository_vue_header_app, @project) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) - push_frontend_feature_flag(:repository_lock_information, @project) push_frontend_feature_flag(:directory_code_dropdown_updates, current_user) push_frontend_feature_flag(:ci_pipeline_status_realtime, @project) end diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index cdd7d69d3429c5..29e4e4a5b0a655 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -23,7 +23,6 @@ class Projects::TreeController < Projects::ApplicationController push_frontend_feature_flag(:blob_overflow_menu, current_user) push_frontend_feature_flag(:filter_blob_path, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) - push_frontend_feature_flag(:repository_lock_information, @project) push_frontend_feature_flag(:directory_code_dropdown_updates, current_user) push_frontend_feature_flag(:ci_pipeline_status_realtime, @project) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2837725b630863..ad557f3694fffb 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -46,7 +46,6 @@ class ProjectsController < Projects::ApplicationController push_frontend_feature_flag(:blob_overflow_menu, current_user) push_frontend_feature_flag(:filter_blob_path, current_user) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) - push_frontend_feature_flag(:repository_lock_information, @project) push_frontend_feature_flag(:directory_code_dropdown_updates, current_user) push_frontend_feature_flag(:ci_pipeline_status_realtime, @project) 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 index 9cb57966834df8..a05843ced7fcac 100644 --- 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 @@ -1,6 +1,7 @@