From 49a63fceac18b8a4703efb128c3d14d761852ca2 Mon Sep 17 00:00:00 2001
From: Paulina Sedlak-Jakubowska <psedlak-jakubowska@gitlab.com>
Date: Fri, 24 May 2024 14:59:47 +0000
Subject: [PATCH] Create ProtectionToggle component for Branch rules

Displays a toggle for anyone who can manage certain protection
and icon for users who do not have edit rights.

EE: true
---
 .../branch_rules/components/view/constants.js | 16 ++-
 .../branch_rules/components/view/index.vue    | 81 +++++++++-------
 .../components/view/protection_toggle.vue     | 97 +++++++++++++++++++
 .../components/view/index_spec.js             | 61 ++++++------
 locale/gitlab.pot                             | 11 ++-
 .../components/view/index_spec.js             | 88 ++++++++++++-----
 .../components/view/protection_toggle_spec.js | 74 ++++++++++++++
 7 files changed, 336 insertions(+), 92 deletions(-)
 create mode 100644 app/assets/javascripts/projects/settings/branch_rules/components/view/protection_toggle.vue
 create mode 100644 spec/frontend/projects/settings/branch_rules/components/view/protection_toggle_spec.js

diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
index 051e56c94f36..46d577a57507 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/constants.js
@@ -27,10 +27,15 @@ export const I18N = {
   statusChecksHeader: s__('BranchRules|Status checks (%{total})'),
   allowedToPushHeader: s__('BranchRules|Allowed to push and merge (%{total})'),
   allowedToMergeHeader: s__('BranchRules|Allowed to merge (%{total})'),
+  allowForcePushLabel: s__('BranchRules|Allow force push'),
   allowForcePushTitle: s__('BranchRules|Allows force push'),
   doesNotAllowForcePushTitle: s__('BranchRules|Does not allow force push'),
-  forcePushDescription: s__('BranchRules|From users with push access.'),
-  requiresCodeOwnerApprovalTitle: s__('BranchRules|Requires approval from code owners'),
+  forcePushIconDescription: s__('BranchRules|From users with push access.'),
+  forcePushDescriptionWithDocs: s__(
+    'BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}.',
+  ),
+  requiresCodeOwnerApprovalLabel: s__('BranchRules|Require code owner approval'),
+  requiresCodeOwnerApprovalTitle: s__('BranchRules|Requires code owner approval'),
   doesNotRequireCodeOwnerApprovalTitle: s__(
     'BranchRules|Does not require approval from code owners',
   ),
@@ -40,6 +45,9 @@ export const I18N = {
   doesNotRequireCodeOwnerApprovalDescription: s__(
     'BranchRules|Also accepts code pushes that change files listed in CODEOWNERS file.',
   ),
+  codeOwnerApprovalDescription: s__(
+    'BranchRules|Changed files listed in %{linkStart}CODEOWNERS%{linkEnd} require an approval for merge requests and will be rejected for code pushes.',
+  ),
   noData: s__('BranchRules|No data to display'),
   deleteRuleModalTitle: s__('BranchRules|Delete branch rule?'),
   deleteRuleModalText: s__(
@@ -70,6 +78,10 @@ export const APPROVALS_HELP_PATH = 'user/project/merge_requests/approvals/index.
 
 export const STATUS_CHECKS_HELP_PATH = 'user/project/merge_requests/status_checks.md';
 
+export const CODE_OWNERS_HELP_PATH = 'user/project/code_owners.md';
+
+export const PUSH_RULES_HELP_PATH = 'user/project/repository/push_rules.md';
+
 export const REQUIRED_ICON = 'check-circle-filled';
 export const NOT_REQUIRED_ICON = 'status-failed';
 
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
index 3cdb3f5c7d4b..6371318e4d66 100644
--- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue
@@ -5,7 +5,6 @@ import {
   GlSprintf,
   GlLink,
   GlLoadingIcon,
-  GlIcon,
   GlCard,
   GlButton,
   GlModal,
@@ -28,36 +27,39 @@ import { getAccessLevels } from '../../../utils';
 import BranchRuleModal from '../../../components/branch_rule_modal.vue';
 import Protection from './protection.vue';
 import RuleDrawer from './rule_drawer.vue';
+import ProtectionToggle from './protection_toggle.vue';
 import {
   I18N,
   ALL_BRANCHES_WILDCARD,
   BRANCH_PARAM_NAME,
   PROTECTED_BRANCHES_HELP_PATH,
-  REQUIRED_ICON,
-  NOT_REQUIRED_ICON,
-  REQUIRED_ICON_CLASS,
-  NOT_REQUIRED_ICON_CLASS,
+  CODE_OWNERS_HELP_PATH,
+  PUSH_RULES_HELP_PATH,
   DELETE_RULE_MODAL_ID,
   EDIT_RULE_MODAL_ID,
 } from './constants';
 
 const protectedBranchesHelpDocLink = helpPagePath(PROTECTED_BRANCHES_HELP_PATH);
+const codeOwnersHelpDocLink = helpPagePath(CODE_OWNERS_HELP_PATH);
+const pushRulesHelpDocLink = helpPagePath(PUSH_RULES_HELP_PATH);
 
 export default {
   name: 'RuleView',
   i18n: I18N,
   deleteModalId: DELETE_RULE_MODAL_ID,
   protectedBranchesHelpDocLink,
+  codeOwnersHelpDocLink,
+  pushRulesHelpDocLink,
   directives: {
     GlModal: GlModalDirective,
   },
   editModalId: EDIT_RULE_MODAL_ID,
   components: {
     Protection,
+    ProtectionToggle,
     GlSprintf,
     GlLink,
     GlLoadingIcon,
-    GlIcon,
     GlCard,
     GlModal,
     GlButton,
@@ -124,26 +126,37 @@ export default {
   computed: {
     forcePushAttributes() {
       const { allowForcePush } = this.branchProtection || {};
-      const icon = allowForcePush ? REQUIRED_ICON : NOT_REQUIRED_ICON;
-      const iconClass = allowForcePush ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
       const title = allowForcePush
         ? this.$options.i18n.allowForcePushTitle
         : this.$options.i18n.doesNotAllowForcePushTitle;
 
-      return { icon, iconClass, title };
+      if (!this.glFeatures.editBranchRules) {
+        return { title, description: this.$options.i18n.forcePushIconDescription };
+      }
+
+      return {
+        title,
+        description: this.$options.i18n.forcePushDescriptionWithDocs,
+      };
     },
     codeOwnersApprovalAttributes() {
       const { codeOwnerApprovalRequired } = this.branchProtection || {};
-      const icon = codeOwnerApprovalRequired ? REQUIRED_ICON : NOT_REQUIRED_ICON;
-      const iconClass = codeOwnerApprovalRequired ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
       const title = codeOwnerApprovalRequired
         ? this.$options.i18n.requiresCodeOwnerApprovalTitle
         : this.$options.i18n.doesNotRequireCodeOwnerApprovalTitle;
-      const description = codeOwnerApprovalRequired
-        ? this.$options.i18n.requiresCodeOwnerApprovalDescription
-        : this.$options.i18n.doesNotRequireCodeOwnerApprovalDescription;
 
-      return { icon, iconClass, title, description };
+      if (!this.glFeatures.editBranchRules) {
+        const description = codeOwnerApprovalRequired
+          ? this.$options.i18n.requiresCodeOwnerApprovalDescription
+          : this.$options.i18n.doesNotRequireCodeOwnerApprovalDescription;
+
+        return { title, description };
+      }
+
+      return {
+        title,
+        description: this.$options.i18n.codeOwnerApprovalDescription,
+      };
     },
     mergeAccessLevels() {
       const { mergeAccessLevels } = this.branchProtection || {};
@@ -353,32 +366,26 @@ export default {
         />
 
         <!-- Force push -->
-        <div class="gl-display-flex gl-align-items-center">
-          <gl-icon
-            :size="14"
-            data-testid="force-push-icon"
-            :name="forcePushAttributes.icon"
-            :class="forcePushAttributes.iconClass"
-          />
-          <strong class="gl-ml-2">{{ forcePushAttributes.title }}</strong>
-        </div>
-
-        <div class="gl-text-secondary gl-mb-2">{{ $options.i18n.forcePushDescription }}</div>
+        <protection-toggle
+          data-test-id="force-push"
+          :is-protected="branchProtection.allowForcePush"
+          :label="$options.i18n.allowForcePushLabel"
+          :icon-title="forcePushAttributes.title"
+          :description="forcePushAttributes.description"
+          :description-link="$options.pushRulesHelpDocLink"
+        />
 
         <!-- EE start -->
         <!-- Code Owners -->
         <div v-if="showCodeOwners">
-          <div class="gl-display-flex gl-align-items-center">
-            <gl-icon
-              data-testid="code-owners-icon"
-              :size="14"
-              :name="codeOwnersApprovalAttributes.icon"
-              :class="codeOwnersApprovalAttributes.iconClass"
-            />
-            <strong class="gl-ml-2">{{ codeOwnersApprovalAttributes.title }}</strong>
-          </div>
-
-          <div class="gl-text-secondary">{{ codeOwnersApprovalAttributes.description }}</div>
+          <protection-toggle
+            data-test-id="code-owners"
+            :is-protected="branchProtection.codeOwnerApprovalRequired"
+            :label="$options.i18n.requiresCodeOwnerApprovalLabel"
+            :icon-title="codeOwnersApprovalAttributes.title"
+            :description="codeOwnersApprovalAttributes.description"
+            :description-link="$options.codeOwnersHelpDocLink"
+          />
         </div>
       </section>
 
diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_toggle.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_toggle.vue
new file mode 100644
index 000000000000..86b133c8bf17
--- /dev/null
+++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_toggle.vue
@@ -0,0 +1,97 @@
+<script>
+import { GlToggle, GlIcon, GlSprintf, GlLink } from '@gitlab/ui';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import {
+  REQUIRED_ICON,
+  NOT_REQUIRED_ICON,
+  REQUIRED_ICON_CLASS,
+  NOT_REQUIRED_ICON_CLASS,
+} from './constants';
+
+export default {
+  components: {
+    GlToggle,
+    GlIcon,
+    GlSprintf,
+    GlLink,
+  },
+  mixins: [glFeatureFlagsMixin()],
+  props: {
+    dataTestId: {
+      type: String,
+      required: true,
+    },
+    label: {
+      type: String,
+      required: true,
+    },
+    description: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    descriptionLink: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    help: {
+      type: String,
+      required: false,
+      default: '',
+    },
+    iconTitle: {
+      type: String,
+      required: true,
+    },
+    isProtected: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  computed: {
+    iconName() {
+      return this.isProtected ? REQUIRED_ICON : NOT_REQUIRED_ICON;
+    },
+    iconClass() {
+      return this.isProtected ? REQUIRED_ICON_CLASS : NOT_REQUIRED_ICON_CLASS;
+    },
+    iconDataTestId() {
+      // eslint-disable-next-line @gitlab/require-i18n-strings
+      return this.dataTestId ? `${this.dataTestId}-icon` : '';
+    },
+    hasDescription() {
+      if (!this.glFeatures.editBranchRules) {
+        return Boolean(this.description);
+      }
+
+      return this.isProtected ? Boolean(this.description) : false;
+    },
+  },
+};
+</script>
+
+<template>
+  <div v-if="glFeatures.editBranchRules">
+    <gl-toggle :label="label" :help="help" :value="isProtected" class="gl-mb-5">
+      <template v-if="hasDescription" #description>
+        <gl-sprintf :message="description">
+          <template #link="{ content }">
+            <gl-link :href="descriptionLink">{{ content }}</gl-link>
+          </template>
+        </gl-sprintf>
+      </template>
+    </gl-toggle>
+  </div>
+  <div v-else class="gl-mb-5">
+    <div class="gl-display-flex gl-align-items-center">
+      <gl-icon :data-testid="iconDataTestId" :size="14" :name="iconName" :class="iconClass" />
+      <strong class="gl-ml-2">{{ iconTitle }}</strong>
+    </div>
+    <gl-sprintf v-if="hasDescription" :message="description" data-testid="protection-description">
+      <template #link="{ content }">
+        <gl-link :href="descriptionLink">{{ content }}</gl-link>
+      </template>
+    </gl-sprintf>
+  </div>
+</template>
diff --git a/ee/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js b/ee/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
index a9e17b5537e5..62feb0fd649f 100644
--- a/ee/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
+++ b/ee/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
@@ -14,14 +14,9 @@ import deleteBranchRuleMutation from '~/projects/settings/branch_rules/mutations
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import createMockApollo from 'helpers/mock_apollo_helper';
 import waitForPromises from 'helpers/wait_for_promises';
-import {
-  I18N,
-  REQUIRED_ICON,
-  NOT_REQUIRED_ICON,
-  REQUIRED_ICON_CLASS,
-  NOT_REQUIRED_ICON_CLASS,
-} from '~/projects/settings/branch_rules/components/view/constants';
+import { I18N } from '~/projects/settings/branch_rules/components/view/constants';
 import Protection from '~/projects/settings/branch_rules/components/view/protection.vue';
+import ProtectionToggle from '~/projects/settings/branch_rules/components/view/protection_toggle.vue';
 import { sprintf } from '~/locale';
 import {
   deleteBranchRuleMockResponse,
@@ -60,6 +55,7 @@ describe('View branch rules in enterprise edition', () => {
     jest.fn().mockResolvedValue(response);
 
   const createComponent = async (
+    glFeatures = { editBranchRules: true },
     { showApprovers, showStatusChecks, showCodeOwners } = {},
     mockResponse,
     mutationMockResponse,
@@ -85,6 +81,7 @@ describe('View branch rules in enterprise edition', () => {
         showApprovers,
         showStatusChecks,
         showCodeOwners,
+        glFeatures,
       },
     });
 
@@ -99,9 +96,7 @@ describe('View branch rules in enterprise edition', () => {
   const findApprovalsApp = () => wrapper.findComponent(ApprovalRulesApp);
   const findProjectRules = () => wrapper.findComponent(ProjectRules);
   const findStatusChecksTitle = () => wrapper.findByText(I18N.statusChecksTitle);
-  const findCodeOwnerApprovalIcon = () => wrapper.findByTestId('code-owners-icon');
-  const findCodeOwnerApprovalTitle = (title) => wrapper.findByText(title);
-  const findCodeOwnerApprovalDescription = (description) => wrapper.findByText(description);
+  const findProtectionToggles = () => wrapper.findAllComponents(ProtectionToggle);
 
   it('renders a branch protection component for push rules', () => {
     expect(findBranchProtections().at(0).props()).toMatchObject({
@@ -119,28 +114,22 @@ describe('View branch rules in enterprise edition', () => {
 
   describe('Code owner approvals', () => {
     it('does not render a code owner approval section by default', () => {
-      expect(findCodeOwnerApprovalIcon().exists()).toBe(false);
-      expect(findCodeOwnerApprovalTitle(I18N.requiresCodeOwnerApprovalTitle).exists()).toBe(false);
-      expect(
-        findCodeOwnerApprovalDescription(I18N.requiresCodeOwnerApprovalDescription).exists(),
-      ).toBe(false);
+      expect(findProtectionToggles().length).toBe(1);
     });
 
     it.each`
-      codeOwnerApprovalRequired | iconName             | iconClass                  | title                                        | description
-      ${true}                   | ${REQUIRED_ICON}     | ${REQUIRED_ICON_CLASS}     | ${I18N.requiresCodeOwnerApprovalTitle}       | ${I18N.requiresCodeOwnerApprovalDescription}
-      ${false}                  | ${NOT_REQUIRED_ICON} | ${NOT_REQUIRED_ICON_CLASS} | ${I18N.doesNotRequireCodeOwnerApprovalTitle} | ${I18N.doesNotRequireCodeOwnerApprovalDescription}
+      codeOwnerApprovalRequired | iconTitle                                    | description
+      ${true}                   | ${I18N.requiresCodeOwnerApprovalTitle}       | ${I18N.codeOwnerApprovalDescription}
+      ${false}                  | ${I18N.doesNotRequireCodeOwnerApprovalTitle} | ${I18N.codeOwnerApprovalDescription}
     `(
-      'code owners with the correct icon, title and description',
-      async ({ codeOwnerApprovalRequired, iconName, iconClass, title, description }) => {
+      'renders code owners approval section with the correct iconTitle and description',
+      async ({ codeOwnerApprovalRequired, iconTitle, description }) => {
         const mockResponse = branchProtectionsMockResponse;
         mockResponse.data.project.branchRules.nodes[0].branchProtection.codeOwnerApprovalRequired = codeOwnerApprovalRequired;
-        await createComponent({ showCodeOwners: true }, mockResponse);
+        await createComponent({ editBranchRules: true }, { showCodeOwners: true }, mockResponse);
 
-        expect(findCodeOwnerApprovalIcon().props('name')).toBe(iconName);
-        expect(findCodeOwnerApprovalIcon().attributes('class')).toBe(iconClass);
-        expect(findCodeOwnerApprovalTitle(title).exists()).toBe(true);
-        expect(findCodeOwnerApprovalTitle(description).exists()).toBe(true);
+        expect(findProtectionToggles().at(1).props('iconTitle')).toEqual(iconTitle);
+        expect(findProtectionToggles().at(1).props('description')).toEqual(description);
       },
     );
   });
@@ -151,7 +140,7 @@ describe('View branch rules in enterprise edition', () => {
   });
 
   describe('if "showApprovers" is true', () => {
-    beforeEach(() => createComponent({ showApprovers: true }));
+    beforeEach(() => createComponent({}, { showApprovers: true }));
 
     it('sets an approval rules filter', () => {
       expect(store.modules.approvals.actions.setRulesFilter).toHaveBeenCalledWith(
@@ -182,7 +171,7 @@ describe('View branch rules in enterprise edition', () => {
   });
 
   it('renders a branch protection component for status checks  if "showStatusChecks" is true', async () => {
-    await createComponent({ showStatusChecks: true });
+    await createComponent({}, { showStatusChecks: true });
 
     expect(findStatusChecksTitle().exists()).toBe(true);
 
@@ -193,4 +182,22 @@ describe('View branch rules in enterprise edition', () => {
       statusChecks: statusChecksRulesMock,
     });
   });
+
+  describe('When edit_branch_rules feature flag is disabled', () => {
+    it.each`
+      codeOwnerApprovalRequired | title                                        | description
+      ${true}                   | ${I18N.requiresCodeOwnerApprovalTitle}       | ${I18N.requiresCodeOwnerApprovalDescription}
+      ${false}                  | ${I18N.doesNotRequireCodeOwnerApprovalTitle} | ${I18N.doesNotRequireCodeOwnerApprovalDescription}
+    `(
+      'renders code owners approval section with the correct title and description',
+      async ({ codeOwnerApprovalRequired, title, description }) => {
+        const mockResponse = branchProtectionsMockResponse;
+        mockResponse.data.project.branchRules.nodes[0].branchProtection.codeOwnerApprovalRequired = codeOwnerApprovalRequired;
+        await createComponent({ editBranchRules: false }, { showCodeOwners: true }, mockResponse);
+
+        expect(findProtectionToggles().at(1).props('iconTitle')).toEqual(title);
+        expect(findProtectionToggles().at(1).props('description')).toEqual(description);
+      },
+    );
+  });
 });
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 68224ca5d3a0..128d4db6e31d 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -9026,6 +9026,9 @@ msgstr ""
 msgid "BranchRules|Allow all users with push access to %{linkStart}force push%{linkEnd}."
 msgstr ""
 
+msgid "BranchRules|Allow force push"
+msgstr ""
+
 msgid "BranchRules|Allowed to force push"
 msgstr ""
 
@@ -9077,6 +9080,9 @@ msgstr ""
 msgid "BranchRules|Cancel"
 msgstr ""
 
+msgid "BranchRules|Changed files listed in %{linkStart}CODEOWNERS%{linkEnd} require an approval for merge requests and will be rejected for code pushes."
+msgstr ""
+
 msgid "BranchRules|Changes require a merge request. The following users can push and merge directly."
 msgstr ""
 
@@ -9164,10 +9170,13 @@ msgstr ""
 msgid "BranchRules|Require approval from code owners."
 msgstr ""
 
+msgid "BranchRules|Require code owner approval"
+msgstr ""
+
 msgid "BranchRules|Requires CODEOWNERS approval"
 msgstr ""
 
-msgid "BranchRules|Requires approval from code owners"
+msgid "BranchRules|Requires code owner approval"
 msgstr ""
 
 msgid "BranchRules|Roles"
diff --git a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
index 84ebeb3de046..e3e2973381fb 100644
--- a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
+++ b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js
@@ -13,16 +13,13 @@ import RuleView from '~/projects/settings/branch_rules/components/view/index.vue
 import RuleDrawer from '~/projects/settings/branch_rules/components/view/rule_drawer.vue';
 import { useMockLocationHelper } from 'helpers/mock_window_location_helper';
 import Protection from '~/projects/settings/branch_rules/components/view/protection.vue';
+import ProtectionToggle from '~/projects/settings/branch_rules/components/view/protection_toggle.vue';
 import BranchRuleModal from '~/projects/settings/components/branch_rule_modal.vue';
 import getProtectableBranches from '~/projects/settings/graphql/queries/protectable_branches.query.graphql';
 
 import {
   I18N,
   ALL_BRANCHES_WILDCARD,
-  REQUIRED_ICON,
-  NOT_REQUIRED_ICON,
-  REQUIRED_ICON_CLASS,
-  NOT_REQUIRED_ICON_CLASS,
   DELETE_RULE_MODAL_ID,
   EDIT_RULE_MODAL_ID,
 } from '~/projects/settings/branch_rules/components/view/constants';
@@ -81,12 +78,12 @@ describe('View branch rules', () => {
   const errorHandler = jest.fn().mockRejectedValue('error');
   const toastMock = { show: jest.fn() };
 
-  const createComponent = async (
+  const createComponent = async ({
     glFeatures = { editBranchRules: true },
     branchRulesQueryHandler = branchRulesMockRequestHandler,
     deleteMutationHandler = deleteBranchRuleSuccessHandler,
     editMutationHandler = editBranchRuleSuccessHandler,
-  ) => {
+  } = {}) => {
     fakeApollo = createMockApollo([
       [branchRulesQuery, branchRulesQueryHandler],
       [getProtectableBranches, protectableBranchesMockRequestHandler],
@@ -96,9 +93,15 @@ describe('View branch rules', () => {
 
     wrapper = shallowMountExtended(RuleView, {
       apolloProvider: fakeApollo,
-      provide: { projectPath, protectedBranchesPath, branchRulesPath, glFeatures },
+      provide: {
+        projectPath,
+        protectedBranchesPath,
+        branchRulesPath,
+        glFeatures,
+      },
       stubs: {
         Protection,
+        ProtectionToggle,
         BranchRuleModal,
         RuleDrawer,
         GlCard: stubComponent(GlCard, { template: RENDER_ALL_SLOTS_TEMPLATE }),
@@ -119,9 +122,7 @@ describe('View branch rules', () => {
   const findAllBranches = () => wrapper.findByTestId('all-branches');
   const findBranchProtectionTitle = () => wrapper.findByText(I18N.protectBranchTitle);
   const findBranchProtections = () => wrapper.findAllComponents(Protection);
-  const findForcePushIcon = () => wrapper.findByTestId('force-push-icon');
-  const findForcePushTitle = (title) => wrapper.findByText(title);
-  const findForcePushDescription = () => wrapper.findByText(I18N.forcePushDescription);
+  const findProtectionToggles = () => wrapper.findAllComponents(ProtectionToggle);
   const findApprovalsTitle = () => wrapper.findByText(I18N.approvalsTitle);
   const findpageTitle = () => wrapper.findByText(I18N.pageTitle);
   const findStatusChecksTitle = () => wrapper.findByText(I18N.statusChecksTitle);
@@ -192,20 +193,21 @@ describe('View branch rules', () => {
   });
 
   it.each`
-    allowForcePush | iconName             | iconClass                  | title
-    ${true}        | ${REQUIRED_ICON}     | ${REQUIRED_ICON_CLASS}     | ${I18N.allowForcePushTitle}
-    ${false}       | ${NOT_REQUIRED_ICON} | ${NOT_REQUIRED_ICON_CLASS} | ${I18N.doesNotAllowForcePushTitle}
+    allowForcePush | iconTitle                          | description
+    ${true}        | ${I18N.allowForcePushTitle}        | ${I18N.forcePushDescriptionWithDocs}
+    ${false}       | ${I18N.doesNotAllowForcePushTitle} | ${I18N.forcePushDescriptionWithDocs}
   `(
-    'renders force push section with the correct icon, title and description',
-    async ({ allowForcePush, iconName, iconClass, title }) => {
+    'renders force push section with the correct title and description',
+    async ({ allowForcePush, iconTitle, description }) => {
       const mockResponse = branchProtectionsMockResponse;
       mockResponse.data.project.branchRules.nodes[0].branchProtection.allowForcePush = allowForcePush;
-      await createComponent(mockResponse);
+      await createComponent({
+        glFeatures: { editBranchRules: true },
+        branchRulesQueryHandler: jest.fn().mockResolvedValue(mockResponse),
+      });
 
-      expect(findForcePushIcon().props('name')).toBe(iconName);
-      expect(findForcePushIcon().attributes('class')).toBe(iconClass);
-      expect(findForcePushTitle(title).exists()).toBe(true);
-      expect(findForcePushDescription().exists()).toBe(true);
+      expect(findProtectionToggles().at(0).props('iconTitle')).toEqual(iconTitle);
+      expect(findProtectionToggles().at(0).props('description')).toEqual(description);
     },
   );
 
@@ -238,6 +240,10 @@ describe('View branch rules', () => {
   });
 
   describe('Editing branch rule', () => {
+    beforeEach(async () => {
+      await createComponent();
+    });
+
     it('renders edit branch rule button', () => {
       expect(findEditRuleNameButton().text()).toBe('Edit');
     });
@@ -277,6 +283,10 @@ describe('View branch rules', () => {
         '/project/Project/-/settings/repository/branch_rules?branch=main',
       );
     });
+
+    it('renders force push section with the correct toggle label and description', () => {
+      expect(findProtectionToggles().at(0).props('label')).toEqual('Allow force push');
+    });
   });
 
   describe('Deleting branch rule', () => {
@@ -314,7 +324,11 @@ describe('View branch rules', () => {
     });
 
     it('if error happens it shows an alert', async () => {
-      await createComponent({ editBranchRules: true }, branchRulesMockRequestHandler, errorHandler);
+      await createComponent({
+        glFeatures: { editBranchRules: true },
+        branchRulesQueryHandler: branchRulesMockRequestHandler,
+        deleteMutationHandler: errorHandler,
+      });
       findDeleteRuleModal().vm.$emit('ok');
       await nextTick();
       await waitForPromises();
@@ -332,7 +346,10 @@ describe('View branch rules', () => {
     beforeEach(async () => {
       jest.spyOn(util, 'getParameterByName').mockReturnValueOnce('All branches');
 
-      await createComponent({ editBranchRules: true }, predefinedBranchRulesMockRequestHandler);
+      await createComponent({
+        glFeatures: { editBranchRules: true },
+        branchRulesQueryHandler: predefinedBranchRulesMockRequestHandler,
+      });
     });
 
     it('renders the correct branch rule title', () => {
@@ -381,7 +398,7 @@ describe('View branch rules', () => {
   describe('When rendered for a non-existing rule', () => {
     beforeEach(async () => {
       jest.spyOn(util, 'getParameterByName').mockReturnValueOnce('non-existing-rule');
-      await createComponent({ editBranchRules: true });
+      await createComponent({ glFeatures: { editBranchRules: true } });
     });
 
     it('shows empty state', () => {
@@ -389,8 +406,9 @@ describe('View branch rules', () => {
     });
   });
 
-  describe('When add_branch_rules feature flag is disabled', () => {
-    beforeEach(() => createComponent({ editBranchRules: false }));
+  describe('When edit_branch_rules feature flag is disabled', () => {
+    beforeEach(() => createComponent({ glFeatures: { editBranchRules: false } }));
+
     it('does not render delete rule button and modal', () => {
       expect(findDeleteRuleButton().exists()).toBe(false);
       expect(findDeleteRuleModal().exists()).toBe(false);
@@ -400,5 +418,25 @@ describe('View branch rules', () => {
       expect(findEditRuleNameButton().exists()).toBe(false);
       expect(findBranchRuleModal().exists()).toBe(false);
     });
+
+    it.each`
+      allowForcePush | title                              | description
+      ${true}        | ${I18N.allowForcePushTitle}        | ${I18N.forcePushIconDescription}
+      ${false}       | ${I18N.doesNotAllowForcePushTitle} | ${I18N.forcePushIconDescription}
+    `(
+      'renders force push section with the correct title and description, when rule is `$allowForcePush`',
+      async ({ allowForcePush, title, description }) => {
+        const mockResponse = branchProtectionsMockResponse;
+        mockResponse.data.project.branchRules.nodes[0].branchProtection.allowForcePush = allowForcePush;
+
+        await createComponent({
+          glFeatures: { editBranchRules: false },
+          branchRulesQueryHandler: jest.fn().mockResolvedValue(mockResponse),
+        });
+
+        expect(findProtectionToggles().at(0).props('iconTitle')).toEqual(title);
+        expect(findProtectionToggles().at(0).props('description')).toEqual(description);
+      },
+    );
   });
 });
diff --git a/spec/frontend/projects/settings/branch_rules/components/view/protection_toggle_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/protection_toggle_spec.js
new file mode 100644
index 000000000000..596c19db6e29
--- /dev/null
+++ b/spec/frontend/projects/settings/branch_rules/components/view/protection_toggle_spec.js
@@ -0,0 +1,74 @@
+import { GlToggle, GlIcon, GlSprintf, GlLink } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ProtectionToggle from '~/projects/settings/branch_rules/components/view/protection_toggle.vue';
+
+describe('ProtectionToggle', () => {
+  let wrapper;
+
+  const createComponent = ({
+    props = {},
+    provided = {},
+    glFeatures = { editBranchRules: true },
+  } = {}) => {
+    wrapper = shallowMountExtended(ProtectionToggle, {
+      stubs: {
+        GlToggle,
+        GlIcon,
+        GlLink,
+        GlSprintf,
+      },
+      provide: {
+        glFeatures,
+        ...provided,
+      },
+      propsData: {
+        dataTestId: 'force-push',
+        label: 'Force Push',
+        iconTitle: 'icon title',
+        isProtected: false,
+        ...props,
+      },
+    });
+  };
+
+  const findToggle = () => wrapper.findComponent(GlToggle);
+  const findIcon = () => wrapper.findByTestId('force-push-icon');
+
+  describe('when user can edit', () => {
+    beforeEach(() => {
+      createComponent();
+    });
+
+    it('renders the toggle', () => {
+      expect(findToggle().exists()).toBe(true);
+    });
+
+    it('does not render the protection icon', () => {
+      expect(findIcon().exists()).toBe(false);
+    });
+
+    it('does not render the toggle description when not provided', () => {
+      expect(wrapper.findComponent(GlSprintf).exists()).toBe(false);
+    });
+
+    it('renders the toggle description, when protection is on', () => {
+      createComponent({ props: { isProtected: true, description: 'Some description' } });
+
+      expect(wrapper.findComponent(GlSprintf).exists()).toBe(true);
+    });
+  });
+
+  describe('when glFeatures.editBranchRules is false', () => {
+    beforeEach(() => {
+      createComponent({ glFeatures: { editBranchRules: false } });
+    });
+
+    it('does not render the toggle even for users with edit privileges', () => {
+      expect(findToggle().exists()).toBe(false);
+    });
+
+    it('does not render the toggle description when not provided', () => {
+      expect(wrapper.findComponent(GlSprintf).exists()).toBe(false);
+    });
+  });
+});
-- 
GitLab