Commit 45f883b4 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '339653-improve-delete-runner-flow' into 'master'

Improve runner deletion modal

See merge request gitlab-org/gitlab!75432
parents 3a313b9c 5dcb3bc9
<script> <script>
import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __, s__ } from '~/locale'; import { __, s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql'; import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql'; import runnerActionsUpdateMutation from '~/runner/graphql/runner_actions_update.mutation.graphql';
import { captureException } from '~/runner/sentry_utils'; import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerDeleteModal from '../runner_delete_modal.vue';
const i18n = { const I18N_EDIT = __('Edit');
I18N_EDIT: __('Edit'), const I18N_PAUSE = __('Pause');
I18N_PAUSE: __('Pause'), const I18N_RESUME = __('Resume');
I18N_RESUME: __('Resume'), const I18N_DELETE = s__('Runners|Delete runner');
I18N_REMOVE: __('Remove'), const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
I18N_REMOVE_CONFIRMATION: s__('Runners|Are you sure you want to delete this runner?'),
};
export default { export default {
name: 'RunnerActionsCell', name: 'RunnerActionsCell',
components: { components: {
GlButton, GlButton,
GlButtonGroup, GlButtonGroup,
RunnerDeleteModal,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
}, },
props: { props: {
runner: { runner: {
...@@ -48,21 +50,29 @@ export default { ...@@ -48,21 +50,29 @@ export default {
// mouseout listeners don't run leaving the tooltip stuck // mouseout listeners don't run leaving the tooltip stuck
return ''; return '';
} }
return this.isActive ? i18n.I18N_PAUSE : i18n.I18N_RESUME; return this.isActive ? I18N_PAUSE : I18N_RESUME;
}, },
deleteTitle() { deleteTitle() {
// Prevent a "sticky" tooltip: If element gets removed, if (this.deleting) {
// mouseout listeners don't run and leaving the tooltip stuck // Prevent a "sticky" tooltip: If this button is disabled,
return this.deleting ? '' : i18n.I18N_REMOVE; // mouseout listeners don't run leaving the tooltip stuck
return '';
}
return I18N_DELETE;
},
runnerId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerName() {
return `#${this.runnerId} (${this.runner.shortSha})`;
},
runnerDeleteModalId() {
return `delete-runner-modal-${this.runnerId}`;
}, },
}, },
methods: { methods: {
async onToggleActive() { async onToggleActive() {
this.updating = true; this.updating = true;
// TODO In HAML iteration we had a confirmation modal via:
// data-confirm="_('Are you sure?')"
// this may not have to ported, this is an easily reversible operation
try { try {
const toggledActive = !this.runner.active; const toggledActive = !this.runner.active;
...@@ -91,12 +101,8 @@ export default { ...@@ -91,12 +101,8 @@ export default {
}, },
async onDelete() { async onDelete() {
// TODO Replace confirmation with gl-modal // Deleting stays "true" until this row is removed,
// eslint-disable-next-line no-alert // should only change back if the operation fails.
if (!window.confirm(i18n.I18N_REMOVE_CONFIRMATION)) {
return;
}
this.deleting = true; this.deleting = true;
try { try {
const { const {
...@@ -115,11 +121,13 @@ export default { ...@@ -115,11 +121,13 @@ export default {
}); });
if (errors && errors.length) { if (errors && errors.length) {
throw new Error(errors.join(' ')); throw new Error(errors.join(' '));
} else {
// Use $root to have the toast message stay after this element is removed
this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
} }
} catch (e) { } catch (e) {
this.onError(e);
} finally {
this.deleting = false; this.deleting = false;
this.onError(e);
} }
}, },
...@@ -133,14 +141,15 @@ export default { ...@@ -133,14 +141,15 @@ export default {
captureException({ error, component: this.$options.name }); captureException({ error, component: this.$options.name });
}, },
}, },
i18n, I18N_EDIT,
I18N_DELETE,
}; };
</script> </script>
<template> <template>
<gl-button-group> <gl-button-group>
<!-- <!--
This button appears for administratos: those with This button appears for administrators: those with
access to the adminUrl. More advanced permissions policies access to the adminUrl. More advanced permissions policies
will allow more granular permissions. will allow more granular permissions.
...@@ -148,16 +157,14 @@ export default { ...@@ -148,16 +157,14 @@ export default {
--> -->
<gl-button <gl-button
v-if="runner.adminUrl" v-if="runner.adminUrl"
v-gl-tooltip.hover.viewport v-gl-tooltip.hover.viewport="$options.I18N_EDIT"
:href="runner.adminUrl" :href="runner.adminUrl"
:title="$options.i18n.I18N_EDIT" :aria-label="$options.I18N_EDIT"
:aria-label="$options.i18n.I18N_EDIT"
icon="pencil" icon="pencil"
data-testid="edit-runner" data-testid="edit-runner"
/> />
<gl-button <gl-button
v-gl-tooltip.hover.viewport v-gl-tooltip.hover.viewport="toggleActiveTitle"
:title="toggleActiveTitle"
:aria-label="toggleActiveTitle" :aria-label="toggleActiveTitle"
:icon="toggleActiveIcon" :icon="toggleActiveIcon"
:loading="updating" :loading="updating"
...@@ -165,14 +172,20 @@ export default { ...@@ -165,14 +172,20 @@ export default {
@click="onToggleActive" @click="onToggleActive"
/> />
<gl-button <gl-button
v-gl-tooltip.hover.viewport v-gl-tooltip.hover.viewport="deleteTitle"
:title="deleteTitle" v-gl-modal="runnerDeleteModalId"
:aria-label="deleteTitle" :aria-label="deleteTitle"
icon="close" icon="close"
:loading="deleting" :loading="deleting"
variant="danger" variant="danger"
data-testid="delete-runner" data-testid="delete-runner"
@click="onDelete" />
<runner-delete-modal
:ref="runnerDeleteModalId"
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
@primary="onDelete"
/> />
</gl-button-group> </gl-button-group>
</template> </template>
<script>
import { GlModal } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
const I18N_TITLE = s__('Runners|Delete runner %{name}?');
const I18N_BODY = s__(
'Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
);
const I18N_PRIMARY = s__('Runners|Delete runner');
const I18N_CANCEL = __('Cancel');
export default {
components: {
GlModal,
},
props: {
runnerName: {
type: String,
required: true,
},
},
computed: {
title() {
return sprintf(I18N_TITLE, { name: this.runnerName });
},
},
methods: {
onPrimary() {
this.$refs.modal.hide();
},
},
actionPrimary: { text: I18N_PRIMARY, attributes: { variant: 'danger' } },
actionCancel: { text: I18N_CANCEL },
I18N_BODY,
};
</script>
<template>
<gl-modal
ref="modal"
size="sm"
:title="title"
:action-primary="$options.actionPrimary"
:action-cancel="$options.actionCancel"
v-bind="$attrs"
v-on="$listeners"
@primary="onPrimary"
>
{{ $options.I18N_BODY }}
</gl-modal>
</template>
...@@ -81,6 +81,7 @@ export default { ...@@ -81,6 +81,7 @@ export default {
:tbody-tr-attr="runnerTrAttr" :tbody-tr-attr="runnerTrAttr"
data-testid="runner-list" data-testid="runner-list"
stacked="md" stacked="md"
primary-key="id"
fixed fixed
> >
<template v-if="!runners.length" #table-busy> <template v-if="!runners.length" #table-busy>
......
...@@ -30097,9 +30097,6 @@ msgstr "" ...@@ -30097,9 +30097,6 @@ msgstr ""
msgid "Runners|Architecture" msgid "Runners|Architecture"
msgstr "" msgstr ""
msgid "Runners|Are you sure you want to delete this runner?"
msgstr ""
msgid "Runners|Associated with one or more projects" msgid "Runners|Associated with one or more projects"
msgstr "" msgstr ""
...@@ -30121,6 +30118,12 @@ msgstr "" ...@@ -30121,6 +30118,12 @@ msgstr ""
msgid "Runners|Copy registration token" msgid "Runners|Copy registration token"
msgstr "" msgstr ""
msgid "Runners|Delete runner"
msgstr ""
msgid "Runners|Delete runner %{name}?"
msgstr ""
msgid "Runners|Deploy GitLab Runner in AWS" msgid "Runners|Deploy GitLab Runner in AWS"
msgstr "" msgstr ""
...@@ -30241,6 +30244,9 @@ msgstr "" ...@@ -30241,6 +30244,9 @@ msgstr ""
msgid "Runners|Runner #%{runner_id}" msgid "Runners|Runner #%{runner_id}"
msgstr "" msgstr ""
msgid "Runners|Runner %{name} was deleted"
msgstr ""
msgid "Runners|Runner ID" msgid "Runners|Runner ID"
msgstr "" msgstr ""
...@@ -30298,6 +30304,9 @@ msgstr "" ...@@ -30298,6 +30304,9 @@ msgstr ""
msgid "Runners|Tags" msgid "Runners|Tags"
msgstr "" msgstr ""
msgid "Runners|The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?"
msgstr ""
msgid "Runners|This runner has never connected to this instance" msgid "Runners|This runner has never connected to this instance"
msgstr "" msgstr ""
......
...@@ -59,6 +59,42 @@ RSpec.describe "Admin Runners" do ...@@ -59,6 +59,42 @@ RSpec.describe "Admin Runners" do
end end
end end
describe 'delete runner' do
let!(:runner) { create(:ci_runner, description: 'runner-foo') }
before do
visit admin_runners_path
within "[data-testid='runner-row-#{runner.id}']" do
click_on 'Delete runner'
end
end
it 'shows a confirmation modal' do
expect(page).to have_text "Delete runner ##{runner.id} (#{runner.short_sha})?"
expect(page).to have_text "Are you sure you want to continue?"
end
it 'deletes a runner' do
within '.modal' do
click_on 'Delete runner'
end
expect(page.find('.gl-toast')).to have_text(/Runner .+ deleted/)
expect(page).not_to have_content 'runner-foo'
end
it 'cancels runner deletion' do
within '.modal' do
click_on 'Cancel'
end
wait_for_requests
expect(page).to have_content 'runner-foo'
end
end
describe 'search' do describe 'search' do
before do before do
create(:ci_runner, :instance, description: 'runner-foo') create(:ci_runner, :instance, description: 'runner-foo')
......
import { GlModal } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
describe('RunnerDeleteModal', () => {
let wrapper;
const findGlModal = () => wrapper.findComponent(GlModal);
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
wrapper = mountFn(RunnerDeleteModal, {
attachTo: document.body,
propsData: {
runnerName: '#99 (AABBCCDD)',
...props,
},
attrs: {
modalId: 'delete-runner-modal-99',
},
});
};
it('Displays title', () => {
createComponent();
expect(findGlModal().props('title')).toBe('Delete runner #99 (AABBCCDD)?');
});
it('Displays buttons', () => {
createComponent();
expect(findGlModal().props('actionPrimary')).toMatchObject({ text: 'Delete runner' });
expect(findGlModal().props('actionCancel')).toMatchObject({ text: 'Cancel' });
});
it('Displays contents', () => {
createComponent();
expect(findGlModal().html()).toContain(
'The runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?',
);
});
describe('When modal is confirmed by the user', () => {
let hideModalSpy;
beforeEach(() => {
createComponent({}, mount);
hideModalSpy = jest.spyOn(wrapper.vm.$refs.modal, 'hide').mockImplementation(() => {});
});
it('Modal gets hidden', () => {
expect(hideModalSpy).toHaveBeenCalledTimes(0);
findGlModal().vm.$emit('primary');
expect(hideModalSpy).toHaveBeenCalledTimes(1);
});
});
});
...@@ -52,6 +52,12 @@ describe('RunnerList', () => { ...@@ -52,6 +52,12 @@ describe('RunnerList', () => {
]); ]);
}); });
it('Sets runner id as a row key', () => {
createComponent({}, shallowMount);
expect(findTable().attributes('primary-key')).toBe('id');
});
it('Displays a list of runners', () => { it('Displays a list of runners', () => {
expect(findRows()).toHaveLength(4); expect(findRows()).toHaveLength(4);
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment