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')
......
...@@ -3,13 +3,17 @@ import VueApollo from 'vue-apollo'; ...@@ -3,13 +3,17 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { captureException } from '~/runner/sentry_utils';
import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue'; import RunnerActionCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
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 { runnersData } from '../../mock_data'; import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0]; const mockRunner = runnersData.data.runners.nodes[0];
...@@ -25,12 +29,16 @@ jest.mock('~/runner/sentry_utils'); ...@@ -25,12 +29,16 @@ jest.mock('~/runner/sentry_utils');
describe('RunnerTypeCell', () => { describe('RunnerTypeCell', () => {
let wrapper; let wrapper;
const mockToastShow = jest.fn();
const runnerDeleteMutationHandler = jest.fn(); const runnerDeleteMutationHandler = jest.fn();
const runnerActionsUpdateMutationHandler = jest.fn(); const runnerActionsUpdateMutationHandler = jest.fn();
const findEditBtn = () => wrapper.findByTestId('edit-runner'); const findEditBtn = () => wrapper.findByTestId('edit-runner');
const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner'); const findToggleActiveBtn = () => wrapper.findByTestId('toggle-active-runner');
const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
const findDeleteBtn = () => wrapper.findByTestId('delete-runner'); const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value;
const createComponent = ({ active = true } = {}, options) => { const createComponent = ({ active = true } = {}, options) => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
...@@ -38,6 +46,7 @@ describe('RunnerTypeCell', () => { ...@@ -38,6 +46,7 @@ describe('RunnerTypeCell', () => {
propsData: { propsData: {
runner: { runner: {
id: mockRunner.id, id: mockRunner.id,
shortSha: mockRunner.shortSha,
adminUrl: mockRunner.adminUrl, adminUrl: mockRunner.adminUrl,
active, active,
}, },
...@@ -47,6 +56,15 @@ describe('RunnerTypeCell', () => { ...@@ -47,6 +56,15 @@ describe('RunnerTypeCell', () => {
[runnerDeleteMutation, runnerDeleteMutationHandler], [runnerDeleteMutation, runnerDeleteMutationHandler],
[runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler], [runnerActionsUpdateMutation, runnerActionsUpdateMutationHandler],
]), ]),
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
mocks: {
$toast: {
show: mockToastShow,
},
},
...options, ...options,
}), }),
); );
...@@ -72,197 +90,85 @@ describe('RunnerTypeCell', () => { ...@@ -72,197 +90,85 @@ describe('RunnerTypeCell', () => {
}); });
afterEach(() => { afterEach(() => {
mockToastShow.mockReset();
runnerDeleteMutationHandler.mockReset(); runnerDeleteMutationHandler.mockReset();
runnerActionsUpdateMutationHandler.mockReset(); runnerActionsUpdateMutationHandler.mockReset();
wrapper.destroy(); wrapper.destroy();
}); });
it('Displays the runner edit link with the correct href', () => { describe('Edit Action', () => {
createComponent(); it('Displays the runner edit link with the correct href', () => {
createComponent();
expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl);
});
describe.each`
state | label | icon | isActive | newActiveValue
${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
`('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
beforeEach(() => {
createComponent({ active: isActive });
});
it(`Displays a ${icon} button`, () => {
expect(findToggleActiveBtn().props('loading')).toBe(false);
expect(findToggleActiveBtn().props('icon')).toBe(icon);
expect(findToggleActiveBtn().attributes('title')).toBe(label);
expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
});
it(`After clicking the ${icon} button, the button has a loading state`, async () => {
await findToggleActiveBtn().vm.$emit('click');
expect(findToggleActiveBtn().props('loading')).toBe(true);
});
it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
await findToggleActiveBtn().vm.$emit('click');
expect(findToggleActiveBtn().attributes('title')).toBe(''); expect(findEditBtn().attributes('href')).toBe(mockRunner.adminUrl);
expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
}); });
});
describe(`When clicking on the ${icon} button`, () => { describe('Toggle active action', () => {
it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => { describe.each`
expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0); state | label | icon | isActive | newActiveValue
${'active'} | ${'Pause'} | ${'pause'} | ${true} | ${false}
await findToggleActiveBtn().vm.$emit('click'); ${'paused'} | ${'Resume'} | ${'play'} | ${false} | ${true}
`('When the runner is $state', ({ label, icon, isActive, newActiveValue }) => {
beforeEach(() => {
createComponent({ active: isActive });
});
expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1); it(`Displays a ${icon} button`, () => {
expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({ expect(findToggleActiveBtn().props('loading')).toBe(false);
input: { expect(findToggleActiveBtn().props('icon')).toBe(icon);
id: mockRunner.id, expect(getTooltip(findToggleActiveBtn())).toBe(label);
active: newActiveValue, expect(findToggleActiveBtn().attributes('aria-label')).toBe(label);
},
});
}); });
it('The button does not have a loading state after the mutation occurs', async () => { it(`After clicking the ${icon} button, the button has a loading state`, async () => {
await findToggleActiveBtn().vm.$emit('click'); await findToggleActiveBtn().vm.$emit('click');
expect(findToggleActiveBtn().props('loading')).toBe(true); expect(findToggleActiveBtn().props('loading')).toBe(true);
await waitForPromises();
expect(findToggleActiveBtn().props('loading')).toBe(false);
}); });
});
describe('When update fails', () => { it(`After the ${icon} button is clicked, stale tooltip is removed`, async () => {
describe('On a network error', () => { await findToggleActiveBtn().vm.$emit('click');
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await findToggleActiveBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`Network error: ${mockErrorMsg}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => { expect(getTooltip(findToggleActiveBtn())).toBe('');
expect(createFlash).toHaveBeenCalledTimes(1); expect(findToggleActiveBtn().attributes('aria-label')).toBe('');
});
}); });
describe('On a validation error', () => { describe(`When clicking on the ${icon} button`, () => {
const mockErrorMsg = 'Runner not found!'; it(`The apollo mutation to set active to ${newActiveValue} is called`, async () => {
const mockErrorMsg2 = 'User not allowed!'; expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(0);
beforeEach(async () => {
runnerActionsUpdateMutationHandler.mockResolvedValue({
data: {
runnerUpdate: {
runner: mockRunner,
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
await findToggleActiveBtn().vm.$emit('click'); await findToggleActiveBtn().vm.$emit('click');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => { expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledTimes(1); expect(runnerActionsUpdateMutationHandler).toHaveBeenCalledWith({
});
});
});
});
describe('When the user clicks a runner', () => {
beforeEach(() => {
jest.spyOn(window, 'confirm');
createComponent();
});
afterEach(() => {
window.confirm.mockRestore();
});
describe('When the user confirms deletion', () => {
beforeEach(async () => {
window.confirm.mockReturnValue(true);
await findDeleteBtn().vm.$emit('click');
});
it('The user sees a confirmation alert', () => {
expect(window.confirm).toHaveBeenCalledTimes(1);
expect(window.confirm).toHaveBeenCalledWith(expect.any(String));
});
it('The delete mutation is called correctly', () => {
expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
input: { id: mockRunner.id },
});
});
it('When delete mutation is called, current runners are refetched', async () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate');
await findDeleteBtn().vm.$emit('click');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runnerDeleteMutation,
variables: {
input: { input: {
id: mockRunner.id, id: mockRunner.id,
active: newActiveValue,
}, },
}, });
awaitRefetchQueries: true,
refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName],
}); });
});
it('The delete button does not have a loading state', () => {
expect(findDeleteBtn().props('loading')).toBe(false);
expect(findDeleteBtn().attributes('title')).toBe('Remove');
});
it('After the delete button is clicked, loading state is shown', async () => { it('The button does not have a loading state after the mutation occurs', async () => {
await findDeleteBtn().vm.$emit('click'); await findToggleActiveBtn().vm.$emit('click');
expect(findDeleteBtn().props('loading')).toBe(true); expect(findToggleActiveBtn().props('loading')).toBe(true);
});
it('After the delete button is clicked, stale tooltip is removed', async () => { await waitForPromises();
await findDeleteBtn().vm.$emit('click');
expect(findDeleteBtn().attributes('title')).toBe(''); expect(findToggleActiveBtn().props('loading')).toBe(false);
});
}); });
describe('When delete fails', () => { describe('When update fails', () => {
describe('On a network error', () => { describe('On a network error', () => {
const mockErrorMsg = 'Delete error!'; const mockErrorMsg = 'Update error!';
beforeEach(async () => { beforeEach(async () => {
runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg)); runnerActionsUpdateMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await findDeleteBtn().vm.$emit('click'); await findToggleActiveBtn().vm.$emit('click');
}); });
it('error is reported to sentry', () => { it('error is reported to sentry', () => {
...@@ -282,15 +188,16 @@ describe('RunnerTypeCell', () => { ...@@ -282,15 +188,16 @@ describe('RunnerTypeCell', () => {
const mockErrorMsg2 = 'User not allowed!'; const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => { beforeEach(async () => {
runnerDeleteMutationHandler.mockResolvedValue({ runnerActionsUpdateMutationHandler.mockResolvedValue({
data: { data: {
runnerDelete: { runnerUpdate: {
runner: mockRunner,
errors: [mockErrorMsg, mockErrorMsg2], errors: [mockErrorMsg, mockErrorMsg2],
}, },
}, },
}); });
await findDeleteBtn().vm.$emit('click'); await findToggleActiveBtn().vm.$emit('click');
}); });
it('error is reported to sentry', () => { it('error is reported to sentry', () => {
...@@ -306,24 +213,129 @@ describe('RunnerTypeCell', () => { ...@@ -306,24 +213,129 @@ describe('RunnerTypeCell', () => {
}); });
}); });
}); });
});
describe('When the user does not confirm deletion', () => { describe('Delete action', () => {
beforeEach(async () => { beforeEach(() => {
window.confirm.mockReturnValue(false); createComponent(
await findDeleteBtn().vm.$emit('click'); {},
{
stubs: { RunnerDeleteModal },
},
);
});
it('Delete button opens delete modal', () => {
const modalId = getBinding(findDeleteBtn().element, 'gl-modal').value;
expect(findRunnerDeleteModal().attributes('modal-id')).toBeDefined();
expect(findRunnerDeleteModal().attributes('modal-id')).toBe(modalId);
});
it('Delete modal shows the runner name', () => {
expect(findRunnerDeleteModal().props('runnerName')).toBe(
`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`,
);
});
it('The delete button does not have a loading icon', () => {
expect(findDeleteBtn().props('loading')).toBe(false);
expect(getTooltip(findDeleteBtn())).toBe('Delete runner');
});
it('When delete mutation is called, current runners are refetched', () => {
jest.spyOn(wrapper.vm.$apollo, 'mutate');
findRunnerDeleteModal().vm.$emit('primary');
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
mutation: runnerDeleteMutation,
variables: {
input: {
id: mockRunner.id,
},
},
awaitRefetchQueries: true,
refetchQueries: [getRunnersQueryName, getGroupRunnersQueryName],
}); });
});
it('The user sees a confirmation alert', () => { describe('When delete is clicked', () => {
expect(window.confirm).toHaveBeenCalledTimes(1); beforeEach(() => {
findRunnerDeleteModal().vm.$emit('primary');
}); });
it('The delete mutation is not called', () => { it('The delete mutation is called correctly', () => {
expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(0); expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
input: { id: mockRunner.id },
});
}); });
it('The delete button does not have a loading state', () => { it('The delete button has a loading icon', () => {
expect(findDeleteBtn().props('loading')).toBe(false); expect(findDeleteBtn().props('loading')).toBe(true);
expect(findDeleteBtn().attributes('title')).toBe('Remove'); expect(getTooltip(findDeleteBtn())).toBe('');
});
it('The toast notification is shown', () => {
expect(mockToastShow).toHaveBeenCalledTimes(1);
expect(mockToastShow).toHaveBeenCalledWith(
expect.stringContaining(`#${getIdFromGraphQLId(mockRunner.id)} (${mockRunner.shortSha})`),
);
});
});
describe('When delete fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Delete error!';
beforeEach(() => {
runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
findRunnerDeleteModal().vm.$emit('primary');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`Network error: ${mockErrorMsg}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
it('toast notification is not shown', () => {
expect(mockToastShow).not.toHaveBeenCalled();
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(() => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
findRunnerDeleteModal().vm.$emit('primary');
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
}); });
}); });
}); });
......
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