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

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
No related branches found
No related tags found
No related merge requests found
Showing
with 529 additions and 144 deletions
Loading
@@ -2,12 +2,16 @@
Loading
@@ -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,
Loading
@@ -78,6 +82,21 @@ export default {
Loading
@@ -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: {
Loading
@@ -99,6 +118,7 @@ export default {
Loading
@@ -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>
Loading
@@ -118,9 +138,13 @@ export default {
Loading
@@ -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') }}
Loading
Loading
<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 = [
{ {
Loading
@@ -58,10 +39,6 @@ export default {
Loading
@@ -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
Loading
@@ -73,62 +50,6 @@ export default {
Loading
@@ -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;
Loading
@@ -161,12 +82,13 @@ export default {
Loading
@@ -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,
};
Loading
@@ -33,6 +33,7 @@ export default {
Loading
@@ -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, {
Loading
Loading
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,
};
Loading
@@ -2,6 +2,7 @@ import { s__ } from '~/locale';
Loading
@@ -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}');
Loading
@@ -50,3 +51,8 @@ export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API
Loading
@@ -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: {
Loading
@@ -24,11 +42,17 @@ export default {
Loading
@@ -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: {},
}, },
}; };
}, },
Loading
@@ -43,9 +67,10 @@ export default {
Loading
@@ -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) {
Loading
@@ -58,6 +83,7 @@ export default {
Loading
@@ -58,6 +83,7 @@ export default {
computed: { computed: {
variables() { variables() {
return { return {
...fromSearchToVariables(this.search),
groupFullPath: this.groupFullPath, groupFullPath: this.groupFullPath,
}; };
}, },
Loading
@@ -67,6 +93,35 @@ export default {
Loading
@@ -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);
Loading
@@ -94,11 +149,22 @@ export default {
Loading
@@ -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>
Loading
@@ -12,7 +12,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
Loading
@@ -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(
Loading
@@ -35,6 +41,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
Loading
@@ -35,6 +41,7 @@ export const initGroupRunners = (selector = '#js-group-runners') => {
props: { props: {
registrationToken, registrationToken,
groupFullPath, groupFullPath,
groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10),
}, },
}); });
}, },
Loading
Loading
Loading
@@ -10,6 +10,8 @@ class Groups::RunnersController < Groups::ApplicationController
Loading
@@ -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
Loading
Loading
Loading
@@ -3,4 +3,4 @@
Loading
@@ -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 } }
Loading
@@ -28892,6 +28892,9 @@ msgstr ""
Loading
@@ -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 ""
   
Loading
Loading
Loading
@@ -3,11 +3,13 @@
Loading
@@ -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 } }
   
Loading
@@ -26,6 +28,7 @@
Loading
@@ -26,6 +28,7 @@
   
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
   
Loading
Loading
Loading
@@ -94,5 +94,14 @@
Loading
@@ -94,5 +94,14 @@
   
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
Loading
@@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
Loading
@@ -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';
Loading
@@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue';
Loading
@@ -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';
   
Loading
@@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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, {
Loading
@@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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();
}); });
   
Loading
@@ -77,6 +86,14 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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);
}); });
Loading
@@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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();
}); });
   
Loading
@@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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,
}); });
}); });
Loading
@@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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 () => {
Loading
@@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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 () => {
Loading
@@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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', () => {
Loading
@@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => {
Loading
@@ -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,
Loading
Loading
Loading
@@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
Loading
@@ -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';
   
Loading
@@ -13,12 +21,12 @@ describe('RunnerList', () => {
Loading
@@ -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;
Loading
@@ -28,13 +36,16 @@ describe('RunnerList', () => {
Loading
@@ -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,
Loading
@@ -64,12 +75,6 @@ describe('RunnerList', () => {
Loading
@@ -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;
   
Loading
@@ -78,7 +83,13 @@ describe('RunnerList', () => {
Loading
@@ -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,
Loading
Loading
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;
Loading
@@ -22,8 +51,14 @@ describe('GroupRunnersApp', () => {
Loading
@@ -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, {
Loading
@@ -32,11 +67,15 @@ describe('GroupRunnersApp', () => {
Loading
@@ -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();
Loading
@@ -48,11 +87,179 @@ describe('GroupRunnersApp', () => {
Loading
@@ -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,
});
});
});
}); });
Loading
@@ -9,3 +9,6 @@ export const runnerData = runnerFixture('get_runner.query.graphql.json');
Loading
@@ -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',
);
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