Commit 2f27901f authored by Miguel Rincon's avatar Miguel Rincon

Filter runner type via tabs

This change allows users to filter their runners by type using
tabs directly.

Changelog: changed
parent f02c40eb
...@@ -10,10 +10,10 @@ import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vu ...@@ -10,10 +10,10 @@ import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vu
import RunnerList from '../components/runner_list.vue'; import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue'; import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue'; import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_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 { 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 {
...@@ -32,6 +32,7 @@ export default { ...@@ -32,6 +32,7 @@ export default {
RunnerList, RunnerList,
RunnerName, RunnerName,
RunnerPagination, RunnerPagination,
RunnerTypeTabs,
}, },
props: { props: {
activeRunnersCount: { activeRunnersCount: {
...@@ -94,7 +95,6 @@ export default { ...@@ -94,7 +95,6 @@ export default {
searchTokens() { searchTokens() {
return [ return [
statusTokenConfig, statusTokenConfig,
typeTokenConfig,
{ {
...tagTokenConfig, ...tagTokenConfig,
recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`,
...@@ -128,7 +128,13 @@ export default { ...@@ -128,7 +128,13 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<div class="gl-py-3 gl-display-flex"> <div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
v-model="search"
content-class="gl-display-none"
nav-class="gl-border-none!"
/>
<registration-dropdown <registration-dropdown
class="gl-ml-auto" class="gl-ml-auto"
:registration-token="registrationToken" :registration-token="registrationToken"
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { __ } from '~/locale'; import { __ } from '~/locale';
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 { searchValidator } from '~/runner/runner_search_utils';
import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants'; import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants';
const sortOptions = [ const sortOptions = [
...@@ -31,9 +32,12 @@ export default { ...@@ -31,9 +32,12 @@ export default {
value: { value: {
type: Object, type: Object,
required: true, required: true,
validator(val) { validator: searchValidator,
return Array.isArray(val?.filters) && typeof val?.sort === 'string'; },
}, tokens: {
type: Array,
required: false,
default: () => [],
}, },
namespace: { namespace: {
type: String, type: String,
...@@ -43,7 +47,7 @@ export default { ...@@ -43,7 +47,7 @@ export default {
data() { data() {
// filtered_search_bar_root.vue may mutate the inital // filtered_search_bar_root.vue may mutate the inital
// filters. Use `cloneDeep` to prevent those mutations // filters. Use `cloneDeep` to prevent those mutations
// from affecting this component // from affecting this component
const { filters, sort } = cloneDeep(this.value); const { filters, sort } = cloneDeep(this.value);
return { return {
initialFilterValue: filters, initialFilterValue: filters,
...@@ -52,19 +56,17 @@ export default { ...@@ -52,19 +56,17 @@ export default {
}, },
methods: { methods: {
onFilter(filters) { onFilter(filters) {
const { sort } = this.value; // Apply new filters, from page 1
this.$emit('input', { this.$emit('input', {
...this.value,
filters, filters,
sort,
pagination: { page: 1 }, pagination: { page: 1 },
}); });
}, },
onSort(sort) { onSort(sort) {
const { filters } = this.value; // Apply new sort, from page 1
this.$emit('input', { this.$emit('input', {
filters, ...this.value,
sort, sort,
pagination: { page: 1 }, pagination: { page: 1 },
}); });
...@@ -74,13 +76,16 @@ export default { ...@@ -74,13 +76,16 @@ export default {
}; };
</script> </script>
<template> <template>
<div> <div
class="gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1"
>
<filtered-search <filtered-search
v-bind="$attrs" v-bind="$attrs"
:namespace="namespace" :namespace="namespace"
recent-searches-storage-key="runners-search" recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions" :sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue" :initial-filter-value="initialFilterValue"
:tokens="tokens"
:initial-sort-by="initialSortBy" :initial-sort-by="initialSortBy"
:search-input-placeholder="__('Search or filter results...')" :search-input-placeholder="__('Search or filter results...')"
data-testid="runners-filtered-search" data-testid="runners-filtered-search"
......
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import { s__ } from '~/locale';
import { searchValidator } from '~/runner/runner_search_utils';
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../constants';
const tabs = [
{
title: s__('Runners|All'),
runnerType: null,
},
{
title: s__('Runners|Instance'),
runnerType: INSTANCE_TYPE,
},
{
title: s__('Runners|Group'),
runnerType: GROUP_TYPE,
},
{
title: s__('Runners|Project'),
runnerType: PROJECT_TYPE,
},
];
export default {
components: {
GlTabs,
GlTab,
},
props: {
value: {
type: Object,
required: true,
validator: searchValidator,
},
},
methods: {
onTabSelected({ runnerType }) {
this.$emit('input', {
...this.value,
runnerType,
pagination: { page: 1 },
});
},
isTabActive({ runnerType }) {
return runnerType === this.value.runnerType;
},
},
tabs,
};
</script>
<template>
<gl-tabs v-bind="$attrs">
<gl-tab
v-for="tab in $options.tabs"
:key="`${tab.runnerType}`"
:active="isTabActive(tab)"
:title="tab.title"
@click="onTabSelected(tab)"
/>
</gl-tabs>
</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 { 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,
};
...@@ -10,9 +10,9 @@ import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vu ...@@ -10,9 +10,9 @@ import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vu
import RunnerList from '../components/runner_list.vue'; import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue'; import RunnerName from '../components/runner_name.vue';
import RunnerPagination from '../components/runner_pagination.vue'; import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { typeTokenConfig } from '../components/search_tokens/type_token_config';
import { import {
I18N_FETCH_ERROR, I18N_FETCH_ERROR,
GROUP_FILTERED_SEARCH_NAMESPACE, GROUP_FILTERED_SEARCH_NAMESPACE,
...@@ -36,6 +36,7 @@ export default { ...@@ -36,6 +36,7 @@ export default {
RunnerList, RunnerList,
RunnerName, RunnerName,
RunnerPagination, RunnerPagination,
RunnerTypeTabs,
}, },
props: { props: {
registrationToken: { registrationToken: {
...@@ -112,7 +113,7 @@ export default { ...@@ -112,7 +113,7 @@ export default {
}); });
}, },
searchTokens() { searchTokens() {
return [statusTokenConfig, typeTokenConfig]; return [statusTokenConfig];
}, },
filteredSearchNamespace() { filteredSearchNamespace() {
return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`;
...@@ -144,7 +145,13 @@ export default { ...@@ -144,7 +145,13 @@ export default {
<template> <template>
<div> <div>
<div class="gl-py-3 gl-display-flex"> <div class="gl-display-flex gl-align-items-center">
<runner-type-tabs
v-model="search"
content-class="gl-display-none"
nav-class="gl-border-none!"
/>
<registration-dropdown <registration-dropdown
class="gl-ml-auto" class="gl-ml-auto"
:registration-token="registrationToken" :registration-token="registrationToken"
......
...@@ -18,6 +18,50 @@ import { ...@@ -18,6 +18,50 @@ import {
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
} from './constants'; } from './constants';
/**
* The filters and sorting of the runners are built around
* an object called "search" that contains the current state
* of search in the UI. For example:
*
* ```
* const search = {
* // The current tab
* runnerType: 'INSTANCE_TYPE',
*
* // Filters in the search bar
* filters: [
* { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
* { type: 'filtered-search-term', value: { data: '' } },
* ],
*
* // Current sorting value
* sort: 'CREATED_DESC',
*
* // Pagination information
* pagination: { page: 1 },
* };
* ```
*
* An object in this format can be used to generate URLs
* with the search parameters or by runner components
* a input using a v-model.
*
* @module runner_search_utils
*/
/**
* Validates a search value
* @param {Object} search
* @returns {boolean} True if the value follows the search format.
*/
export const searchValidator = ({ runnerType, filters, sort }) => {
return (
(runnerType === null || typeof runnerType === 'string') &&
Array.isArray(filters) &&
typeof sort === 'string'
);
};
const getPaginationFromParams = (params) => { const getPaginationFromParams = (params) => {
const page = parseInt(params[PARAM_KEY_PAGE], 10); const page = parseInt(params[PARAM_KEY_PAGE], 10);
const after = params[PARAM_KEY_AFTER]; const after = params[PARAM_KEY_AFTER];
...@@ -35,13 +79,20 @@ const getPaginationFromParams = (params) => { ...@@ -35,13 +79,20 @@ const getPaginationFromParams = (params) => {
}; };
}; };
/**
* Takes a URL query and transforms it into a "search" object
* @param {String?} query
* @returns {Object} A search object
*/
export const fromUrlQueryToSearch = (query = window.location.search) => { export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true }); const params = queryToObject(query, { gatherArrays: true });
const runnerType = params[PARAM_KEY_RUNNER_TYPE]?.[0] || null;
return { return {
runnerType,
filters: prepareTokens( filters: prepareTokens(
urlQueryToFilter(query, { urlQueryToFilter(query, {
filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG], filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_TAG],
filteredSearchTermKey: PARAM_KEY_SEARCH, filteredSearchTermKey: PARAM_KEY_SEARCH,
}), }),
), ),
...@@ -50,8 +101,15 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { ...@@ -50,8 +101,15 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
}; };
}; };
/**
* Takes a "search" object and transforms it into a URL.
*
* @param {Object} search
* @param {String} url
* @returns {String} New URL for the page
*/
export const fromSearchToUrl = ( export const fromSearchToUrl = (
{ filters = [], sort = null, pagination = {} }, { runnerType = null, filters = [], sort = null, pagination = {} },
url = window.location.href, url = window.location.href,
) => { ) => {
const filterParams = { const filterParams = {
...@@ -65,6 +123,10 @@ export const fromSearchToUrl = ( ...@@ -65,6 +123,10 @@ export const fromSearchToUrl = (
}), }),
}; };
if (runnerType) {
filterParams[PARAM_KEY_RUNNER_TYPE] = [runnerType];
}
if (!filterParams[PARAM_KEY_SEARCH]) { if (!filterParams[PARAM_KEY_SEARCH]) {
filterParams[PARAM_KEY_SEARCH] = null; filterParams[PARAM_KEY_SEARCH] = null;
} }
...@@ -82,21 +144,31 @@ export const fromSearchToUrl = ( ...@@ -82,21 +144,31 @@ export const fromSearchToUrl = (
return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true); return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
}; };
export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => { /**
* Takes a "search" object and transforms it into variables for runner a GraphQL query.
*
* @param {Object} search
* @returns {Object} Hash of filter values
*/
export const fromSearchToVariables = ({
runnerType = null,
filters = [],
sort = null,
pagination = {},
} = {}) => {
const variables = {}; const variables = {};
const queryObj = filterToQueryObject(processFilters(filters), { const queryObj = filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH, filteredSearchTermKey: PARAM_KEY_SEARCH,
}); });
variables.search = queryObj[PARAM_KEY_SEARCH];
// TODO Get more than one value when GraphQL API supports OR for "status" or "runner_type"
[variables.status] = queryObj[PARAM_KEY_STATUS] || []; [variables.status] = queryObj[PARAM_KEY_STATUS] || [];
[variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || []; variables.search = queryObj[PARAM_KEY_SEARCH];
variables.tagList = queryObj[PARAM_KEY_TAG]; variables.tagList = queryObj[PARAM_KEY_TAG];
if (runnerType) {
variables.type = runnerType;
}
if (sort) { if (sort) {
variables.sort = sort; variables.sort = sort;
} }
......
...@@ -29639,6 +29639,9 @@ msgstr "" ...@@ -29639,6 +29639,9 @@ msgstr ""
msgid "Runners|Active" msgid "Runners|Active"
msgstr "" msgstr ""
msgid "Runners|All"
msgstr ""
msgid "Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot." msgid "Runners|Amazon Linux 2 Docker HA with manual scaling and optional scheduling. %{percentage} spot."
msgstr "" msgstr ""
...@@ -29693,6 +29696,9 @@ msgstr "" ...@@ -29693,6 +29696,9 @@ msgstr ""
msgid "Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet." msgid "Runners|For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet."
msgstr "" msgstr ""
msgid "Runners|Group"
msgstr ""
msgid "Runners|Group Runners" msgid "Runners|Group Runners"
msgstr "" msgstr ""
...@@ -29705,6 +29711,9 @@ msgstr "" ...@@ -29705,6 +29711,9 @@ msgstr ""
msgid "Runners|Install a runner" msgid "Runners|Install a runner"
msgstr "" msgstr ""
msgid "Runners|Instance"
msgstr ""
msgid "Runners|Last contact" msgid "Runners|Last contact"
msgstr "" msgstr ""
...@@ -29747,6 +29756,9 @@ msgstr "" ...@@ -29747,6 +29756,9 @@ msgstr ""
msgid "Runners|Platform" msgid "Runners|Platform"
msgstr "" msgstr ""
msgid "Runners|Project"
msgstr ""
msgid "Runners|Property Name" msgid "Runners|Property Name"
msgstr "" msgstr ""
...@@ -29897,9 +29909,6 @@ msgstr "" ...@@ -29897,9 +29909,6 @@ msgstr ""
msgid "Runners|group" msgid "Runners|group"
msgstr "" msgstr ""
msgid "Runners|instance"
msgstr ""
msgid "Runners|locked" msgid "Runners|locked"
msgstr "" msgstr ""
...@@ -29915,9 +29924,6 @@ msgstr "" ...@@ -29915,9 +29924,6 @@ msgstr ""
msgid "Runners|paused" msgid "Runners|paused"
msgstr "" msgstr ""
msgid "Runners|project"
msgstr ""
msgid "Runners|shared" msgid "Runners|shared"
msgstr "" msgstr ""
......
...@@ -66,6 +66,13 @@ RSpec.describe "Admin Runners" do ...@@ -66,6 +66,13 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
end end
it 'runner type can be selected' do
expect(page).to have_link('All')
expect(page).to have_link('Instance')
expect(page).to have_link('Group')
expect(page).to have_link('Project')
end
it 'shows runners' do it 'shows runners' do
expect(page).to have_content("runner-foo") expect(page).to have_content("runner-foo")
expect(page).to have_content("runner-bar") expect(page).to have_content("runner-bar")
...@@ -155,13 +162,21 @@ RSpec.describe "Admin Runners" do ...@@ -155,13 +162,21 @@ RSpec.describe "Admin Runners" do
create(:ci_runner, :group, description: 'runner-group', groups: [group]) create(:ci_runner, :group, description: 'runner-group', groups: [group])
end end
it 'shows correct runner when type matches' do
visit admin_runners_path
expect(page).to have_link('All', class: 'active')
end
it 'shows correct runner when type matches' do it 'shows correct runner when type matches' do
visit admin_runners_path visit admin_runners_path
expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-group' expect(page).to have_content 'runner-group'
input_filtered_search_filter_is_only('Type', 'project') click_on 'Project'
expect(page).to have_link('Project', class: 'active')
expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-project'
expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-group'
...@@ -170,7 +185,9 @@ RSpec.describe "Admin Runners" do ...@@ -170,7 +185,9 @@ RSpec.describe "Admin Runners" do
it 'shows no runner when type does not match' do it 'shows no runner when type does not match' do
visit admin_runners_path visit admin_runners_path
input_filtered_search_filter_is_only('Type', 'instance') click_on 'Instance'
expect(page).to have_link('Instance', class: 'active')
expect(page).not_to have_content 'runner-project' expect(page).not_to have_content 'runner-project'
expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-group'
...@@ -183,7 +200,7 @@ RSpec.describe "Admin Runners" do ...@@ -183,7 +200,7 @@ RSpec.describe "Admin Runners" do
visit admin_runners_path visit admin_runners_path
input_filtered_search_filter_is_only('Type', 'project') click_on 'Project'
expect(page).to have_content 'runner-project' expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-2-project' expect(page).to have_content 'runner-2-project'
...@@ -195,6 +212,24 @@ RSpec.describe "Admin Runners" do ...@@ -195,6 +212,24 @@ RSpec.describe "Admin Runners" do
expect(page).not_to have_content 'runner-2-project' expect(page).not_to have_content 'runner-2-project'
expect(page).not_to have_content 'runner-group' expect(page).not_to have_content 'runner-group'
end end
it 'maintains the same filter when switching between runner types' do
create(:ci_runner, :project, description: 'runner-paused-project', active: false, projects: [project])
visit admin_runners_path
input_filtered_search_filter_is_only('Status', 'Active')
expect(page).to have_content 'runner-project'
expect(page).to have_content 'runner-group'
expect(page).not_to have_content 'runner-paused-project'
click_on 'Project'
expect(page).to have_content 'runner-project'
expect(page).not_to have_content 'runner-group'
expect(page).not_to have_content 'runner-paused-project'
end
end end
describe 'filter by tag' do describe 'filter by tag' do
......
...@@ -22,7 +22,6 @@ import { ...@@ -22,7 +22,6 @@ import {
DEFAULT_SORT, DEFAULT_SORT,
INSTANCE_TYPE, INSTANCE_TYPE,
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG, PARAM_KEY_TAG,
STATUS_ACTIVE, STATUS_ACTIVE,
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
...@@ -126,10 +125,6 @@ describe('AdminRunnersApp', () => { ...@@ -126,10 +125,6 @@ describe('AdminRunnersApp', () => {
type: PARAM_KEY_STATUS, type: PARAM_KEY_STATUS,
options: expect.any(Array), options: expect.any(Array),
}), }),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
options: expect.any(Array),
}),
expect.objectContaining({ expect.objectContaining({
type: PARAM_KEY_TAG, type: PARAM_KEY_TAG,
recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`,
...@@ -155,9 +150,9 @@ describe('AdminRunnersApp', () => { ...@@ -155,9 +150,9 @@ describe('AdminRunnersApp', () => {
it('sets the filters in the search bar', () => { it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({ expect(findRunnerFilteredSearchBar().props('value')).toEqual({
runnerType: INSTANCE_TYPE,
filters: [ filters: [
{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
{ type: 'tag', value: { data: 'tag1', operator: '=' } }, { type: 'tag', value: { data: 'tag1', operator: '=' } },
], ],
sort: 'CREATED_DESC', sort: 'CREATED_DESC',
...@@ -179,6 +174,7 @@ describe('AdminRunnersApp', () => { ...@@ -179,6 +174,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', {
runnerType: null,
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC, sort: CREATED_ASC,
}); });
......
...@@ -5,13 +5,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ ...@@ -5,13 +5,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; 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 { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; 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_TAG, STATUS_ACTIVE, INSTANCE_TYPE } from '~/runner/constants';
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';
...@@ -31,6 +25,11 @@ describe('RunnerList', () => { ...@@ -31,6 +25,11 @@ describe('RunnerList', () => {
]; ];
const mockActiveRunnersCount = 2; const mockActiveRunnersCount = 2;
const expectToHaveLastEmittedInput = (value) => {
const inputs = wrapper.emitted('input');
expect(inputs[inputs.length - 1][0]).toEqual(value);
};
const createComponent = ({ props = {}, options = {} } = {}) => { const createComponent = ({ props = {}, options = {} } = {}) => {
wrapper = extendedWrapper( wrapper = extendedWrapper(
shallowMount(RunnerFilteredSearchBar, { shallowMount(RunnerFilteredSearchBar, {
...@@ -38,6 +37,7 @@ describe('RunnerList', () => { ...@@ -38,6 +37,7 @@ describe('RunnerList', () => {
namespace: 'runners', namespace: 'runners',
tokens: [], tokens: [],
value: { value: {
runnerType: null,
filters: [], filters: [],
sort: mockDefaultSort, sort: mockDefaultSort,
}, },
...@@ -86,7 +86,7 @@ describe('RunnerList', () => { ...@@ -86,7 +86,7 @@ describe('RunnerList', () => {
it('sets tokens to the filtered search', () => { it('sets tokens to the filtered search', () => {
createComponent({ createComponent({
props: { props: {
tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig], tokens: [statusTokenConfig, tagTokenConfig],
}, },
}); });
...@@ -96,11 +96,6 @@ describe('RunnerList', () => { ...@@ -96,11 +96,6 @@ describe('RunnerList', () => {
token: BaseToken, token: BaseToken,
options: expect.any(Array), options: expect.any(Array),
}), }),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
token: BaseToken,
options: expect.any(Array),
}),
expect.objectContaining({ expect.objectContaining({
type: PARAM_KEY_TAG, type: PARAM_KEY_TAG,
token: TagToken, token: TagToken,
...@@ -123,6 +118,7 @@ describe('RunnerList', () => { ...@@ -123,6 +118,7 @@ describe('RunnerList', () => {
createComponent({ createComponent({
props: { props: {
value: { value: {
runnerType: INSTANCE_TYPE,
sort: mockOtherSort, sort: mockOtherSort,
filters: mockFilters, filters: mockFilters,
}, },
...@@ -142,30 +138,40 @@ describe('RunnerList', () => { ...@@ -142,30 +138,40 @@ describe('RunnerList', () => {
.text(), .text(),
).toEqual('Last contact'); ).toEqual('Last contact');
}); });
it('when the user sets a filter, the "search" preserves the other filters', () => {
findGlFilteredSearch().vm.$emit('input', mockFilters);
findGlFilteredSearch().vm.$emit('submit');
expectToHaveLastEmittedInput({
runnerType: INSTANCE_TYPE,
filters: mockFilters,
sort: mockOtherSort,
pagination: { page: 1 },
});
});
}); });
it('when the user sets a filter, the "search" is emitted with filters', () => { it('when the user sets a filter, the "search" is emitted with filters', () => {
findGlFilteredSearch().vm.$emit('input', mockFilters); findGlFilteredSearch().vm.$emit('input', mockFilters);
findGlFilteredSearch().vm.$emit('submit'); findGlFilteredSearch().vm.$emit('submit');
expect(wrapper.emitted('input')[0]).toEqual([ expectToHaveLastEmittedInput({
{ runnerType: null,
filters: mockFilters, filters: mockFilters,
sort: mockDefaultSort, sort: mockDefaultSort,
pagination: { page: 1 }, pagination: { page: 1 },
}, });
]);
}); });
it('when the user sets a sorting method, the "search" is emitted with the sort', () => { it('when the user sets a sorting method, the "search" is emitted with the sort', () => {
findSortOptions().at(1).vm.$emit('click'); findSortOptions().at(1).vm.$emit('click');
expect(wrapper.emitted('input')[0]).toEqual([ expectToHaveLastEmittedInput({
{ runnerType: null,
filters: [], filters: [],
sort: mockOtherSort, sort: mockOtherSort,
pagination: { page: 1 }, pagination: { page: 1 },
}, });
]);
}); });
}); });
import { GlTab } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import { INSTANCE_TYPE, GROUP_TYPE } from '~/runner/constants';
const mockSearch = { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' };
describe('RunnerTypeTabs', () => {
let wrapper;
const findTabs = () => wrapper.findAll(GlTab);
const findActiveTab = () =>
findTabs()
.filter((tab) => tab.attributes('active') === 'true')
.at(0);
const createComponent = ({ value = mockSearch } = {}) => {
wrapper = shallowMount(RunnerTypeTabs, {
propsData: {
value,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Renders options to filter runners', () => {
expect(findTabs().wrappers.map((tab) => tab.attributes('title'))).toEqual([
'All',
'Instance',
'Group',
'Project',
]);
});
it('"All" is selected by default', () => {
expect(findActiveTab().attributes('title')).toBe('All');
});
it('Another tab can be preselected by the user', () => {
createComponent({
value: {
...mockSearch,
runnerType: INSTANCE_TYPE,
},
});
expect(findActiveTab().attributes('title')).toBe('Instance');
});
describe('When the user selects a tab', () => {
const emittedValue = () => wrapper.emitted('input')[0][0];
beforeEach(() => {
findTabs().at(2).vm.$emit('click');
});
it(`Runner type is emitted`, () => {
expect(emittedValue()).toEqual({
...mockSearch,
runnerType: GROUP_TYPE,
});
});
it('Runner type is selected', async () => {
const newValue = emittedValue();
await wrapper.setProps({ value: newValue });
expect(findActiveTab().attributes('title')).toBe('Group');
});
});
});
import { nextTick } from 'vue';
import { GlLink } from '@gitlab/ui'; import { GlLink } from '@gitlab/ui';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
...@@ -21,7 +22,6 @@ import { ...@@ -21,7 +22,6 @@ import {
INSTANCE_TYPE, INSTANCE_TYPE,
GROUP_TYPE, GROUP_TYPE,
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
STATUS_ACTIVE, STATUS_ACTIVE,
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
} from '~/runner/constants'; } from '~/runner/constants';
...@@ -88,9 +88,8 @@ describe('GroupRunnersApp', () => { ...@@ -88,9 +88,8 @@ describe('GroupRunnersApp', () => {
}); });
it('shows the runners list', () => { it('shows the runners list', () => {
expect(findRunnerList().props('runners')).toEqual( const runners = findRunnerList().props('runners');
groupRunnersData.data.group.runners.edges.map(({ node }) => node), expect(runners).toEqual(groupRunnersData.data.group.runners.edges.map(({ node }) => node));
);
}); });
it('runner item links to the runner group page', async () => { it('runner item links to the runner group page', async () => {
...@@ -119,16 +118,15 @@ describe('GroupRunnersApp', () => { ...@@ -119,16 +118,15 @@ describe('GroupRunnersApp', () => {
it('sets tokens in the filtered search', () => { it('sets tokens in the filtered search', () => {
createComponent({ mountFn: mount }); createComponent({ mountFn: mount });
expect(findFilteredSearch().props('tokens')).toEqual([ const tokens = findFilteredSearch().props('tokens');
expect(tokens).toHaveLength(1);
expect(tokens[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
type: PARAM_KEY_STATUS, type: PARAM_KEY_STATUS,
options: expect.any(Array), options: expect.any(Array),
}), }),
expect.objectContaining({ );
type: PARAM_KEY_RUNNER_TYPE,
options: expect.any(Array),
}),
]);
}); });
describe('shows the active runner count', () => { describe('shows the active runner count', () => {
...@@ -163,10 +161,8 @@ describe('GroupRunnersApp', () => { ...@@ -163,10 +161,8 @@ describe('GroupRunnersApp', () => {
it('sets the filters in the search bar', () => { it('sets the filters in the search bar', () => {
expect(findRunnerFilteredSearchBar().props('value')).toEqual({ expect(findRunnerFilteredSearchBar().props('value')).toEqual({
filters: [ runnerType: INSTANCE_TYPE,
{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, filters: [{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }],
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
],
sort: 'CREATED_DESC', sort: 'CREATED_DESC',
pagination: { page: 1 }, pagination: { page: 1 },
}); });
...@@ -184,11 +180,14 @@ describe('GroupRunnersApp', () => { ...@@ -184,11 +180,14 @@ describe('GroupRunnersApp', () => {
}); });
describe('when a filter is selected by the user', () => { describe('when a filter is selected by the user', () => {
beforeEach(() => { beforeEach(async () => {
findRunnerFilteredSearchBar().vm.$emit('input', { findRunnerFilteredSearchBar().vm.$emit('input', {
runnerType: null,
filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }],
sort: CREATED_ASC, sort: CREATED_ASC,
}); });
await nextTick();
}); });
it('updates the browser url', () => { it('updates the browser url', () => {
......
import { RUNNER_PAGE_SIZE } from '~/runner/constants'; import { RUNNER_PAGE_SIZE } from '~/runner/constants';
import { import {
searchValidator,
fromUrlQueryToSearch, fromUrlQueryToSearch,
fromSearchToUrl, fromSearchToUrl,
fromSearchToVariables, fromSearchToVariables,
...@@ -10,13 +11,14 @@ describe('search_params.js', () => { ...@@ -10,13 +11,14 @@ describe('search_params.js', () => {
{ {
name: 'a default query', name: 'a default query',
urlQuery: '', urlQuery: '',
search: { filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' }, search: { runnerType: null, filters: [], pagination: { page: 1 }, sort: 'CREATED_DESC' },
graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, graphqlVariables: { sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
}, },
{ {
name: 'a single status', name: 'a single status',
urlQuery: '?status[]=ACTIVE', urlQuery: '?status[]=ACTIVE',
search: { search: {
runnerType: null,
filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }], filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
pagination: { page: 1 }, pagination: { page: 1 },
sort: 'CREATED_DESC', sort: 'CREATED_DESC',
...@@ -27,6 +29,7 @@ describe('search_params.js', () => { ...@@ -27,6 +29,7 @@ describe('search_params.js', () => {
name: 'a single term text search', name: 'a single term text search',
urlQuery: '?search=something', urlQuery: '?search=something',
search: { search: {
runnerType: null,
filters: [ filters: [
{ {
type: 'filtered-search-term', type: 'filtered-search-term',
...@@ -42,6 +45,7 @@ describe('search_params.js', () => { ...@@ -42,6 +45,7 @@ describe('search_params.js', () => {
name: 'a two terms text search', name: 'a two terms text search',
urlQuery: '?search=something+else', urlQuery: '?search=something+else',
search: { search: {
runnerType: null,
filters: [ filters: [
{ {
type: 'filtered-search-term', type: 'filtered-search-term',
...@@ -61,7 +65,8 @@ describe('search_params.js', () => { ...@@ -61,7 +65,8 @@ describe('search_params.js', () => {
name: 'single instance type', name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE', urlQuery: '?runner_type[]=INSTANCE_TYPE',
search: { search: {
filters: [{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } }], runnerType: 'INSTANCE_TYPE',
filters: [],
pagination: { page: 1 }, pagination: { page: 1 },
sort: 'CREATED_DESC', sort: 'CREATED_DESC',
}, },
...@@ -71,6 +76,7 @@ describe('search_params.js', () => { ...@@ -71,6 +76,7 @@ describe('search_params.js', () => {
name: 'multiple runner status', name: 'multiple runner status',
urlQuery: '?status[]=ACTIVE&status[]=PAUSED', urlQuery: '?status[]=ACTIVE&status[]=PAUSED',
search: { search: {
runnerType: null,
filters: [ filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'status', value: { data: 'PAUSED', operator: '=' } }, { type: 'status', value: { data: 'PAUSED', operator: '=' } },
...@@ -84,10 +90,8 @@ describe('search_params.js', () => { ...@@ -84,10 +90,8 @@ describe('search_params.js', () => {
name: 'multiple status, a single instance type and a non default sort', name: 'multiple status, a single instance type and a non default sort',
urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC', urlQuery: '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&sort=CREATED_ASC',
search: { search: {
filters: [ runnerType: 'INSTANCE_TYPE',
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }, filters: [{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }],
{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } },
],
pagination: { page: 1 }, pagination: { page: 1 },
sort: 'CREATED_ASC', sort: 'CREATED_ASC',
}, },
...@@ -102,6 +106,7 @@ describe('search_params.js', () => { ...@@ -102,6 +106,7 @@ describe('search_params.js', () => {
name: 'a tag', name: 'a tag',
urlQuery: '?tag[]=tag-1', urlQuery: '?tag[]=tag-1',
search: { search: {
runnerType: null,
filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }], filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }],
pagination: { page: 1 }, pagination: { page: 1 },
sort: 'CREATED_DESC', sort: 'CREATED_DESC',
...@@ -116,6 +121,7 @@ describe('search_params.js', () => { ...@@ -116,6 +121,7 @@ describe('search_params.js', () => {
name: 'two tags', name: 'two tags',
urlQuery: '?tag[]=tag-1&tag[]=tag-2', urlQuery: '?tag[]=tag-1&tag[]=tag-2',
search: { search: {
runnerType: null,
filters: [ filters: [
{ type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'tag', value: { data: 'tag-2', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } },
...@@ -132,13 +138,19 @@ describe('search_params.js', () => { ...@@ -132,13 +138,19 @@ describe('search_params.js', () => {
{ {
name: 'the next page', name: 'the next page',
urlQuery: '?page=2&after=AFTER_CURSOR', urlQuery: '?page=2&after=AFTER_CURSOR',
search: { filters: [], pagination: { page: 2, after: 'AFTER_CURSOR' }, sort: 'CREATED_DESC' }, search: {
runnerType: null,
filters: [],
pagination: { page: 2, after: 'AFTER_CURSOR' },
sort: 'CREATED_DESC',
},
graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE }, graphqlVariables: { sort: 'CREATED_DESC', after: 'AFTER_CURSOR', first: RUNNER_PAGE_SIZE },
}, },
{ {
name: 'the previous page', name: 'the previous page',
urlQuery: '?page=2&before=BEFORE_CURSOR', urlQuery: '?page=2&before=BEFORE_CURSOR',
search: { search: {
runnerType: null,
filters: [], filters: [],
pagination: { page: 2, before: 'BEFORE_CURSOR' }, pagination: { page: 2, before: 'BEFORE_CURSOR' },
sort: 'CREATED_DESC', sort: 'CREATED_DESC',
...@@ -150,9 +162,9 @@ describe('search_params.js', () => { ...@@ -150,9 +162,9 @@ describe('search_params.js', () => {
urlQuery: urlQuery:
'?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR', '?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&tag[]=tag-1&tag[]=tag-2&sort=CREATED_ASC&page=2&after=AFTER_CURSOR',
search: { search: {
runnerType: 'INSTANCE_TYPE',
filters: [ filters: [
{ type: 'status', value: { data: 'ACTIVE', operator: '=' } }, { type: 'status', value: { data: 'ACTIVE', operator: '=' } },
{ type: 'runner_type', value: { data: 'INSTANCE_TYPE', operator: '=' } },
{ type: 'tag', value: { data: 'tag-1', operator: '=' } }, { type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'tag', value: { data: 'tag-2', operator: '=' } }, { type: 'tag', value: { data: 'tag-2', operator: '=' } },
], ],
...@@ -170,6 +182,14 @@ describe('search_params.js', () => { ...@@ -170,6 +182,14 @@ describe('search_params.js', () => {
}, },
]; ];
describe('searchValidator', () => {
examples.forEach(({ name, search }) => {
it(`Validates ${name} as a search object`, () => {
expect(searchValidator(search)).toBe(true);
});
});
});
describe('fromUrlQueryToSearch', () => { describe('fromUrlQueryToSearch', () => {
examples.forEach(({ name, urlQuery, search }) => { examples.forEach(({ name, urlQuery, search }) => {
it(`Converts ${name} to a search object`, () => { it(`Converts ${name} to a search object`, () => {
......
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