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'; ...@@ -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