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';
// - 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_SORT = 'sort';
......
......@@ -6,6 +6,7 @@ query getRunners(
$after: String
$first: Int
$last: Int
$search: String
$status: CiRunnerStatus
$type: CiRunnerType
$sort: CiRunnerSort
......@@ -15,6 +16,7 @@ query getRunners(
after: $after
first: $first
last: $last
search: $search
status: $status
type: $type
sort: $sort
......
......@@ -12,7 +12,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from './filtered_search_utils';
} from './runner_search_utils';
export default {
components: {
......
import { queryToObject, setUrlParams } from '~/lib/utils/url_utility';
import {
filterToQueryObject,
processFilters,
urlQueryToFilter,
prepareTokens,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
PARAM_KEY_SEARCH,
PARAM_KEY_STATUS,
PARAM_KEY_RUNNER_TYPE,
PARAM_KEY_SORT,
......@@ -10,30 +17,6 @@ import {
RUNNER_PAGE_SIZE,
} 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 page = parseInt(params[PARAM_KEY_PAGE], 10);
const after = params[PARAM_KEY_AFTER];
......@@ -55,10 +38,13 @@ export const fromUrlQueryToSearch = (query = window.location.search) => {
const params = queryToObject(query, { gatherArrays: true });
return {
filters: [
...getFilterFromParams(PARAM_KEY_STATUS, params),
...getFilterFromParams(PARAM_KEY_RUNNER_TYPE, params),
],
filters: prepareTokens(
urlQueryToFilter(query, {
filterNamesAllowList: [PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE],
filteredSearchTermKey: PARAM_KEY_SEARCH,
legacySpacesDecode: false,
}),
),
sort: params[PARAM_KEY_SORT] || DEFAULT_SORT,
pagination: getPaginationFromParams(params),
};
......@@ -68,37 +54,44 @@ export const fromSearchToUrl = (
{ filters = [], sort = null, pagination = {} },
url = window.location.href,
) => {
const urlParams = {
[PARAM_KEY_STATUS]: getValuesFromFilters(PARAM_KEY_STATUS, filters),
[PARAM_KEY_RUNNER_TYPE]: getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters),
const filterParams = {
// Defaults
[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) {
urlParams[PARAM_KEY_SORT] = sort;
}
// Remove pagination params for first page
if (pagination?.page === 1) {
urlParams[PARAM_KEY_PAGE] = null;
urlParams[PARAM_KEY_BEFORE] = null;
urlParams[PARAM_KEY_AFTER] = null;
} else {
urlParams[PARAM_KEY_PAGE] = pagination.page;
urlParams[PARAM_KEY_BEFORE] = pagination.before;
urlParams[PARAM_KEY_AFTER] = pagination.after;
}
const isDefaultSort = sort !== DEFAULT_SORT;
const isFirstPage = pagination?.page === 1;
const otherParams = {
// Sorting & Pagination
[PARAM_KEY_SORT]: isDefaultSort ? sort : null,
[PARAM_KEY_PAGE]: isFirstPage ? null : pagination.page,
[PARAM_KEY_BEFORE]: isFirstPage ? null : pagination.before,
[PARAM_KEY_AFTER]: isFirstPage ? null : pagination.after,
};
return setUrlParams(urlParams, url, false, true, true);
return setUrlParams({ ...filterParams, ...otherParams }, url, false, true, true);
};
export const fromSearchToVariables = ({ filters = [], sort = null, pagination = {} } = {}) => {
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"
[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"
[variables.type] = getValuesFromFilters(PARAM_KEY_RUNNER_TYPE, filters);
[variables.type] = queryObj[PARAM_KEY_RUNNER_TYPE] || [];
if (sort) {
variables.sort = sort;
......
......@@ -2,7 +2,7 @@ import { isEmpty, uniqWith, isEqual } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
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.
......@@ -23,7 +23,7 @@ export const stripQuotes = (value) => value.replace(/^('|")(.*)('|")$/, '$2');
export const uniqueTokens = (tokens) => {
const knownTokens = [];
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}`;
if (!knownTokens.includes(tokenString)) {
uniques.push(token);
......@@ -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:
* { myFilterName: { value: 'foo', operator: '=' } }
* { myFilterName: { value: 'foo', operator: '=' }, search: [{ value: 'my' }, { value: 'search' }] }
* gets translated into:
* { myFilterName: 'foo', 'not[myFilterName]': null }
* { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' }
* @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
*/
export function filterToQueryObject(filters = {}) {
export function filterToQueryObject(filters = {}, options = {}) {
const { filteredSearchTermKey } = options;
return Object.keys(filters).reduce((memo, key) => {
const filter = filters[key];
if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM) {
return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) };
}
let selected;
let unselected;
if (Array.isArray(filter)) {
selected = 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 = {}) {
* and returns the operator with it depending on the filter name
* @param {String} filterName from url
* @return {Object}
* @return {Object.filterName} extracted filtern ame
* @return {Object.filterName} extracted filter name
* @return {Object.operator} `=` or `!=`
*/
function extractNameAndOperator(filterName) {
......@@ -137,22 +153,53 @@ function extractNameAndOperator(filterName) {
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:
* '?myFilterName=foo'
* gets translated into:
* { myFilterName: { value: 'foo', operator: '=' } }
* @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
*/
export function urlQueryToFilter(query = '') {
const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode: true });
export function urlQueryToFilter(query = '', options = {}) {
const { filteredSearchTermKey, filterNamesAllowList, legacySpacesDecode = true } = options;
const filters = queryToObject(query, { gatherArrays: true, legacySpacesDecode });
return Object.keys(filters).reduce((memo, key) => {
const value = filters[key];
if (!value) {
return memo;
}
if (key === filteredSearchTermKey) {
return {
...memo,
[FILTERED_SEARCH_TERM]: filteredSearchTermValue(value),
};
}
const { filterName, operator } = extractNameAndOperator(key);
if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) {
return memo;
}
let previousValues = [];
if (Array.isArray(memo[filterName])) {
previousValues = memo[filterName];
......
......@@ -3,7 +3,7 @@ import {
fromUrlQueryToSearch,
fromSearchToUrl,
fromSearchToVariables,
} from '~/runner/runner_list/filtered_search_utils';
} from '~/runner/runner_list/runner_search_utils';
describe('search_params.js', () => {
const examples = [
......@@ -23,6 +23,40 @@ describe('search_params.js', () => {
},
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',
urlQuery: '?runner_type[]=INSTANCE_TYPE',
......@@ -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`', () => {
expect(fromUrlQueryToSearch('?page=NONSENSE&after=AFTER_CURSOR').pagination).toEqual({
page: 1,
......@@ -136,12 +177,15 @@ describe('search_params.js', () => {
});
});
it('When a filtered search parameter is already present, it gets removed', () => {
const initialUrl = `http://test.host/?status[]=ACTIVE`;
it.each([
'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 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', () => {
......@@ -159,5 +203,37 @@ describe('search_params.js', () => {
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 AccessorUtilities from '~/lib/utils/accessor';
import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants';
import {
stripQuotes,
uniqueTokens,
......@@ -210,6 +213,19 @@ describe('filterToQueryObject', () => {
const res = filterToQueryObject({ [token]: value });
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', () => {
......@@ -255,10 +271,61 @@ describe('urlQueryToFilter', () => {
},
],
['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }],
])('gathers filter values %s into query object=%j', (query, result) => {
const res = urlQueryToFilter(query);
expect(res).toEqual(result);
});
['nop=1&not[nop]=2', {}, { filterNamesAllowList: ['foo'] }],
[
'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', () => {
......
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