diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index cd5ce3e2976d46aabe414637c65c14f69ff5c223..c66a1eecfc5084e4cc0a82d2cdb2ba58078fb0d0 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -12,6 +12,7 @@ import { isSearchFiltered, } from 'ee_else_ce/runner/runner_search_utils'; import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; +import allRunnersCountQuery from 'ee_else_ce/runner/graphql/list/all_runners_count.query.graphql'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; @@ -138,11 +139,15 @@ export default { onToggledPaused() { // When a runner becomes Paused, the tab count can // become stale, refetch outdated counts. - this.$refs['runner-type-tabs'].refetch(); + this.refetchCounts(); }, onDeleted({ message }) { + this.refetchCounts(); this.$root.$toast?.show(message); }, + refetchCounts() { + this.$apollo.getClient().refetchQueries({ include: [allRunnersCountQuery] }); + }, reportToSentry(error) { captureException({ error, component: this.$options.name }); }, @@ -166,7 +171,6 @@ export default { class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > <runner-type-tabs - ref="runner-type-tabs" v-model="search" :count-scope="$options.INSTANCE_TYPE" :count-variables="countVariables" @@ -199,7 +203,7 @@ export default { :filtered-svg-path="emptyStateFilteredSvgPath" /> <template v-else> - <runner-bulk-delete v-if="isBulkDeleteEnabled" /> + <runner-bulk-delete v-if="isBulkDeleteEnabled" @deleted="onDeleted" /> <runner-list :runners="runners.items" :loading="runnersLoading" diff --git a/app/assets/javascripts/runner/components/runner_bulk_delete.vue b/app/assets/javascripts/runner/components/runner_bulk_delete.vue index 6f065c38b255711249aece5023183f438f8b1abb..fd55487c4586a465f5319088ed4f89f32a8329ee 100644 --- a/app/assets/javascripts/runner/components/runner_bulk_delete.vue +++ b/app/assets/javascripts/runner/components/runner_bulk_delete.vue @@ -1,13 +1,15 @@ <script> -import { GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; -import { n__, sprintf } from '~/locale'; -import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { GlButton, GlModalDirective, GlModal, GlSprintf } from '@gitlab/ui'; +import { createAlert } from '~/flash'; +import { __, s__, n__, sprintf } from '~/locale'; import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql'; +import BulkRunnerDelete from '../graphql/list/bulk_runner_delete.mutation.graphql'; +import { RUNNER_TYPENAME } from '../constants'; export default { components: { GlButton, + GlModal, GlSprintf, }, directives: { @@ -16,6 +18,7 @@ export default { inject: ['localMutations'], data() { return { + isDeleting: false, checkedRunnerIds: [], }; }, @@ -43,48 +46,103 @@ export default { modalTitle() { return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount); }, - modalHtmlMessage() { + modalActionPrimary() { + return { + text: n__( + 'Runners|Permanently delete %d runner', + 'Runners|Permanently delete %d runners', + this.checkedCount, + ), + attributes: { + loading: this.isDeleting, + variant: 'danger', + }, + }; + }, + modalActionCancel() { + return { + text: __('Cancel'), + attributes: { + loading: this.isDeleting, + }, + }; + }, + modalMessage() { return sprintf( n__( 'Runners|%{strongStart}%{count}%{strongEnd} runner will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', 'Runners|%{strongStart}%{count}%{strongEnd} runners will be permanently deleted and no longer available for projects or groups in the instance. Are you sure you want to continue?', this.checkedCount, ), - { - strongStart: '<strong>', - strongEnd: '</strong>', - count: this.checkedCount, - }, - false, + { count: this.checkedCount }, ); }, - primaryBtnText() { + }, + methods: { + toastConfirmationMessage(deletedCount) { return n__( - 'Runners|Permanently delete %d runner', - 'Runners|Permanently delete %d runners', - this.checkedCount, + 'Runners|%d selected runner deleted', + 'Runners|%d selected runners deleted', + deletedCount, ); }, - }, - methods: { onClearChecked() { this.localMutations.clearChecked(); }, - onClickDelete: ignoreWhilePending(async function onClickDelete() { - const confirmed = await confirmAction(null, { - title: this.modalTitle, - modalHtmlMessage: this.modalHtmlMessage, - primaryBtnVariant: 'danger', - primaryBtnText: this.primaryBtnText, - }); + async onConfirmDelete(e) { + this.isDeleting = true; + e.preventDefault(); // don't close modal until deletion is complete + + try { + await this.$apollo.mutate({ + mutation: BulkRunnerDelete, + variables: { + input: { + ids: this.checkedRunnerIds, + }, + }, + update: (cache, { data }) => { + const { errors, deletedIds } = data.bulkRunnerDelete; + + if (errors?.length) { + this.onError(new Error(errors.join(' '))); + this.$refs.modal.hide(); + return; + } + + this.$emit('deleted', { + message: this.toastConfirmationMessage(deletedIds.length), + }); + + // Clean up - if (confirmed) { - // TODO Call $apollo.mutate with list of runner - // ids in `this.checkedRunnerIds`. - // See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/ + // Remove deleted runners from the cache + deletedIds.forEach((id) => { + const cacheId = cache.identify({ __typename: RUNNER_TYPENAME, id }); + cache.evict({ id: cacheId }); + }); + cache.gc(); + + this.$refs.modal.hide(); + }, + }); + } catch (error) { + this.onError(error); + } finally { + this.isDeleting = false; } - }), + }, + onError(error) { + createAlert({ + message: s__( + 'Runners|Something went wrong while deleting. Please refresh the page to try again.', + ), + captureError: true, + error, + }); + }, }, + BULK_DELETE_MODAL_ID: 'bulk-delete-modal', }; </script> @@ -102,10 +160,25 @@ export default { <gl-button variant="default" @click="onClearChecked">{{ s__('Runners|Clear selection') }}</gl-button> - <gl-button variant="danger" @click="onClickDelete">{{ + <gl-button v-gl-modal="$options.BULK_DELETE_MODAL_ID" variant="danger">{{ s__('Runners|Delete selected') }}</gl-button> </div> </div> + <gl-modal + ref="modal" + size="sm" + :modal-id="$options.BULK_DELETE_MODAL_ID" + :title="modalTitle" + :action-primary="modalActionPrimary" + :action-cancel="modalActionCancel" + @primary="onConfirmDelete" + > + <gl-sprintf :message="modalMessage"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 57cc9a645fad56813f74a64120eb8e9cb0c5e8f0..ed1afcbf691d92801702530181114d15209532d5 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -1,5 +1,7 @@ import { __, s__ } from '~/locale'; +export const RUNNER_TYPENAME = 'CiRunner'; // __typename + export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; diff --git a/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql new file mode 100644 index 0000000000000000000000000000000000000000..b73c016b1decb56a51365370ebf4787e2d95d423 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/list/bulk_runner_delete.mutation.graphql @@ -0,0 +1,6 @@ +mutation bulkRunnerDelete($input: BulkRunnerDeleteInput!) { + bulkRunnerDelete(input: $input) { + deletedIds + errors + } +} diff --git a/app/assets/javascripts/runner/graphql/list/local_state.js b/app/assets/javascripts/runner/graphql/list/local_state.js index e87bc72c86a86ca952a192e01a6a9175d907b2eb..ce6d125311ce552af5a3aada7e62d8f2f2766c64 100644 --- a/app/assets/javascripts/runner/graphql/list/local_state.js +++ b/app/assets/javascripts/runner/graphql/list/local_state.js @@ -1,4 +1,5 @@ import { makeVar } from '@apollo/client/core'; +import { RUNNER_TYPENAME } from '../../constants'; import typeDefs from './typedefs.graphql'; /** @@ -33,10 +34,16 @@ export const createLocalState = () => { typePolicies: { Query: { fields: { - checkedRunnerIds() { + checkedRunnerIds(_, { canRead, toReference }) { return Object.entries(checkedRunnerIdsVar()) + .filter(([id]) => { + // Some runners may be deleted by the user separately. + // Skip dangling references, those not in the cache. + // See: https://www.apollographql.com/docs/react/caching/garbage-collection/#dangling-references + return canRead(toReference({ __typename: RUNNER_TYPENAME, id })); + }) .filter(([, isChecked]) => isChecked) - .map(([key]) => key); + .map(([id]) => id); }, }, }, diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8a3e4220fe8ab4fb17520a088e2b4ff89d8c9631..370df98cc71dc1a45f31f44cab1e6abcd7a6534e 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -33606,6 +33606,11 @@ msgstr "" msgid "Runners page." msgstr "" +msgid "Runners|%d selected runner deleted" +msgid_plural "Runners|%d selected runners deleted" +msgstr[0] "" +msgstr[1] "" + msgid "Runners|%{percentage} spot." msgstr "" @@ -33965,6 +33970,9 @@ msgstr "" msgid "Runners|Show runner installation instructions" msgstr "" +msgid "Runners|Something went wrong while deleting. Please refresh the page to try again." +msgstr "" + msgid "Runners|Something went wrong while fetching runner data." msgstr "" diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index 531c60c9c3e05a53180e3caaf6201412018f6ee3..f8f834c7ad4dd691236fb31f19802a65435d3777 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -19,6 +19,7 @@ import { createLocalState } from '~/runner/graphql/list/local_state'; import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue'; import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; import RunnerList from '~/runner/components/runner_list.vue'; import RunnerListEmptyState from '~/runner/components/runner_list_empty_state.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue'; @@ -36,8 +37,6 @@ import { PARAM_KEY_STATUS, PARAM_KEY_TAG, STATUS_ONLINE, - STATUS_OFFLINE, - STATUS_STALE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import allRunnersQuery from 'ee_else_ce/runner/graphql/list/all_runners.query.graphql'; @@ -72,15 +71,19 @@ jest.mock('~/lib/utils/url_utility', () => ({ Vue.use(VueApollo); Vue.use(GlToast); +const COUNT_QUERIES = 7; // 4 tabs + 3 status queries + describe('AdminRunnersApp', () => { let wrapper; let cacheConfig; let localMutations; + let showToast; const findRunnerStats = () => wrapper.findComponent(RunnerStats); const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); + const findRunnerBulkDelete = () => wrapper.findComponent(RunnerBulkDelete); const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerListEmptyState = () => wrapper.findComponent(RunnerListEmptyState); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); @@ -117,6 +120,8 @@ describe('AdminRunnersApp', () => { ...options, }); + showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); + return waitForPromises(); }; @@ -128,17 +133,10 @@ describe('AdminRunnersApp', () => { afterEach(() => { mockRunnersHandler.mockReset(); mockRunnersCountHandler.mockReset(); + showToast.mockReset(); wrapper.destroy(); }); - it('shows the runner tabs with a runner count for each type', async () => { - await createComponent({ mountFn: mountExtended }); - - expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( - `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`, - ); - }); - it('shows the runner setup instructions', () => { createComponent(); @@ -146,27 +144,38 @@ describe('AdminRunnersApp', () => { expect(findRegistrationDropdown().props('type')).toBe(INSTANCE_TYPE); }); - it('shows total runner counts', async () => { - await createComponent({ mountFn: mountExtended }); + describe('shows total runner counts', () => { + beforeEach(async () => { + await createComponent({ mountFn: mountExtended }); + }); - expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_ONLINE }); - expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_OFFLINE }); - expect(mockRunnersCountHandler).toHaveBeenCalledWith({ status: STATUS_STALE }); - - expect(findRunnerStats().text()).toContain( - `${s__('Runners|Online runners')} ${mockRunnersCount}`, - ); - expect(findRunnerStats().text()).toContain( - `${s__('Runners|Offline runners')} ${mockRunnersCount}`, - ); - expect(findRunnerStats().text()).toContain( - `${s__('Runners|Stale runners')} ${mockRunnersCount}`, - ); + it('fetches counts', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); + }); + + it('shows the runner tabs', () => { + expect(findRunnerTypeTabs().text()).toMatchInterpolatedText( + `All ${mockRunnersCount} Instance ${mockRunnersCount} Group ${mockRunnersCount} Project ${mockRunnersCount}`, + ); + }); + + it('shows the total', () => { + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Online runners')} ${mockRunnersCount}`, + ); + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Offline runners')} ${mockRunnersCount}`, + ); + expect(findRunnerStats().text()).toContain( + `${s__('Runners|Stale runners')} ${mockRunnersCount}`, + ); + }); }); it('shows the runners list', async () => { await createComponent(); + expect(mockRunnersHandler).toHaveBeenCalledTimes(1); expect(findRunnerList().props('runners')).toEqualGraphqlFixture(mockRunners); }); @@ -226,18 +235,13 @@ describe('AdminRunnersApp', () => { }); describe('Single runner row', () => { - let showToast; - const { id: graphqlId, shortSha } = mockRunners[0]; const id = getIdFromGraphQLId(graphqlId); - const COUNT_QUERIES = 7; // Smart queries that display a filtered count of runners - const FILTERED_COUNT_QUERIES = 4; // Smart queries that display a count of runners in tabs beforeEach(async () => { mockRunnersCountHandler.mockClear(); await createComponent({ mountFn: mountExtended }); - showToast = jest.spyOn(wrapper.vm.$root.$toast, 'show'); }); it('Links to the runner page', async () => { @@ -252,7 +256,7 @@ describe('AdminRunnersApp', () => { findRunnerActionsCell().vm.$emit('toggledPaused'); - expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES + FILTERED_COUNT_QUERIES); + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); expect(showToast).toHaveBeenCalledTimes(0); }); @@ -344,36 +348,70 @@ describe('AdminRunnersApp', () => { }); describe('when bulk delete is enabled', () => { - beforeEach(() => { - createComponent({ - provide: { - glFeatures: { adminRunnersBulkDelete: true }, - }, + describe('Before runners are deleted', () => { + beforeEach(async () => { + await createComponent({ + provide: { + glFeatures: { adminRunnersBulkDelete: true }, + }, + }); }); - }); - it('runner list is checkable', () => { - expect(findRunnerList().props('checkable')).toBe(true); + it('runner bulk delete is available', () => { + expect(findRunnerBulkDelete().exists()).toBe(true); + }); + + it('runner list is checkable', () => { + expect(findRunnerList().props('checkable')).toBe(true); + }); + + it('responds to checked items by updating the local cache', () => { + const setRunnerCheckedMock = jest + .spyOn(localMutations, 'setRunnerChecked') + .mockImplementation(() => {}); + + const runner = mockRunners[0]; + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); + + findRunnerList().vm.$emit('checked', { + runner, + isChecked: true, + }); + + expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); + expect(setRunnerCheckedMock).toHaveBeenCalledWith({ + runner, + isChecked: true, + }); + }); }); - it('responds to checked items by updating the local cache', () => { - const setRunnerCheckedMock = jest - .spyOn(localMutations, 'setRunnerChecked') - .mockImplementation(() => {}); + describe('When runners are deleted', () => { + beforeEach(async () => { + await createComponent({ + mountFn: mountExtended, + provide: { + glFeatures: { adminRunnersBulkDelete: true }, + }, + }); + }); - const runner = mockRunners[0]; + it('count data is refetched', async () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); - expect(setRunnerCheckedMock).toHaveBeenCalledTimes(0); + findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); - findRunnerList().vm.$emit('checked', { - runner, - isChecked: true, + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES * 2); }); - expect(setRunnerCheckedMock).toHaveBeenCalledTimes(1); - expect(setRunnerCheckedMock).toHaveBeenCalledWith({ - runner, - isChecked: true, + it('toast is shown', async () => { + expect(showToast).toHaveBeenCalledTimes(0); + + findRunnerBulkDelete().vm.$emit('deleted', { message: 'Runners deleted' }); + + expect(showToast).toHaveBeenCalledTimes(1); + expect(showToast).toHaveBeenCalledWith('Runners deleted'); }); }); }); diff --git a/spec/frontend/runner/components/runner_bulk_delete_spec.js b/spec/frontend/runner/components/runner_bulk_delete_spec.js index cc679a52b348446e35001410d6c04ff96ee0828c..e14d1bba5beca8fe46024f29c541ced470fc4803 100644 --- a/spec/frontend/runner/components/runner_bulk_delete_spec.js +++ b/spec/frontend/runner/components/runner_bulk_delete_spec.js @@ -1,38 +1,57 @@ import Vue from 'vue'; -import { GlSprintf } from '@gitlab/ui'; +import { GlModal, GlSprintf } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; +import { createAlert } from '~/flash'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import RunnerBulkDelete from '~/runner/components/runner_bulk_delete.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; +import BulkRunnerDeleteMutation from '~/runner/graphql/list/bulk_runner_delete.mutation.graphql'; import { createLocalState } from '~/runner/graphql/list/local_state'; import waitForPromises from 'helpers/wait_for_promises'; Vue.use(VueApollo); -jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); +jest.mock('~/flash'); describe('RunnerBulkDelete', () => { let wrapper; + let apolloCache; let mockState; let mockCheckedRunnerIds; const findClearBtn = () => wrapper.findByText(s__('Runners|Clear selection')); const findDeleteBtn = () => wrapper.findByText(s__('Runners|Delete selected')); + const findModal = () => wrapper.findComponent(GlModal); + + const bulkRunnerDeleteHandler = jest.fn(); const createComponent = () => { const { cacheConfig, localMutations } = mockState; + const apolloProvider = createMockApollo( + [[BulkRunnerDeleteMutation, bulkRunnerDeleteHandler]], + undefined, + cacheConfig, + ); wrapper = shallowMountExtended(RunnerBulkDelete, { - apolloProvider: createMockApollo(undefined, undefined, cacheConfig), + apolloProvider, provide: { localMutations, }, + directives: { + GlTooltip: createMockDirective(), + }, stubs: { GlSprintf, + GlModal, }, }); + + apolloCache = apolloProvider.defaultClient.cache; + jest.spyOn(apolloCache, 'evict'); + jest.spyOn(apolloCache, 'gc'); }; beforeEach(() => { @@ -44,6 +63,7 @@ describe('RunnerBulkDelete', () => { }); afterEach(() => { + bulkRunnerDeleteHandler.mockReset(); wrapper.destroy(); }); @@ -65,7 +85,7 @@ describe('RunnerBulkDelete', () => { count | ids | text ${1} | ${['gid:Runner/1']} | ${'1 runner'} ${2} | ${['gid:Runner/1', 'gid:Runner/2']} | ${'2 runners'} - `('When $count runner(s) are checked', ({ count, ids, text }) => { + `('When $count runner(s) are checked', ({ ids, text }) => { beforeEach(() => { mockCheckedRunnerIds = ids; @@ -87,18 +107,129 @@ describe('RunnerBulkDelete', () => { }); it('shows confirmation modal', () => { - expect(confirmAction).toHaveBeenCalledTimes(0); + const modalId = getBinding(findDeleteBtn().element, 'gl-modal'); + + expect(findModal().props('modal-id')).toBe(modalId); + expect(findModal().text()).toContain(text); + }); + }); + + describe('when runners are deleted', () => { + let evt; + let mockHideModal; + + beforeEach(() => { + mockCheckedRunnerIds = ['gid:Runner/1', 'gid:Runner/2']; + + createComponent(); + + jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {}); + mockHideModal = jest.spyOn(findModal().vm, 'hide'); + }); + + describe('when deletion is successful', () => { + beforeEach(() => { + bulkRunnerDeleteHandler.mockResolvedValue({ + data: { + bulkRunnerDelete: { deletedIds: mockCheckedRunnerIds, errors: [] }, + }, + }); + + evt = { + preventDefault: jest.fn(), + }; + findModal().vm.$emit('primary', evt); + }); + + it('has loading state', async () => { + expect(findModal().props('actionPrimary').attributes.loading).toBe(true); + expect(findModal().props('actionCancel').attributes.loading).toBe(true); + + await waitForPromises(); + + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); + expect(findModal().props('actionCancel').attributes.loading).toBe(false); + }); + + it('modal is not prevented from closing', () => { + expect(evt.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('mutation is called', async () => { + expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({ + input: { ids: mockCheckedRunnerIds }, + }); + }); + + it('user interface is updated', async () => { + const { evict, gc } = apolloCache; + + expect(evict).toHaveBeenCalledTimes(mockCheckedRunnerIds.length); + expect(evict).toHaveBeenCalledWith({ + id: expect.stringContaining(mockCheckedRunnerIds[0]), + }); + expect(evict).toHaveBeenCalledWith({ + id: expect.stringContaining(mockCheckedRunnerIds[1]), + }); + + expect(gc).toHaveBeenCalledTimes(1); + }); + + it('modal is hidden', () => { + expect(mockHideModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('when deletion fails', () => { + beforeEach(() => { + bulkRunnerDeleteHandler.mockRejectedValue(new Error('error!')); + + evt = { + preventDefault: jest.fn(), + }; + findModal().vm.$emit('primary', evt); + }); + + it('has loading state', async () => { + expect(findModal().props('actionPrimary').attributes.loading).toBe(true); + expect(findModal().props('actionCancel').attributes.loading).toBe(true); + + await waitForPromises(); + + expect(findModal().props('actionPrimary').attributes.loading).toBe(false); + expect(findModal().props('actionCancel').attributes.loading).toBe(false); + }); + + it('modal is not prevented from closing', () => { + expect(evt.preventDefault).toHaveBeenCalledTimes(1); + }); + + it('mutation is called', () => { + expect(bulkRunnerDeleteHandler).toHaveBeenCalledWith({ + input: { ids: mockCheckedRunnerIds }, + }); + }); + + it('user interface is not updated', async () => { + await waitForPromises(); - findDeleteBtn().vm.$emit('click'); + const { evict, gc } = apolloCache; - expect(confirmAction).toHaveBeenCalledTimes(1); + expect(evict).not.toHaveBeenCalled(); + expect(gc).not.toHaveBeenCalled(); + expect(mockState.localMutations.clearChecked).not.toHaveBeenCalled(); + }); - const [, confirmOptions] = confirmAction.mock.calls[0]; - const { title, modalHtmlMessage, primaryBtnText } = confirmOptions; + it('alert is called', async () => { + await waitForPromises(); - expect(title).toMatch(text); - expect(primaryBtnText).toMatch(text); - expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`); + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledWith({ + message: expect.any(String), + captureError: true, + error: expect.any(Error), + }); + }); }); }); }); diff --git a/spec/frontend/runner/graphql/local_state_spec.js b/spec/frontend/runner/graphql/local_state_spec.js index 5c4302e4aa2824b829015885701bcb2b4a5533ee..8530df43791c899013b8e3050fefe2fdfebfbf2f 100644 --- a/spec/frontend/runner/graphql/local_state_spec.js +++ b/spec/frontend/runner/graphql/local_state_spec.js @@ -1,6 +1,8 @@ +import { gql } from '@apollo/client/core'; import createApolloClient from '~/lib/graphql'; import { createLocalState } from '~/runner/graphql/list/local_state'; import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql'; +import { RUNNER_TYPENAME } from '~/runner/constants'; describe('~/runner/graphql/list/local_state', () => { let localState; @@ -18,6 +20,21 @@ describe('~/runner/graphql/list/local_state', () => { apolloClient = createApolloClient({}, { cacheConfig, typeDefs }); }; + const addMockRunnerToCache = (id) => { + // mock some runners in the cache to prevent dangling references + apolloClient.writeFragment({ + id: `${RUNNER_TYPENAME}:${id}`, + fragment: gql` + fragment DummyRunner on CiRunner { + __typename + } + `, + data: { + __typename: RUNNER_TYPENAME, + }, + }); + }; + const queryCheckedRunnerIds = () => { const { checkedRunnerIds } = apolloClient.readQuery({ query: getCheckedRunnerIdsQuery, @@ -34,10 +51,25 @@ describe('~/runner/graphql/list/local_state', () => { apolloClient = null; }); - describe('default', () => { - it('has empty checked list', () => { + describe('queryCheckedRunnerIds', () => { + it('has empty checked list by default', () => { expect(queryCheckedRunnerIds()).toEqual([]); }); + + it('returns checked runners that have a reference in the cache', () => { + addMockRunnerToCache('a'); + localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); + + expect(queryCheckedRunnerIds()).toEqual(['a']); + }); + + it('return checked runners that are not dangling references', () => { + addMockRunnerToCache('a'); // 'b' is missing from the cache, perhaps because it was deleted + localState.localMutations.setRunnerChecked({ runner: { id: 'a' }, isChecked: true }); + localState.localMutations.setRunnerChecked({ runner: { id: 'b' }, isChecked: true }); + + expect(queryCheckedRunnerIds()).toEqual(['a']); + }); }); describe.each` @@ -48,6 +80,7 @@ describe('~/runner/graphql/list/local_state', () => { `('setRunnerChecked', ({ inputs, expected }) => { beforeEach(() => { inputs.forEach(([id, isChecked]) => { + addMockRunnerToCache(id); localState.localMutations.setRunnerChecked({ runner: { id }, isChecked }); }); }); @@ -59,6 +92,7 @@ describe('~/runner/graphql/list/local_state', () => { describe('clearChecked', () => { it('clears all checked items', () => { ['a', 'b', 'c'].forEach((id) => { + addMockRunnerToCache(id); localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true }); });