From 3834e6b30ab570edfc44f7f018bedeb0b7269d51 Mon Sep 17 00:00:00 2001
From: Lindsey Shelton <lshelton@gitlab.com>
Date: Fri, 2 Aug 2024 18:12:01 +0000
Subject: [PATCH] Lock Tooltip and Button changes

Cleanup of branch into singular commit for Lock changes
Upgrades LockPopover to LockTooltip
Adds new CascadingLockButton

Other changes include:
Removing BE changes so into new Issue 474535
Put changes into .patch
---
 .../components/cascading_lock_icon.vue        | 60 +++++++++++++
 ...ck_popovers.vue => haml_lock_tooltips.vue} | 18 ++--
 .../{lock_popover.vue => lock_tooltip.vue}    | 14 +--
 .../namespaces/cascading_settings/index.js    |  8 +-
 .../javascripts/pages/groups/edit/index.js    |  4 +-
 .../components/project_setting_row.vue        | 16 +++-
 .../permissions/components/settings_panel.vue |  2 +-
 .../projects/shared/permissions/index.js      |  7 +-
 app/helpers/namespaces_helper.rb              |  8 +-
 app/views/groups/edit.html.haml               |  2 +-
 .../cascading_settings/_lock_icon.html.haml   |  2 +-
 .../_lock_popovers.html.haml                  |  1 -
 .../_lock_tooltips.html.haml                  |  1 +
 doc/development/cascading_settings.md         | 14 +--
 locale/gitlab.pot                             |  3 +
 .../settings/packages_settings_spec.rb        | 20 +++--
 .../components/cascading_lock_icon_spec.js    | 89 +++++++++++++++++++
 ...ers_spec.js => haml_lock_tooltips_spec.js} | 58 ++++++------
 ...k_popover_spec.js => lock_tooltip_spec.js} | 50 ++++++-----
 .../components/project_setting_row_spec.js    | 30 +++++--
 spec/helpers/namespaces_helper_spec.rb        |  8 +-
 .../cascading_settings_shared_examples.rb     |  4 +-
 22 files changed, 301 insertions(+), 118 deletions(-)
 create mode 100644 app/assets/javascripts/namespaces/cascading_settings/components/cascading_lock_icon.vue
 rename app/assets/javascripts/namespaces/cascading_settings/components/{haml_lock_popovers.vue => haml_lock_tooltips.vue} (75%)
 rename app/assets/javascripts/namespaces/cascading_settings/components/{lock_popover.vue => lock_tooltip.vue} (86%)
 delete mode 100644 app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
 create mode 100644 app/views/shared/namespaces/cascading_settings/_lock_tooltips.html.haml
 create mode 100644 spec/frontend/cascading_settings/components/cascading_lock_icon_spec.js
 rename spec/frontend/cascading_settings/components/{haml_lock_popovers_spec.js => haml_lock_tooltips_spec.js} (56%)
 rename spec/frontend/cascading_settings/components/{lock_popover_spec.js => lock_tooltip_spec.js} (61%)

diff --git a/app/assets/javascripts/namespaces/cascading_settings/components/cascading_lock_icon.vue b/app/assets/javascripts/namespaces/cascading_settings/components/cascading_lock_icon.vue
new file mode 100644
index 0000000000000..6cb344ad711d6
--- /dev/null
+++ b/app/assets/javascripts/namespaces/cascading_settings/components/cascading_lock_icon.vue
@@ -0,0 +1,60 @@
+<script>
+import { GlIcon, GlButton } from '@gitlab/ui';
+import { uniqueId } from 'lodash';
+import { s__ } from '~/locale';
+import LockTooltip from './lock_tooltip.vue';
+
+export default {
+  name: 'CascadingLockIcon',
+  i18n: {
+    lockIconLabel: s__('CascadingSettings|Lock tooltip icon'),
+  },
+  components: {
+    GlIcon,
+    GlButton,
+    LockTooltip,
+  },
+  props: {
+    ancestorNamespace: {
+      type: Object,
+      required: false,
+      default: null,
+      validator: (value) => value?.path && value?.fullName,
+    },
+    isLockedByApplicationSettings: {
+      type: Boolean,
+      required: true,
+    },
+    isLockedByGroupAncestor: {
+      type: Boolean,
+      required: true,
+    },
+  },
+  data() {
+    return {
+      targetElement: null,
+    };
+  },
+  async mounted() {
+    // Wait until all children components are mounted
+    await this.$nextTick();
+    this.targetElement = this.$refs[this.$options.refName].$el;
+  },
+  refName: uniqueId('cascading-lock-icon-'),
+};
+</script>
+
+<template>
+  <span>
+    <gl-button :ref="$options.refName" class="gl-hover-bg-transparent! !gl-p-0" category="tertiary">
+      <gl-icon name="lock" :aria-label="$options.i18n.lockIconLabel" class="!gl-text-gray-400" />
+    </gl-button>
+    <lock-tooltip
+      v-if="targetElement"
+      :ancestor-namespace="ancestorNamespace"
+      :is-locked-by-admin="isLockedByApplicationSettings"
+      :is-locked-by-group-ancestor="isLockedByGroupAncestor"
+      :target-element="targetElement"
+    />
+  </span>
+</template>
diff --git a/app/assets/javascripts/namespaces/cascading_settings/components/haml_lock_popovers.vue b/app/assets/javascripts/namespaces/cascading_settings/components/haml_lock_tooltips.vue
similarity index 75%
rename from app/assets/javascripts/namespaces/cascading_settings/components/haml_lock_popovers.vue
rename to app/assets/javascripts/namespaces/cascading_settings/components/haml_lock_tooltips.vue
index 585fdc93bbf1a..6a95c16302471 100644
--- a/app/assets/javascripts/namespaces/cascading_settings/components/haml_lock_popovers.vue
+++ b/app/assets/javascripts/namespaces/cascading_settings/components/haml_lock_tooltips.vue
@@ -1,17 +1,17 @@
 <script>
 /**
  * This component is a utility that can be used in a HAML settings pages
- * It will get all popover targets and create a popover for each one.
+ * It will get all tooltip targets and create a tooltip for each one.
  * This should not be used in Vue Apps as we we are breaking component isolation.
- * Instead, use `lock_popover.vue` and provide a list of vue $refs to loop through.
+ * Instead, use `lock_tooltip.vue` and provide a list of vue $refs to loop through.
  */
 import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import LockPopover from './lock_popover.vue';
