Commit af54953b authored by Miguel Rincon's avatar Miguel Rincon Committed by Paul Slaughter

Introduce admin_runners_bulk_delete feature flag

This MR introduces the `admin_runners_bulk_delete` feature flag along
with the first steps for the frontend development of this feature.
parent 0a6ec0fe
......@@ -4,9 +4,11 @@ import { createAlert } from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber } from '~/locale';
import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import RegistrationDropdown from '../components/registration/registration_dropdown.vue';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerBulkDelete from '../components/runner_bulk_delete.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
......@@ -53,6 +55,7 @@ export default {
GlLink,
RegistrationDropdown,
RunnerFilteredSearchBar,
RunnerBulkDelete,
RunnerList,
RunnerName,
RunnerStats,
......@@ -60,6 +63,8 @@ export default {
RunnerTypeTabs,
RunnerActionsCell,
},
mixins: [glFeatureFlagMixin()],
inject: ['localMutations'],
props: {
registrationToken: {
type: String,
......@@ -180,6 +185,11 @@ export default {
},
];
},
isBulkDeleteEnabled() {
// Feature flag: admin_runners_bulk_delete
// Rollout issue: https://gitlab.com/gitlab-org/gitlab/-/issues/353981
return this.glFeatures.adminRunnersBulkDelete;
},
},
watch: {
search: {
......@@ -238,6 +248,12 @@ export default {
reportToSentry(error) {
captureException({ error, component: this.$options.name });
},
onChecked({ runner, isChecked }) {
this.localMutations.setRunnerChecked({
runner,
isChecked,
});
},
},
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE,
......@@ -286,7 +302,13 @@ export default {
{{ __('No runners found') }}
</div>
<template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading">
<runner-bulk-delete v-if="isBulkDeleteEnabled" />
<runner-list
:runners="runners.items"
:loading="runnersLoading"
:checkable="isBulkDeleteEnabled"
@checked="onChecked"
>
<template #runner-name="{ runner }">
<gl-link :href="runner.adminUrl">
<runner-name :runner="runner" />
......
import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { visitUrl } from '~/lib/utils/url_utility';
import { updateOutdatedUrl } from '~/runner/runner_search_utils';
import createDefaultClient from '~/lib/graphql';
import { createLocalState } from '../graphql/list/local_state';
import AdminRunnersApp from './admin_runners_app.vue';
Vue.use(GlToast);
......@@ -27,8 +28,10 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
const { runnerInstallHelpPage, registrationToken } = el.dataset;
const { cacheConfig, typeDefs, localMutations } = createLocalState();
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
defaultClient: createDefaultClient({}, { cacheConfig, typeDefs }),
});
return new Vue({
......@@ -36,6 +39,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
apolloProvider,
provide: {
runnerInstallHelpPage,
localMutations,
},
render(h) {
return h(AdminRunnersApp, {
......
<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 checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
export default {
components: {
GlButton,
GlSprintf,
},
directives: {
GlModal: GlModalDirective,
},
inject: ['localMutations'],
data() {
return {
checkedRunnerIds: [],
};
},
apollo: {
checkedRunnerIds: {
query: checkedRunnerIdsQuery,
},
},
computed: {
checkedCount() {
return this.checkedRunnerIds.length || 0;
},
bannerMessage() {
return sprintf(
n__(
'Runners|%{strongStart}%{count}%{strongEnd} runner selected',
'Runners|%{strongStart}%{count}%{strongEnd} runners selected',
this.checkedCount,
),
{
count: this.checkedCount,
},
);
},
modalTitle() {
return n__('Runners|Delete %d runner', 'Runners|Delete %d runners', this.checkedCount);
},
modalHtmlMessage() {
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,
);
},
primaryBtnText() {
return n__(
'Runners|Permanently delete %d runner',
'Runners|Permanently delete %d runners',
this.checkedCount,
);
},
},
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,
});
if (confirmed) {
// TODO Call $apollo.mutate with list of runner
// ids in `this.checkedRunnerIds`.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
}
}),
},
};
</script>
<template>
<div v-if="checkedCount" class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
<div class="gl-display-flex gl-align-items-center">
<div>
<gl-sprintf :message="bannerMessage">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</div>
<div class="gl-ml-auto">
<gl-button data-testid="clear-btn" variant="default" @click="onClearChecked">{{
s__('Runners|Clear selection')
}}</gl-button>
<gl-button data-testid="delete-btn" variant="danger" @click="onClickDelete">{{
s__('Runners|Delete selected')
}}</gl-button>
</div>
</div>
</div>
</template>
......@@ -4,11 +4,22 @@ import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/toolt
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import checkedRunnerIdsQuery from '../graphql/list/checked_runner_ids.query.graphql';
import { formatJobCount, tableField } from '../utils';
import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue';
const defaultFields = [
tableField({ key: 'status', label: s__('Runners|Status') }),
tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'jobCount', label: __('Jobs') }),
tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
];
export default {
components: {
GlTableLite,
......@@ -22,7 +33,20 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
apollo: {
checkedRunnerIds: {
query: checkedRunnerIdsQuery,
skip() {
return !this.checkable;
},
},
},
props: {
checkable: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Boolean,
required: false,
......@@ -33,6 +57,10 @@ export default {
required: true,
},
},
emits: ['checked'],
data() {
return { checkedRunnerIds: [] };
},
computed: {
tableClass() {
// <gl-table-lite> does not provide a busy state, add
......@@ -42,6 +70,18 @@ export default {
'gl-opacity-6': this.loading,
};
},
fields() {
if (this.checkable) {
const checkboxField = tableField({
key: 'checkbox',
label: s__('Runners|Checkbox'),
thClasses: ['gl-w-9'],
tdClass: ['gl-text-center'],
});
return [checkboxField, ...defaultFields];
}
return defaultFields;
},
},
methods: {
formatJobCount(jobCount) {
......@@ -55,16 +95,16 @@ export default {
}
return {};
},
onCheckboxChange(runner, isChecked) {
this.$emit('checked', {
runner,
isChecked,
});
},
isChecked(runner) {
return this.checkedRunnerIds.includes(runner.id);
},
},
fields: [
tableField({ key: 'status', label: s__('Runners|Status') }),
tableField({ key: 'summary', label: s__('Runners|Runner'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'version', label: __('Version') }),
tableField({ key: 'jobCount', label: __('Jobs') }),
tableField({ key: 'tagList', label: __('Tags'), thClasses: ['gl-lg-w-25p'] }),
tableField({ key: 'contactedAt', label: __('Last contact') }),
tableField({ key: 'actions', label: '' }),
],
};
</script>
<template>
......@@ -73,13 +113,29 @@ export default {
:aria-busy="loading"
:class="tableClass"
:items="runners"
:fields="$options.fields"
:fields="fields"
:tbody-tr-attr="runnerTrAttr"
data-testid="runner-list"
stacked="md"
primary-key="id"
fixed
>
<template #head(checkbox)>
<!--
Checkbox to select all to be added here
See https://gitlab.com/gitlab-org/gitlab/-/issues/339525/
-->
<span></span>
</template>
<template #cell(checkbox)="{ item }">
<input
type="checkbox"
:checked="isChecked(item)"
@change="onCheckboxChange(item, $event.target.checked)"
/>
</template>
<template #cell(status)="{ item }">
<runner-status-cell :runner="item" />
</template>
......
query getCheckedRunnerIds {
checkedRunnerIds @client
}
import { makeVar } from '@apollo/client/core';
import typeDefs from './typedefs.graphql';
/**
* Local state for checkable runner items.
*
* Usage:
*
* ```
* import { createLocalState } from '~/runner/graphql/list/local_state';
*
* // initialize local state
* const { cacheConfig, typeDefs, localMutations } = createLocalState();
*
* // configure the client
* apolloClient = createApolloClient({}, { cacheConfig, typeDefs });
*
* // modify local state
* localMutations.setRunnerChecked( ... )
* ```
*
* Note: Currently only in use behind a feature flag:
* admin_runners_bulk_delete for the admin list, rollout issue:
* https://gitlab.com/gitlab-org/gitlab/-/issues/353981
*
* @returns {Object} An object to configure an Apollo client:
* contains cacheConfig, typeDefs, localMutations.
*/
export const createLocalState = () => {
const checkedRunnerIdsVar = makeVar({});
const cacheConfig = {
typePolicies: {
Query: {
fields: {
checkedRunnerIds() {
return Object.entries(checkedRunnerIdsVar())
.filter(([, isChecked]) => isChecked)
.map(([key]) => key);
},
},
},
},
};
const localMutations = {
setRunnerChecked({ runner, isChecked }) {
checkedRunnerIdsVar({
...checkedRunnerIdsVar(),
[runner.id]: isChecked,
});
},
clearChecked() {
checkedRunnerIdsVar({});
},
};
return {
cacheConfig,
typeDefs,
localMutations,
};
};
extend type Query {
checkedRunnerIds: [ID!]!
}
......@@ -24,7 +24,7 @@ export const formatJobCount = (jobCount) => {
* @param {Object} options
* @returns Field object to add to GlTable fields
*/
export const tableField = ({ key, label = '', thClasses = [] }) => {
export const tableField = ({ key, label = '', thClasses = [], ...options }) => {
return {
key,
label,
......@@ -32,6 +32,7 @@ export const tableField = ({ key, label = '', thClasses = [] }) => {
tdAttr: {
'data-testid': `td-${key}`,
},
...options,
};
};
......
......@@ -4,6 +4,9 @@ class Admin::RunnersController < Admin::ApplicationController
include RunnerSetupScripts
before_action :runner, except: [:index, :tag_list, :runner_setup_scripts]
before_action only: [:index] do
push_frontend_feature_flag(:admin_runners_bulk_delete, default_enabled: :yaml)
end
feature_category :runner
......
---
name: admin_runners_bulk_delete
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/81894
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/353981
milestone: '14.9'
type: development
group: group::runner
default_enabled: false
......@@ -32178,6 +32178,16 @@ msgstr ""
msgid "Runners|%{percentage} spot."
msgstr ""
msgid "Runners|%{strongStart}%{count}%{strongEnd} runner selected"
msgid_plural "Runners|%{strongStart}%{count}%{strongEnd} runners selected"
msgstr[0] ""
msgstr[1] ""
msgid "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?"
msgid_plural "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?"
msgstr[0] ""
msgstr[1] ""
msgid "Runners|A capacity of 1 enables warm HA through Auto Scaling group re-spawn. A capacity of 2 enables hot HA because the service is available even when a node is lost. A capacity of 3 or more enables hot HA and manual scaling of runner fleet."
msgstr ""
......@@ -32223,9 +32233,15 @@ msgstr ""
msgid "Runners|Change to project runner"
msgstr ""
msgid "Runners|Checkbox"
msgstr ""
msgid "Runners|Choose your preferred GitLab Runner"
msgstr ""
msgid "Runners|Clear selection"
msgstr ""
msgid "Runners|Command to register runner"
msgstr ""
......@@ -32238,12 +32254,20 @@ msgstr ""
msgid "Runners|Copy registration token"
msgstr ""
msgid "Runners|Delete %d runner"
msgid_plural "Runners|Delete %d runners"
msgstr[0] ""
msgstr[1] ""
msgid "Runners|Delete runner"
msgstr ""
msgid "Runners|Delete runner %{name}?"
msgstr ""
msgid "Runners|Delete selected"
msgstr ""
msgid "Runners|Deploy GitLab Runner in AWS"
msgstr ""
......@@ -32334,6 +32358,11 @@ msgstr ""
msgid "Runners|Paused"
msgstr ""
msgid "Runners|Permanently delete %d runner"
msgid_plural "Runners|Permanently delete %d runners"
msgstr[0] ""
msgstr[1] ""
msgid "Runners|Platform"
msgstr ""
......
......@@ -13,6 +13,7 @@ import { createAlert } from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { updateHistory } from '~/lib/utils/url_utility';
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';
......@@ -43,6 +44,7 @@ import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered
import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockRunners = runnersData.data.runners.nodes;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
......@@ -58,6 +60,8 @@ describe('AdminRunnersApp', () => {
let wrapper;
let mockRunnersQuery;
let mockRunnersCountQuery;
let cacheConfig;
let localMutations;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRunnerActionsCell = () => wrapper.findComponent(RunnerActionsCell);
......@@ -69,18 +73,30 @@ describe('AdminRunnersApp', () => {
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
const createComponent = ({
props = {},
mountFn = shallowMountExtended,
provide,
...options
} = {}) => {
({ cacheConfig, localMutations } = createLocalState());
const handlers = [
[adminRunnersQuery, mockRunnersQuery],
[adminRunnersCountQuery, mockRunnersCountQuery],
];
wrapper = mountFn(AdminRunnersApp, {
apolloProvider: createMockApollo(handlers),
apolloProvider: createMockApollo(handlers, {}, cacheConfig),
propsData: {
registrationToken: mockRegistrationToken,
...props,
},
provide: {
localMutations,
...provide,
},
...options,
});
};
......@@ -173,7 +189,7 @@ describe('AdminRunnersApp', () => {
});
it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes);
expect(findRunnerList().props('runners')).toEqual(mockRunners);
});
it('runner item links to the runner admin page', async () => {
......@@ -181,7 +197,7 @@ describe('AdminRunnersApp', () => {
await waitForPromises();
const { id, shortSha } = runnersData.data.runners.nodes[0];
const { id, shortSha } = mockRunners[0];
const numericId = getIdFromGraphQLId(id);
const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink);
......@@ -197,7 +213,7 @@ describe('AdminRunnersApp', () => {
const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
const runner = runnersData.data.runners.nodes[0];
const runner = mockRunners[0];
expect(runnerActions.props()).toEqual({
runner,
......@@ -232,8 +248,7 @@ describe('AdminRunnersApp', () => {
describe('Single runner row', () => {
let showToast;
const mockRunner = runnersData.data.runners.nodes[0];
const { id: graphqlId, shortSha } = mockRunner;
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
......@@ -333,6 +348,41 @@ describe('AdminRunnersApp', () => {
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when bulk delete is enabled', () => {
beforeEach(() => {
createComponent({
provide: {
glFeatures: { adminRunnersBulkDelete: 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,
});
});
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({
......
import Vue from 'vue';
import { GlSprintf } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
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 { 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');
describe('RunnerBulkDelete', () => {
let wrapper;
let mockState;
let mockCheckedRunnerIds;
const findClearBtn = () => wrapper.findByTestId('clear-btn');
const findDeleteBtn = () => wrapper.findByTestId('delete-btn');
const createComponent = () => {
const { cacheConfig, localMutations } = mockState;
wrapper = shallowMountExtended(RunnerBulkDelete, {
apolloProvider: createMockApollo(undefined, undefined, cacheConfig),
provide: {
localMutations,
},
stubs: {
GlSprintf,
},
});
};
beforeEach(() => {
mockState = createLocalState();
jest
.spyOn(mockState.cacheConfig.typePolicies.Query.fields, 'checkedRunnerIds')
.mockImplementation(() => mockCheckedRunnerIds);
});
afterEach(() => {
wrapper.destroy();
});
describe('When no runners are checked', () => {
beforeEach(async () => {
mockCheckedRunnerIds = [];
createComponent();
await waitForPromises();
});
it('shows no contents', () => {
expect(wrapper.html()).toBe('');
});
});
describe.each`
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 }) => {
beforeEach(() => {
mockCheckedRunnerIds = ids;
createComponent();
jest.spyOn(mockState.localMutations, 'clearChecked').mockImplementation(() => {});
});
it(`shows "${text}"`, () => {
expect(wrapper.text()).toContain(text);
});
it('clears selection', () => {
expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(0);
findClearBtn().vm.$emit('click');
expect(mockState.localMutations.clearChecked).toHaveBeenCalledTimes(1);
});
it('shows confirmation modal', () => {
expect(confirmAction).toHaveBeenCalledTimes(0);
findDeleteBtn().vm.$emit('click');
expect(confirmAction).toHaveBeenCalledTimes(1);
const [, confirmOptions] = confirmAction.mock.calls[0];
const { title, modalHtmlMessage, primaryBtnText } = confirmOptions;
expect(title).toMatch(text);
expect(primaryBtnText).toMatch(text);
expect(modalHtmlMessage).toMatch(`<strong>${count}</strong>`);
});
});
});
......@@ -55,7 +55,7 @@ describe('RunnerList', () => {
});
it('Sets runner id as a row key', () => {
createComponent({});
createComponent();
expect(findTable().attributes('primary-key')).toBe('id');
});
......@@ -90,6 +90,35 @@ describe('RunnerList', () => {
expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
});
describe('When the list is checkable', () => {
beforeEach(() => {
createComponent(
{
props: {
checkable: true,
},
},
mountExtended,
);
});
it('Displays a checkbox field', () => {
expect(findCell({ fieldKey: 'checkbox' }).find('input').exists()).toBe(true);
});
it('Emits a checked event', () => {
const checkbox = findCell({ fieldKey: 'checkbox' }).find('input');
checkbox.setChecked();
expect(wrapper.emitted('checked')).toHaveLength(1);
expect(wrapper.emitted('checked')[0][0]).toEqual({
isChecked: true,
runner: mockRunners[0],
});
});
});
describe('Scoped cell slots', () => {
it('Render #runner-name slot in "summary" cell', () => {
createComponent(
......
import createApolloClient from '~/lib/graphql';
import { createLocalState } from '~/runner/graphql/list/local_state';
import getCheckedRunnerIdsQuery from '~/runner/graphql/list/checked_runner_ids.query.graphql';
describe('~/runner/graphql/list/local_state', () => {
let localState;
let apolloClient;
const createSubject = () => {
if (apolloClient) {
throw new Error('test subject already exists!');
}
localState = createLocalState();
const { cacheConfig, typeDefs } = localState;
apolloClient = createApolloClient({}, { cacheConfig, typeDefs });
};
const queryCheckedRunnerIds = () => {
const { checkedRunnerIds } = apolloClient.readQuery({
query: getCheckedRunnerIdsQuery,
});
return checkedRunnerIds;
};
beforeEach(() => {
createSubject();
});
afterEach(() => {
localState = null;
apolloClient = null;
});
describe('default', () => {
it('has empty checked list', () => {
expect(queryCheckedRunnerIds()).toEqual([]);
});
});
describe.each`
inputs | expected
${[['a', true], ['b', true], ['b', true]]} | ${['a', 'b']}
${[['a', true], ['b', true], ['a', false]]} | ${['b']}
${[['c', true], ['b', true], ['a', true], ['d', false]]} | ${['c', 'b', 'a']}
`('setRunnerChecked', ({ inputs, expected }) => {
beforeEach(() => {
inputs.forEach(([id, isChecked]) => {
localState.localMutations.setRunnerChecked({ runner: { id }, isChecked });
});
});
it(`for inputs="${inputs}" has a ids="[${expected}]"`, () => {
expect(queryCheckedRunnerIds()).toEqual(expected);
});
});
describe('clearChecked', () => {
it('clears all checked items', () => {
['a', 'b', 'c'].forEach((id) => {
localState.localMutations.setRunnerChecked({ runner: { id }, isChecked: true });
});
expect(queryCheckedRunnerIds()).toEqual(['a', 'b', 'c']);
localState.localMutations.clearChecked();
expect(queryCheckedRunnerIds()).toEqual([]);
});
});
});
......@@ -44,6 +44,10 @@ describe('~/runner/utils', () => {
thClass: expect.arrayContaining(mockClasses),
});
});
it('a field with custom options', () => {
expect(tableField({ foo: 'bar' })).toMatchObject({ foo: 'bar' });
});
});
describe('getPaginationVariables', () => {
......
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