Clean up the UI for the protected environments settings

Problem to solve

The UI of the protected environments settings is a bit messy when items are missing (group inheritance toggle, the lack of photo for maintainers). We need to clean it up for these cases:

Screenshot_2024-07-02_at_1.29.30_PM

UX Proposal

Move the Approval Rules section into a table and remove the space for when the first column has no avatar:

Design
Screenshot_2024-07-22_at_4.25.06_PM

Add a loading state to the table as approval rules load:

Design
Screenshot_2024-07-23_at_1.08.39_PM

Implementation guide

  1. Update edit_protected_environments_list.vue to replace the current list-based layout with GlTableLite component.
    • use the current template as a guide on adding table cell templates
    • use not labeled cell for the actions (example: <template #cell(actions)="{ item: rule }">)
    • keep the empty-state template to show a message if the table has no content
Example of the updated code
diff --git a/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue b/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue
index 6538ceb2b1d1..f9ab78c51b82 100644
--- a/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue
+++ b/ee/app/assets/javascripts/protected_environments/edit_protected_environments_list.vue
@@ -8,6 +8,7 @@ import {
   GlTooltipDirective as GlTooltip,
   GlToggle,
   GlSprintf,
+  GlTableLite,
 } from '@gitlab/ui';
 // eslint-disable-next-line no-restricted-imports
 import { mapState, mapActions, mapGetters } from 'vuex';
@@ -16,7 +17,14 @@ import AccessDropdown from '~/projects/settings/components/access_dropdown.vue';
 import GroupsAccessDropdown from '~/groups/settings/components/access_dropdown.vue';
 import ShowMore from '~/vue_shared/components/show_more.vue';
 import HelpPageLink from '~/vue_shared/components/help_page_link/help_page_link.vue';
-import { ACCESS_LEVELS, DEPLOYER_RULE_KEY, APPROVER_RULE_KEY, INHERITED_GROUPS } from './constants';
+import {
+  ACCESS_LEVELS,
+  DEPLOYER_RULE_KEY,
+  APPROVER_RULE_KEY,
+  INHERITED_GROUPS,
+  APPROVER_FIELDS,
+  DEPLOYER_FIELDS,
+} from './constants';
 import EditProtectedEnvironmentRulesCard from './edit_protected_environment_rules_card.vue';
 import AddRuleModal from './add_rule_modal.vue';
 import AddApprovers from './add_approvers.vue';
@@ -30,6 +38,7 @@ export default {
     GlFormInput,
     GlIcon,
     GlToggle,
+    GlTableLite,
     AccessDropdown,
     GroupsAccessDropdown,
     ProtectedEnvironments,
@@ -127,6 +136,8 @@ export default {
   DEPLOYER_RULE_KEY,
   APPROVER_RULE_KEY,
   AVATAR_LIMIT: 5,
+  approverFields: APPROVER_FIELDS,
+  deployerFields: DEPLOYER_FIELDS,
 };
 </script>
 <template>
@@ -181,47 +192,55 @@ export default {
           :environment="environment"
           :rule-key="$options.DEPLOYER_RULE_KEY"
           :data-testid="`protected-environment-${environment.name}-deployers`"
-          class="gl-border gl-rounded-t-base gl-border-b-initial"
+          class="gl-border-t"
           @addRule="addRule"
         >
-          <template #card-header>
-            <span class="gl-w-3/10">{{ $options.i18n.deployersHeader }}</span>
-            <span class="">{{ $options.i18n.usersHeader }}</span>
-          </template>
-          <template #rule="{ rule, ruleKey }">
-            <span class="gl-w-3/10" data-testid="rule-description">
-              {{ rule.access_level_description }}
-            </span>
+          <template #table>
+            <gl-table-lite
+              :fields="$options.deployerFields"
+              :items="environment[$options.DEPLOYER_RULE_KEY]"
+              stacked="md"
+            >
+              <template #cell(deployers)="{ item: rule }">
+                <span data-testid="rule-description">
+                  {{ rule.access_level_description }}
+                </span>
+              </template>
 
-            <div class="gl-w-1/2">
-              <show-more
-                #default="{ item }"
-                :limit="$options.AVATAR_LIMIT"
-                :items="getUsersForRule(rule, ruleKey)"
-              >
-                <gl-avatar
-                  :key="item.id"
-                  v-gl-tooltip
-                  :src="item.avatar_url"
-                  :title="item.name"
-                  :size="24"
-                  class="gl-mr-2"
-                />
-              </show-more>
-            </div>
+              <template #cell(users)="{ item: rule }">
+                <show-more
+                  #default="{ item }"
+                  :limit="$options.AVATAR_LIMIT"
+                  :items="getUsersForRule(rule, $options.DEPLOYER_RULE_KEY)"
+                >
+                  <gl-avatar
+                    :key="item.id"
+                    v-gl-tooltip
+                    :src="item.avatar_url"
+                    :title="item.name"
+                    :size="24"
+                    class="gl-mr-2"
+                  />
+                </show-more>
+              </template>
 
-            <gl-button
-              v-if="canDeleteDeployerRules(environment)"
-              v-gl-tooltip
-              category="secondary"
-              variant="danger"
-              icon="remove"
-              :loading="loading"
-              :title="$options.i18n.deployerDeleteButtonTitle"
-              :aria-label="$options.i18n.deployerDeleteButtonTitle"
-              class="gl-ml-auto"
-              @click="deleteRule({ environment, rule, ruleKey })"
-            />
+              <template #cell(actions)="{ item: rule }">
+                <div class="gl-justify-content-end gl-flex">
+                  <gl-button
+                    v-if="canDeleteDeployerRules(environment)"
+                    v-gl-tooltip
+                    category="secondary"
+                    variant="danger"
+                    icon="remove"
+                    :loading="loading"
+                    :title="$options.i18n.deployerDeleteButtonTitle"
+                    :aria-label="$options.i18n.deployerDeleteButtonTitle"
+                    class="gl-ml-auto"
+                    @click="deleteRule({ environment, rule, ruleKey: $options.DEPLOYER_RULE_KEY })"
+                  />
+                </div>
+              </template>
+            </gl-table-lite>
           </template>
         </edit-protected-environment-rules-card>
         <edit-protected-environment-rules-card
@@ -230,23 +249,131 @@ export default {
           :environment="environment"
           :rule-key="$options.APPROVER_RULE_KEY"
           :data-testid="`protected-environment-${environment.name}-approvers`"
-          class="gl-border gl-rounded-bl-base gl-rounded-br-base"
+          class="gl-border-t"
           @addRule="addRule"
         >
-          <template #card-header>
-            <span class="gl-w-3/10">{{ $options.i18n.approversHeader }}</span>
-            <span class="gl-w-2/10">{{ $options.i18n.usersHeader }}</span>
-            <span class="gl-w-2/10">{{ $options.i18n.approvalsHeader }}</span>
-            <div class="gl-w-3/10">
-              <span>{{ $options.i18n.inheritanceLabel }}</span>
-              <gl-icon
-                v-gl-tooltip
-                :title="$options.i18n.inheritanceTooltip"
-                :aria-label="$options.i18n.inheritanceTooltip"
-                name="question-o"
-                class="gl-ml-2"
-              />
-            </div>
+          <template #table>
+            <gl-table-lite
+              :fields="$options.approverFields"
+              :items="environment[$options.APPROVER_RULE_KEY]"
+              stacked="md"
+              show-empty
+            >
+              <template #head(inheritance)="{ label }">
+                <span>{{ $options.i18n.inheritanceLabel }}</span>
+                <gl-icon
+                  v-gl-tooltip
+                  :title="$options.i18n.inheritanceTooltip"
+                  :aria-label="$options.i18n.inheritanceTooltip"
+                  name="question-o"
+                  class="gl-ml-2"
+                />
+              </template>
+
+              <template #cell(approvers)="{ item: rule }">
+                <span data-testid="rule-description">
+                  {{ rule.access_level_description }}
+                </span>
+              </template>
+
+              <template #cell(users)="{ item: rule }">
+                <show-more
+                  #default="{ item }"
+                  :limit="$options.AVATAR_LIMIT"
+                  :items="getUsersForRule(rule, $options.APPROVER_RULE_KEY)"
+                >
+                  <gl-avatar
+                    :key="item.id"
+                    v-gl-tooltip
+                    :src="item.avatar_url"
+                    :title="item.name"
+                    :size="24"
+                    class="gl-mr-2"
+                  />
+                </show-more>
+              </template>
+
+              <template #cell(approvals)="{ item: rule }">
+                <template v-if="editingRules[rule.id]">
+                  <gl-form-group
+                    :label-for="`approval-count-${rule.id}`"
+                    :label="$options.i18n.approvalCount"
+                    label-sr-only
+                    class="gl-mb-0"
+                  >
+                    <gl-form-input
+                      :id="`approval-count-${rule.id}`"
+                      v-model="editingRules[rule.id].required_approvals"
+                      :name="`approval-count-${rule.id}`"
+                      class="gl-text-center"
+                    />
+                  </gl-form-group>
+                </template>
+
+                <template v-else>
+                  <span class="gl-text-center">{{ rule.required_approvals }}</span>
+                </template>
+              </template>
+
+              <template #cell(inheritance)="{ item: rule }">
+                <template v-if="editingRules[rule.id]">
+                  <gl-toggle
+                    v-if="isGroupRule(rule)"
+                    :id="`approval-inheritance-${rule.id}`"
+                    :label="$options.i18n.inheritanceLabel"
+                    :name="`approval-inheritance-${rule.id}`"
+                    :value="isUsingGroupInheritance(editingRules[rule.id])"
+                    label-position="hidden"
+                    class="gl-align-items-center gl-ml-11"
+                    @change="updateApproverInheritance({ rule, value: $event })"
+                  />
+                </template>
+
+                <template v-else>
+                  <gl-toggle
+                    v-if="isGroupRule(rule)"
+                    :label="$options.i18n.inheritanceLabel"
+                    :name="`approval-inheritance-${rule.id}`"
+                    :value="isUsingGroupInheritance(rule)"
+                    class="gl-align-items-center gl-ml-11"
+                    label-position="hidden"
+                    disabled
+                  />
+                </template>
+              </template>
+
+              <template #cell(actions)="{ item: rule }">
+                <div class="gl-justify-content-end gl-flex">
+                  <gl-button
+                    v-if="editingRules[rule.id]"
+                    class="gl-ml-auto gl-mr-4"
+                    @click="updateRule({ rule, environment, ruleKey: $options.APPROVER_RULE_KEY })"
+                  >
+                    {{ $options.i18n.saveApproverButton }}
+                  </gl-button>
+
+                  <gl-button
+                    v-else-if="!isUserRule(rule)"
+                    class="gl-ml-auto gl-mr-4"
+                    @click="editRule(rule)"
+                  >
+                    {{ $options.i18n.editApproverButton }}
+                  </gl-button>
+
+                  <gl-button
+                    v-gl-tooltip
+                    category="secondary"
+                    variant="danger"
+                    icon="remove"
+                    :class="{ 'gl-ml-auto': isUserRule(rule) }"
+                    :loading="loading"
+                    :title="$options.i18n.approverDeleteButtonTitle"
+                    :aria-label="$options.i18n.approverDeleteButtonTitle"
+                    @click="deleteRule({ environment, rule, ruleKey: $options.APPROVER_RULE_KEY })"
+                  />
+                </div>
+              </template>
+            </gl-table-lite>
           </template>
           <template #empty-state>
             <gl-sprintf :message="$options.i18n.approvalRulesEmptyStateMessage">
@@ -257,96 +384,6 @@ export default {
               </template>
             </gl-sprintf>
           </template>
-          <template #rule="{ rule, ruleKey }">
-            <span class="gl-w-3/10" data-testid="rule-description">
-              {{ rule.access_level_description }}
-            </span>
-
-            <div class="gl-w-2/10">
-              <show-more
-                #default="{ item }"
-                :limit="$options.AVATAR_LIMIT"
-                :items="getUsersForRule(rule, ruleKey)"
-              >
-                <gl-avatar
-                  :key="item.id"
-                  v-gl-tooltip
-                  :src="item.avatar_url"
-                  :title="item.name"
-                  :size="24"
-                  class="gl-mr-2"
-                />
-              </show-more>
-            </div>
-
-            <template v-if="editingRules[rule.id]">
-              <gl-form-group
-                :label-for="`approval-count-${rule.id}`"
-                :label="$options.i18n.approvalCount"
-                label-sr-only
-                class="gl-mb-0 gl-w-2/10"
-              >
-                <gl-form-input
-                  :id="`approval-count-${rule.id}`"
-                  v-model="editingRules[rule.id].required_approvals"
-                  :name="`approval-count-${rule.id}`"
-                  class="gl-text-center"
-                />
-              </gl-form-group>
-
-              <gl-toggle
-                v-if="isGroupRule(rule)"
-                :id="`approval-inheritance-${rule.id}`"
-                :label="$options.i18n.inheritanceLabel"
-                :name="`approval-inheritance-${rule.id}`"
-                :value="isUsingGroupInheritance(editingRules[rule.id])"
-                label-position="hidden"
-                class="gl-ml-11 gl-items-center"
-                @change="updateApproverInheritance({ rule, value: $event })"
-              />
-              <span v-else></span>
-              <gl-button
-                class="gl-ml-auto gl-mr-4"
-                @click="updateRule({ rule, environment, ruleKey })"
-              >
-                {{ $options.i18n.saveApproverButton }}
-              </gl-button>
-            </template>
-            <template v-else>
-              <span class="gl-w-2/10 gl-text-center">{{ rule.required_approvals }}</span>
-
-              <gl-toggle
-                v-if="isGroupRule(rule)"
-                :label="$options.i18n.inheritanceLabel"
-                :name="`approval-inheritance-${rule.id}`"
-                :value="isUsingGroupInheritance(rule)"
-                class="gl-ml-11 gl-items-center"
-                label-position="hidden"
-                disabled
-              />
-              <span v-else></span>
-
-              <gl-button
-                v-if="!isUserRule(rule)"
-                class="gl-ml-auto gl-mr-4"
-                @click="editRule(rule)"
-              >
-                {{ $options.i18n.editApproverButton }}
-              </gl-button>
-            </template>
-
-            <gl-button
-              v-gl-tooltip
-              category="secondary"
-              variant="danger"
-              icon="remove"
-              :class="{ 'gl-ml-auto': isUserRule(rule) }"
-              :loading="loading"
-              :title="$options.i18n.approverDeleteButtonTitle"
-              :aria-label="$options.i18n.approverDeleteButtonTitle"
-              @click="deleteRule({ environment, rule, ruleKey })"
-            />
-          </template>
         </edit-protected-environment-rules-card>
       </template>
     </protected-environments>
  1. Use add_approvers.vue as an example. There's already a table representing approvers rule.
    To avoid code duplication, store table fields in constants.js. Create another set of fields for the deployers table.
Example of the updated constants
diff --git a/ee/app/assets/javascripts/protected_environments/constants.js b/ee/app/assets/javascripts/protected_environments/constants.js
index 16746a5f992e..eb69f9bbff2e 100644
--- a/ee/app/assets/javascripts/protected_environments/constants.js
+++ b/ee/app/assets/javascripts/protected_environments/constants.js
@@ -1,3 +1,5 @@
+import { s__ } from '~/locale';
+
 export const INHERITED_GROUPS = 1;
 export const NON_INHERITED_GROUPS = 0;
 export const GROUP_INHERITANCE_KEY = 'group_inheritance_type';
@@ -14,3 +16,39 @@ export const LEVEL_TYPES = {
   USER: 'user',
   GROUP: 'group',
 };
+
+export const DEPLOYER_FIELDS = [
+  {
+    key: 'deployers',
+    label: s__('ProtectedEnvironments|Allowed to deploy'),
+    tdClass: 'md:gl-w-3/10',
+  },
+  {
+    key: 'users',
+    label: s__('ProtectedEnvironments|Users'),
+  },
+  {
+    key: 'actions',
+    label: '',
+  },
+];
+
+export const APPROVER_FIELDS = [
+  {
+    key: 'approvers',
+    label: s__('ProtectedEnvironments|Approvers'),
+  },
+  {
+    key: 'approvals',
+    label: s__('ProtectedEnvironments|Approvals required'),
+  },
+  {
+    key: 'inheritance',
+    label: s__('ProtectedEnvironments|Enable group inheritance'),
+  },
+  {
+    key: 'remove',
+    label: '',
+    tdClass: 'gl-text-right',
+  },
+];
  1. Update edit_protected_rules_card.vue:
    • Remove unnecessary slots (such as card-header and rule)
Example of the updated code
diff --git a/ee/app/assets/javascripts/protected_environments/edit_protected_environment_rules_card.vue b/ee/app/assets/javascripts/protected_environments/edit_protected_environment_rules_card.vue
index c0acafe79f82..81ee089445cf 100644
--- a/ee/app/assets/javascripts/protected_environments/edit_protected_environment_rules_card.vue
+++ b/ee/app/assets/javascripts/protected_environments/edit_protected_environment_rules_card.vue
@@ -41,20 +41,11 @@ export default {
 </script>
 <template>
   <div>
-    <div class="gl-flex gl-w-full gl-bg-gray-50 gl-p-5 gl-font-bold">
-      <slot name="card-header"></slot>
-    </div>
-    <div v-if="!rules.length" data-testid="empty-state" class="gl-border-t gl-bg-white gl-p-5">
+    <slot name="table"></slot>
+    <div v-if="!rules.length" data-testid="empty-state" class="gl-bg-white gl-p-5">
       <slot name="empty-state"></slot>
     </div>
-    <div
-      v-for="rule in rules"
-      :key="rule.id"
-      :data-testid="`${ruleKey}-${rule.id}`"
-      class="gl-border-t gl-flex gl-w-full gl-items-center gl-bg-white gl-p-5"
-    >
-      <slot name="rule" :rule="rule" :rule-key="ruleKey"></slot>
-    </div>
+
     <div class="gl-border-t gl-flex gl-items-center gl-p-5">
       <gl-button
         category="secondary"
  1. Fix layout shifts and border issues in the protected_environments.vue component
Example of the updated code
diff --git a/ee/app/assets/javascripts/protected_environments/protected_environments.vue b/ee/app/assets/javascripts/protected_environments/protected_environments.vue
index d348783a9d57..7ab5dba32472 100644
--- a/ee/app/assets/javascripts/protected_environments/protected_environments.vue
+++ b/ee/app/assets/javascripts/protected_environments/protected_environments.vue
@@ -69,9 +69,6 @@ export default {
   },
   methods: {
     ...mapActions(['setPage', 'fetchProtectedEnvironments']),
-    isLast(index) {
-      return index === this.environments.length - 1;
-    },
     toggleCollapse({ name }) {
       this.expanded = {
         ...this.expanded,
@@ -160,54 +157,52 @@ export default {
         {{ emptyMessage }}
       </div>
       <template v-else>
-        <div class="-gl-mx-5 -gl-my-4">
-          <div
-            v-for="(environment, index) in environments"
-            :key="environment.name"
-            :class="{ 'gl-border-b': !isLast(index) }"
+        <div
+          v-for="(environment, index) in environments"
+          :key="environment.name"
+          class="gl-border-b"
+        >
+          <gl-button
+            block
+            category="tertiary"
+            variant="confirm"
+            class="!gl-rounded-none !gl-px-5 !gl-py-4"
+            button-text-classes="gl-flex gl-w-full gl-items-baseline"
+            :aria-label="environment.name"
+            data-testid="protected-environment-item-toggle"
+            @click="toggleCollapse(environment)"
+          >
+            <span class="gl-py-2 gl-text-gray-900">{{ environment.name }}</span>
+            <gl-badge v-if="!isExpanded(environment)" class="gl-ml-auto">
+              {{ deploymentRulesText(environment) }}
+            </gl-badge>
+            <gl-badge v-if="!isExpanded(environment)" class="gl-ml-3">
+              {{ approvalRulesText(environment) }}
+            </gl-badge>
+            <gl-icon
+              :name="icon(environment)"
+              :size="14"
+              :class="{
+                'gl-ml-3': !isExpanded(environment),
+                'gl-ml-auto': isExpanded(environment),
+              }"
+              class="gl-text-gray-500"
+            />
+          </gl-button>
+          <gl-collapse
+            :visible="isExpanded(environment)"
+            class="gl-flex gl-flex-col gl-rounded-b-base gl-bg-white gl-pb-5"
           >
+            <slot :environment="environment"></slot>
             <gl-button
-              block
-              category="tertiary"
-              variant="confirm"
-              class="!gl-rounded-none !gl-px-5 !gl-py-4"
-              button-text-classes="gl-flex gl-w-full gl-items-baseline"
-              :aria-label="environment.name"
-              data-testid="protected-environment-item-toggle"
-              @click="toggleCollapse(environment)"
+              category="secondary"
+              variant="danger"
+              class="gl-mr-5 gl-mt-5 gl-self-end"
+              @click="confirmUnprotect(environment)"
             >
-              <span class="gl-py-2 gl-text-gray-900">{{ environment.name }}</span>
-              <gl-badge v-if="!isExpanded(environment)" class="gl-ml-auto">
-                {{ deploymentRulesText(environment) }}
-              </gl-badge>
-              <gl-badge v-if="!isExpanded(environment)" class="gl-ml-3">
-                {{ approvalRulesText(environment) }}
-              </gl-badge>
-              <gl-icon
-                :name="icon(environment)"
-                :size="14"
-                :class="{
-                  'gl-ml-3': !isExpanded(environment),
-                  'gl-ml-auto': isExpanded(environment),
-                }"
-                class="gl-text-gray-500"
-              />
+              {{ s__('ProtectedEnvironments|Unprotect') }}
             </gl-button>
-            <gl-collapse
-              :visible="isExpanded(environment)"
-              class="gl-mx-5 gl-mb-5 gl-mt-3 gl-flex gl-flex-col"
-            >
-              <slot :environment="environment"></slot>
-              <gl-button
-                category="secondary"
-                variant="danger"
-                class="gl-mt-5 gl-self-end"
-                @click="confirmUnprotect(environment)"
-              >
-                {{ s__('ProtectedEnvironments|Unprotect') }}
-              </gl-button>
-            </gl-collapse>
-          </div>
+          </gl-collapse>
         </div>
       </template>
       <template v-if="showPagination" #pagination>
  1. Check and update the related specs at protected_environments

How to set up and validate locally

  1. Note: this is an EE feature, you'll need a license to verify it locally
  2. Validate the project-level view works as expected:
    • Visit Project -> Settings -> CI/CD
    • Select Protected environments section
    • Verify the settings: you should be able to select an environment, the list of deployers and approvers (could be roles, related groups, or users), save / edit / unprotect an environment.
  3. Validate the group-level view works as expected:
    • Visit Group -> Settings -> CI/CD
    • Select Protected environments section
    • Verify the settings: you should be able to select an environment tier, the list of deployer groups, and save / edit / unprotect an environment.
Edited by Anna Vovchenko