+import LockTooltip from './lock_tooltip.vue';
 
 export default {
-  name: 'HamlLockPopovers',
+  name: 'HamlLockTooltips',
   components: {
-    LockPopover,
+    LockTooltip,
   },
   data() {
     return {
@@ -19,14 +19,14 @@ export default {
     };
   },
   mounted() {
-    this.targets = [...document.querySelectorAll('.js-cascading-settings-lock-popover-target')].map(
+    this.targets = [...document.querySelectorAll('.js-cascading-settings-lock-tooltip-target')].map(
       (el) => {
         const {
-          dataset: { popoverData },
+          dataset: { tooltipData },
         } = el;
 
         const { lockedByAncestor, lockedByApplicationSetting, ancestorNamespace } =
-          convertObjectPropsToCamelCase(JSON.parse(popoverData || '{}'), { deep: true });
+          convertObjectPropsToCamelCase(JSON.parse(tooltipData || '{}'), { deep: true });
 
         return {
           el,
@@ -42,7 +42,7 @@ export default {
 
 <template>
   <div>
-    <lock-popover
+    <lock-tooltip
       v-for="(
         { el, lockedByApplicationSetting, lockedByAncestor, ancestorNamespace }, index
       ) in targets"
diff --git a/app/assets/javascripts/namespaces/cascading_settings/components/lock_popover.vue b/app/assets/javascripts/namespaces/cascading_settings/components/lock_tooltip.vue
similarity index 86%
rename from app/assets/javascripts/namespaces/cascading_settings/components/lock_popover.vue
rename to app/assets/javascripts/namespaces/cascading_settings/components/lock_tooltip.vue
index 367af68e2d21a..300b980023343 100644
--- a/app/assets/javascripts/namespaces/cascading_settings/components/lock_popover.vue
+++ b/app/assets/javascripts/namespaces/cascading_settings/components/lock_tooltip.vue
@@ -1,10 +1,10 @@
 <script>
-import { GlPopover, GlSprintf, GlLink } from '@gitlab/ui';
+import { GlTooltip, GlSprintf, GlLink } from '@gitlab/ui';
 
 export default {
-  name: 'LockPopover',
+  name: 'LockTooltip',
   components: {
-    GlPopover,
+    GlTooltip,
     GlSprintf,
     GlLink,
   },
@@ -24,8 +24,8 @@ export default {
       required: true,
     },
     targetElement: {
-      required: true,
       type: Element,
+      required: true,
     },
   },
   computed: {
@@ -37,9 +37,9 @@ export default {
 </script>
 
 <template>
-  <gl-popover v-if="isLocked" :target="targetElement" placement="top">
+  <gl-tooltip v-if="isLocked" :target="targetElement" placement="top">
     <template #title>{{ s__('CascadingSettings|Setting cannot be changed') }}</template>
-    <span data-testid="cascading-settings-lock-popover">
+    <span data-testid="cascading-settings-lock-tooltip">
       <template v-if="isLockedByAdmin">{{
         s__(
           'CascadingSettings|An administrator selected this setting for the instance and you cannot change it.',
@@ -61,5 +61,5 @@ export default {
         }}
       </template>
     </span>
-  </gl-popover>
+  </gl-tooltip>
 </template>
diff --git a/app/assets/javascripts/namespaces/cascading_settings/index.js b/app/assets/javascripts/namespaces/cascading_settings/index.js
index d0368b054e137..af4e2ee100258 100644
--- a/app/assets/javascripts/namespaces/cascading_settings/index.js
+++ b/app/assets/javascripts/namespaces/cascading_settings/index.js
@@ -1,15 +1,15 @@
 import Vue from 'vue';
-import HamlLockPopovers from './components/haml_lock_popovers.vue';
+import HamlLockTooltips from './components/haml_lock_tooltips.vue';
 
-export const initCascadingSettingsLockPopovers = () => {
-  const el = document.querySelector('.js-cascading-settings-lock-popovers');
+export const initCascadingSettingsLockTooltips = () => {
+  const el = document.querySelector('.js-cascading-settings-lock-tooltips');
 
   if (!el) return false;
 
   return new Vue({
     el,
     render(createElement) {
-      return createElement(HamlLockPopovers);
+      return createElement(HamlLockTooltips);
     },
   });
 };
diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js
index 560e41396f19f..853d9fe9191aa 100644
--- a/app/assets/javascripts/pages/groups/edit/index.js
+++ b/app/assets/javascripts/pages/groups/edit/index.js
@@ -4,7 +4,7 @@ import initFilePickers from '~/file_pickers';
 import initTransferGroupForm from '~/groups/init_transfer_group_form';
 import { initGroupSelects } from '~/vue_shared/components/entity_select/init_group_selects';
 import { initProjectSelects } from '~/vue_shared/components/entity_select/init_project_selects';
-import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
+import { initCascadingSettingsLockTooltips } from '~/namespaces/cascading_settings';
 import { initDormantUsersInputSection } from '~/pages/admin/application_settings/account_and_limits';
 import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
 import initSearchSettings from '~/search_settings';
@@ -42,6 +42,6 @@ initGroupSelects();
 initProjectSelects();
 
 initSearchSettings();
-initCascadingSettingsLockPopovers();
+initCascadingSettingsLockTooltips();
 
 initGroupSettingsReadme();
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
index cc92a8cd4768a..10a00d37ad395 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/project_setting_row.vue
@@ -16,17 +16,25 @@ export default {
       required: false,
       default: null,
     },
+    locked: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
 };
 </script>
 
 <template>
   <div class="project-feature-row">
-    <label v-if="label" class="label-bold">
-      {{ label }}
-    </label>
+    <div class="gl-flex">
+      <label v-if="label" class="label-bold" :class="{ 'gl-text-gray-400': locked }">
+        {{ label }}
+      </label>
+      <slot name="label-icon"></slot>
+    </div>
     <div>
-      <span v-if="helpText" class="text-muted"> {{ helpText }} </span>
+      <span v-if="helpText" class="gl-text-gray-400"> {{ helpText }} </span>
       <span v-if="helpPath"
         ><a :href="helpPath" target="_blank">{{ __('Learn more') }}</a
         >.</span
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
index 4ca6defff6cfb..1d10579af5a9d 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
+++ b/app/assets/javascripts/pages/projects/shared/permissions/components/settings_panel.vue
@@ -114,7 +114,6 @@ export default {
       ),
   },
   mixins: [settingsMixin, glFeatureFlagMixin()],
-
   props: {
     requestCveAvailable: {
       type: Boolean,
@@ -1056,6 +1055,7 @@ export default {
         :label="$options.i18n.duoLabel"
         :help-text="$options.i18n.duoHelpText"
         :help-path="$options.duoHelpPath"
+        :locked="duoFeaturesLocked"
       >
         <gl-toggle
           v-model="duoFeaturesEnabled"
diff --git a/app/assets/javascripts/pages/projects/shared/permissions/index.js b/app/assets/javascripts/pages/projects/shared/permissions/index.js
index 78dd456aad93b..a900d028bd8ba 100644
--- a/app/assets/javascripts/pages/projects/shared/permissions/index.js
+++ b/app/assets/javascripts/pages/projects/shared/permissions/index.js
@@ -1,7 +1,6 @@
 import Vue from 'vue';
 import VueApollo from 'vue-apollo';
 import createDefaultClient from '~/lib/graphql';
-
 import { parseBoolean } from '~/lib/utils/common_utils';
 import settingsPanel from './components/settings_panel.vue';
 
@@ -20,12 +19,12 @@ export default function initProjectPermissionsSettings() {
   const componentProps = JSON.parse(componentPropsEl.innerHTML);
 
   const {
-    targetFormId,
     additionalInformation,
-    confirmDangerMessage,
     confirmButtonText,
-    showVisibilityConfirmModal,
+    confirmDangerMessage,
     htmlConfirmationMessage,
+    showVisibilityConfirmModal,
+    targetFormId,
     phrase: confirmationPhrase,
   } = mountPoint.dataset;
 
diff --git a/app/helpers/namespaces_helper.rb b/app/helpers/namespaces_helper.rb
index ff5e4248d9844..24172d177c44a 100644
--- a/app/helpers/namespaces_helper.rb
+++ b/app/helpers/namespaces_helper.rb
@@ -5,10 +5,10 @@ def namespace_id_from(params)
     params.dig(:project, :namespace_id) || params[:namespace_id]
   end
 
-  def cascading_namespace_settings_popover_data(attribute, group, settings_path_helper)
+  def cascading_namespace_settings_tooltip_data(attribute, group, settings_path_helper)
     locked_by_ancestor = group.namespace_settings.public_send("#{attribute}_locked_by_ancestor?") # rubocop:disable GitlabSecurity/PublicSend
 
-    popover_data = {
+    tooltip_data = {
       locked_by_application_setting: group.namespace_settings.public_send("#{attribute}_locked_by_application_setting?"), # rubocop:disable GitlabSecurity/PublicSend
       locked_by_ancestor: locked_by_ancestor
     }
@@ -16,14 +16,14 @@ def cascading_namespace_settings_popover_data(attribute, group, settings_path_he
     if locked_by_ancestor
       ancestor_namespace = group.namespace_settings.public_send("#{attribute}_locked_ancestor").namespace # rubocop:disable GitlabSecurity/PublicSend
 
-      popover_data[:ancestor_namespace] = {
+      tooltip_data[:ancestor_namespace] = {
         full_name: ancestor_namespace.full_name,
         path: settings_path_helper.call(ancestor_namespace)
       }
     end
 
     {
-      popover_data: popover_data.to_json,
+      tooltip_data: tooltip_data.to_json,
       testid: 'cascading-settings-lock-icon'
     }
   end
diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml
index 0e891095d2cfa..f8bce65a39494 100644
--- a/app/views/groups/edit.html.haml
+++ b/app/views/groups/edit.html.haml
@@ -3,7 +3,7 @@
 - expanded = expanded_by_default?
 - @force_desktop_expanded_sidebar = true
 
-= render 'shared/namespaces/cascading_settings/lock_popovers'
+= render 'shared/namespaces/cascading_settings/lock_tooltips'
 
 %h1.gl-sr-only= @breadcrumb_title
 
diff --git a/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml b/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml
index b2c13c8b1d78e..3b105bc1e7866 100644
--- a/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml
+++ b/app/views/shared/namespaces/cascading_settings/_lock_icon.html.haml
@@ -1,3 +1,3 @@
 - class_list = local_assigns.fetch(:class_list, '')
 
-= render Pajamas::ButtonComponent.new(category: 'tertiary', icon: 'lock', button_options: { class: "gl-absolute gl-top-3 gl-right-0 -gl-translate-y-1/2 gl-p-1! gl-bg-transparent! gl-cursor-default! js-cascading-settings-lock-popover-target #{class_list}", data: cascading_namespace_settings_popover_data(attribute, group, settings_path_helper) })
+= render Pajamas::ButtonComponent.new(category: 'tertiary', icon: 'lock', button_options: { class: "gl-absolute gl-top-3 gl-right-0 -gl-translate-y-1/2 !gl-p-1 !gl-bg-transparent !gl-cursor-default js-cascading-settings-lock-tooltip-target #{class_list}", data: cascading_namespace_settings_tooltip_data(attribute, group, settings_path_helper) })
diff --git a/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml b/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
deleted file mode 100644
index 91458bf180b8c..0000000000000
--- a/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml
+++ /dev/null
@@ -1 +0,0 @@
-.js-cascading-settings-lock-popovers
diff --git a/app/views/shared/namespaces/cascading_settings/_lock_tooltips.html.haml b/app/views/shared/namespaces/cascading_settings/_lock_tooltips.html.haml
new file mode 100644
index 0000000000000..91aff10a9c55a
--- /dev/null
+++ b/app/views/shared/namespaces/cascading_settings/_lock_tooltips.html.haml
@@ -0,0 +1 @@
+.js-cascading-settings-lock-tooltips
diff --git a/doc/development/cascading_settings.md b/doc/development/cascading_settings.md
index adadeed2e2426..ab54c3bb161b1 100644
--- a/doc/development/cascading_settings.md
+++ b/doc/development/cascading_settings.md
@@ -159,15 +159,15 @@ Renders the label for a `fieldset` setting.
 | `settings_path_helper` | Lambda function that generates a path to the ancestor setting. For example, `-> (locked_ancestor) { edit_group_path(locked_ancestor, anchor: 'js-permissions-settings') }`                                           | `Lambda`             | `true`                   |
 | `help_text`            | Text shown below the checkbox.                                                                                                                                                                                       | `String`             | `false` (`nil`)          |
 
-[`_lock_popovers.html.haml`](https://gitlab.com/gitlab-org/gitlab/-/blob/b73353e47e283a7d9c9eda5bdedb345dcfb685b6/app/views/shared/namespaces/cascading_settings/_lock_popovers.html.haml)
+[`_lock_tooltips.html.haml`](https://gitlab.com/gitlab-org/gitlab/-/blob/b73353e47e283a7d9c9eda5bdedb345dcfb685b6/app/views/shared/namespaces/cascading_settings/_lock_tooltips.html.haml)
 
-Renders the mount element needed to initialize the JavaScript used to display the popover when hovering over the lock icon. This partial is only needed once per page.
+Renders the mount element needed to initialize the JavaScript used to display the tooltip when hovering over the lock icon. This partial is only needed once per page.
 
 ### JavaScript
 
-[`initCascadingSettingsLockPopovers`](https://gitlab.com/gitlab-org/gitlab/-/blob/b73353e47e283a7d9c9eda5bdedb345dcfb685b6/app/assets/javascripts/namespaces/cascading_settings/index.js#L4)
+[`initCascadingSettingsLockTooltips`](https://gitlab.com/gitlab-org/gitlab/-/blob/b73353e47e283a7d9c9eda5bdedb345dcfb685b6/app/assets/javascripts/namespaces/cascading_settings/index.js#L4)
 
-Initializes the JavaScript needed to display the popover when hovering over the lock icon (**{lock}**).
+Initializes the JavaScript needed to display the tooltip when hovering over the lock icon (**{lock}**).
 This function should be imported and called in the [page-specific JavaScript](fe_guide/performance.md#page-specific-javascript).
 
 ### Put it all together
@@ -175,7 +175,7 @@ This function should be imported and called in the [page-specific JavaScript](fe
 ```haml
 -# app/views/groups/edit.html.haml
 
-= render 'shared/namespaces/cascading_settings/lock_popovers'
+= render 'shared/namespaces/cascading_settings/lock_tooltips'
 
 - delayed_project_removal_locked = cascading_namespace_setting_locked?(:delayed_project_removal, @group)
 - merge_method_locked = cascading_namespace_setting_locked?(:merge_method, @group)
@@ -222,7 +222,7 @@ This function should be imported and called in the [page-specific JavaScript](fe
 ```javascript
 // app/assets/javascripts/pages/groups/edit/index.js
 
-import { initCascadingSettingsLockPopovers } from '~/namespaces/cascading_settings';
+import { initCascadingSettingsLockTooltips } from '~/namespaces/cascading_settings';
 
-initCascadingSettingsLockPopovers();
+initCascadingSettingsLockTooltips();
 ```
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 946fe9e6a4911..6cd275b949aa0 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -10530,6 +10530,9 @@ msgstr ""
 msgid "CascadingSettings|Enforce for all subgroups"
 msgstr ""
 
+msgid "CascadingSettings|Lock tooltip icon"
+msgstr ""
+
 msgid "CascadingSettings|Setting cannot be changed"
 msgstr ""
 
diff --git a/spec/features/projects/settings/packages_settings_spec.rb b/spec/features/projects/settings/packages_settings_spec.rb
index 28fa27471ef6d..9ffdd775d69a7 100644
--- a/spec/features/projects/settings/packages_settings_spec.rb
+++ b/spec/features/projects/settings/packages_settings_spec.rb
@@ -19,15 +19,17 @@
     let(:packages_enabled) { true }
 
     it 'displays the packages access level setting' do
-      expect(page).to have_selector('[data-testid="package-registry-access-level"] > label', text: 'Package registry')
-      expect(page).to have_selector('input[name="package_registry_enabled"]', visible: false)
-      expect(page).to have_selector('input[name="package_registry_enabled"] + button', visible: true)
-      expect(page).to have_selector('input[name="package_registry_api_for_everyone_enabled"]', visible: false)
-      expect(page).to have_selector('input[name="package_registry_api_for_everyone_enabled"] + button', visible: true)
-      expect(page).to have_selector(
-        'input[name="project[project_feature_attributes][package_registry_access_level]"]',
-        visible: false
-      )
+      within_testid('package-registry-access-level') do
+        expect(page).to have_content('Package registry')
+        expect(page).to have_selector('input[name="package_registry_enabled"]', visible: false)
+        expect(page).to have_selector('input[name="package_registry_enabled"] + button', visible: true)
+        expect(page).to have_selector('input[name="package_registry_api_for_everyone_enabled"]', visible: false)
+        expect(page).to have_selector('input[name="package_registry_api_for_everyone_enabled"] + button', visible: true)
+        expect(page).to have_selector(
+          'input[name="project[project_feature_attributes][package_registry_access_level]"]',
+          visible: false
+        )
+      end
     end
   end
 
diff --git a/spec/frontend/cascading_settings/components/cascading_lock_icon_spec.js b/spec/frontend/cascading_settings/components/cascading_lock_icon_spec.js
new file mode 100644
index 0000000000000..55131697cb5c6
--- /dev/null
+++ b/spec/frontend/cascading_settings/components/cascading_lock_icon_spec.js
@@ -0,0 +1,89 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlIcon } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import CascadingLockIcon from '~/namespaces/cascading_settings/components/cascading_lock_icon.vue';
+import LockTooltip from '~/namespaces/cascading_settings/components/lock_tooltip.vue';
+
+describe('CascadingLockIcon', () => {
+  let wrapper;
+
+  const createComponent = (props = {}) => {
+    return shallowMount(CascadingLockIcon, {
+      propsData: {
+        isLockedByApplicationSettings: false,
+        isLockedByGroupAncestor: false,
+        ...props,
+      },
+    });
+  };
+
+  const findLockTooltip = () => wrapper.findComponent(LockTooltip);
+  const findIcon = () => wrapper.findComponent(GlIcon);
+
+  beforeEach(() => {
+    wrapper = createComponent();
+  });
+
+  it('renders the GlIcon component', () => {
+    expect(findIcon().exists()).toBe(true);
+  });
+
+  it('sets correct attributes on GlIcon', () => {
+    wrapper = createComponent();
+    expect(findIcon().props()).toMatchObject({
+      name: 'lock',
+      ariaLabel: 'Lock tooltip icon',
+    });
+  });
+
+  it('does not render LockTooltip when targetElement is null', () => {
+    wrapper = createComponent();
+    expect(findLockTooltip().exists()).toBe(false);
+  });
+
+  it('renders LockTooltip after mounting', async () => {
+    wrapper = createComponent();
+    await nextTick();
+    await nextTick();
+    expect(findLockTooltip().exists()).toBe(true);
+  });
+
+  it('sets targetElement after mounting', async () => {
+    wrapper = createComponent();
+    await nextTick();
+    await nextTick();
+    expect(findLockTooltip().props().targetElement).not.toBeNull();
+  });
+
+  it('passes correct props to LockTooltip', async () => {
+    const ancestorNamespace = { path: '/test', fullName: 'Test' };
+    wrapper = createComponent({
+      ancestorNamespace,
+      isLockedByApplicationSettings: true,
+      isLockedByGroupAncestor: true,
+    });
+
+    await nextTick();
+    await nextTick();
+
+    expect(findLockTooltip().props()).toMatchObject({
+      ancestorNamespace,
+      isLockedByAdmin: true,
+      isLockedByGroupAncestor: true,
+    });
+  });
+
+  it('validates ancestorNamespace prop', () => {
+    const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+
+    // Valid prop
+    createComponent({ ancestorNamespace: { path: '/test', fullName: 'Test' } });
+    expect(consoleErrorSpy).not.toHaveBeenCalled();
+
+    // Invalid prop
+    createComponent({ ancestorNamespace: { path: '/test' } });
+    expect(consoleErrorSpy).toHaveBeenCalled();
+
+    consoleErrorSpy.mockRestore();
+  });
+});
diff --git a/spec/frontend/cascading_settings/components/haml_lock_popovers_spec.js b/spec/frontend/cascading_settings/components/haml_lock_tooltips_spec.js
similarity index 56%
rename from spec/frontend/cascading_settings/components/haml_lock_popovers_spec.js
rename to spec/frontend/cascading_settings/components/haml_lock_tooltips_spec.js
index 40124f5749ce4..338cca4efb707 100644
--- a/spec/frontend/cascading_settings/components/haml_lock_popovers_spec.js
+++ b/spec/frontend/cascading_settings/components/haml_lock_tooltips_spec.js
@@ -1,44 +1,44 @@
-import { GlPopover } from '@gitlab/ui';
+import { GlTooltip } from '@gitlab/ui';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
-import HamlLockPopover from '~/namespaces/cascading_settings/components/haml_lock_popovers.vue';
-import LockPopover from '~/namespaces/cascading_settings/components/lock_popover.vue';
+import HamlLockTooltips from '~/namespaces/cascading_settings/components/haml_lock_tooltips.vue';
+import LockTooltip from '~/namespaces/cascading_settings/components/lock_tooltip.vue';
 
-describe('HamlLockPopover', () => {
+describe('HamlLockTooltips', () => {
   const mockNamespace = {
     fullName: 'GitLab Org / GitLab',
     path: '/gitlab-org/gitlab/-/edit',
   };
 
-  const createPopoverMountEl = ({
+  const createTooltipMountEl = ({
     lockedByApplicationSetting = false,
     lockedByAncestor = false,
   }) => {
-    const popoverMountEl = document.createElement('div');
-    popoverMountEl.classList.add('js-cascading-settings-lock-popover-target');
+    const tooltipMountEl = document.createElement('div');
+    tooltipMountEl.classList.add('js-cascading-settings-lock-tooltip-target');
 
-    const popoverData = {
+    const tooltipData = {
       locked_by_application_setting: lockedByApplicationSetting,
       locked_by_ancestor: lockedByAncestor,
     };
 
-    popoverMountEl.dataset.popoverData = JSON.stringify(popoverData);
-    popoverMountEl.dataset.popoverData = JSON.stringify({
-      ...popoverData,
+    tooltipMountEl.dataset.tooltipData = JSON.stringify(tooltipData);
+    tooltipMountEl.dataset.tooltipData = JSON.stringify({
+      ...tooltipData,
       ancestor_namespace: lockedByAncestor && !lockedByApplicationSetting ? mockNamespace : null,
     });
 
-    document.body.appendChild(popoverMountEl);
+    document.body.appendChild(tooltipMountEl);
 
-    return popoverMountEl;
+    return tooltipMountEl;
   };
 
   let wrapper;
 
   const createWrapper = () => {
-    wrapper = mountExtended(HamlLockPopover);
+    wrapper = mountExtended(HamlLockTooltips);
   };
 
-  const findLockPopovers = () => wrapper.findAllComponents(LockPopover);
+  const findLockTooltips = () => wrapper.findAllComponents(LockTooltip);
 
   afterEach(() => {
     document.body.innerHTML = '';
@@ -57,7 +57,7 @@ describe('HamlLockPopover', () => {
       'when locked_by_application_setting is $lockedByApplicationSetting and locked_by_ancestor is $lockedByAncestor and ancestor_namespace is $ancestorNamespace',
       ({ ancestorNamespace, lockedByAncestor, lockedByApplicationSetting }) => {
         beforeEach(() => {
-          domElement = createPopoverMountEl({
+          domElement = createTooltipMountEl({
             ancestorNamespace,
             lockedByApplicationSetting,
             lockedByAncestor,
@@ -66,40 +66,40 @@ describe('HamlLockPopover', () => {
         });
 
         it('locked_by_application_setting attribute', () => {
-          expect(findLockPopovers().at(0).props().isLockedByAdmin).toBe(lockedByApplicationSetting);
+          expect(findLockTooltips().at(0).props().isLockedByAdmin).toBe(lockedByApplicationSetting);
         });
 
         it('locked_by_ancestor attribute', () => {
-          expect(findLockPopovers().at(0).props().isLockedByGroupAncestor).toBe(lockedByAncestor);
+          expect(findLockTooltips().at(0).props().isLockedByGroupAncestor).toBe(lockedByAncestor);
         });
 
         it('ancestor_namespace attribute', () => {
-          expect(findLockPopovers().at(0).props().ancestorNamespace).toEqual(ancestorNamespace);
+          expect(findLockTooltips().at(0).props().ancestorNamespace).toEqual(ancestorNamespace);
         });
 
         it('target element', () => {
-          expect(findLockPopovers().at(0).props().targetElement).toBe(domElement);
+          expect(findLockTooltips().at(0).props().targetElement).toBe(domElement);
         });
       },
     );
   });
 
   describe('when there are multiple mount elements', () => {
-    let popoverMountEl1;
-    let popoverMountEl2;
+    let tooltipMountEl1;
+    let tooltipMountEl2;
 
     beforeEach(() => {
-      popoverMountEl1 = createPopoverMountEl({ lockedByApplicationSetting: true });
-      popoverMountEl2 = createPopoverMountEl({ lockedByAncestor: true });
+      tooltipMountEl1 = createTooltipMountEl({ lockedByApplicationSetting: true });
+      tooltipMountEl2 = createTooltipMountEl({ lockedByAncestor: true });
       createWrapper();
     });
 
-    it('mounts multiple popovers', () => {
-      const popovers = wrapper.findAllComponents(GlPopover).wrappers;
+    it('mounts multiple tooltips', () => {
+      const tooltips = wrapper.findAllComponents(GlTooltip).wrappers;
 
-      expect(popovers).toHaveLength(2);
-      expect(popovers[0].props('target')).toBe(popoverMountEl1);
-      expect(popovers[1].props('target')).toBe(popoverMountEl2);
+      expect(tooltips).toHaveLength(2);
+      expect(tooltips[0].props('target')).toBe(tooltipMountEl1);
+      expect(tooltips[1].props('target')).toBe(tooltipMountEl2);
     });
   });
 });
diff --git a/spec/frontend/cascading_settings/components/lock_popover_spec.js b/spec/frontend/cascading_settings/components/lock_tooltip_spec.js
similarity index 61%
rename from spec/frontend/cascading_settings/components/lock_popover_spec.js
rename to spec/frontend/cascading_settings/components/lock_tooltip_spec.js
index 516f2875289da..c874afa7287af 100644
--- a/spec/frontend/cascading_settings/components/lock_popover_spec.js
+++ b/spec/frontend/cascading_settings/components/lock_tooltip_spec.js
@@ -1,8 +1,8 @@
-import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui';
+import { GlLink, GlTooltip, GlSprintf } from '@gitlab/ui';
 import { shallowMount } from '@vue/test-utils';
-import LockPopover from '~/namespaces/cascading_settings/components/lock_popover.vue';
+import LockTooltip from '~/namespaces/cascading_settings/components/lock_tooltip.vue';
 
-describe('LockPopover', () => {
+describe('LockTooltip', () => {
   const mockNamespace = {
     fullName: 'GitLab Org / GitLab',
     path: '/gitlab-org/gitlab/-/edit',
@@ -12,15 +12,15 @@ describe('LockPopover', () => {
     'An administrator selected this setting for the instance and you cannot change it.';
 
   let wrapper;
-  const popoverMountEl = document.createElement('div');
+  const tooltipMountEl = document.createElement('div');
 
   const createWrapper = (props = {}) => {
-    wrapper = shallowMount(LockPopover, {
+    wrapper = shallowMount(LockTooltip, {
       propsData: {
         ancestorNamespace: mockNamespace,
         isLockedByAdmin: false,
-        isLockedByGroupAncestor: true,
-        targetElement: popoverMountEl,
+        isLockedByGroupAncestor: false,
+        targetElement: tooltipMountEl,
         ...props,
       },
       stubs: {
@@ -30,30 +30,33 @@ describe('LockPopover', () => {
   };
 
   const findLink = () => wrapper.findComponent(GlLink);
-  const findPopover = () => wrapper.findComponent(GlPopover);
+  const findTooltip = () => wrapper.findComponent(GlTooltip);
 
   describe('when setting is locked by an admin setting', () => {
     beforeEach(() => {
       createWrapper({ isLockedByAdmin: true });
     });
 
-    it('displays correct popover message', () => {
-      expect(findPopover().text()).toBe(applicationSettingMessage);
+    it('displays correct tooltip message', () => {
+      expect(findTooltip().text()).toBe(applicationSettingMessage);
     });
 
     it('sets `target` prop correctly', () => {
-      expect(findPopover().props().target).toBe(popoverMountEl);
+      expect(findTooltip().props().target).toBe(tooltipMountEl);
     });
   });
 
   describe('when setting is locked by an ancestor namespace', () => {
     describe('and ancestorNamespace is set', () => {
       beforeEach(() => {
-        createWrapper({ isLockedByGroupAncestor: true, ancestorNamespace: mockNamespace });
+        createWrapper({
+          isLockedByGroupAncestor: true,
+          ancestorNamespace: mockNamespace,
+        });
       });
 
-      it('displays correct popover message', () => {
-        expect(findPopover().text()).toBe(
+      it('displays correct tooltip message', () => {
+        expect(findTooltip().text()).toBe(
           `This setting has been enforced by an owner of ${mockNamespace.fullName}.`,
         );
       });
@@ -63,7 +66,7 @@ describe('LockPopover', () => {
       });
 
       it('sets `target` prop correctly', () => {
-        expect(findPopover().props().target).toBe(popoverMountEl);
+        expect(findTooltip().props().target).toBe(tooltipMountEl);
       });
     });
 
@@ -73,7 +76,7 @@ describe('LockPopover', () => {
       });
 
       it('displays a generic message', () => {
-        expect(findPopover().text()).toBe(
+        expect(findTooltip().text()).toBe(
           `This setting has been enforced by an owner and cannot be changed.`,
         );
       });
@@ -82,15 +85,18 @@ describe('LockPopover', () => {
 
   describe('when setting is locked by an application setting and an ancestor namespace', () => {
     beforeEach(() => {
-      createWrapper({ isLockedByAdmin: true, isLockedByGroupAncestor: true });
+      createWrapper({
+        isLockedByAdmin: true,
+        isLockedByGroupAncestor: true,
+      });
     });
 
-    it('displays correct popover message', () => {
-      expect(findPopover().text()).toBe(applicationSettingMessage);
+    it('displays correct tooltip message', () => {
+      expect(findTooltip().text()).toBe(applicationSettingMessage);
     });
 
     it('sets `target` prop correctly', () => {
-      expect(findPopover().props().target).toBe(popoverMountEl);
+      expect(findTooltip().props().target).toBe(tooltipMountEl);
     });
   });
 
@@ -99,8 +105,8 @@ describe('LockPopover', () => {
       createWrapper({ isLockedByAdmin: false, isLockedByGroupAncestor: false });
     });
 
-    it('does not render popover', () => {
-      expect(findPopover().exists()).toBe(false);
+    it('does not render tooltip', () => {
+      expect(findTooltip().exists()).toBe(false);
     });
   });
 });
diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
index 91d3057aec51d..3ff6687d06961 100644
--- a/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
+++ b/spec/frontend/pages/projects/shared/permissions/components/project_setting_row_spec.js
@@ -1,22 +1,22 @@
 import { shallowMount } from '@vue/test-utils';
-
 import { nextTick } from 'vue';
+import { GlIcon } from '@gitlab/ui';
 import projectSettingRow from '~/pages/projects/shared/permissions/components/project_setting_row.vue';
 
 describe('Project Setting Row', () => {
   let wrapper;
 
-  const mountComponent = (customProps = {}) => {
+  const createComponent = (customProps = {}) => {
     const propsData = { ...customProps };
     return shallowMount(projectSettingRow, { propsData });
   };
 
   beforeEach(() => {
-    wrapper = mountComponent();
+    wrapper = createComponent();
   });
 
   it('should show the label if it is set', async () => {
-    wrapper.setProps({ label: 'Test label' });
+    wrapper = createComponent({ label: 'Test label' });
 
     await nextTick();
     expect(wrapper.find('label').text()).toEqual('Test label');
@@ -26,8 +26,24 @@ describe('Project Setting Row', () => {
     expect(wrapper.find('label').exists()).toBe(false);
   });
 
+  it('should apply gl-text-gray-400 class to label when locked', async () => {
+    wrapper = createComponent({ label: 'Test label', locked: true });
+
+    await nextTick();
+    expect(wrapper.find('label').classes()).toContain('gl-text-gray-400');
+  });
+
+  it('should render default slot content', () => {
+    wrapper = shallowMount(projectSettingRow, {
+      slots: {
+        'label-icon': GlIcon,
+      },
+    });
+    expect(wrapper.findComponent(GlIcon).exists()).toBe(true);
+  });
+
   it('should show the help icon with the correct help path if it is set', async () => {
-    wrapper.setProps({ label: 'Test label', helpPath: '/123' });
+    wrapper = createComponent({ label: 'Test label', helpPath: '/123' });
 
     await nextTick();
     const link = wrapper.find('a');
@@ -37,14 +53,14 @@ describe('Project Setting Row', () => {
   });
 
   it('should hide the help icon if no help path is set', async () => {
-    wrapper.setProps({ label: 'Test label' });
+    wrapper = createComponent({ label: 'Test label' });
 
     await nextTick();
     expect(wrapper.find('a').exists()).toBe(false);
   });
 
   it('should show the help text if it is set', async () => {
-    wrapper.setProps({ helpText: 'Test text' });
+    wrapper = createComponent({ helpText: 'Test text' });
 
     await nextTick();
     expect(wrapper.find('span').text()).toEqual('Test text');
diff --git a/spec/helpers/namespaces_helper_spec.rb b/spec/helpers/namespaces_helper_spec.rb
index 2f90f251a04a9..a888448ad7575 100644
--- a/spec/helpers/namespaces_helper_spec.rb
+++ b/spec/helpers/namespaces_helper_spec.rb
@@ -42,11 +42,11 @@
     user_group.add_owner(user)
   end
 
-  describe '#cascading_namespace_settings_popover_data' do
+  describe '#cascading_namespace_settings_tooltip_data' do
     attribute = :math_rendering_limits_enabled
 
     subject do
-      helper.cascading_namespace_settings_popover_data(
+      helper.cascading_namespace_settings_tooltip_data(
         attribute,
         subgroup1,
         ->(locked_ancestor) { edit_group_path(locked_ancestor, anchor: 'js-permissions-settings') }
@@ -61,7 +61,7 @@
 
       it 'returns expected hash' do
         expect(subject).to match({
-          popover_data: {
+          tooltip_data: {
             locked_by_application_setting: true,
             locked_by_ancestor: false
           }.to_json,
@@ -79,7 +79,7 @@
 
       it 'returns expected hash' do
         expect(subject).to match({
-          popover_data: {
+          tooltip_data: {
             locked_by_application_setting: false,
             locked_by_ancestor: true,
             ancestor_namespace: {
diff --git a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb
index 2bda352c11fc5..14d179b2cdf39 100644
--- a/spec/support/shared_examples/features/cascading_settings_shared_examples.rb
+++ b/spec/support/shared_examples/features/cascading_settings_shared_examples.rb
@@ -30,14 +30,14 @@
       expect(page).not_to have_selector '[data-testid="enforce-for-all-subgroups-checkbox"]'
     end
 
-    it 'displays lock icon with popover', :js do
+    it 'displays lock icon with tooltip', :js do
       visit subgroup_path
 
       page.within form_group_selector do
         find('[data-testid="cascading-settings-lock-icon"]').click
       end
 
-      page.within '[data-testid="cascading-settings-lock-popover"]' do
+      page.within '[data-testid="cascading-settings-lock-tooltip"]' do
         expect(page).to have_text 'This setting has been enforced by an owner of Foo bar.'
         expect(page).to have_link 'Foo bar', href: setting_path
       end
-- 
GitLab