Commit 7638f46c authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '347856-runner-delete-button' into 'master'

Refactor delete action to reusable component

See merge request gitlab-org/gitlab!80632
parents d2f1a542 d233b6db
<script>
import { GlButton, GlButtonGroup, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { s__, sprintf } from '~/locale';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { GlButtonGroup } from '@gitlab/ui';
import RunnerEditButton from '../runner_edit_button.vue';
import RunnerPauseButton from '../runner_pause_button.vue';
import RunnerDeleteModal from '../runner_delete_modal.vue';
const I18N_DELETE = s__('Runners|Delete runner');
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
import RunnerDeleteButton from '../runner_delete_button.vue';
export default {
name: 'RunnerActionsCell',
components: {
GlButton,
GlButtonGroup,
RunnerEditButton,
RunnerPauseButton,
RunnerDeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
RunnerDeleteButton,
},
props: {
runner: {
......@@ -31,30 +18,7 @@ export default {
required: true,
},
},
data() {
return {
updating: false,
deleting: false,
};
},
computed: {
deleteTitle() {
if (this.deleting) {
// Prevent a "sticky" tooltip: If this button is disabled,
// 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}`;
},
canUpdate() {
return this.runner.userPermissions?.updateRunner;
},
......@@ -62,49 +26,6 @@ export default {
return this.runner.userPermissions?.deleteRunner;
},
},
methods: {
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
this.deleting = true;
try {
const {
data: {
runnerDelete: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
},
},
awaitRefetchQueries: true,
refetchQueries: ['getRunners', 'getGroupRunners'],
});
if (errors && errors.length) {
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) {
this.deleting = false;
this.onError(e);
}
},
onError(error) {
const { message } = error;
createAlert({ message });
this.reportToSentry(error);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
I18N_DELETE,
};
</script>
......@@ -119,23 +40,6 @@ export default {
-->
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<gl-button
v-if="canDelete"
v-gl-tooltip.hover.viewport="deleteTitle"
v-gl-modal="runnerDeleteModalId"
:aria-label="deleteTitle"
icon="close"
:loading="deleting"
variant="danger"
data-testid="delete-runner"
/>
<runner-delete-modal
v-if="canDelete"
:ref="runnerDeleteModalId"
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
@primary="onDelete"
/>
<runner-delete-button v-if="canDelete" :runner="runner" :compact="true" />
</gl-button-group>
</template>
<script>
import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import { createAlert } from '~/flash';
import { s__, sprintf } from '~/locale';
import { captureException } from '~/runner/sentry_utils';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { I18N_DELETE_RUNNER } from '../constants';
import RunnerDeleteModal from './runner_delete_modal.vue';
const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted');
export default {
name: 'RunnerDeleteButton',
components: {
GlButton,
RunnerDeleteModal,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
props: {
runner: {
type: Object,
required: true,
validator: (runner) => {
return runner?.id && runner?.shortSha;
},
},
compact: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
deleting: false,
};
},
computed: {
runnerId() {
return getIdFromGraphQLId(this.runner.id);
},
runnerName() {
return `#${this.runnerId} (${this.runner.shortSha})`;
},
runnerDeleteModalId() {
return `delete-runner-modal-${this.runnerId}`;
},
icon() {
if (this.compact) {
return 'close';
}
return '';
},
buttonContent() {
if (this.compact) {
return null;
}
return I18N_DELETE_RUNNER;
},
buttonClass() {
// Ensure a square button is shown when compact: true.
// Without this class we will have distorted/rectangular button.
if (this.compact) {
return 'btn-icon';
}
return null;
},
ariaLabel() {
if (this.compact) {
return I18N_DELETE_RUNNER;
}
return null;
},
tooltip() {
// Only show 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) {
return I18N_DELETE_RUNNER;
}
return '';
},
},
methods: {
async onDelete() {
// Deleting stays "true" until this row is removed,
// should only change back if the operation fails.
this.deleting = true;
try {
const {
data: {
runnerDelete: { errors },
},
} = await this.$apollo.mutate({
mutation: runnerDeleteMutation,
variables: {
input: {
id: this.runner.id,
},
},
refetchQueries: ['getRunners', 'getGroupRunners'],
});
if (errors && errors.length) {
throw new Error(errors.join(' '));
} else {
this.$root.$toast?.show(sprintf(I18N_DELETED_TOAST, { name: this.runnerName }));
}
} catch (e) {
this.deleting = false;
this.onError(e);
}
},
onError(error) {
const { message } = error;
createAlert({ message });
this.reportToSentry(error);
},
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
},
};
</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 }}
<runner-delete-modal
:modal-id="runnerDeleteModalId"
:runner-name="runnerName"
@primary="onDelete"
/>
</gl-button>
</template>
......@@ -38,6 +38,7 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
// Active flag
export const I18N_PAUSE = __('Pause');
export const I18N_RESUME = __('Resume');
export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { createAlert } 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 RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
const getRunnersQueryName = getRunnersQuery.definitions[0].name.value;
const getGroupRunnersQueryName = getGroupRunnersQuery.definitions[0].name.value;
Vue.use(VueApollo);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
describe('RunnerTypeCell', () => {
describe('RunnerActionsCell', () => {
let wrapper;
const mockToastShow = jest.fn();
const runnerDeleteMutationHandler = jest.fn();
const findEditBtn = () => wrapper.findComponent(RunnerEditButton);
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
const findRunnerDeleteModal = () => wrapper.findComponent(RunnerDeleteModal);
const findDeleteBtn = () => wrapper.findByTestId('delete-runner');
const getTooltip = (w) => getBinding(w.element, 'gl-tooltip')?.value;
const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
const createComponent = (runner = {}, options) => {
wrapper = shallowMountExtended(RunnerActionCell, {
wrapper = shallowMountExtended(RunnerActionsCell, {
propsData: {
runner: {
id: mockRunner.id,
shortSha: mockRunner.shortSha,
editAdminUrl: mockRunner.editAdminUrl,
userPermissions: mockRunner.userPermissions,
active: mockRunner.active,
...runner,
},
},
apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteMutationHandler]]),
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
mocks: {
$toast: {
show: mockToastShow,
},
},
...options,
});
};
beforeEach(() => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [],
},
},
});
});
afterEach(() => {
mockToastShow.mockReset();
runnerDeleteMutationHandler.mockReset();
wrapper.destroy();
});
......@@ -129,51 +81,10 @@ describe('RunnerTypeCell', () => {
});
describe('Delete action', () => {
beforeEach(() => {
createComponent(
{},
{
stubs: { RunnerDeleteModal },
},
);
});
it('Renders delete button', () => {
expect(findDeleteBtn().exists()).toBe(true);
});
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('Renders a compact delete button', () => {
createComponent();
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],
});
expect(findDeleteBtn().props('compact')).toBe(true);
});
it('Does not render the runner delete button when user cannot delete', () => {
......@@ -185,91 +96,6 @@ describe('RunnerTypeCell', () => {
});
expect(findDeleteBtn().exists()).toBe(false);
expect(findRunnerDeleteModal().exists()).toBe(false);
});
describe('When delete is clicked', () => {
beforeEach(async () => {
findRunnerDeleteModal().vm.$emit('primary');
await waitForPromises();
});
it('The delete mutation is called correctly', () => {
expect(runnerDeleteMutationHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteMutationHandler).toHaveBeenCalledWith({
input: { id: mockRunner.id },
});
});
it('The delete button has a loading icon', () => {
expect(findDeleteBtn().props('loading')).toBe(true);
expect(getTooltip(findDeleteBtn())).toBe('');
});
it('The toast notification is shown', async () => {
await waitForPromises();
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(async () => {
runnerDeleteMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
findRunnerDeleteModal().vm.$emit('primary');
await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(mockErrorMsg),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createAlert).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(async () => {
runnerDeleteMutationHandler.mockResolvedValue({
data: {
runnerDelete: {
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
findRunnerDeleteModal().vm.$emit('primary');
await waitForPromises();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerActionsCell',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
});
});
});
import Vue from 'vue';
import { GlButton, GlToast } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
import runnerDeleteMutation from '~/runner/graphql/runner_delete.mutation.graphql';
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 RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import RunnerDeleteModal from '~/runner/components/runner_delete_modal.vue';
import { runnersData } from '../mock_data';
const mockRunner = runnersData.data.runners.nodes[0];
const mockRunnerId = getIdFromGraphQLId(mockRunner.id);
Vue.use(VueApollo);
Vue.use(GlToast);
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
describe('RunnerDeleteButton', () => {
let wrapper;
let runnerDeleteHandler;
let showToast;
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 createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const { runner, ...propsData } = props;
wrapper = mountFn(RunnerDeleteButton, {
propsData: {
runner: {
id: mockRunner.id,
shortSha: mockRunner.shortSha,
...runner,
},
...propsData,
},
apolloProvider: createMockApollo([[runnerDeleteMutation, runnerDeleteHandler]]),
directives: {
GlTooltip: createMockDirective(),
GlModal: createMockDirective(),
},
});
showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show').mockImplementation(() => {});
};
const clickOkAndWait = async () => {
findModal().vm.$emit('primary');
await waitForPromises();
};
beforeEach(() => {
runnerDeleteHandler = jest.fn().mockImplementation(() => {
return Promise.resolve({
data: {
runnerDelete: {
errors: [],
},
},
});
});
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays a delete button without an icon', () => {
expect(findBtn().props()).toMatchObject({
loading: false,
icon: '',
});
expect(findBtn().classes('btn-icon')).toBe(false);
expect(findBtn().text()).toBe(I18N_DELETE_RUNNER);
});
it('Displays a modal with the runner name', () => {
expect(findModal().props('runnerName')).toBe(`#${mockRunnerId} (${mockRunner.shortSha})`);
});
it('Displays a modal when clicked', () => {
const modalId = `delete-runner-modal-${mockRunnerId}`;
expect(getModal()).toBe(modalId);
expect(findModal().attributes('modal-id')).toBe(modalId);
});
it('Does not display redundant text for screen readers', () => {
expect(findBtn().attributes('aria-label')).toBe(undefined);
});
describe(`Before the delete button is clicked`, () => {
it('The mutation has not been called', () => {
expect(runnerDeleteHandler).toHaveBeenCalledTimes(0);
});
});
describe('Immediately after the delete button is clicked', () => {
beforeEach(async () => {
findModal().vm.$emit('primary');
});
it('The button has a loading state', async () => {
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
expect(getTooltip()).toBe('');
});
});
describe('After clicking on the delete button', () => {
beforeEach(async () => {
await clickOkAndWait();
});
it('The mutation to delete is called', async () => {
expect(runnerDeleteHandler).toHaveBeenCalledTimes(1);
expect(runnerDeleteHandler).toHaveBeenCalledWith({
input: {
id: mockRunner.id,
},
});
});
it('The user is notified', async () => {
expect(showToast).toHaveBeenCalledTimes(1);
});
});
describe('When update fails', () => {
describe('On a network error', () => {
const mockErrorMsg = 'Update error!';
beforeEach(async () => {
runnerDeleteHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
await clickOkAndWait();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(mockErrorMsg),
component: 'RunnerDeleteButton',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
describe('On a validation error', () => {
const mockErrorMsg = 'Runner not found!';
const mockErrorMsg2 = 'User not allowed!';
beforeEach(async () => {
runnerDeleteHandler.mockResolvedValueOnce({
data: {
runnerDelete: {
errors: [mockErrorMsg, mockErrorMsg2],
},
},
});
await clickOkAndWait();
});
it('error is reported to sentry', () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error(`${mockErrorMsg} ${mockErrorMsg2}`),
component: 'RunnerDeleteButton',
});
});
it('error is shown to the user', () => {
expect(createAlert).toHaveBeenCalledTimes(1);
});
});
});
describe('When displaying a compact button for an active runner', () => {
beforeEach(() => {
createComponent({
props: {
runner: {
active: true,
},
compact: true,
},
mountFn: mountExtended,
});
});
it('Displays no text', () => {
expect(findBtn().text()).toBe('');
expect(findBtn().classes('btn-icon')).toBe(true);
});
it('Display correctly for screen readers', () => {
expect(findBtn().attributes('aria-label')).toBe(I18N_DELETE_RUNNER);
expect(getTooltip()).toBe(I18N_DELETE_RUNNER);
});
describe('Immediately after the button is clicked', () => {
beforeEach(async () => {
findModal().vm.$emit('primary');
});
it('The button has a loading state', async () => {
expect(findBtn().props('loading')).toBe(true);
});
it('The stale tooltip is removed', async () => {
expect(getTooltip()).toBe('');
});
});
});
});
......@@ -8,6 +8,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes;
......@@ -94,7 +95,7 @@ describe('RunnerList', () => {
expect(actions.findComponent(RunnerEditButton).exists()).toBe(true);
expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true);
expect(actions.findByTestId('delete-runner').exists()).toBe(true);
expect(actions.findComponent(RunnerDeleteButton).exists()).toBe(true);
});
describe('Table data formatting', () => {
......
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