Commit ea1e1a4e authored by Miguel Rincon's avatar Miguel Rincon

Search runners by free text search

This change adds search free text search query parameters to the runner
UI and sends them to the backend for filtering.
parent ff470db2
...@@ -10,6 +10,7 @@ export const RUNNER_ENTITY_TYPE = 'Ci::Runner'; ...@@ -10,6 +10,7 @@ export const RUNNER_ENTITY_TYPE = 'Ci::Runner';
// - Used for URL params names // - Used for URL params names
// - GlFilteredSearch tokens type // - GlFilteredSearch tokens type
export const PARAM_KEY_SEARCH = 'search';
export const PARAM_KEY_STATUS = 'status'; export const PARAM_KEY_STATUS = 'status';
export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; export const PARAM_KEY_RUNNER_TYPE = 'runner_type';
export const PARAM_KEY_SORT = 'sort'; export const PARAM_KEY_SORT = 'sort';
......
...@@ -6,6 +6,7 @@ query getRunners( ...@@ -6,6 +6,7 @@ query getRunners(
$after: String $after: String
$first: Int $first: Int
$last: Int $last: Int
$search: String
$status: CiRunnerStatus $status: CiRunnerStatus
$type: CiRunnerType $type: CiRunnerType
$sort: CiRunnerSort $sort: CiRunnerSort
...@@ -15,6 +16,7 @@ query getRunners( ...@@ -15,6 +16,7 @@ query getRunners(
after: $after after: $after
first: $first first: $first
last: $last last: $last
search: $search
status: $status status: $status
type: $type type: $type
sort: $sort sort: $sort
......
...@@ -12,7 +12,7 @@ import { ...@@ -12,7 +12,7 @@ import {
fromUrlQueryToSearch, fromUrlQueryToSearch,
fromSearchToUrl, fromSearchToUrl,
fromSearchToVariables, fromSearchToVariables,
} from './filtered_search_utils'; } from './runner_search_utils';
export default { export default {
components: { components: {
......
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility'; import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import { import {
filterToQueryObject,
processFilters,
urlQueryToFilter,
prepareTokens,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
PARAM_KEY_SEARCH,
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE, PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_SORT, PARAM_KEY_SORT,
...@@ -10,30 +17,6 @@ import { ...@@ -10,30 +17,6 @@ import {
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
} from '../constants'; } from '../constants';
const getValuesFromFilters = (paramKey, filters) => {
return filters
.filter(({ type, value }) => type === paramKey && value.operator === '=')
.map(({ value }) => value.data);
};
const getFilterFromParams = (paramKey, params) => {
const value = params[paramKey];
if (!value) {
return [];
}
const values = Array.isArray(value) ? value : [value];
return values.map((data) => {
return {
type: paramKey,
value: {
data,
operator: '=',
},
};
});
};
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];
...@@ -55,10 +38,13 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { ...@@ -55,10 +38,13 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true }); const params = queryToObject(query, { gatherArrays: true });
return { return {
filters: [ filters: prepareTokens(
...getFilterFromParams(PARAM_KEY_STATUS, params), urlQueryToFilter(query, {
...getFilterFromParams(PARAM_KEY_RUNNER_TYPE, params), filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE],
], filteredSearchTermKey: PARAM_KEY_SEARCH,
legacySpacesDecode: false,
}),
),
sort: params[PARAM_KEY_SORT] || DEFAULT_SORT, sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
pagination: getPaginationFromParams(params), pagination: getPaginationFromParams(params),
}; };
...@@ -68,37 +54,44 @@ export const fromSearchToUrl = ( ...@@ -68,37 +54,44 @@ export const fromSearchToUrl = (
{ filters = [], sort = null, pagination = {} }, { filters = [], sort = null, pagination = {} },
url = window.location.href, url = window.location.href,
) => { ) => {
const urlParams = { const filterParams = {
[PARAM_KEY_STATUS]: getValuesFromFilters(PARAM_KEY_STATUS, filters), // Defaults
[PARAM_KEY_RUNNER_TYPE]: getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters), [PARAM_KEY_SEARCH]: null,
[PARAM_KEY_STATUS]: [],
[PARAM_KEY_RUNNER_TYPE]: [],
// Current filters
...filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
}),
}; };
if (sort && sort !== DEFAULT_SORT) { const isDefaultSort = sort !== DEFAULT_SORT;
urlParams[PARAM_KEY_SORT] = sort; const isFirstPage = pagination?.page === 1;
} const otherParams = {
// Sorting & Pagination
// Remove pagination params for first page [PARAM_KEY_SORT]: isDefaultSort ? sort : null,
if (pagination?.page === 1) { [PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page,
urlParams[PARAM_KEY_PAGE] = null; [PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before,
urlParams[PARAM_KEY_BEFORE] = null; [PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after,
urlParams[PARAM_KEY_AFTER] = null; };
} else {
urlParams[PARAM_KEY_PAGE] = pagination.page;
urlParams[PARAM_KEY_BEFORE] = pagination.before;
urlParams[PARAM_KEY_AFTER] = pagination.after;
}
return setUrlParams(urlParams, url, false, true, true); return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
}; };
export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => { export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => {
const variables = {}; const variables = {};
const queryObj = filterToQueryObject(processFilters(filters), {
filteredSearchTermKey: PARAM_KEY_SEARCH,
});
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"
[variables.status] = getValuesFromFilters(PARAM_KEY_STATUS, filters); [variables.status] = queryObj[PARAM_KEY_STATUS] || [];
// TODO Get more than one value when GraphQL API supports OR for "runner type" // TODO Get more than one value when GraphQL API supports OR for "runner type"
[variables.type] = getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters); [variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || [];
if (sort) { if (sort) {
variables.sort = sort; variables.sort = sort;
......
...@@ -2,7 +2,7 @@ import { isEmpty, uniqWith, isEqual } from 'lodash'; ...@@ -2,7 +2,7 @@ import { isEmpty, uniqWith, isEqual } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor'; import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility'; import { queryToObject } from '~/lib/utils/url_utility';
import { MAX_RECENT_TOKENS_SIZE } from './constants'; import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants';
/** /**
* Strips enclosing quotations from a string if it has one. * Strips enclosing quotations from a string if it has one.
...@@ -23,7 +23,7 @@ export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2'); ...@@ -23,7 +23,7 @@ export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2');
export const uniqueTokens = (tokens) => { export const uniqueTokens = (tokens) => {
const knownTokens = []; const knownTokens = [];
return tokens.reduce((uniques, token) => { return tokens.reduce((uniques, token) => {
if (typeof token === 'object' && token.type !== 'filtered-search-term') { if (typeof token === 'object' && token.type !== FILTERED_SEARCH_TERM) {
const tokenString = `${token.type}${token.value.operator}${token.value.data}`; const tokenString = `${token.type}${token.value.operator}${token.value.data}`;
if (!knownTokens.includes(tokenString)) { if (!knownTokens.includes(tokenString)) {
uniques.push(token); uniques.push(token);
...@@ -86,21 +86,37 @@ export function processFilters(filters) { ...@@ -86,21 +86,37 @@ export function processFilters(filters) {
}, {}); }, {});
} }
function filteredSearchQueryParam(filter) {
return filter
.map(({ value }) => value)
.join(' ')
.trim();
}
/** /**
* This function takes a filter object and maps it into a query object. Example filter: * This function takes a filter object and maps it into a query object. Example filter:
* { myFilterName: { value: 'foo', operator: '=' } } * { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] }
* gets translated into: * gets translated into:
* { myFilterName: 'foo', 'not[myFilterName]': null } * { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' }
* @param {Object} filters * @param {Object} filters
* @param {Object.myFilterName} a single filter value or an array of filters * @param {Object} filters.myFilterName a single filter value or an array of filters
* @param {Object} options
* @param {Object} [options.filteredSearchTermKey] if set, 'filtered-search-term' filters are assigned to this key, 'search' is suggested
* @return {Object} query object with both filter name and not-name with values * @return {Object} query object with both filter name and not-name with values
*/ */
export function filterToQueryObject(filters = {}) { export function filterToQueryObject(filters = {}, options = {}) {
const { filteredSearchTermKey } = options;
return Object.keys(filters).reduce((memo, key) => { return Object.keys(filters).reduce((memo, key) => {
const filter = filters[key]; const filter = filters[key];
if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) {
return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) };
}
let selected; let selected;
let unselected; let unselected;
if (Array.isArray(filter)) { if (Array.isArray(filter)) {
selected = filter.filter((item) => item.operator === '=').map((item) => item.value); selected = filter.filter((item) => item.operator === '=').map((item) => item.value);
unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value); unselected = filter.filter((item) => item.operator === '!=').map((item) => item.value);
...@@ -125,7 +141,7 @@ export function filterToQueryObject(filters = {}) { ...@@ -125,7 +141,7 @@ export function filterToQueryObject(filters = {}) {
* and returns the operator with it depending on the filter name * and returns the operator with it depending on the filter name
* @param {String} filterName from url * @param {String} filterName from url
* @return {Object} * @return {Object}
* @return {Object.filterName} extracted filtern ame * @return {Object.filterName} extracted filter name
* @return {Object.operator} `=` or `!=` * @return {Object.operator} `=` or `!=`
*/ */
function extractNameAndOperator(filterName) { function extractNameAndOperator(filterName) {
...@@ -137,22 +153,53 @@ function extractNameAndOperator(filterName) { ...@@ -137,22 +153,53 @@ function extractNameAndOperator(filterName) {
return { filterName, operator: '=' }; return { filterName, operator: '=' };
} }
/**
* Gathers search term as values
* @param {String|Array} value
* @returns {Array} List of search terms split by word
*/
function filteredSearchTermValue(value) {
const values = Array.isArray(value) ? value : [value];
return values
.filter((term) => term)
.join(' ')
.split(' ')
.map((term) => ({ value: term }));
}
/** /**
* This function takes a URL query string and maps it into a filter object. Example query string: * This function takes a URL query string and maps it into a filter object. Example query string:
* '?myFilterName=foo' * '?myFilterName=foo'
* gets translated into: * gets translated into:
* { myFilterName: { value: 'foo', operator: '=' } } * { myFilterName: { value: 'foo', operator: '=' } }
* @param {String} query URL query string, e.g. from `window.location.search` * @param {String} query URL query string, e.g. from `window.location.search`
* @param {Object} options
* @param {Object} options
* @param {String} [options.filteredSearchTermKey] if set, a FILTERED_SEARCH_TERM filter is created to this parameter. `'search'` is suggested
* @param {String[]} [options.filterNamesAllowList] if set, only this list of filters names is mapped
* @param {Boolean} [options.legacySpacesDecode] if set, plus symbols (+) are not encoded as spaces. `false` is suggested
* @return {Object} filter object with filter names and their values * @return {Object} filter object with filter names and their values
*/ */
export function urlQueryToFilter(query = '') { export function urlQueryToFilter(query = '', options = {}) {
const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode: true }); const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options;
const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode });
return Object.keys(filters).reduce((memo, key) => { return Object.keys(filters).reduce((memo, key) => {
const value = filters[key]; const value = filters[key];
if (!value) { if (!value) {
return memo; return memo;
} }
if (key === filteredSearchTermKey) {
return {
...memo,
[FILTERED_SEARCH_TERM]: filteredSearchTermValue(value),
};
}
const { filterName, operator } = extractNameAndOperator(key); const { filterName, operator } = extractNameAndOperator(key);
if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) {
return memo;
}
let previousValues = []; let previousValues = [];
if (Array.isArray(memo[filterName])) { if (Array.isArray(memo[filterName])) {
previousValues = memo[filterName]; previousValues = memo[filterName];
......
...@@ -3,7 +3,7 @@ import { ...@@ -3,7 +3,7 @@ import {
fromUrlQueryToSearch, fromUrlQueryToSearch,
fromSearchToUrl, fromSearchToUrl,
fromSearchToVariables, fromSearchToVariables,
} from '~/runner/runner_list/filtered_search_utils'; } from '~/runner/runner_list/runner_search_utils';
describe('search_params.js', () => { describe('search_params.js', () => {
const examples = [ const examples = [
...@@ -23,6 +23,40 @@ describe('search_params.js', () => { ...@@ -23,6 +23,40 @@ describe('search_params.js', () => {
}, },
graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE }, graphqlVariables: { status: 'ACTIVE', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
}, },
{
name: 'a single term text search',
urlQuery: '?search=something',
search: {
filters: [
{
type: 'filtered-search-term',
value: { data: 'something' },
},
],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: { search: 'something', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
},
{
name: 'a two terms text search',
urlQuery: '?search=something+else',
search: {
filters: [
{
type: 'filtered-search-term',
value: { data: 'something' },
},
{
type: 'filtered-search-term',
value: { data: 'else' },
},
],
pagination: { page: 1 },
sort: 'CREATED_DESC',
},
graphqlVariables: { search: 'something else', sort: 'CREATED_DESC', first: RUNNER_PAGE_SIZE },
},
{ {
name: 'single instance type', name: 'single instance type',
urlQuery: '?runner_type[]=INSTANCE_TYPE', urlQuery: '?runner_type[]=INSTANCE_TYPE',
...@@ -110,6 +144,13 @@ describe('search_params.js', () => { ...@@ -110,6 +144,13 @@ describe('search_params.js', () => {
}); });
}); });
it('When search params appear as array, they are concatenated', () => {
expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([
{ type: 'filtered-search-term', value: { data: 'my' } },
{ type: 'filtered-search-term', value: { data: 'text' } },
]);
});
it('When a page cannot be parsed as a number, it defaults to `1`', () => { it('When a page cannot be parsed as a number, it defaults to `1`', () => {
expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({ expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({
page: 1, page: 1,
...@@ -136,12 +177,15 @@ describe('search_params.js', () => { ...@@ -136,12 +177,15 @@ describe('search_params.js', () => {
}); });
}); });
it('When a filtered search parameter is already present, it gets removed', () => { it.each([
const initialUrl = `http://test.host/?status[]=ACTIVE`; 'http://test.host/?status[]=ACTIVE',
'http://test.host/?runner_type[]=INSTANCE_TYPE',
'http://test.host/?search=my_text',
])('When a filter is removed, it is removed from the URL', (initalUrl) => {
const search = { filters: [], sort: 'CREATED_DESC' }; const search = { filters: [], sort: 'CREATED_DESC' };
const expectedUrl = `http://test.host/`; const expectedUrl = `http://test.host/`;
expect(fromSearchToUrl(search, initialUrl)).toEqual(expectedUrl); expect(fromSearchToUrl(search, initalUrl)).toEqual(expectedUrl);
}); });
it('When unrelated search parameter is present, it does not get removed', () => { it('When unrelated search parameter is present, it does not get removed', () => {
...@@ -159,5 +203,37 @@ describe('search_params.js', () => { ...@@ -159,5 +203,37 @@ describe('search_params.js', () => {
expect(fromSearchToVariables(search)).toEqual(graphqlVariables); expect(fromSearchToVariables(search)).toEqual(graphqlVariables);
}); });
}); });
it('When a search param is empty, it gets removed', () => {
expect(
fromSearchToVariables({
filters: [
{
type: 'filtered-search-term',
value: { data: '' },
},
],
}),
).toMatchObject({
search: '',
});
expect(
fromSearchToVariables({
filters: [
{
type: 'filtered-search-term',
value: { data: 'something' },
},
{
type: 'filtered-search-term',
value: { data: '' },
},
],
}),
).toMatchObject({
search: 'something',
});
});
}); });
}); });
import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import AccessorUtilities from '~/lib/utils/accessor'; import AccessorUtilities from '~/lib/utils/accessor';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import { import {
stripQuotes, stripQuotes,
uniqueTokens, uniqueTokens,
...@@ -210,6 +213,19 @@ describe('filterToQueryObject', () => { ...@@ -210,6 +213,19 @@ describe('filterToQueryObject', () => {
const res = filterToQueryObject({ [token]: value }); const res = filterToQueryObject({ [token]: value });
expect(res).toEqual(result); expect(res).toEqual(result);
}); });
it.each([
[FILTERED_SEARCH_TERM, [{ value: '' }], { search: '' }],
[FILTERED_SEARCH_TERM, [{ value: 'bar' }], { search: 'bar' }],
[FILTERED_SEARCH_TERM, [{ value: 'bar' }, { value: '' }], { search: 'bar' }],
[FILTERED_SEARCH_TERM, [{ value: 'bar' }, { value: 'baz' }], { search: 'bar baz' }],
])(
'when filteredSearchTermKey=search gathers filter values %s=%j into query object=%j',
(token, value, result) => {
const res = filterToQueryObject({ [token]: value }, { filteredSearchTermKey: 'search' });
expect(res).toEqual(result);
},
);
}); });
describe('urlQueryToFilter', () => { describe('urlQueryToFilter', () => {
...@@ -255,10 +271,61 @@ describe('urlQueryToFilter', () => { ...@@ -255,10 +271,61 @@ describe('urlQueryToFilter', () => {
}, },
], ],
['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }], ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }],
])('gathers filter values %s into query object=%j', (query, result) => { ['nop=1&not[nop]=2', {}, { filterNamesAllowList: ['foo'] }],
const res = urlQueryToFilter(query); [
expect(res).toEqual(result); 'foo[]=bar&not[foo][]=baz&nop=xxx&not[nop]=yyy',
}); {
foo: [
{ value: 'bar', operator: '=' },
{ value: 'baz', operator: '!=' },
],
},
{ filterNamesAllowList: ['foo'] },
],
[
'search=term&foo=bar',
{
[FILTERED_SEARCH_TERM]: [{ value: 'term' }],
foo: { value: 'bar', operator: '=' },
},
{ filteredSearchTermKey: 'search' },
],
[
'search=my terms',
{
[FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }],
},
{ filteredSearchTermKey: 'search' },
],
[
'search[]=my&search[]=terms',
{
[FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }],
},
{ filteredSearchTermKey: 'search' },
],
[
'search=my+terms',
{
[FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }],
},
{ filteredSearchTermKey: 'search', legacySpacesDecode: false },
],
[
'search=my terms&foo=bar&nop=xxx',
{
[FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }],
foo: { value: 'bar', operator: '=' },
},
{ filteredSearchTermKey: 'search', filterNamesAllowList: ['foo'] },
],
])(
'gathers filter values %s into query object=%j when options %j',
(query, result, options = undefined) => {
const res = urlQueryToFilter(query, options);
expect(res).toEqual(result);
},
);
}); });
describe('getRecentlyUsedTokenValues', () => { describe('getRecentlyUsedTokenValues', () => {
......
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