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 {
>
{{ directoryName }}
+
@@ -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: {