diff --git a/app/assets/javascripts/issuable/issuable_label_selector.js b/app/assets/javascripts/issuable/issuable_label_selector.js index 804f7384732e333237054a2e78100af077c72272..0793ad2d3e00af8fee24e93bbc2421337c50d143 100644 --- a/app/assets/javascripts/issuable/issuable_label_selector.js +++ b/app/assets/javascripts/issuable/issuable_label_selector.js @@ -1,6 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; import { VARIANT_EMBEDDED } from '~/sidebar/components/labels/labels_select_widget/constants'; import { WORKSPACE_PROJECT } from '~/issues/constants'; import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue'; @@ -25,6 +26,7 @@ export default () => { issuableType, labelsFilterBasePath, labelsManagePath, + supportsLockOnMerge, } = el.dataset; return new Vue({ @@ -46,6 +48,7 @@ export default () => { variant: VARIANT_EMBEDDED, workspaceType: WORKSPACE_PROJECT, toggleAttrs: { 'data-testid': 'issuable-label-dropdown' }, + supportsLockOnMerge: parseBoolean(supportsLockOnMerge), }, render(createElement) { return createElement(IssuableLabelSelector); diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue index f2ce02526e70f7a50e8e786f09802f7dfe66e5b0..e3be7d549abcd3c6466446585214baa588ba7e74 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue @@ -27,6 +27,11 @@ export default { type: Boolean, required: true, }, + supportsLockOnMerge: { + type: Boolean, + required: false, + default: false, + }, labelsFilterBasePath: { type: String, required: true, @@ -67,6 +72,12 @@ export default { scopedLabel(label) { return this.allowScopedLabels && isScopedLabel(label); }, + isLabelLocked(label) { + return label.lockOnMerge && this.supportsLockOnMerge; + }, + showCloseButton(label) { + return this.allowLabelRemove && !this.isLabelLocked(label); + }, removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, @@ -115,7 +126,7 @@ export default { :background-color="label.color" :target="labelFilterUrl(label)" :scoped="scopedLabel(label)" - :show-close-button="allowLabelRemove" + :show-close-button="showCloseButton(label)" :disabled="disableLabels" tooltip-placement="top" @close="removeLabel(label.id)" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue index a3bacc4a6740e6b32966e54ad364677346a3c1f9..9af251304244cee9960d586e4b0bd01f9d063dd1 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue @@ -22,6 +22,11 @@ export default { type: Boolean, required: true, }, + supportsLockOnMerge: { + type: Boolean, + required: false, + default: false, + }, labelsFilterBasePath: { type: String, required: true, @@ -42,9 +47,15 @@ export default { return `${basePath}?${filterParam}[]=${encodeURIComponent(title)}`; }, - showScopedLabel(label) { + scopedLabel(label) { return this.allowScopedLabels && isScopedLabel(label); }, + isLabelLocked(label) { + return label.lockOnMerge && this.supportsLockOnMerge; + }, + showCloseButton(label) { + return this.allowLabelRemove && !this.isLabelLocked(label); + }, removeLabel(labelId) { this.$emit('onLabelRemove', labelId); }, @@ -63,8 +74,8 @@ export default { :description="label.description" :background-color="label.color" :target="buildFilterUrl(label)" - :scoped="showScopedLabel(label)" - :show-close-button="allowLabelRemove" + :scoped="scopedLabel(label)" + :show-close-button="showCloseButton(label)" :disabled="disabled" tooltip-placement="top" @close="removeLabel(label.id)" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql index e0cdfd91658a96717d596ad9f41ed2e4790333a2..8daa6e1138f73dfd84c7ea2cea1e4567034254ac 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -8,6 +8,7 @@ query mergeRequestLabels($fullPath: ID!, $iid: String!) { labels { nodes { ...Label + lockOnMerge } } } diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue index ac52e4dbf3f92b4acfb4037a09f191db9d04291d..7c658d804200ef24489ebb1b87559db3eda9e022 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -52,6 +52,11 @@ export default { required: false, default: false, }, + supportsLockOnMerge: { + type: Boolean, + required: false, + default: false, + }, showEmbeddedLabelsList: { type: Boolean, required: false, @@ -151,6 +156,9 @@ export default { isLabelListEnabled() { return this.showEmbeddedLabelsList && isDropdownVariantEmbedded(this.variant); }, + isLockOnMergeSupported() { + return this.enforceLockedLabelsOnMerge; + }, }, apollo: { issuable: { @@ -376,6 +384,7 @@ export default { :disable-labels="labelsSelectInProgress" :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" + :supports-lock-on-merge="isLockOnMergeSupported" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" @@ -389,6 +398,7 @@ export default { :disable-labels="labelsSelectInProgress" :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" + :supports-lock-on-merge="isLockOnMergeSupported" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" class="gl-mb-2" @@ -440,6 +450,7 @@ export default { :disabled="labelsSelectInProgress" :selected-labels="issuableLabels" :allow-label-remove="allowLabelRemove" + :supports-lock-on-merge="isLockOnMergeSupported" :labels-filter-base-path="labelsFilterBasePath" :labels-filter-param="labelsFilterParam" @onLabelRemove="handleLabelRemove" diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 12e60a9ed4ec8b415f96f4776ed20047cebb31e4..802eb9a1867a61904094fbb6364eee3ac8408196 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -355,6 +355,7 @@ export function mountSidebarLabelsWidget() { workspaceType: WORKSPACE_PROJECT, attrWorkspacePath: el.dataset.projectPath, labelCreateType: WORKSPACE_PROJECT, + enforceLockedLabelsOnMerge: gon.features.enforceLockedLabelsOnMerge, }, class: ['block labels js-labels-block'], scopedSlots: { diff --git a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue index b4287d8628900dce599b4b3468d672e2ec541c5d..52e992a272993ab334aa29372ec46bb170da07e2 100644 --- a/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue +++ b/app/assets/javascripts/vue_shared/issuable/create/components/issuable_label_selector.vue @@ -18,6 +18,7 @@ export default { 'initialLabels', 'issuableType', 'labelType', + 'supportsLockOnMerge', 'variant', 'workspaceType', ], @@ -76,6 +77,7 @@ export default { :issuable-type="issuableType" :label-create-type="labelType" :selected-labels="selectedLabels" + :supports-lock-on-merge="supportsLockOnMerge" @updateSelectedLabels="handleUpdateSelectedLabels" @onLabelRemove="handleLabelRemove" > diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 1af0ce3c35e0f50f149fb1438dbd7cc11f75eaac..fe2a4080e0c0bf39aa58ab50acf365ec8b1ea423 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -4,6 +4,9 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont before_action :check_merge_requests_available! before_action :merge_request before_action :authorize_read_merge_request! + before_action do + push_force_frontend_feature_flag(:enforce_locked_labels_on_merge, project&.supports_lock_on_merge?) + end feature_category :code_review_workflow diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index f2f20fa1b50e70f8a629cdb4d73a40b66d74a42b..2cc83a936d8956066efbb4138a9f3f167699b749 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -248,7 +248,8 @@ def issuable_label_selector_data(project, issuable) title: label.title, description: label.description, color: label.color, - text_color: label.text_color + text_color: label.text_color, + lock_on_merge: label.lock_on_merge } end @@ -265,7 +266,8 @@ def issuable_label_selector_data(project, issuable) initial_labels: initial_labels.to_json, issuable_type: issuable.issuable_type, labels_filter_base_path: filter_base_path, - labels_manage_path: project_labels_path(project) + labels_manage_path: project_labels_path(project), + supports_lock_on_merge: issuable.supports_lock_on_merge?.to_s } end diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js index d70b989b4939e43c4939c4fd3a6a563e36d09ab4..21068c2858d7c59963fca253c99bd257756aa11f 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js @@ -3,14 +3,15 @@ import { shallowMount } from '@vue/test-utils'; import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; -import { mockRegularLabel, mockScopedLabel } from './mock_data'; +import { mockRegularLabel, mockScopedLabel, mockLockedLabel } from './mock_data'; describe('DropdownValue', () => { let wrapper; const findAllLabels = () => wrapper.findAllComponents(GlLabel); - const findRegularLabel = () => findAllLabels().at(1); + const findRegularLabel = () => findAllLabels().at(2); const findScopedLabel = () => findAllLabels().at(0); + const findLockedLabel = () => findAllLabels().at(1); const findWrapper = () => wrapper.find('[data-testid="value-wrapper"]'); const findEmptyPlaceholder = () => wrapper.find('[data-testid="empty-placeholder"]'); @@ -18,7 +19,7 @@ describe('DropdownValue', () => { wrapper = shallowMount(DropdownValue, { slots, propsData: { - selectedLabels: [mockRegularLabel, mockScopedLabel], + selectedLabels: [mockLockedLabel, mockRegularLabel, mockScopedLabel], allowLabelRemove: true, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', @@ -69,8 +70,8 @@ describe('DropdownValue', () => { expect(findEmptyPlaceholder().exists()).toBe(false); }); - it('renders a list of two labels', () => { - expect(findAllLabels().length).toBe(2); + it('renders a list of three labels', () => { + expect(findAllLabels().length).toBe(3); }); it('passes correct props to the regular label', () => { @@ -96,5 +97,19 @@ describe('DropdownValue', () => { wrapper.find('.sidebar-collapsed-icon').trigger('click'); expect(wrapper.emitted('onCollapsedValueClick')).toEqual([[]]); }); + + it('does not show close button if label is locked', () => { + createComponent({ + supportsLockOnMerge: true, + }); + expect(findLockedLabel().props('showCloseButton')).toBe(false); + }); + + it('shows close button if label is not locked', () => { + createComponent({ + supportsLockOnMerge: true, + }); + expect(findRegularLabel().props('showCloseButton')).toBe(true); + }); }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js index 715dd4e034e0020e363c213876fe3e2f547ff720..c516dddf0ce67e78e81e1508902807ff684df8c8 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js @@ -1,7 +1,7 @@ import { GlLabel } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue'; -import { mockRegularLabel, mockScopedLabel } from './mock_data'; +import { mockRegularLabel, mockScopedLabel, mockLockedLabel } from './mock_data'; describe('EmbeddedLabelsList', () => { let wrapper; @@ -13,12 +13,13 @@ describe('EmbeddedLabelsList', () => { .at(0); const findRegularLabel = () => findLabelByTitle(mockRegularLabel.title); const findScopedLabel = () => findLabelByTitle(mockScopedLabel.title); + const findLockedLabel = () => findLabelByTitle(mockLockedLabel.title); const createComponent = (props = {}, slots = {}) => { wrapper = shallowMountExtended(EmbeddedLabelsList, { slots, propsData: { - selectedLabels: [mockRegularLabel, mockScopedLabel], + selectedLabels: [mockRegularLabel, mockScopedLabel, mockLockedLabel], allowLabelRemove: true, labelsFilterBasePath: '/gitlab-org/my-project/issues', labelsFilterParam: 'label_name', @@ -47,8 +48,8 @@ describe('EmbeddedLabelsList', () => { createComponent(); }); - it('renders a list of two labels', () => { - expect(findAllLabels()).toHaveLength(2); + it('renders a list of three labels', () => { + expect(findAllLabels()).toHaveLength(3); }); it('passes correct props to the regular label', () => { @@ -69,5 +70,12 @@ describe('EmbeddedLabelsList', () => { findRegularLabel().vm.$emit('close'); expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[mockRegularLabel.id]]); }); + + it('does not show close button if label is locked', () => { + createComponent({ + supportsLockOnMerge: true, + }); + expect(findLockedLabel().props('showCloseButton')).toBe(false); + }); }); }); diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js index b0b473625bb77e948007c74d93c0812a97145e1d..2e586961be168ce93b4b8b71881a968c733f7a2f 100644 --- a/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js @@ -14,6 +14,15 @@ export const mockScopedLabel = { textColor: '#FFFFFF', }; +export const mockLockedLabel = { + id: 30, + title: 'Bar Label', + description: 'Bar', + color: '#DADA55', + textColor: '#FFFFFF', + lockOnMerge: true, +}; + export const mockLabels = [ mockRegularLabel, mockScopedLabel, diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js index 1a4903590400fc8a20460223b1c08cfb5bb48b2c..a7fade4fc5c00e9b708ffe5ffbd155bb89ceb45f 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js @@ -17,6 +17,7 @@ const labelsFilterBasePath = '/labels-filter-base-path'; const initialLabels = []; const issuableType = 'issue'; const labelType = WORKSPACE_PROJECT; +const supportsLockOnMerge = false; const variant = VARIANT_EMBEDDED; const workspaceType = WORKSPACE_PROJECT; @@ -37,6 +38,7 @@ describe('IssuableLabelSelector', () => { initialLabels, issuableType, labelType, + supportsLockOnMerge, variant, workspaceType, ...injectedProps, diff --git a/spec/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index 6abce4c59839919362e0e31bfa20c157f6328395..525a19284743dfb3480d88ac5fb2069505ecffea 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -600,7 +600,8 @@ initial_labels: '[]', issuable_type: issuable.issuable_type, labels_filter_base_path: project_issues_path(project), - labels_manage_path: project_labels_path(project) + labels_manage_path: project_labels_path(project), + supports_lock_on_merge: issuable.supports_lock_on_merge?.to_s }) end end @@ -620,7 +621,8 @@ title: label.title, description: label.description, color: label.color, - text_color: label.text_color + text_color: label.text_color, + lock_on_merge: label.lock_on_merge }, { __typename: "Label", @@ -628,7 +630,8 @@ title: label2.title, description: label2.description, color: label2.color, - text_color: label2.text_color + text_color: label2.text_color, + lock_on_merge: label.lock_on_merge } ] @@ -638,7 +641,8 @@ initial_labels: initial_labels.to_json, issuable_type: issuable.issuable_type, labels_filter_base_path: project_merge_requests_path(project), - labels_manage_path: project_labels_path(project) + labels_manage_path: project_labels_path(project), + supports_lock_on_merge: issuable.supports_lock_on_merge?.to_s }) end end diff --git a/spec/support/shared_contexts/merge_request_edit_shared_context.rb b/spec/support/shared_contexts/merge_request_edit_shared_context.rb index f0e89b0c5f94d83773312958eb89f27d892f6116..cceaa14b3d215f58f2691dd235493c36b3f4a12c 100644 --- a/spec/support/shared_contexts/merge_request_edit_shared_context.rb +++ b/spec/support/shared_contexts/merge_request_edit_shared_context.rb @@ -5,7 +5,7 @@ let(:user2) { create(:user) } let!(:milestone) { create(:milestone, project: target_project) } let!(:label) { create(:label, project: target_project) } - let!(:label2) { create(:label, project: target_project) } + let!(:label2) { create(:label, project: target_project, lock_on_merge: true) } let(:target_project) { create(:project, :public, :repository) } let(:source_project) { target_project } let(:merge_request) do diff --git a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb index 9f884683f47bc479983d5c0a9664f3496fe922de..1bee8184e61bf92c215fe779334959fd4b79f310 100644 --- a/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb +++ b/spec/support/shared_examples/features/editable_merge_request_shared_examples.rb @@ -54,6 +54,8 @@ page.within '.labels' do expect(page).to have_content label.title expect(page).to have_content label2.title + + expect(page).to have_selector("[data-testid='close-icon']", count: 1) end end end