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

Add tags filters to runners search

This change allow the user to filter by tags in the
runner UI when using the GraphQL API.
parent a6b764b6
<script>
import { GlFilteredSearchToken } from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { __, s__ } from '~/locale';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import {
STATUS_ACTIVE,
STATUS_PAUSED,
......@@ -19,50 +19,9 @@ import {
CONTACTED_ASC,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
} from '../constants';
const searchTokens = [
{
icon: 'status',
title: __('Status'),
type: PARAM_KEY_STATUS,
token: GlFilteredSearchToken,
// TODO Get more than one value when GraphQL API supports OR for "status"
unique: true,
options: [
{ value: STATUS_ACTIVE, title: s__('Runners|Active') },
{ value: STATUS_PAUSED, title: s__('Runners|Paused') },
{ value: STATUS_ONLINE, title: s__('Runners|Online') },
{ value: STATUS_OFFLINE, title: s__('Runners|Offline') },
// Added extra quotes in this title to avoid splitting this value:
// see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438
{ value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` },
],
// TODO In principle we could support more complex search rules,
// this can be added to a separate issue.
operators: OPERATOR_IS_ONLY,
},
{
icon: 'file-tree',
title: __('Type'),
type: PARAM_KEY_RUNNER_TYPE,
token: GlFilteredSearchToken,
// TODO Get more than one value when GraphQL API supports OR for "status"
unique: true,
options: [
{ value: INSTANCE_TYPE, title: s__('Runners|shared') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|specific') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
},
// TODO Support tags
];
import TagToken from './search_tokens/tag_token.vue';
const sortOptions = [
{
......@@ -95,6 +54,10 @@ export default {
return Array.isArray(val?.filters) && typeof val?.sort === 'string';
},
},
namespace: {
type: String,
required: true,
},
},
data() {
// filtered_search_bar_root.vue may mutate the inital
......@@ -106,6 +69,57 @@ export default {
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|shared') },
{ value: GROUP_TYPE, title: s__('Runners|group') },
{ value: PROJECT_TYPE, title: s__('Runners|specific') },
],
// TODO We should support more complex search rules,
// search for multiple states (OR) or have NOT operators
operators: OPERATOR_IS_ONLY,
},
{
icon: 'tag',
title: s__('Runners|Tags'),
type: PARAM_KEY_TAG,
token: TagToken,
recentTokenValuesStorageKey: `${this.namespace}-recent-tags`,
operators: OPERATOR_IS_ONLY,
},
];
},
},
methods: {
onFilter(filters) {
const { sort } = this.value;
......@@ -127,17 +141,17 @@ export default {
},
},
sortOptions,
searchTokens,
};
</script>
<template>
<filtered-search
v-bind="$attrs"
:namespace="namespace"
recent-searches-storage-key="runners-search"
:sort-options="$options.sortOptions"
:initial-filter-value="initialFilterValue"
:initial-sort-by="initialSortBy"
:tokens="$options.searchTokens"
:tokens="searchTokens"
:search-input-placeholder="__('Search or filter results...')"
@onFilter="onFilter"
@onSort="onSort"
......
<script>
import { GlBadge } from '@gitlab/ui';
import { RUNNER_TAG_BADGE_VARIANT } from '../constants';
export default {
components: {
GlBadge,
},
props: {
tag: {
type: String,
required: true,
},
size: {
type: String,
required: false,
default: 'md',
},
},
RUNNER_TAG_BADGE_VARIANT,
};
</script>
<template>
<gl-badge :size="size" :variant="$options.RUNNER_TAG_BADGE_VARIANT">
{{ tag }}
</gl-badge>
</template>
<script>
import { GlBadge } from '@gitlab/ui';
import RunnerTag from './runner_tag.vue';
export default {
components: {
GlBadge,
RunnerTag,
},
props: {
tagList: {
......@@ -16,18 +16,11 @@ export default {
required: false,
default: 'md',
},
variant: {
type: String,
required: false,
default: 'info',
},
},
};
</script>
<template>
<div>
<gl-badge v-for="tag in tagList" :key="tag" :size="size" :variant="variant">
{{ tag }}
</gl-badge>
<runner-tag v-for="tag in tagList" :key="tag" :tag="tag" :size="size" />
</div>
</template>
<script>
import { GlFilteredSearchSuggestion, GlToken } from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
import { RUNNER_TAG_BG_CLASS } from '../../constants';
export const TAG_SUGGESTIONS_PATH = '/admin/runners/tag_list.json';
export default {
components: {
BaseToken,
GlFilteredSearchSuggestion,
GlToken,
},
props: {
config: {
type: Object,
required: true,
},
},
data() {
return {
tags: [],
loading: false,
};
},
methods: {
fnCurrentTokenValue(data) {
// By default, values are transformed with `toLowerCase`
// however, runner tags are case sensitive.
return data;
},
getTagsOptions(search) {
// TODO This should be implemented via a GraphQL API
// The API should
// 1) scope to the rights of the user
// 2) stay up to date to the removal of old tags
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796
return axios
.get(TAG_SUGGESTIONS_PATH, {
params: {
search,
},
})
.then(({ data }) => {
return data.map(({ id, name }) => ({ id, value: name, text: name }));
});
},
async fetchTags(searchTerm) {
this.loading = true;
try {
this.tags = await this.getTagsOptions(searchTerm);
} catch {
createFlash({
message: s__('Runners|Something went wrong while fetching the tags suggestions'),
});
} finally {
this.loading = false;
}
},
},
RUNNER_TAG_BG_CLASS,
};
</script>
<template>
<base-token
v-bind="$attrs"
:config="config"
:suggestions-loading="loading"
:suggestions="tags"
:fn-current-token-value="fnCurrentTokenValue"
:recent-suggestions-storage-key="config.recentTokenValuesStorageKey"
@fetch-suggestions="fetchTags"
v-on="$listeners"
>
<template #view-token="{ viewTokenProps: { listeners, inputValue, activeTokenValue } }">
<gl-token variant="search-value" :class="$options.RUNNER_TAG_BG_CLASS" v-on="listeners">
{{ activeTokenValue ? activeTokenValue.text : inputValue }}
</gl-token>
</template>
<template #suggestions-list="{ suggestions }">
<gl-filtered-search-suggestion v-for="tag in suggestions" :key="tag.id" :value="tag.value">
{{ tag.text }}
</gl-filtered-search-suggestion>
</template>
</base-token>
</template>
......@@ -6,13 +6,18 @@ export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
export const RUNNER_TAG_BADGE_VARIANT = 'info';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
// Filtered search parameter names
// - Used for URL params names
// - GlFilteredSearch tokens type
export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_STATUS = 'status';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_TAG = 'tag';
export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_SORT = 'sort';
export const PARAM_KEY_PAGE = 'page';
export const PARAM_KEY_AFTER = 'after';
......
......@@ -6,9 +6,10 @@ query getRunners(
$after: String
$first: Int
$last: Int
$search: String
$status: CiRunnerStatus
$type: CiRunnerType
$tagList: [String!]
$search: String
$sort: CiRunnerSort
) {
runners(
......@@ -16,9 +17,10 @@ query getRunners(
after: $after
first: $first
last: $last
search: $search
status: $status
type: $type
tagList: $tagList
search: $search
sort: $sort
) {
nodes {
......
......@@ -6,9 +6,10 @@ import {
prepareTokens,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
PARAM_KEY_SEARCH,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_TAG,
PARAM_KEY_SEARCH,
PARAM_KEY_SORT,
PARAM_KEY_PAGE,
PARAM_KEY_AFTER,
......@@ -40,7 +41,7 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
return {
filters: prepareTokens(
urlQueryToFilter(query, {
filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE],
filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG],
filteredSearchTermKey: PARAM_KEY_SEARCH,
legacySpacesDecode: false,
}),
......@@ -56,15 +57,19 @@ export const fromSearchToUrl = (
) => {
const filterParams = {
// Defaults
[PARAM_KEY_SEARCH]: null,
[PARAM_KEY_STATUS]: [],
[PARAM_KEY_RUNNER_TYPE]: [],
[PARAM_KEY_TAG]: [],
// Current filters
...filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
}),
};
if (!filterParams[PARAM_KEY_SEARCH]) {
filterParams[PARAM_KEY_SEARCH] = null;
}
const isDefaultSort = sort !== DEFAULT_SORT;
const isFirstPage = pagination?.page === 1;
const otherParams = {
......@@ -87,12 +92,12 @@ export const fromSearchToVariables = ({ filters = [], sort = null, pagination =
variables.search = queryObj[PARAM_KEY_SEARCH];
// TODO Get more than one value when GraphQL API supports OR for "status"
// TODO Get more than one value when GraphQL API supports OR for "status" or "runner_type"
[variables.status] = queryObj[PARAM_KEY_STATUS] || [];
// TODO Get more than one value when GraphQL API supports OR for "runner type"
[variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || [];
variables.tagList = queryObj[PARAM_KEY_TAG];
if (sort) {
variables.sort = sort;
}
......
......@@ -28141,6 +28141,9 @@ msgstr ""
msgid "Runners|Show Runner installation instructions"
msgstr ""
msgid "Runners|Something went wrong while fetching the tags suggestions"
msgstr ""
msgid "Runners|Tags"
msgstr ""
......
......@@ -2,8 +2,10 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE } from '~/runner/constants';
import TagToken from '~/runner/components/search_tokens/tag_token.vue';
import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants';
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';
describe('RunnerList', () => {
let wrapper;
......@@ -23,13 +25,13 @@ describe('RunnerList', () => {
wrapper = extendedWrapper(
shallowMount(RunnerFilteredSearchBar, {
propsData: {
namespace: 'runners',
value: {
filters: [],
sort: mockDefaultSort,
},
...props,
},
attrs: { namespace: 'runners' },
stubs: {
FilteredSearch,
GlFilteredSearch,
......@@ -65,12 +67,18 @@ describe('RunnerList', () => {
expect(findFilteredSearch().props('tokens')).toEqual([
expect.objectContaining({
type: PARAM_KEY_STATUS,
token: BaseToken,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_RUNNER_TYPE,
token: BaseToken,
options: expect.any(Array),
}),
expect.objectContaining({
type: PARAM_KEY_TAG,
token: TagToken,
}),
]);
});
......
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import RunnerTag from '~/runner/components/runner_tag.vue';
describe('RunnerTag', () => {
let wrapper;
const findBadge = () => wrapper.findComponent(GlBadge);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(RunnerTag, {
propsData: {
tag: 'tag1',
...props,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('Displays tag text', () => {
expect(wrapper.text()).toBe('tag1');
});
it('Displays tags with correct style', () => {
expect(findBadge().props()).toMatchObject({
size: 'md',
variant: 'info',
});
});
it('Displays tags with small size', () => {
createComponent({
props: { size: 'sm' },
});
expect(findBadge().props('size')).toBe('sm');
});
});
import { GlBadge } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { mount } from '@vue/test-utils';
import RunnerTags from '~/runner/components/runner_tags.vue';
describe('RunnerTags', () => {
......@@ -9,7 +9,7 @@ describe('RunnerTags', () => {
const findBadgesAt = (i = 0) => wrapper.findAllComponents(GlBadge).at(i);
const createComponent = ({ props = {} } = {}) => {
wrapper = shallowMount(RunnerTags, {
wrapper = mount(RunnerTags, {
propsData: {
tagList: ['tag1', 'tag2'],
...props,
......@@ -45,14 +45,6 @@ describe('RunnerTags', () => {
expect(findBadge().props('size')).toBe('sm');
});
it('Displays tags with a variant', () => {
createComponent({
props: { variant: 'warning' },
});
expect(findBadge().props('variant')).toBe('warning');
});
it('Is empty when there are no tags', () => {
createComponent({
props: { tagList: null },
......
import { GlFilteredSearchSuggestion, GlLoadingIcon, GlToken } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import TagToken, { TAG_SUGGESTIONS_PATH } from '~/runner/components/search_tokens/tag_token.vue';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
jest.mock('~/flash');
jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({
...jest.requireActual('~/vue_shared/components/filtered_search_bar/filtered_search_utils'),
getRecentlyUsedSuggestions: jest.fn(),
}));
const mockStorageKey = 'stored-recent-tags';
const mockTags = [
{ id: 1, name: 'linux' },
{ id: 2, name: 'windows' },
{ id: 3, name: 'mac' },
];
const mockTagsFiltered = [mockTags[0]];
const mockSearchTerm = mockTags[0].name;
const GlFilteredSearchTokenStub = {
template: `<div>
<slot name="view-token"></slot>
<slot name="suggestions"></slot>
</div>`,
};
const mockTagTokenConfig = {
icon: 'tag',
title: 'Tags',
type: 'tag',
token: TagToken,
recentTokenValuesStorageKey: mockStorageKey,
operators: OPERATOR_IS_ONLY,
};
describe('TagToken', () => {
let mock;
let wrapper;
const createComponent = (props = {}) => {
wrapper = mount(TagToken, {
propsData: {
config: mockTagTokenConfig,
value: { data: '' },
active: false,
...props,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
filteredSearchSuggestionListInstance: {
register: jest.fn(),
unregister: jest.fn(),
},
},
stubs: {
GlFilteredSearchToken: GlFilteredSearchTokenStub,
},
});
};
const findGlFilteredSearchSuggestions = () =>
wrapper.findAllComponents(GlFilteredSearchSuggestion);
const findGlFilteredSearchToken = () => wrapper.findComponent(GlFilteredSearchTokenStub);
const findToken = () => wrapper.findComponent(GlToken);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
beforeEach(async () => {
mock = new MockAdapter(axios);
mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(200, mockTags);
mock
.onGet(TAG_SUGGESTIONS_PATH, { params: { search: mockSearchTerm } })
.reply(200, mockTagsFiltered);
getRecentlyUsedSuggestions.mockReturnValue([]);
createComponent();
await waitForPromises();
});
afterEach(() => {
getRecentlyUsedSuggestions.mockReset();
wrapper.destroy();
});
describe('when the tags token is displayed', () => {
it('requests tags suggestions', () => {
expect(mock.history.get[0].params).toEqual({ search: '' });
});
it('displays tags suggestions', () => {
mockTags.forEach(({ name }, i) => {
expect(findGlFilteredSearchSuggestions().at(i).text()).toBe(name);
});
});
});
describe('when suggestions are stored', () => {
const storedSuggestions = [{ id: 4, value: 'docker', text: 'docker' }];
beforeEach(async () => {
getRecentlyUsedSuggestions.mockReturnValue(storedSuggestions);
createComponent();
await waitForPromises();
});
it('suggestions are loaded from a correct key', () => {
expect(getRecentlyUsedSuggestions).toHaveBeenCalledWith(mockStorageKey);
});
it('displays stored tags suggestions', () => {
expect(findGlFilteredSearchSuggestions()).toHaveLength(
mockTags.length + storedSuggestions.length,
);
expect(findGlFilteredSearchSuggestions().at(0).text()).toBe(storedSuggestions[0].text);
});
});
describe('when the users filters suggestions', () => {
beforeEach(async () => {
findGlFilteredSearchToken().vm.$emit('input', { data: mockSearchTerm });
jest.runAllTimers();
});
it('requests filtered tags suggestions', async () => {
await waitForPromises();
expect(mock.history.get[1].params).toEqual({ search: mockSearchTerm });
});
it('shows the loading icon', async () => {
await nextTick();
expect(findGlLoadingIcon().exists()).toBe(true);
});
it('displays filtered tags suggestions', async () => {
await waitForPromises();
expect(findGlFilteredSearchSuggestions()).toHaveLength(mockTagsFiltered.length);
expect(findGlFilteredSearchSuggestions().at(0).text()).toBe(mockTagsFiltered[0].name);
});
});
describe('when suggestions cannot be loaded', () => {
beforeEach(async () => {
mock.onGet(TAG_SUGGESTIONS_PATH, { params: { search: '' } }).reply(500);
createComponent();
await waitForPromises();
});
it('error is shown', async () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({ message: expect.any(String) });
});
});
describe('when the user selects a value', () => {
beforeEach(async () => {
createComponent({ value: { data: mockTags[0].name } });
findGlFilteredSearchToken().vm.$emit('select');
await waitForPromises();
});
it('selected tag is displayed', async () => {
expect(findToken().exists()).toBe(true);
});
});
});
......@@ -64,7 +64,7 @@ describe('RunnerListApp', () => {
};
const setQuery = (query) => {
window.location.href = `${TEST_HOST}/admin/runners/${query}`;
window.location.href = `${TEST_HOST}/admin/runners?${query}`;
window.location.search = query;
};
......@@ -119,7 +119,7 @@ describe('RunnerListApp', () => {
describe('when a filter is preselected', () => {
beforeEach(async () => {
window.location.search = `?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`;
setQuery(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);
createComponentWithApollo();
await waitForPromises();
......@@ -130,6 +130,7 @@ describe('RunnerListApp', () => {
filters: [
{ type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } },
{ type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } },
{ type: 'tag', value: { data: 'tag1', operator: '=' } },
],
sort: 'CREATED_DESC',
pagination: { page: 1 },
......@@ -140,6 +141,7 @@ describe('RunnerListApp', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: STATUS_ACTIVE,
type: INSTANCE_TYPE,
tagList: ['tag1'],
sort: DEFAULT_SORT,
first: RUNNER_PAGE_SIZE,
});
......@@ -157,7 +159,7 @@ describe('RunnerListApp', () => {
it('updates the browser url', () => {
expect(updateHistory).toHaveBeenLastCalledWith({
title: expect.any(String),
url: 'http://test.host/admin/runners/?status[]=ACTIVE&sort=CREATED_ASC',
url: 'http://test.host/admin/runners?status[]=ACTIVE&sort=CREATED_ASC',
});
});
......
......@@ -98,6 +98,37 @@ describe('search_params.js', () => {
first: RUNNER_PAGE_SIZE,
},
},
{
name: 'a tag',
urlQuery: '?tag[]=tag-1',
search: {
filters: [{ type: 'tag', value: { data: 'tag-1', operator: '=' } }],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: {
tagList: ['tag-1'],
first: 20,
sort: 'CREATED_DESC',
},
},
{
name: 'two tags',
urlQuery: '?tag[]=tag-1&tag[]=tag-2',
search: {
filters: [
{ type: 'tag', value: { data: 'tag-1', operator: '=' } },
{ type: 'tag', value: { data: 'tag-2', operator: '=' } },
],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: {
tagList: ['tag-1', 'tag-2'],
first: 20,
sort: 'CREATED_DESC',
},
},
{
name: 'the next page',
urlQuery: '?page=2&after=AFTER_CURSOR',
......@@ -115,14 +146,15 @@ describe('search_params.js', () => {
graphqlVariables: { sort: 'CREATED_DESC', before: 'BEFORE_CURSOR', last: RUNNER_PAGE_SIZE },
},
{
name:
'the next page filtered by multiple status, a single instance type and a non default sort',
name: 'the next page filtered by a status, an instance type, tags and a non default sort',
urlQuery:
'?status[]=ACTIVE&runner_type[]=INSTANCE_TYPE&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: {
filters: [
{ 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-2', operator: '=' } },
],
pagination: { page: 2, after: 'AFTER_CURSOR' },
sort: 'CREATED_ASC',
......@@ -130,6 +162,7 @@ describe('search_params.js', () => {
graphqlVariables: {
status: 'ACTIVE',
type: 'INSTANCE_TYPE',
tagList: ['tag-1', 'tag-2'],
sort: 'CREATED_ASC',
after: 'AFTER_CURSOR',
first: RUNNER_PAGE_SIZE,
......
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