From c3b70ef5bb35ad4f2e1ae3d556c68a024f208501 Mon Sep 17 00:00:00 2001
From: Miguel Rincon <mrincon@gitlab.com>
Date: Mon, 14 Mar 2022 11:48:48 +0100
Subject: [PATCH] Explain delete permission on multi-project runners

This change adds a tooltip explaining to group owners why they can't
delete a runner that has been already added to multiple projects.
---
 .../components/cells/runner_actions_cell.vue  |  7 ++-
 .../components/runner_delete_button.vue       | 53 ++++++++++++++-----
 app/assets/javascripts/runner/constants.js    |  6 +++
 .../graphql/list/group_runners.query.graphql  |  1 +
 locale/gitlab.pot                             |  6 +++
 .../cells/runner_actions_cell_spec.js         | 12 ++++-
 .../components/runner_delete_button_spec.js   | 40 ++++++++++++--
 7 files changed, 106 insertions(+), 19 deletions(-)

diff --git a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
index c69321de0018f..d3535f8942791 100644
--- a/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
+++ b/app/assets/javascripts/runner/components/cells/runner_actions_cell.vue
@@ -44,6 +44,11 @@ export default {
   <gl-button-group>
     <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
     <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
-    <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" />
+    <runner-delete-button
+      :disabled="!canDelete"
+      :runner="runner"
+      :compact="true"
+      @deleted="onDeleted"
+    />
   </gl-button-group>
 </template>
diff --git a/app/assets/javascripts/runner/components/runner_delete_button.vue b/app/assets/javascripts/runner/components/runner_delete_button.vue
index 854c983f4daac..a546a2788de87 100644
--- a/app/assets/javascripts/runner/components/runner_delete_button.vue
+++ b/app/assets/javascripts/runner/components/runner_delete_button.vue
@@ -5,7 +5,12 @@ import { createAlert } from '~/flash';
 import { sprintf } from '~/locale';
 import { captureException } from '~/runner/sentry_utils';
 import { getIdFromGraphQLId } from '~/graphql_shared/utils';
-import { I18N_DELETE_RUNNER, I18N_DELETED_TOAST } from '../constants';
+import {
+  I18N_DELETE_DISABLED_MANY_PROJECTS,
+  I18N_DELETE_DISABLED_UNKNOWN_REASON,
+  I18N_DELETE_RUNNER,
+  I18N_DELETED_TOAST,
+} from '../constants';
 import RunnerDeleteModal from './runner_delete_modal.vue';
 
 export default {
@@ -26,6 +31,11 @@ export default {
         return runner?.id && runner?.shortSha;
       },
     },
+    disabled: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
     compact: {
       type: Boolean,
       required: false,
@@ -75,7 +85,14 @@ export default {
       return null;
     },
     tooltip() {
-      // Only show tooltip when compact.
+      if (this.disabled && this.runner.projectCount > 1) {
+        return I18N_DELETE_DISABLED_MANY_PROJECTS;
+      }
+      if (this.disabled) {
+        return I18N_DELETE_DISABLED_UNKNOWN_REASON;
+      }
+
+      // Only show basic "delete" tooltip when compact.
       // Also prevent a "sticky" tooltip: If this button is
       // disabled, mouseout listeners don't run leaving the tooltip stuck
       if (this.compact && !this.deleting) {
@@ -83,6 +100,14 @@ export default {
       }
       return '';
     },
+    wrapperTabindex() {
+      if (this.disabled) {
+        // Trigger tooltip on keyboard-focusable wrapper
+        // See https://bootstrap-vue.org/docs/directives/tooltip
+        return '0';
+      }
+      return null;
+    },
   },
   methods: {
     async onDelete() {
@@ -125,20 +150,22 @@ export default {
 </script>
 
 <template>
-  <gl-button
-    v-gl-tooltip.hover.viewport="tooltip"
-    v-gl-modal="runnerDeleteModalId"
-    :aria-label="ariaLabel"
-    :icon="icon"
-    :class="buttonClass"
-    :loading="deleting"
-    variant="danger"
-  >
-    {{ buttonContent }}
+  <div v-gl-tooltip="tooltip" class="btn-group" :tabindex="wrapperTabindex">
+    <gl-button
+      v-gl-modal="runnerDeleteModalId"
+      :aria-label="ariaLabel"
+      :icon="icon"
+      :class="buttonClass"
+      :loading="deleting"
+      :disabled="disabled"
+      variant="danger"
+    >
+      {{ buttonContent }}
+    </gl-button>
     <runner-delete-modal
       :modal-id="runnerDeleteModalId"
       :runner-name="runnerName"
       @primary="onDelete"
     />
-  </gl-button>
+  </div>
 </template>
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index bd5be2175adb1..1e6f9d7c66916 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -46,6 +46,12 @@ export const I18N_RESUME = __('Resume');
 export const I18N_RESUME_TOOLTIP = s__('Runners|Resume accepting jobs');
 
 export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
+export const I18N_DELETE_DISABLED_MANY_PROJECTS = s__(
+  'Runners|Multi-project runners cannot be deleted',
+);
+export const I18N_DELETE_DISABLED_UNKNOWN_REASON = s__(
+  'Runners|Runner cannot be deleted, please contact your administrator',
+);
 export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
 
 export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
diff --git a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
index b517f5e89a882..7aac2c1d4f2d4 100644
--- a/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
+++ b/app/assets/javascripts/runner/graphql/list/group_runners.query.graphql
@@ -30,6 +30,7 @@ query getGroupRunners(
         editUrl
         node {
           ...ListItem
+          projectCount # Used to determine why some project runners can't be deleted
         }
       }
       pageInfo {
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 36f573bb5a3fb..fc0be9f54f4ab 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -32054,6 +32054,9 @@ msgstr ""
 msgid "Runners|Members of the %{type} can register runners"
 msgstr ""
 
+msgid "Runners|Multi-project runners cannot be deleted"
+msgstr ""
+
 msgid "Runners|Name"
 msgstr ""
 
@@ -32156,6 +32159,9 @@ msgstr ""
 msgid "Runners|Runner assigned to project."
 msgstr ""
 
+msgid "Runners|Runner cannot be deleted, please contact your administrator"
+msgstr ""
+
 msgid "Runners|Runner is offline, last contact was %{runner_contact} ago"
 msgstr ""
 
diff --git a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
index 0d57910686039..9ca99d1109b9d 100644
--- a/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
+++ b/spec/frontend/runner/components/cells/runner_actions_cell_spec.js
@@ -92,6 +92,14 @@ describe('RunnerActionsCell', () => {
       expect(findDeleteBtn().props('compact')).toBe(true);
     });
 
+    it('Passes runner data to delete button', () => {
+      createComponent({
+        runner: mockRunner,
+      });
+
+      expect(findDeleteBtn().props('runner')).toEqual(mockRunner);
+    });
+
     it('Emits delete events', () => {
       const value = { name: 'Runner' };
 
@@ -104,7 +112,7 @@ describe('RunnerActionsCell', () => {
       expect(wrapper.emitted('deleted')).toEqual([[value]]);
     });
 
-    it('Does not render the runner delete button when user cannot delete', () => {
+    it('Renders the runner delete disabled button when user cannot delete', () => {
       createComponent({
         runner: {
           userPermissions: {
@@ -114,7 +122,7 @@ describe('RunnerActionsCell', () => {
         },
       });
 
-      expect(findDeleteBtn().exists()).toBe(false);
+      expect(findDeleteBtn().props('disabled')).toBe(true);
     });
   });
 });
diff --git a/spec/frontend/runner/components/runner_delete_button_spec.js b/spec/frontend/runner/components/runner_delete_button_spec.js
index 81c870f23cfbc..262658800bdaf 100644
--- a/spec/frontend/runner/components/runner_delete_button_spec.js
+++ b/spec/frontend/runner/components/runner_delete_button_spec.js
@@ -9,7 +9,11 @@ import waitForPromises from 'helpers/wait_for_promises';
 import { captureException } from '~/runner/sentry_utils';
 import { getIdFromGraphQLId } from '~/graphql_shared/utils';
 import { createAlert } from '~/flash';
-import { I18N_DELETE_RUNNER } from '~/runner/constants';
+import {
+  I18N_DELETE_RUNNER,
+  I18N_DELETE_DISABLED_MANY_PROJECTS,
+  I18N_DELETE_DISABLED_UNKNOWN_REASON,
+} from '~/runner/constants';
 
 import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
 import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
@@ -27,11 +31,12 @@ describe('RunnerDeleteButton', () => {
   let wrapper;
   let runnerDeleteHandler;
 
-  const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
-  const getModal = () => getBinding(wrapper.element, 'gl-modal').value;
   const findBtn = () => wrapper.findComponent(GlButton);
   const findModal = () => wrapper.findComponent(RunnerDeleteModal);
 
+  const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value;
+  const getModal = () => getBinding(findBtn().element, 'gl-modal').value;
+
   const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
     const { runner, ...propsData } = props;
 
@@ -88,6 +93,10 @@ describe('RunnerDeleteButton', () => {
     expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`);
   });
 
+  it('Does not have tabindex when button is enabled', () => {
+    expect(wrapper.attributes('tabindex')).toBeUndefined();
+  });
+
   it('Displays a modal when clicked', () => {
     const modalId = `delete-runner-modal-${mockRunnerId}`;
 
@@ -230,4 +239,29 @@ describe('RunnerDeleteButton', () => {
       });
     });
   });
+
+  describe.each`
+    reason                                     | runner                 | tooltip
+    ${'runner belongs to more than 1 project'} | ${{ projectCount: 2 }} | ${I18N_DELETE_DISABLED_MANY_PROJECTS}
+    ${'unknown reason'}                        | ${{}}                  | ${I18N_DELETE_DISABLED_UNKNOWN_REASON}
+  `('When button is disabled because $reason', ({ runner, tooltip }) => {
+    beforeEach(() => {
+      createComponent({
+        props: {
+          disabled: true,
+          runner,
+        },
+      });
+    });
+
+    it('Displays a disabled delete button', () => {
+      expect(findBtn().props('disabled')).toBe(true);
+    });
+
+    it(`Tooltip "${tooltip}" is shown`, () => {
+      // tabindex is required for a11y
+      expect(wrapper.attributes('tabindex')).toBe('0');
+      expect(getTooltip()).toBe(tooltip);
+    });
+  });
 });
-- 
GitLab