All files / app/assets/javascripts/vue_shared/components/filtered_search_bar filtered_search_utils.js

100% Statements 92/92
98.55% Branches 68/69
100% Functions 25/25
100% Lines 86/86

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296                          276x 1430x 1430x 27184x 25000x 25000x 24999x 24999x     2184x   27184x                               512x                   455x 941x 941x 518x   423x 491x     21x                   385x       250x 2x 2x   248x 248x 248x     250x 216x     250x 250x         37x 41x                                               780x   780x 1892x   1892x 37x     1855x           1855x   1855x 4832x   4832x   3937x 1615x   2322x   3937x 3407x   3937x 2082x   1855x         1855x                                   439x               439x 901x     439x 338x   101x 101x                 23x 25x                                           401x 401x 465x 465x 3x   462x 23x           439x 439x 94x   345x 345x 52x   345x 347x 319x     26x                       308x 308x 306x   308x                     6x   6x   6x 5x                         276x 1023x  
import { isEmpty, uniqWith, isEqual, isString } from 'lodash';
import AccessorUtilities from '~/lib/utils/accessor';
import { queryToObject } from '~/lib/utils/url_utility';
 
import { MAX_RECENT_TOKENS_SIZE, FILTERED_SEARCH_TERM } from './constants';
 
/**
 * This method removes duplicate tokens from tokens array.
 *
 * @param {Array} tokens Array of tokens as defined by `GlFilteredSearch`
 *
 * @returns {Array} Unique array of tokens
 */
export const uniqueTokens = (tokens) => {
  const knownTokens = [];
  return tokens.reduce((uniques, token) => {
    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);
        knownTokens.push(tokenString);
      }
    } else {
      uniques.push(token);
    }
    return uniques;
  }, []);
};
 
/**
 * Creates a token from a type and a filter. Example returned object
 * { type: 'myType', value: { data: 'myData', operator: '= '} }
 * @param  {String} type the name of the filter
 * @param  {Object}
 * @param  {Object.value} filter value to be returned as token data
 * @param  {Object.operator} filter operator to be retuned as token operator
 * @return {Object}
 * @return {Object.type} token type
 * @return {Object.value} token value
 */
function createToken(type, filter) {
  return { type, value: { data: filter.value, operator: filter.operator } };
}
 
/**
 * This function takes a filter object and translates it into a token array
 * @param  {Object} filters
 * @param  {Object.myFilterName} a single filter value or an array of filters
 * @return {Array} tokens an array of tokens created from filter values
 */
export function prepareTokens(filters = {}) {
  return Object.keys(filters).reduce((memo, key) => {
    const value = filters[key];
    if (!value) {
      return memo;
    }
    if (Array.isArray(value)) {
      return [...memo, ...value.map((filterValue) => createToken(key, filterValue))];
    }
 
    return [...memo, createToken(key, value)];
  }, []);
}
 
/**
 * This function takes a token array and translates it into a filter object
 * @param filters
 * @returns A Filter Object
 */
export function processFilters(filters) {
  return filters.reduce((acc, token) => {
    let type;
    let value;
    let operator;
    if (typeof token === 'string') {
      type = FILTERED_SEARCH_TERM;
      value = token;
    } else {
      type = token?.type;
      operator = token?.value?.operator;
      value = token?.value?.data;
    }
 
    if (!acc[type]) {
      acc[type] = [];
    }
 
    acc[type].push({ value, operator });
    return acc;
  }, {});
}
 
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: '=' }, search: [{ value: 'my' }, { value: 'search' }] }
 * gets translated into:
 * { myFilterName: 'foo', 'not[myFilterName]': null, search: 'my search' }
 * By default it supports '=' and '!=' operators. This can be extended by providing the `customOperators` option
 * @param  {Object} 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
 * @param  {Object} [options.customOperators] Allows to extend the supported operators, e.g.
 *
 *    filterToQueryObject({foo: [{ value: '100', operator: '>' }]}, {customOperators: {operator: '>',prefix: 'gt'}})
 *      returns {gt[foo]: '100'}
 *    It's also possible to restrict custom operators to a given key by setting `applyOnlyToKey` string attribute.
 *
 * @return {Object} query object with both filter name and not-name with values
 */
export function filterToQueryObject(filters = {}, options = {}) {
  const { filteredSearchTermKey, customOperators } = options;
 
  return Object.keys(filters).reduce((memo, key) => {
    const filter = filters[key];
 
    if (typeof filteredSearchTermKey === 'string' && key === FILTERED_SEARCH_TERM && filter) {
      return { ...memo, [filteredSearchTermKey]: filteredSearchQueryParam(filter) };
    }
 
    const operators = [
      { operator: '=' },
      { operator: '!=', prefix: 'not' },
      ...(customOperators ?? []),
    ];
 
    const result = {};
 
    for (const op of operators) {
      const { operator, prefix, applyOnlyToKey } = op;
 
      if (!applyOnlyToKey || applyOnlyToKey === key) {
        let value;
        if (Array.isArray(filter)) {
          value = filter.filter((item) => item.operator === operator).map((item) => item.value);
        } else {
          value = filter?.operator === operator ? filter.value : null;
        }
        if (isEmpty(value)) {
          value = null;
        }
        if (prefix) {
          result[`${prefix}[${key}]`] = value;
        } else {
          result[key] = value;
        }
      }
    }
 
    return { ...memo, ...result };
  }, {});
}
 
