Commit b2126710 authored by Michael Lunøe's avatar Michael Lunøe

Merge branch '331595-admin-runner-list-to-use-filtered_search_utils-js' into 'master'

Search runners by free text search

See merge request gitlab-org/gitlab!63745
parents e5239097 ea1e1a4e
......@@ -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