diff --git a/awx/ui_next/src/util/qs.js b/awx/ui_next/src/util/qs.js index d10585aa99..cf182445c5 100644 --- a/awx/ui_next/src/util/qs.js +++ b/awx/ui_next/src/util/qs.js @@ -1,10 +1,226 @@ +/** + * Returns queryset config with defaults, if needed + * @param {string} namespace for appending to url querystring + * @param {object} default params that are not handled with search (page, page_size and order_by) + * @param {array} params that are number fields + * @return {object} query param object + */ +export function getQSConfig( + namespace, + defaultParams = { page: 1, page_size: 5, order_by: 'name' }, + integerFields = ['page', 'page_size'], + dateFields = ['modified', 'created'] +) { + if (!namespace) { + throw new Error('a QS namespace is required'); + } + return { + namespace, + defaultParams, + integerFields, + dateFields, + }; +} + +/** + * Convert url query string to query param object + * @param {object} qs config object (used for getting defaults, current query params etc.) + * @param {string} url query string + * @return {object} query param object + */ +// TODO: rename to parseNamespacedQueryString? +export function parseQueryString(config, queryString) { + if (!queryString) { + return config.defaultParams; + } + const params = stringToObject(config, queryString); + return addDefaultsToObject(config, params); +} + +/** + * Convert query param object to url query string + * Used to encode params for interacting with the api + * @param {object} qs config object for namespacing params, filtering defaults + * @param {object} query param object + * @return {string} url query string + */ +export const encodeQueryString = params => { + if (!params) return ''; + + return Object.keys(params) + .sort() + .filter(key => params[key] !== null) + .map(key => [key, params[key]]) + .map(([key, value]) => { + // if value is array, should return more than one key value pair + if (Array.isArray(value)) { + return value + .map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) + .join('&'); + } + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + }) + .join('&'); +}; + +/** + * Convert query param object to url query string, adding namespace and removing defaults + * Used to put into url bar after ui route + * @param {object} qs config object for namespacing params, filtering defaults + * @param {object} query param object + * @return {string} url query string + */ +export const encodeNonDefaultQueryString = (config, params) => { + if (!params) return ''; + + const namespacedParams = namespaceParams(config.namespace, params); + const namespacedDefaults = namespaceParams( + config.namespace, + config.defaultParams + ); + const namespacedDefaultKeys = Object.keys(namespacedDefaults); + const namespacedParamsWithoutDefaultsKeys = Object.keys( + namespacedParams + ).filter( + key => + namespacedDefaultKeys.indexOf(key) === -1 || + !paramValueIsEqual(namespacedParams[key], namespacedDefaults[key]) + ); + + return namespacedParamsWithoutDefaultsKeys + .sort() + .filter(key => namespacedParams[key] !== null) + .map(key => { + return [key, namespacedParams[key]]; + }) + .map(([key, value]) => { + // if value is array, should return more than one key value pair + if (Array.isArray(value)) { + return value + .map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) + .join('&'); + } + return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; + }) + .join('&'); +}; + +/** + * Merges existing params of search string with new ones and returns the updated list of params + * @param {object} qs config object (used for getting defaults, current query params etc.) + * @param {object} object with params from existing search + * @param {object} object with new params to add + * @return {object} query param object + */ +export function addParams(config, oldParams, paramsToAdd) { + const namespacedOldParams = namespaceParams(config.namespace, oldParams); + const namespacedParamsToAdd = namespaceParams(config.namespace, paramsToAdd); + const namespacedDefaultParams = namespaceParams( + config.namespace, + config.defaultParams + ); + + const namespacedOldParamsNotDefaults = getNonDefaultParams( + namespacedOldParams, + namespacedDefaultParams + ); + const namespacedMergedParams = getMergedParams( + namespacedOldParamsNotDefaults, + namespacedParamsToAdd + ); + + // return updated params. + // If newParams includes updates to the defaults, they will be replaced, + // not concatenated. + return denamespaceParams(config.namespace, { + ...getDefaultParams(namespacedOldParams, namespacedDefaultParams), + ...namespacedMergedParams, + ...getRemainingNewParams(namespacedMergedParams, namespacedParamsToAdd), + }); +} + +/** + * Removes params from the search string and returns the updated list of params + * @param {object} qs config object (used for getting defaults, current query params etc.) + * @param {object} object with params from existing search + * @param {object} object with new params to remove + * @return {object} query param object + */ +export function removeParams(config, oldParams, paramsToRemove) { + const paramsEntries = []; + Object.entries(oldParams).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(val => { + paramsEntries.push([key, val]); + }); + } else { + paramsEntries.push([key, value]); + } + }); + const paramsToRemoveEntries = Object.entries(paramsToRemove); + const remainingEntries = paramsEntries.filter( + ([key, value]) => + paramsToRemoveEntries.filter( + ([newKey, newValue]) => key === newKey && value === newValue + ).length === 0 + ); + const remainingObject = arrayToObject(remainingEntries); + const defaultEntriesLeftover = Object.entries(config.defaultParams).filter( + ([key]) => !remainingObject[key] + ); + const finalParamsEntries = remainingEntries; + defaultEntriesLeftover.forEach(value => { + finalParamsEntries.push(value); + }); + return arrayToObject(finalParamsEntries); +} + +const stringToObject = (config, qs) => { + const params = {}; + qs.replace(/^\?/, '') + .split('&') + .map(s => s.split('=')) + .forEach(([nsKey, rawValue]) => { + if (!nsKey || !nsKey.startsWith(`${config.namespace}.`)) { + return; + } + const key = decodeURIComponent(nsKey.substr(config.namespace.length + 1)); + const value = parseValue(config, key, rawValue); + if (!params[key]) { + params[key] = value; + } else if (Array.isArray(params[key])) { + params[key].push(value); + } else { + params[key] = [params[key], value]; + } + }); + return params; +}; +export { stringToObject as _stringToObject }; + +function addDefaultsToObject(config, params) { + return { + ...config.defaultParams, + ...params, + }; +} +export { addDefaultsToObject as _addDefaultsToObject }; + +function parseValue(config, key, rawValue) { + if (config.integerFields && config.integerFields.some(v => v === key)) { + return parseInt(rawValue, 10); + } + // TODO: parse date fields + return decodeURIComponent(rawValue); +} + /** * helper function used to convert from * Object.entries format ([ [ key, value ], ... ]) to object * @param {array} array in the format [ [ key, value ], ...] * @return {object} object in the forms { key: value, ... } */ -const toObject = entriesArr => +const arrayToObject = entriesArr => entriesArr.reduce((acc, [key, value]) => { if (acc[key] && Array.isArray(acc[key])) { acc[key].push(value); @@ -83,176 +299,6 @@ const paramValueIsEqual = (one, two) => { return isEqual; }; -/** - * Convert query param object to url query string - * Used to encode params for interacting with the api - * @param {object} qs config object for namespacing params, filtering defaults - * @param {object} query param object - * @return {string} url query string - */ -export const encodeQueryString = params => { - if (!params) return ''; - - return Object.keys(params) - .sort() - .filter(key => params[key] !== null) - .map(key => [key, params[key]]) - .map(([key, value]) => { - // if value is array, should return more than one key value pair - if (Array.isArray(value)) { - return value - .map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) - .join('&'); - } - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - }) - .join('&'); -}; - -/** - * Convert query param object to url query string, adding namespace and removing defaults - * Used to put into url bar after ui route - * @param {object} qs config object for namespacing params, filtering defaults - * @param {object} query param object - * @return {string} url query string - */ -export const encodeNonDefaultQueryString = (config, params) => { - if (!params) return ''; - - const namespacedParams = namespaceParams(config.namespace, params); - const namespacedDefaults = namespaceParams( - config.namespace, - config.defaultParams - ); - const namespacedDefaultKeys = Object.keys(namespacedDefaults); - const namespacedParamsWithoutDefaultsKeys = Object.keys( - namespacedParams - ).filter( - key => - namespacedDefaultKeys.indexOf(key) === -1 || - !paramValueIsEqual(namespacedParams[key], namespacedDefaults[key]) - ); - - return namespacedParamsWithoutDefaultsKeys - .sort() - .filter(key => namespacedParams[key] !== null) - .map(key => { - return [key, namespacedParams[key]]; - }) - .map(([key, value]) => { - // if value is array, should return more than one key value pair - if (Array.isArray(value)) { - return value - .map(val => `${encodeURIComponent(key)}=${encodeURIComponent(val)}`) - .join('&'); - } - return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; - }) - .join('&'); -}; - -/** - * Returns queryset config with defaults, if needed - * @param {string} namespace for appending to url querystring - * @param {object} default params that are not handled with search (page, page_size and order_by) - * @param {array} params that are number fields - * @return {object} query param object - */ -export function getQSConfig( - namespace, - defaultParams = { page: 1, page_size: 5, order_by: 'name' }, - integerFields = ['page', 'page_size'], - dateFields = ['modified', 'created'] -) { - if (!namespace) { - throw new Error('a QS namespace is required'); - } - return { - namespace, - defaultParams, - integerFields, - dateFields, - }; -} - -/** - * Convert url query string to query param object - * @param {object} qs config object (used for getting defaults, current query params etc.) - * @param {string} url query string - * @return {object} query param object - */ -export function parseQueryString(config, queryString) { - if (!queryString) return config.defaultParams; - - const namespacedIntegerFields = config.integerFields.map(f => - config.namespace ? `${config.namespace}.${f}` : f - ); - - const keyValuePairs = queryString - .replace(/^\?/, '') - .split('&') - .map(s => s.split('=')) - .map(([key, value]) => { - if (namespacedIntegerFields.includes(key)) { - return [decodeURIComponent(key), parseInt(value, 10)]; - } - - return [decodeURIComponent(key), decodeURIComponent(value)]; - }); - - const keyValueObject = toObject(keyValuePairs); - - // needs to return array for duplicate keys - // ie [[k1, v1], [k1, v2], [k2, v3]] - // -> [[k1, [v1, v2]], [k2, v3]] - const dedupedKeyValuePairs = Object.keys(keyValueObject).map(key => { - const values = keyValuePairs.filter(([k]) => k === key).map(([, v]) => v); - - if (values.length === 1) { - return [key, values[0]]; - } - - return [key, values]; - }); - - const parsed = Object.assign( - ...dedupedKeyValuePairs.map(([k, v]) => ({ - [k]: v, - })) - ); - - const namespacedParams = {}; - - Object.keys(parsed).forEach(field => { - if (namespaceMatches(config.namespace, field)) { - let fieldname = field; - if (config.namespace) { - fieldname = field.substr(config.namespace.length + 1); - } - namespacedParams[fieldname] = parsed[field]; - } - }); - - const namespacedDefaults = namespaceParams( - config.namespace, - config.defaultParams - ); - - Object.keys(namespacedDefaults) - .filter(key => Object.keys(parsed).indexOf(key) === -1) - .forEach(field => { - if (namespaceMatches(config.namespace, field)) { - let fieldname = field; - if (config.namespace) { - fieldname = field.substr(config.namespace.length + 1); - } - namespacedParams[fieldname] = namespacedDefaults[field]; - } - }); - - return namespacedParams; -} - /** * helper function to get params that are defaults * @param {object} namespaced params object @@ -260,7 +306,7 @@ export function parseQueryString(config, queryString) { * @return {object} namespaced params object of only defaults */ const getDefaultParams = (params, defaults) => - toObject( + arrayToObject( Object.keys(params) .filter(key => Object.keys(defaults).indexOf(key) > -1) .map(key => [key, params[key]]) @@ -273,7 +319,7 @@ const getDefaultParams = (params, defaults) => * @return {object} namespaced params object of non-defaults */ const getNonDefaultParams = (params, defaults) => - toObject( + arrayToObject( Object.keys(params) .filter(key => Object.keys(defaults).indexOf(key) === -1) .map(key => [key, params[key]]) @@ -286,7 +332,7 @@ const getNonDefaultParams = (params, defaults) => * @return {object} merged namespaced params object */ const getMergedParams = (oldParams, newParams) => - toObject( + arrayToObject( Object.keys(oldParams).map(key => { let oldVal = oldParams[key]; const newVal = newParams[key]; @@ -308,78 +354,8 @@ const getMergedParams = (oldParams, newParams) => * @return {object} remaining new namespaced params object */ const getRemainingNewParams = (mergedParams, newParams) => - toObject( + arrayToObject( Object.keys(newParams) .filter(key => Object.keys(mergedParams).indexOf(key) === -1) .map(key => [key, newParams[key]]) ); - -/** - * Merges existing params of search string with new ones and returns the updated list of params - * @param {object} qs config object (used for getting defaults, current query params etc.) - * @param {object} object with params from existing search - * @param {object} object with new params to add - * @return {object} query param object - */ -export function addParams(config, oldParams, paramsToAdd) { - const namespacedOldParams = namespaceParams(config.namespace, oldParams); - const namespacedParamsToAdd = namespaceParams(config.namespace, paramsToAdd); - const namespacedDefaultParams = namespaceParams( - config.namespace, - config.defaultParams - ); - - const namespacedOldParamsNotDefaults = getNonDefaultParams( - namespacedOldParams, - namespacedDefaultParams - ); - const namespacedMergedParams = getMergedParams( - namespacedOldParamsNotDefaults, - namespacedParamsToAdd - ); - - // return updated params. - // If newParams includes updates to the defaults, they will be replaced, - // not concatenated. - return denamespaceParams(config.namespace, { - ...getDefaultParams(namespacedOldParams, namespacedDefaultParams), - ...namespacedMergedParams, - ...getRemainingNewParams(namespacedMergedParams, namespacedParamsToAdd), - }); -} - -/** - * Removes params from the search string and returns the updated list of params - * @param {object} qs config object (used for getting defaults, current query params etc.) - * @param {object} object with params from existing search - * @param {object} object with new params to remove - * @return {object} query param object - */ -export function removeParams(config, oldParams, paramsToRemove) { - const paramsEntries = []; - Object.entries(oldParams).forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach(val => { - paramsEntries.push([key, val]); - }); - } else { - paramsEntries.push([key, value]); - } - }); - const paramsToRemoveEntries = Object.entries(paramsToRemove); - const remainingEntries = paramsEntries.filter( - ([key, value]) => - paramsToRemoveEntries.filter( - ([newKey, newValue]) => key === newKey && value === newValue - ).length === 0 - ); - const remainingObject = toObject(remainingEntries); - const defaultEntriesLeftover = Object.entries(config.defaultParams).filter( - ([key]) => !remainingObject[key] - ); - const finalParamsEntries = remainingEntries; - defaultEntriesLeftover.forEach(value => { - finalParamsEntries.push(value); - }); - return toObject(finalParamsEntries); -} diff --git a/awx/ui_next/src/util/qs.test.js b/awx/ui_next/src/util/qs.test.js index b3c459b9ba..3ddd3a94c4 100644 --- a/awx/ui_next/src/util/qs.test.js +++ b/awx/ui_next/src/util/qs.test.js @@ -5,6 +5,9 @@ import { getQSConfig, addParams, removeParams, + _paramStringToArray, + _stringToObject, + _addDefaultsToObject, } from './qs'; describe('qs (qs.js)', () => { @@ -81,6 +84,7 @@ describe('qs (qs.js)', () => { dateFields: ['modified', 'created'], defaultParams: { page: 1, page_size: 5, order_by: 'name' }, integerFields: ['page', 'page_size'], + dateFields: ['modified', 'created'], }); }); @@ -98,6 +102,7 @@ describe('qs (qs.js)', () => { dateFields: ['modified', 'created'], defaultParams: { page: 1, page_size: 15 }, integerFields: ['page', 'page_size'], + dateFields: ['modified', 'created'], }); }); }); @@ -105,11 +110,11 @@ describe('qs (qs.js)', () => { describe('parseQueryString', () => { test('should get query params', () => { const config = { - namespace: null, + namespace: 'item', defaultParams: { page: 1, page_size: 15 }, integerFields: ['page', 'page_size'], }; - const query = '?baz=bar&page=3'; + const query = '?item.baz=bar&item.page=3'; expect(parseQueryString(config, query)).toEqual({ baz: 'bar', page: 3, @@ -132,11 +137,11 @@ describe('qs (qs.js)', () => { test('should get query params with correct integer fields', () => { const config = { - namespace: null, + namespace: 'item', defaultParams: {}, integerFields: ['page', 'foo'], }; - const query = '?foo=4&bar=5'; + const query = '?item.foo=4&item.bar=5'; expect(parseQueryString(config, query)).toEqual({ foo: 4, bar: '5', @@ -145,16 +150,28 @@ describe('qs (qs.js)', () => { test('should decode parsed params', () => { const config = { - namespace: null, + namespace: 'item', defaultParams: {}, integerFields: ['page'], }; - const query = '?foo=bar%20baz'; + const query = '?item.foo=bar%20baz'; expect(parseQueryString(config, query)).toEqual({ foo: 'bar baz', }); }); + test('should decode param keys', () => { + const config = { + namespace: 'item', + defaultParams: {}, + integerFields: ['page'], + }; + const query = '?item.foo%20bar=baz'; + expect(parseQueryString(config, query)).toEqual({ + 'foo bar': 'baz', + }); + }); + test('should get namespaced query params', () => { const config = { namespace: 'inventory', @@ -199,11 +216,11 @@ describe('qs (qs.js)', () => { test('should add duplicate non-default params as array', () => { const config = { - namespace: null, + namespace: 'item', defaultParams: { page: 1, page_size: 15 }, integerFields: ['page', 'page_size'], }; - const query = '?baz=bar&baz=boo&page=3'; + const query = '?item.baz=bar&item.baz=boo&item.page=3'; expect(parseQueryString(config, query)).toEqual({ baz: ['bar', 'boo'], page: 3, @@ -550,4 +567,79 @@ describe('qs (qs.js)', () => { }); }); }); + + describe('_stringToObject', () => { + it('should convert to object', () => { + const config = { namespace: 'unit' }; + expect(_stringToObject(config, '?unit.foo=bar&unit.baz=bam')).toEqual({ + foo: 'bar', + baz: 'bam', + }); + }); + + it('should convert duplicated keys to array', () => { + const config = { namespace: 'unit' }; + expect(_stringToObject(config, '?unit.foo=bar&unit.foo=bam')).toEqual({ + foo: ['bar', 'bam'], + }); + }); + + it('should omit keys from other namespaces', () => { + const config = { namespace: 'unit' }; + expect( + _stringToObject(config, '?unit.foo=bar&other.bar=bam&one=two') + ).toEqual({ + foo: 'bar', + }); + }); + + it('should convert numbers to correct type', () => { + const config = { + namespace: 'unit', + integerFields: ['page'], + }; + expect(_stringToObject(config, '?unit.page=3')).toEqual({ + page: 3, + }); + }); + }); + + describe('_addDefaultsToObject', () => { + it('should add missing default values', () => { + const config = { + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + } + expect(_addDefaultsToObject(config, {})).toEqual({ + page: 1, + page_size: 5, + order_by: 'name', + }); + }); + + it('should not override existing params', () => { + const config = { + defaultParams: { page: 1, page_size: 5, order_by: 'name' }, + } + const params = { + page: 2, + order_by: 'date_created', + } + expect(_addDefaultsToObject(config, params)).toEqual({ + page: 2, + page_size: 5, + order_by: 'date_created', + }); + }); + + it('should handle missing defaultParams', () => { + const params = { + page: 2, + order_by: 'date_created', + } + expect(_addDefaultsToObject({}, params)).toEqual({ + page: 2, + order_by: 'date_created', + }); + }) + }) });