/**
 * Extracts filter name from url name and operator, e.g.
 *  e.g. input: not[my_filter]` output: {filterName: `my_filter`, operator: '!='}`
 *
 * By default it supports filter with the format `my_filter=foo` and `not[my_filter]=bar`. This can be extended with the `customOperators` option.
 * @param  {String} filterName from url
 * @param {Object.customOperators} It allows to extend the supported parameter, e.g.
 *  input: 'gt[filter]', { customOperators: [{ operator: '>', prefix: 'gt' }]})
 *  output: '{filterName: 'filter', operator: '>'}
 * @return {Object}
 * @return {Object.filterName} extracted filter name
 * @return {Object.operator} `=` or `!=`
 */
function extractNameAndOperator(filterName, customOperators) {
  const ops = [
    {
      prefix: 'not',
      operator: '!=',
    },
    ...(customOperators ?? []),
  ];
 
  const operator = ops.find(
    ({ prefix }) => filterName.startsWith(`${prefix}[`) && filterName.endsWith(']'),
  );
 
  if (!operator) {
    return { filterName, operator: '=' };
  }
  const { prefix } = operator;
  return { filterName: filterName.slice(prefix.length + 1, -1), operator: operator.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 [{ value: values.filter((term) => term).join(' ') }];
}
 
/**
 * 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: '=' } }
 * By default it only support '=' and '!=' operators. This can be extended with the customOperator option.
 * @param  {String|Object} query URL query string or object, e.g. from `window.location.search` or `this.$route.query`
 * @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  {Object} [options.customOperator] It allows to extend the supported parameter, e.g.
 *  input: 'gt[myFilter]=100', { customOperators: [{ operator: '>', prefix: 'gt' }]})
 *  output: '{ myFilter: {value: '100', operator: '>'}}
 * @return {Object} filter object with filter names and their values
 */
export function urlQueryToFilter(
  query = '',
  { filteredSearchTermKey, filterNamesAllowList, customOperators } = {},
) {
  const filters = isString(query) ? queryToObject(query, { gatherArrays: true }) : query;
  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, customOperators);
    if (filterNamesAllowList && !filterNamesAllowList.includes(filterName)) {
      return memo;
    }
    let previousValues = [];
    if (Array.isArray(memo[filterName])) {
      previousValues = memo[filterName];
    }
    if (Array.isArray(value)) {
      const newAdditions = value.filter(Boolean).map((item) => ({ value: item, operator }));
      return { ...memo, [filterName]: [...previousValues, ...newAdditions] };
    }
 
    return { ...memo, [filterName]: { value, operator } };
  }, {});
}
 
/**
 * Returns array of token values from localStorage
 * based on provided recentSuggestionsStorageKey
 *
 * @param {String} recentSuggestionsStorageKey
 * @returns
 */
export function getRecentlyUsedSuggestions(recentSuggestionsStorageKey) {
  let recentlyUsedSuggestions = [];
  if (AccessorUtilities.canUseLocalStorage()) {
    recentlyUsedSuggestions = JSON.parse(localStorage.getItem(recentSuggestionsStorageKey)) || [];
  }
  return recentlyUsedSuggestions;
}
 
/**
 * Sets provided token value to recently used array
 * within localStorage for provided recentSuggestionsStorageKey
 *
 * @param {String} recentSuggestionsStorageKey
 * @param {Object} tokenValue
 */
export function setTokenValueToRecentlyUsed(recentSuggestionsStorageKey, tokenValue) {
  const recentlyUsedSuggestions = getRecentlyUsedSuggestions(recentSuggestionsStorageKey);
 
  recentlyUsedSuggestions.splice(0, 0, { ...tokenValue });
 
  if (AccessorUtilities.canUseLocalStorage()) {
    localStorage.setItem(
      recentSuggestionsStorageKey,
      JSON.stringify(uniqWith(recentlyUsedSuggestions, isEqual).slice(0, MAX_RECENT_TOKENS_SIZE)),
    );
  }
}
 
/**
 * Removes `FILTERED_SEARCH_TERM` tokens with empty data
 *
 * @param filterTokens array of filtered search tokens
 * @return {Array} array of filtered search tokens
 */
export const filterEmptySearchTerm = (filterTokens = []) =>
  filterTokens.filter((token) => token.type === FILTERED_SEARCH_TERM && token.value.data);