Commit 6f2ecbd3 authored by Miguel Rincon's avatar Miguel Rincon

Filter and search a list of group runners

This change adds the capability of filtering and pagination for group
runners, group administrators can search and locate their runners.
parent 61508b55
...@@ -2,12 +2,16 @@ ...@@ -2,12 +2,16 @@
import createFlash from '~/flash'; import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility'; import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, __ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue'; import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue'; import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue'; import RunnerTypeHelp from '../components/runner_type_help.vue';
import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql'; import getRunnersQuery from '../graphql/get_runners.query.graphql';
import { import {
fromUrlQueryToSearch, fromUrlQueryToSearch,
...@@ -78,6 +82,21 @@ export default { ...@@ -78,6 +82,21 @@ export default {
noRunnersFound() { noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length; return !this.runnersLoading && !this.runners.items.length;
}, },
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
searchTokens() {
return [
statusTokenConfig,
typeTokenConfig,
{
...tagTokenConfig,
recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
},
];
},
}, },
watch: { watch: {
search: { search: {
...@@ -99,6 +118,7 @@ export default { ...@@ -99,6 +118,7 @@ export default {
captureException({ error, component: this.$options.name }); captureException({ error, component: this.$options.name });
}, },
}, },
filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE,
INSTANCE_TYPE, INSTANCE_TYPE,
}; };
</script> </script>
...@@ -118,9 +138,13 @@ export default { ...@@ -118,9 +138,13 @@ export default {
<runner-filtered-search-bar <runner-filtered-search-bar
v-model="search" v-model="search"
namespace="admin_runners" :tokens="searchTokens"
:active-runners-count="activeRunnersCount" :namespace="$options.filteredSearchNamespace"
/> >
<template #runner-count>
{{ activeRunnersMessage }}
</template>
</runner-filtered-search-bar>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5"> <div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }} {{ __('No runners found') }}
......
<script> <script>
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { formatNumber, sprintf, __, s__ } from '~/locale'; import { __ } 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 FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants';
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,
PARAM_KEY_TAG,
} from '../constants';
import TagToken from './search_tokens/tag_token.vue';
const sortOptions = [ const sortOptions = [
{ {
...@@ -58,10 +39,6 @@ export default { ...@@ -58,10 +39,6 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
activeRunnersCount: {
type: Number,
required: true,
},
}, },
data() { data() {
// filtered_search_bar_root.vue may mutate the inital // filtered_search_bar_root.vue may mutate the inital
...@@ -73,62 +50,6 @@ export default { ...@@ -73,62 +50,6 @@ export default {
initialSortBy: sort, initialSortBy: sort,
}; };
}, },
computed: {
searchTokens() {
return [
{
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: BaseToken,
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: BaseToken,
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|instance') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|project') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
},
{
icon: 'tag',
title: s__('Runners|Tags'),
type: PARAM_KEY_TAG,
token: TagToken,
recentTokenValuesStorageKey: `${this.namespace}-recent-tags`,
operators: OPERATOR_IS_ONLY,
},
];
},
activeRunnersMessage() {
return sprintf(__('Runners currently online: %{active_runners_count}'), {
active_runners_count: formatNumber(this.activeRunnersCount),
});
},
},
methods: { methods: {
onFilter(filters) { onFilter(filters) {
const { sort } = this.value; const { sort } = this.value;
...@@ -161,12 +82,13 @@ export default { ...@@ -161,12 +82,13 @@ export default {
:sort-options="$options.sortOptions" :sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue" :initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy" :initial-sort-by="initialSortBy"
:tokens="searchTokens"
:search-input-placeholder="__('Search or filter results...')" :search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search" data-testid="runners-filtered-search"
@onFilter="onFilter" @onFilter="onFilter"
@onSort="onSort" @onSort="onSort"
/> />
<div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div> <div class="gl-text-right" data-testid="runner-count">
<slot name="runner-count"></slot>
</div>
</div> </div>
</template> </template>
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_NOT_CONNECTED,
PARAM_KEY_STATUS,
} from '../../constants';
export const statusTokenConfig = {
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: BaseToken,
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,
};
...@@ -33,6 +33,7 @@ export default { ...@@ -33,6 +33,7 @@ export default {
// The API should // The API should
// 1) scope to the rights of the user // 1) scope to the rights of the user
// 2) stay up to date to the removal of old tags // 2) stay up to date to the removal of old tags
// 3) consider the scope of search, like searching within the tags of a group
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796
return axios return axios
.get(TAG_SUGGESTIONS_PATH, { .get(TAG_SUGGESTIONS_PATH, {
......
import { s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { PARAM_KEY_TAG } from '../../constants';
import TagToken from './tag_token.vue';
export const tagTokenConfig = {
icon: 'tag',
title: s__('Runners|Tags'),
type: PARAM_KEY_TAG,
token: TagToken,
operators: OPERATOR_IS_ONLY,
};
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, PARAM_KEY_RUNNER_TYPE } from '../../constants';
export const typeTokenConfig = {
icon: 'file-tree',
title: __('Type'),
type: PARAM_KEY_RUNNER_TYPE,
token: BaseToken,
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|instance') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|project') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
};
...@@ -2,6 +2,7 @@ import { s__ } from '~/locale'; ...@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20; export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000; export const RUNNER_JOB_COUNT_LIMIT = 1000;
export const GROUP_RUNNER_COUNT_LIMIT = 1000;
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
...@@ -50,3 +51,8 @@ export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API ...@@ -50,3 +51,8 @@ export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
export const CONTACTED_ASC = 'CONTACTED_ASC'; export const CONTACTED_ASC = 'CONTACTED_ASC';
export const DEFAULT_SORT = CREATED_DESC; export const DEFAULT_SORT = CREATED_DESC;
// Local storage namespaces
export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners';
export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners';
#import "~/runner/graphql/runner_node.fragment.graphql" #import "~/runner/graphql/runner_node.fragment.graphql"
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getGroupRunners($groupFullPath: ID!) { query getGroupRunners(
$groupFullPath: ID!
$before: String
$after: String
$first: Int
$last: Int
$status: CiRunnerStatus
$type: CiRunnerType
$search: String
$sort: CiRunnerSort
) {
group(fullPath: $groupFullPath) { group(fullPath: $groupFullPath) {
runners(membership: DESCENDANTS) { runners(
membership: DESCENDANTS
before: $before
after: $after
first: $first
last: $last
status: $status
type: $type
search: $search
sort: $sort
) {
nodes { nodes {
...RunnerNode ...RunnerNode
} }
pageInfo {
...PageInfo
}
} }
} }
} }
<script> <script>
import createFlash from '~/flash'; import createFlash from '~/flash';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import { updateHistory } from '~/lib/utils/url_utility';
import { formatNumber, sprintf, s__ } from '~/locale';
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue'; import RunnerList from '../components/runner_list.vue';
import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeHelp from '../components/runner_type_help.vue'; import RunnerTypeHelp from '../components/runner_type_help.vue';
import { I18N_FETCH_ERROR, GROUP_TYPE } from '../constants'; import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import {
I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
GROUP_RUNNER_COUNT_LIMIT,
} from '../constants';
import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql'; import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from '../runner_search_utils';
import { captureException } from '../sentry_utils'; import { captureException } from '../sentry_utils';
export default { export default {
name: 'GroupRunnersApp', name: 'GroupRunnersApp',
components: { components: {
RunnerFilteredSearchBar,
RunnerList, RunnerList,
RunnerManualSetupHelp, RunnerManualSetupHelp,
RunnerTypeHelp, RunnerTypeHelp,
RunnerPagination,
}, },
props: { props: {
registrationToken: { registrationToken: {
...@@ -24,11 +42,17 @@ export default { ...@@ -24,11 +42,17 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
groupRunnersLimitedCount: {
type: Number,
required: true,
},
}, },
data() { data() {
return { return {
search: fromUrlQueryToSearch(),
runners: { runners: {
items: [], items: [],
pageInfo: {},
}, },
}; };
}, },
...@@ -43,9 +67,10 @@ export default { ...@@ -43,9 +67,10 @@ export default {
return this.variables; return this.variables;
}, },
update(data) { update(data) {
const { runners } = data?.group; const { runners } = data?.group || {};
return { return {
items: runners?.nodes || [], items: runners?.nodes || [],
pageInfo: runners?.pageInfo || {},
}; };
}, },
error(error) { error(error) {
...@@ -58,6 +83,7 @@ export default { ...@@ -58,6 +83,7 @@ export default {
computed: { computed: {
variables() { variables() {
return { return {
...fromSearchToVariables(this.search),
groupFullPath: this.groupFullPath, groupFullPath: this.groupFullPath,
}; };
}, },
...@@ -67,6 +93,35 @@ export default { ...@@ -67,6 +93,35 @@ export default {
noRunnersFound() { noRunnersFound() {
return !this.runnersLoading && !this.runners.items.length; return !this.runnersLoading && !this.runners.items.length;
}, },
groupRunnersCount() {
if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) {
return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`;
}
return formatNumber(this.groupRunnersLimitedCount);
},
runnerCountMessage() {
return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), {
groupRunnersCount: this.groupRunnersCount,
});
},
searchTokens() {
return [statusTokenConfig, typeTokenConfig];
},
filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
},
},
watch: {
search: {
deep: true,
handler() {
// TODO Implement back button reponse using onpopstate
updateHistory({
url: fromSearchToUrl(this.search),
title: document.title,
});
},
},
}, },
errorCaptured(error) { errorCaptured(error) {
this.reportToSentry(error); this.reportToSentry(error);
...@@ -94,11 +149,22 @@ export default { ...@@ -94,11 +149,22 @@ export default {
</div> </div>
</div> </div>
<runner-filtered-search-bar
v-model="search"
:tokens="searchTokens"
:namespace="filteredSearchNamespace"
>
<template #runner-count>
{{ runnerCountMessage }}
</template>
</runner-filtered-search-bar>
<div v-if="noRunnersFound" class="gl-text-center gl-p-5"> <div v-if="noRunnersFound" class="gl-text-center gl-p-5">
{{ __('No runners found') }} {{ __('No runners found') }}
</div> </div>
<template v-else> <template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading" /> <runner-list :runners="runners.items" :loading="runnersLoading" />
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" />
</template> </template>
</div> </div>
</template> </template>
...@@ -12,7 +12,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => { ...@@ -12,7 +12,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
return null; return null;
} }
const { registrationToken, runnerInstallHelpPage, groupId, groupFullPath } = el.dataset; const {
registrationToken,
runnerInstallHelpPage,
groupId,
groupFullPath,
groupRunnersLimitedCount,
} = el.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient( defaultClient: createDefaultClient(
...@@ -35,6 +41,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => { ...@@ -35,6 +41,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
props: { props: {
registrationToken, registrationToken,
groupFullPath, groupFullPath,
groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10),
}, },
}); });
}, },
......
...@@ -10,6 +10,8 @@ class Groups::RunnersController < Groups::ApplicationController ...@@ -10,6 +10,8 @@ class Groups::RunnersController < Groups::ApplicationController
feature_category :runner feature_category :runner
def index def index
finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group })
@group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000)
end end
def runner_list_group_view_vue_ui_enabled def runner_list_group_view_vue_ui_enabled
......
...@@ -3,4 +3,4 @@ ...@@ -3,4 +3,4 @@
%h2.page-title %h2.page-title
= s_('Runners|Group Runners') = s_('Runners|Group Runners')
#js-group-runners{ data: { registration_token: @group.runners_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', group_id: @group.id, group_full_path: @group.full_path } } #js-group-runners{ data: { registration_token: @group.runners_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', group_id: @group.id, group_full_path: @group.full_path, group_runners_limited_count: @group_runners_limited_count } }
...@@ -28892,6 +28892,9 @@ msgstr "" ...@@ -28892,6 +28892,9 @@ msgstr ""
msgid "Runners|Runners" msgid "Runners|Runners"
msgstr "" msgstr ""
msgid "Runners|Runners in this group: %{groupRunnersCount}"
msgstr ""
msgid "Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner." msgid "Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner."
msgstr "" msgstr ""
......
...@@ -3,11 +3,13 @@ ...@@ -3,11 +3,13 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Groups::RunnersController do RSpec.describe Groups::RunnersController do
let(:user) { create(:user) } let_it_be(:user) { create(:user) }
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let(:runner) { create(:ci_runner, :group, groups: [group]) } let_it_be(:project) { create(:project, group: group) }
let(:project) { create(:project, group: group) }
let(:runner_project) { create(:ci_runner, :project, projects: [project]) } let!(:runner) { create(:ci_runner, :group, groups: [group]) }
let!(:runner_project) { create(:ci_runner, :project, projects: [project]) }
let(:params_runner_project) { { group_id: group, id: runner_project } } let(:params_runner_project) { { group_id: group, id: runner_project } }
let(:params) { { group_id: group, id: runner } } let(:params) { { group_id: group, id: runner } }
...@@ -26,6 +28,7 @@ RSpec.describe Groups::RunnersController do ...@@ -26,6 +28,7 @@ RSpec.describe Groups::RunnersController do
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response).to render_template(:index) expect(response).to render_template(:index)
expect(assigns(:group_runners_limited_count)).to be(2)
end end
end end
......
...@@ -94,5 +94,14 @@ RSpec.describe 'Runner (JavaScript fixtures)' do ...@@ -94,5 +94,14 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty expect_graphql_errors_to_be_empty
end end
it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path,
first: 1
})
expect_graphql_errors_to_be_empty
end
end end
end end
...@@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; ...@@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { updateHistory } from '~/lib/utils/url_utility'; import { updateHistory } from '~/lib/utils/url_utility';
...@@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue'; ...@@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import { import {
ADMIN_FILTERED_SEARCH_NAMESPACE,
CREATED_ASC, CREATED_ASC,
CREATED_DESC, CREATED_DESC,
DEFAULT_SORT, DEFAULT_SORT,
INSTANCE_TYPE, INSTANCE_TYPE,
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
STATUS_ACTIVE, STATUS_ACTIVE,
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
} from '~/runner/constants'; } from '~/runner/constants';
import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql';
import { captureException } from '~/runner/sentry_utils'; import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { runnersData, runnersDataPaginated } from '../mock_data'; import { runnersData, runnersDataPaginated } from '../mock_data';
...@@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => { ...@@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => {
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
findRunnerPagination().findByLabelText('Go to previous page');
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getRunnersQuery, mockRunnersQuery]]; const handlers = [[getRunnersQuery, mockRunnersQuery]];
wrapper = mountFn(AdminRunnersApp, { wrapper = mountFn(AdminRunnersApp, {
...@@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => { ...@@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => {
setWindowLocation('/admin/runners'); setWindowLocation('/admin/runners');
mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData);
createComponentWithApollo(); createComponent();
await waitForPromises(); await waitForPromises();
}); });
...@@ -77,6 +86,14 @@ describe('AdminRunnersApp', () => { ...@@ -77,6 +86,14 @@ describe('AdminRunnersApp', () => {
wrapper.destroy(); wrapper.destroy();
}); });
it('shows the runner type help', () => {
expect(findRunnerTypeHelp().exists()).toBe(true);
});
it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
});
it('shows the runners list', () => { it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes);
}); });
...@@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => { ...@@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => {
}); });
}); });
it('shows the runner type help', () => { it('sets tokens in the filtered search', () => {
expect(findRunnerTypeHelp().exists()).toBe(true); createComponent({ mountFn: mount });
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),
}),
expect.objectContaining({
type: PARAM_KEY_TAG,
recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
}),
]);
}); });
it('shows the runner setup instructions', () => { it('shows the active runner count', () => {
expect(findRunnerManualSetupHelp().exists()).toBe(true); createComponent({ mountFn: mount });
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRunnerFilteredSearchBar().text()).toMatch(
`Runners currently online: ${mockActiveRunnersCount}`,
);
}); });
describe('when a filter is preselected', () => { describe('when a filter is preselected', () => {
beforeEach(async () => { beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
createComponentWithApollo(); createComponent();
await waitForPromises(); await waitForPromises();
}); });
...@@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => { ...@@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => {
describe('when a filter is selected by the user', () => { describe('when a filter is selected by the user', () => {
beforeEach(() => { beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', { findRunnerFilteredSearchBar().vm.$emit('input', {
filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC, sort: CREATED_ASC,
}); });
}); });
...@@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => { ...@@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => {
}); });
}); });
it('when runners have not loaded, shows a loading state', () => {
createComponent();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when no runners are found', () => { describe('when no runners are found', () => {
beforeEach(async () => { beforeEach(async () => {
mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); mockRunnersQuery = jest.fn().mockResolvedValue({
createComponentWithApollo(); data: {
await waitForPromises(); runners: { nodes: [] },
},
});
createComponent();
}); });
it('shows a message for no results', async () => { it('shows a message for no results', async () => {
...@@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => { ...@@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => {
}); });
}); });
it('when runners have not loaded, shows a loading state', () => {
createComponentWithApollo();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when runners query fails', () => { describe('when runners query fails', () => {
beforeEach(async () => { beforeEach(() => {
mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponentWithApollo(); createComponent();
});
await waitForPromises(); it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
}); });
it('error is reported to sentry', async () => { it('error is reported to sentry', async () => {
...@@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => { ...@@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => {
component: 'AdminRunnersApp', component: 'AdminRunnersApp',
}); });
}); });
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
}); });
describe('Pagination', () => { describe('Pagination', () => {
beforeEach(() => { beforeEach(() => {
mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated);
createComponentWithApollo({ mountFn: mount }); createComponent({ mountFn: mount });
}); });
it('more pages can be selected', () => { it('more pages can be selected', () => {
...@@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => { ...@@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => {
}); });
it('cannot navigate to the previous page', () => { it('cannot navigate to the previous page', () => {
expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
}); });
it('navigates to the next page', async () => { it('navigates to the next page', async () => {
const nextPageBtn = findRunnerPagination().find('a'); await findRunnerPaginationNext().trigger('click');
expect(nextPageBtn.text()).toBe('Next');
await nextPageBtn.trigger('click');
expect(mockRunnersQuery).toHaveBeenLastCalledWith({ expect(mockRunnersQuery).toHaveBeenLastCalledWith({
sort: CREATED_DESC, sort: CREATED_DESC,
......
...@@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; ...@@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config';
import TagToken from '~/runner/components/search_tokens/tag_token.vue'; import TagToken from '~/runner/components/search_tokens/tag_token.vue';
import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants'; import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config';
import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config';
import {
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
STATUS_ACTIVE,
} from '~/runner/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
...@@ -13,12 +21,12 @@ describe('RunnerList', () => { ...@@ -13,12 +21,12 @@ describe('RunnerList', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch);
const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem);
const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count');
const mockDefaultSort = 'CREATED_DESC'; const mockDefaultSort = 'CREATED_DESC';
const mockOtherSort = 'CONTACTED_DESC'; const mockOtherSort = 'CONTACTED_DESC';
const mockFilters = [ const mockFilters = [
{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'filtered-search-term', value: { data: '' } }, { type: 'filtered-search-term', value: { data: '' } },
]; ];
const mockActiveRunnersCount = 2; const mockActiveRunnersCount = 2;
...@@ -28,13 +36,16 @@ describe('RunnerList', () => { ...@@ -28,13 +36,16 @@ describe('RunnerList', () => {
shallowMount(RunnerFilteredSearchBar, { shallowMount(RunnerFilteredSearchBar, {
propsData: { propsData: {
namespace: 'runners', namespace: 'runners',
tokens: [],
value: { value: {
filters: [], filters: [],
sort: mockDefaultSort, sort: mockDefaultSort,
}, },
activeRunnersCount: mockActiveRunnersCount,
...props, ...props,
}, },
slots: {
'runner-count': `Runners currently online: ${mockActiveRunnersCount}`,
},
stubs: { stubs: {
FilteredSearch, FilteredSearch,
GlFilteredSearch, GlFilteredSearch,
...@@ -64,12 +75,6 @@ describe('RunnerList', () => { ...@@ -64,12 +75,6 @@ describe('RunnerList', () => {
); );
}); });
it('Displays a large active runner count', () => {
createComponent({ props: { activeRunnersCount: 2000 } });
expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000');
});
it('sets sorting options', () => { it('sets sorting options', () => {
const SORT_OPTIONS_COUNT = 2; const SORT_OPTIONS_COUNT = 2;
...@@ -78,7 +83,13 @@ describe('RunnerList', () => { ...@@ -78,7 +83,13 @@ describe('RunnerList', () => {
expect(findSortOptions().at(1).text()).toBe('Last contact'); expect(findSortOptions().at(1).text()).toBe('Last contact');
}); });
it('sets tokens', () => { it('sets tokens to the filtered search', () => {
createComponent({
props: {
tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig],
},
});
expect(findFilteredSearch().props('tokens')).toEqual([ expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({ expect.objectContaining({
type: PARAM_KEY_STATUS, type: PARAM_KEY_STATUS,
......
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
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 RunnerList from '~/runner/components/runner_list.vue';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue';
import {
CREATED_ASC,
CREATED_DESC,
DEFAULT_SORT,
INSTANCE_TYPE,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
STATUS_ACTIVE,
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
import { groupRunnersData } from '../mock_data'; import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
const mockGroupFullPath = 'group1'; const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC'; const mockRegistrationToken = 'AABBCC';
const mockRunners = groupRunnersData.data.group.runners.nodes;
const mockGroupRunnersLimitedCount = mockRunners.length;
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
jest.mock('~/lib/utils/url_utility', () => ({
...jest.requireActual('~/lib/utils/url_utility'),
updateHistory: jest.fn(),
}));
describe('GroupRunnersApp', () => { describe('GroupRunnersApp', () => {
let wrapper; let wrapper;
...@@ -22,8 +51,14 @@ describe('GroupRunnersApp', () => { ...@@ -22,8 +51,14 @@ describe('GroupRunnersApp', () => {
const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp);
const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp);
const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () =>
findRunnerPagination().findByLabelText('Go to previous page');
const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page');
const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar);
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponent = ({ mountFn = shallowMount } = {}) => { const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]]; const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]];
wrapper = mountFn(GroupRunnersApp, { wrapper = mountFn(GroupRunnersApp, {
...@@ -32,11 +67,15 @@ describe('GroupRunnersApp', () => { ...@@ -32,11 +67,15 @@ describe('GroupRunnersApp', () => {
propsData: { propsData: {
registrationToken: mockRegistrationToken, registrationToken: mockRegistrationToken,
groupFullPath: mockGroupFullPath, groupFullPath: mockGroupFullPath,
groupRunnersLimitedCount: mockGroupRunnersLimitedCount,
...props,
}, },
}); });
}; };
beforeEach(async () => { beforeEach(async () => {
setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`);
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData);
createComponent(); createComponent();
...@@ -48,11 +87,179 @@ describe('GroupRunnersApp', () => { ...@@ -48,11 +87,179 @@ describe('GroupRunnersApp', () => {
}); });
it('shows the runner setup instructions', () => { it('shows the runner setup instructions', () => {
expect(findRunnerManualSetupHelp().exists()).toBe(true);
expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken);
}); });
it('shows the runners list', () => { it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes); expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes);
}); });
it('requests the runners with group path and no other filters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: undefined,
type: undefined,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
it('sets tokens in the filtered search', () => {
createComponent({ mountFn: mount });
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),
}),
]);
});
describe('shows the active runner count', () => {
it('with a regular value', () => {
createComponent({ mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(
`Runners in this group: ${mockGroupRunnersLimitedCount}`,
);
});
it('at the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`);
});
it('over the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount });
expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`);
});
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);
createComponent();
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',
pagination: { page: 1 },
});
});
it('requests the runners with filter parameters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
});
});
describe('when a filter is selected by the user', () => {
beforeEach(() => {
findRunnerFilteredSearchBar().vm.$emit('input', {
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC,
});
});
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC',
});
});
it('requests the runners with filters', () => {
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
status: STATUS_ACTIVE,
sort: CREATED_ASC,
first: RUNNER_PAGE_SIZE,
});
});
});
it('when runners have not loaded, shows a loading state', () => {
createComponent();
expect(findRunnerList().props('loading')).toBe(true);
});
describe('when no runners are found', () => {
beforeEach(async () => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue({
data: {
group: {
runners: { nodes: [] },
},
},
});
createComponent();
});
it('shows a message for no results', async () => {
expect(wrapper.text()).toContain('No runners found');
});
});
describe('when runners query fails', () => {
beforeEach(() => {
mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!'));
createComponent();
});
it('error is shown to the user', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
});
it('error is reported to sentry', async () => {
expect(captureException).toHaveBeenCalledWith({
error: new Error('Network error: Error!'),
component: 'GroupRunnersApp',
});
});
});
describe('Pagination', () => {
beforeEach(() => {
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated);
createComponent({ mountFn: mount });
});
it('more pages can be selected', () => {
expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next');
});
it('cannot navigate to the previous page', () => {
expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true');
});
it('navigates to the next page', async () => {
await findRunnerPaginationNext().trigger('click');
expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({
groupFullPath: mockGroupFullPath,
sort: CREATED_DESC,
first: RUNNER_PAGE_SIZE,
after: groupRunnersDataPaginated.data.group.runners.pageInfo.endCursor,
});
});
});
}); });
...@@ -9,3 +9,6 @@ export const runnerData = runnerFixture('get_runner.query.graphql.json'); ...@@ -9,3 +9,6 @@ export const runnerData = runnerFixture('get_runner.query.graphql.json');
// Group queries // Group queries
export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json'); export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json');
export const groupRunnersDataPaginated = runnerFixture(
'get_group_runners.query.graphql.paginated.json',
);
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