Skip to content
Snippets Groups Projects
Commit bd6da5e9 authored by Miguel Rincon's avatar Miguel Rincon
Browse files

Add filtered search to runner list

Thi changes adds filtered search to runners.
parent 19d68686
No related branches found
No related tags found
No related merge requests found
Showing
with 624 additions and 12 deletions
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
CREATED_DESC,
CREATED_ASC,
CONTACTED_DESC,
CONTACTED_ASC,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
} from '../constants';
const searchTokens = [
{
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: GlFilteredSearchToken,
// TODO Get more than one value when GraphQL API supports OR for "status"
unique: true,
options: [
{ value: STATUS_ACTIVE, title: s__('Runners|Active') },
{ value: STATUS_PAUSED, title: s__('Runners|Paused') },
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
// Added extra quotes in this title to avoid splitting this value:
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
{ value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
],
// TODO In principle we could support more complex search rules,
// this can be added to a separate issue.
operators: OPERATOR_IS_ONLY,
},
{
icon: 'file-tree',
title: __('Type'),
type: PARAM_KEY_RUNNER_TYPE,
token: GlFilteredSearchToken,
// TODO Get more than one value when GraphQL API supports OR for "status"
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|shared') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|specific') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
},
// TODO Support tags
];
const sortOptions = [
{
id: 1,
title: __('Created date'),
sortDirection: {
descending: CREATED_DESC,
ascending: CREATED_ASC,
},
},
{
id: 2,
title: __('Last contact'),
sortDirection: {
descending: CONTACTED_DESC,
ascending: CONTACTED_ASC,
},
},
];
export default {
components: {
FilteredSearch,
},
props: {
value: {
type: Object,
required: true,
validator(val) {
return Array.isArray(val?.filters) && typeof val?.sort === 'string';
},
},
},
data() {
// filtered_search_bar_root.vue may mutate the inital
// filters. Use `cloneDeep` to prevent those mutations
// from affecting this component
const { filters, sort } = cloneDeep(this.value);
return {
initialFilterValue: filters,
initialSortBy: sort,
};
},
methods: {
onFilter(filters) {
const { sort } = this.value;
this.$emit('input', {
filters,
sort,
});
},
onSort(sort) {
const { filters } = this.value;
this.$emit('input', {
filters,
sort,
});
},
},
sortOptions,
searchTokens,
};
</script>
<template>
<filtered-search
v-bind="$attrs"
recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
:tokens="$options.searchTokens"
:search-input-placeholder="__('Search or filter results...')"
@onFilter="onFilter"
@onSort="onSort"
/>
</template>
Loading
Loading
@@ -95,8 +95,8 @@ export default {
stacked="md"
fixed
>
<template #table-busy>
<gl-skeleton-loader />
<template v-if="!runners.length" #table-busy>
<gl-skeleton-loader v-for="i in 4" :key="i" />
</template>
 
<template #cell(type)="{ item }">
Loading
Loading
Loading
Loading
@@ -4,8 +4,33 @@ export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
 
export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
 
// Filtered search parameter names
// - Used for URL params names
// - GlFilteredSearch tokens type
export const PARAM_KEY_STATUS = 'status';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_SORT = 'sort';
// CiRunnerType
 
export const INSTANCE_TYPE = 'INSTANCE_TYPE';
export const GROUP_TYPE = 'GROUP_TYPE';
export const PROJECT_TYPE = 'PROJECT_TYPE';
// CiRunnerStatus
export const STATUS_ACTIVE = 'ACTIVE';
export const STATUS_PAUSED = 'PAUSED';
export const STATUS_ONLINE = 'ONLINE';
export const STATUS_OFFLINE = 'OFFLINE';
export const STATUS_NOT_CONNECTED = 'NOT_CONNECTED';
// CiRunnerSort
export const CREATED_DESC = 'CREATED_DESC';
export const CREATED_ASC = 'CREATED_ASC'; // TODO Add this to the API
export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC;
query getRunners {
runners {
query getRunners($status: CiRunnerStatus, $type: CiRunnerType, $sort: CiRunnerSort) {
runners(status: $status, type: $type, sort: $sort) {
nodes {
id
description
Loading
Loading
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import {
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_SORT,
DEFAULT_SORT,
} from '../constants';
const getValuesFromFilters = (paramKey, filters) => {
return filters
.filter(({ type, value }) => type === paramKey && value.operator === '=')
.map(({ value }) => value.data);
};
const getFilterFromParams = (paramKey, params) => {
const value = params[paramKey];
if (!value) {
return [];
}
const values = Array.isArray(value) ? value : [value];
return values.map((data) => {
return {
type: paramKey,
value: {
data,
operator: '=',
},
};
});
};
export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true });
return {
filters: [
...getFilterFromParams(PARAM_KEY_STATUS, params),
...getFilterFromParams(PARAM_KEY_RUNNER_TYPE, params),
],
sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
};
};
export const fromSearchToUrl = ({ filters = [], sort = null }, url = window.location.href) => {
const urlParams = {
[PARAM_KEY_STATUS]: getValuesFromFilters(PARAM_KEY_STATUS, filters),
[PARAM_KEY_RUNNER_TYPE]: getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters),
};
if (sort && sort !== DEFAULT_SORT) {
urlParams[PARAM_KEY_SORT] = sort;
}
return setUrlParams(urlParams, url, false, true, true);
};
export const fromSearchToVariables = ({ filters = [], sort = null } = {}) => {
const variables = {};
// TODO Get more than one value when GraphQL API supports OR for "status"
[variables.status] = getValuesFromFilters(PARAM_KEY_STATUS, filters);
// TODO Get more than one value when GraphQL API supports OR for "runner type"
[variables.type] = getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters);
if (sort) {
variables.sort = sort;
}
return variables;
};
<script>
import * as Sentry from '@sentry/browser';
import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from './filtered_search_utils';
 
export default {
components: {
RunnerFilteredSearchBar,
RunnerList,
RunnerManualSetupHelp,
RunnerTypeHelp,
Loading
Loading
@@ -23,12 +31,16 @@ export default {
},
data() {
return {
search: fromUrlQueryToSearch(),
runners: [],
};
},
apollo: {
runners: {
query: getRunnersQuery,
variables() {
return this.variables;
},
update({ runners }) {
return runners?.nodes || [];
},
Loading
Loading
@@ -38,6 +50,9 @@ export default {
},
},
computed: {
variables() {
return fromSearchToVariables(this.search);
},
runnersLoading() {
return this.$apollo.queries.runners.loading;
},
Loading
Loading
@@ -45,6 +60,16 @@ export default {
return !this.runnersLoading && !this.runners.length;
},
},
watch: {
search() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
},
},
errorCaptured(err) {
this.captureException(err);
},
Loading
Loading
@@ -69,6 +94,8 @@ export default {
</div>
</div>
 
<runner-filtered-search-bar v-model="search" namespace="admin_runners" />
<div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }}
</div>
Loading
Loading
Loading
Loading
@@ -28355,6 +28355,18 @@ msgstr ""
msgid "Runners|New runner, has not connected yet"
msgstr ""
 
msgid "Runners|Not connected"
msgstr ""
msgid "Runners|Offline"
msgstr ""
msgid "Runners|Online"
msgstr ""
msgid "Runners|Paused"
msgstr ""
msgid "Runners|Platform"
msgstr ""
 
Loading
Loading
import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
describe('RunnerList', () => {
let wrapper;
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC';
const mockFilters = [
{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } },
];
const createComponent = ({ props = {}, options = {} } = {}) => {
wrapper = extendedWrapper(
shallowMount(RunnerFilteredSearchBar, {
propsData: {
value: {
filters: [],
sort: mockDefaultSort,
},
...props,
},
attrs: { namespace: 'runners' },
stubs: {
FilteredSearch,
GlFilteredSearch,
GlDropdown,
GlDropdownItem,
},
...options,
}),
);
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('binds a namespace to the filtered search', () => {
expect(findFilteredSearch().props('namespace')).toBe('runners');
});
it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2;
expect(findSortOptions()).toHaveLength(SORT_OPTIONS_COUNT);
expect(findSortOptions().at(0).text()).toBe('Created date');
expect(findSortOptions().at(1).text()).toBe('Last contact');
});
it('sets tokens', () => {
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
options: expect.any(Array),
}),
]);
});
it('fails validation for v-model with the wrong shape', () => {
expect(() => {
createComponent({ props: { value: { filters: 'wrong_filters', sort: 'sort' } } });
}).toThrow('Invalid prop: custom validator check failed');
expect(() => {
createComponent({ props: { value: { sort: 'sort' } } });
}).toThrow('Invalid prop: custom validator check failed');
});
describe('when a search is preselected', () => {
beforeEach(() => {
createComponent({
props: {
value: {
sort: mockOtherSort,
filters: mockFilters,
},
},
});
});
it('filter values are shown', () => {
expect(findGlFilteredSearch().props('value')).toEqual(mockFilters);
});
it('sort option is selected', () => {
expect(
findSortOptions()
.filter((w) => w.props('isChecked'))
.at(0)
.text(),
).toEqual('Last contact');
});
});
it('when the user sets a filter, the "search" is emitted with filters', () => {
findGlFilteredSearch().vm.$emit('input', mockFilters);
findGlFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('input')[0]).toEqual([
{
filters: mockFilters,
sort: mockDefaultSort,
},
]);
});
it('when the user sets a sorting method, the "search" is emitted with the sort', () => {
findSortOptions().at(1).vm.$emit('click');
expect(wrapper.emitted('input')[0]).toEqual([
{
filters: [],
sort: mockOtherSort,
},
]);
});
});
import { GlLink, GlSkeletonLoader } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlLink, GlTable, GlSkeletonLoader } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue';
Loading
Loading
@@ -13,14 +13,15 @@ describe('RunnerList', () => {
 
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message');
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findTable = () => wrapper.findComponent(GlTable);
const findHeaders = () => wrapper.findAll('th');
const findRows = () => wrapper.findAll('[data-testid^="runner-row-"]');
const findCell = ({ row = 0, fieldKey }) =>
findRows().at(row).find(`[data-testid="td-${fieldKey}"]`);
 
const createComponent = ({ props = {} } = {}) => {
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
wrapper = extendedWrapper(
mount(RunnerList, {
mountFn(RunnerList, {
propsData: {
runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount,
Loading
Loading
@@ -31,7 +32,7 @@ describe('RunnerList', () => {
};
 
beforeEach(() => {
createComponent();
createComponent({}, mount);
});
 
afterEach(() => {
Loading
Loading
@@ -104,12 +105,21 @@ describe('RunnerList', () => {
});
 
describe('When data is loading', () => {
beforeEach(() => {
createComponent({ props: { loading: true } });
it('shows a busy state', () => {
createComponent({ props: { runners: [], loading: true } });
expect(findTable().attributes('busy')).toBeTruthy();
});
 
it('shows an skeleton loader', () => {
it('when there are no runners, shows an skeleton loader', () => {
createComponent({ props: { runners: [], loading: true } }, mount);
expect(findSkeletonLoader().exists()).toBe(true);
});
it('when there are runners, shows a busy indicator skeleton loader', () => {
createComponent({ props: { loading: true } }, mount);
expect(findSkeletonLoader().exists()).toBe(false);
});
});
});
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from '~/runner/runner_list/filtered_search_utils';
describe('search_params.js', () => {
const examples = [
{
name: 'a default query',
urlQuery: '',
search: { filters: [], sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC' },
},
{
name: 'a single status',
urlQuery: '?status[]=ACTIVE',
search: {
filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC' },
},
{
name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE',
search: {
filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }],
sort: 'CREATED_DESC',
},
graphqlVariables: { type: 'INSTANCE_TYPE', sort: 'CREATED_DESC' },
},
{
name: 'multiple runner status',
urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
search: {
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'status', value: { data: 'PAUSED', operator: '=' } },
],
sort: 'CREATED_DESC',
},
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC' },
},
{
name: 'multiple status, a single instance type and a non default sort',
urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
search: {
filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } },
],
sort: 'CREATED_ASC',
},
graphqlVariables: { status: 'ACTIVE', type: 'INSTANCE_TYPE', sort: 'CREATED_ASC' },
},
];
describe('fromUrlQueryToSearch', () => {
examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a search object`, () => {
expect(fromUrlQueryToSearch(urlQuery)).toEqual(search);
});
});
});
describe('fromSearchToUrl', () => {
examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a url`, () => {
expect(fromSearchToUrl(search)).toEqual(`http://test.host/${urlQuery}`);
});
});
it('When a filtered search parameter is already present, it gets removed', () => {
const initialUrl = `http://test.host/?status[]=ACTIVE`;
const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/`;
expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl);
});
it('When unrelated search parameter is present, it does not get removed', () => {
const initialUrl = `http://test.host/?unrelated=UNRELATED&status[]=ACTIVE`;
const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/?unrelated=UNRELATED`;
expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl);
});
});
describe('fromSearchToVariables', () => {
examples.forEach(({ name, graphqlVariables, search }) => {
it(`Converts ${name} to a GraphQL query variables object`, () => {
expect(fromSearchToVariables(search)).toEqual(graphqlVariables);
});
});
});
});
Loading
Loading
@@ -2,12 +2,22 @@ import * as Sentry from '@sentry/browser';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import { updateHistory } from '~/lib/utils/url_utility';
 
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
 
import {
CREATED_ASC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
STATUS_ACTIVE,
} from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import RunnerListApp from '~/runner/runner_list/runner_list_app.vue';
 
Loading
Loading
@@ -18,6 +28,10 @@ const mockActiveRunnersCount = 2;
const mocKRunners = runnersData.data.runners.nodes;
 
jest.mock('@sentry/browser');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
}));
 
const localVue = createLocalVue();
localVue.use(VueApollo);
Loading
Loading
@@ -25,10 +39,12 @@ localVue.use(VueApollo);
describe('RunnerListApp', () => {
let wrapper;
let mockRunnersQuery;
let originalLocation;
 
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
 
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]];
Loading
Loading
@@ -44,7 +60,23 @@ describe('RunnerListApp', () => {
});
};
 
const setQuery = (query) => {
window.location.href = `${TEST_HOST}/admin/runners/${query}`;
window.location.search = query;
};
beforeAll(() => {
originalLocation = window.location;
Object.defineProperty(window, 'location', { writable: true, value: { href: '', search: '' } });
});
afterAll(() => {
window.location = originalLocation;
});
beforeEach(async () => {
setQuery('');
Sentry.withScope.mockImplementation((fn) => {
const scope = { setTag: jest.fn() };
fn(scope);
Loading
Loading
@@ -64,6 +96,14 @@ describe('RunnerListApp', () => {
expect(mocKRunners).toMatchObject(findRunnerList().props('runners'));
});
 
it('requests the runners with no filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
});
});
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
});
Loading
Loading
@@ -73,6 +113,56 @@ describe('RunnerListApp', () => {
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
 
describe('when a filter is preselected', () => {
beforeEach(async () => {
window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`;
createComponentWithApollo();
await waitForPromises();
});
it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({
filters: [
{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
],
sort: 'CREATED_DESC',
});
});
it('requests the runners with filter parameters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
});
});
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }],
sort: CREATED_ASC,
});
});
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
sort: CREATED_ASC,
});
});
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } });
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment