diff --git a/src/redux/hooks/useReactRedux.js b/src/redux/hooks/useReactRedux.js index 6861acfe6..c3fbdaafd 100644 --- a/src/redux/hooks/useReactRedux.js +++ b/src/redux/hooks/useReactRedux.js @@ -1,4 +1,5 @@ import { useSelector as UseSelector, shallowEqual } from 'react-redux'; +import { createSelector } from 'reselect'; import { store } from '../store'; import { helpers } from '../../common/helpers'; @@ -28,10 +29,25 @@ const useSelector = (selector, value = null, options = {}) => { return UseSelector(selector, options.equality) ?? value; }; +/** + * Generate a selector from multiple selectors for use in "useSelector". + * + * @param {Array} params + * @returns {Array} + */ +const useMultiSelector = (...params) => { + const [firstSel, ...selectors] = params; + const updatedSelectors = (Array.isArray(firstSel) && firstSel) || selectors; + const multiSelector = createSelector(updatedSelectors, (...results) => results); + + return useSelector(multiSelector, shallowEqual); +}; + const reactReduxHooks = { shallowEqual, useDispatch, + useMultiSelector, useSelector }; -export { reactReduxHooks as default, reactReduxHooks, shallowEqual, useDispatch, useSelector }; +export { reactReduxHooks as default, reactReduxHooks, shallowEqual, useDispatch, useMultiSelector, useSelector }; diff --git a/src/redux/selectors/__tests__/graphSelectors.test.js b/src/redux/selectors/__tests__/graphSelectors.test.js deleted file mode 100644 index 16ae874e2..000000000 --- a/src/redux/selectors/__tests__/graphSelectors.test.js +++ /dev/null @@ -1,160 +0,0 @@ -import graphSelectors from '../graphSelectors'; -import { - RHSM_API_RESPONSE_TALLY_META_TYPES as TALLY_META_TYPES, - rhsmConstants -} from '../../../services/rhsm/rhsmConstants'; - -describe('GraphSelectors', () => { - it('should return specific selectors', () => { - expect(graphSelectors).toMatchSnapshot('selectors'); - }); - - it('should pass minimal data on missing a reducer response', () => { - const state = {}; - expect(graphSelectors.graph(state)).toMatchSnapshot('missing reducer error'); - }); - - it('should handle an error state', () => { - const data = { - productId: 'Lorem Ipsum', - metrics: ['Dolor Sit', 'Et all'] - }; - const props = {}; - const state = { - graph: { - tally: { - 'Lorem Ipsum_Dolor Sit': { - error: true, - errorMessage: `I'm a teapot`, - fulfilled: false, - pending: false, - status: 418, - meta: {}, - metaId: 'Lorem Ipsum_Dolor Sit', - metaIdMetric: { id: 'Lorem Ipsum', metric: 'Dolor Sit' }, - metaQuery: {} - }, - 'Lorem Ipsum_Et all': { - error: true, - errorMessage: `I'm a teapot`, - fulfilled: false, - pending: false, - status: 418, - meta: {}, - metaId: 'Lorem Ipsum_Et all', - metaIdMetric: { id: 'Lorem Ipsum', metric: 'Et all' }, - metaQuery: {} - } - } - } - }; - - expect(graphSelectors.graph(state, props, data)).toMatchSnapshot('error state'); - }); - - it('should handle a cancelled state', () => { - const data = { - productId: 'Lorem Ipsum', - metrics: ['Dolor Sit'] - }; - const props = {}; - const state = { - graph: { - tally: { - 'Lorem Ipsum_Dolor Sit': { - date: 'mock date', - cancelled: true, - error: false, - errorMessage: '', - fulfilled: false, - pending: false, - meta: {}, - metaId: 'Lorem Ipsum_Dolor Sit', - metaIdMetric: { id: 'Lorem Ipsum', metric: 'Dolor Sit' }, - metaQuery: {} - } - } - } - }; - - expect(graphSelectors.graph(state, props, data)).toMatchSnapshot('cancelled state'); - }); - - it('should handle a pending state', () => { - const data = { - productId: 'Lorem Ipsum', - metrics: ['Dolor Sit'] - }; - const props = {}; - const state = { - graph: { - tally: { - 'Lorem Ipsum_Dolor Sit': { - error: false, - errorMessage: '', - fulfilled: false, - pending: true, - meta: {}, - metaId: 'Lorem Ipsum_Dolor Sit', - metaIdMetric: { id: 'Lorem Ipsum', metric: 'Dolor Sit' }, - metaQuery: {} - } - } - } - }; - - expect(graphSelectors.graph(state, props, data)).toMatchSnapshot('pending state'); - }); - - it('should handle a fulfilled state', () => { - const data = { - productId: 'Lorem Ipsum', - metrics: ['Dolor Sit'] - }; - const props = {}; - const state = { - graph: { - tally: { - 'Lorem Ipsum_Dolor Sit': { - error: false, - errorMessage: '', - fulfilled: true, - pending: false, - meta: {}, - metaId: 'Lorem Ipsum_Dolor Sit', - metaIdMetric: { id: 'Lorem Ipsum', metric: 'Dolor Sit' }, - metaQuery: {}, - date: '2019-09-05T00:00:00.000Z', - data: { - [rhsmConstants.RHSM_API_RESPONSE_DATA]: [ - { - [rhsmConstants.RHSM_API_RESPONSE_TALLY_DATA_TYPES.DATE]: '2019-09-04T00:00:00.000Z', - [rhsmConstants.RHSM_API_RESPONSE_TALLY_DATA_TYPES.VALUE]: 10, - [rhsmConstants.RHSM_API_RESPONSE_TALLY_DATA_TYPES.HAS_DATA]: true - }, - { - [rhsmConstants.RHSM_API_RESPONSE_TALLY_DATA_TYPES.DATE]: '2019-09-05T00:00:00.000Z', - [rhsmConstants.RHSM_API_RESPONSE_TALLY_DATA_TYPES.VALUE]: 15, - [rhsmConstants.RHSM_API_RESPONSE_TALLY_DATA_TYPES.HAS_DATA]: false - } - ], - [rhsmConstants.RHSM_API_RESPONSE_META]: { - [TALLY_META_TYPES.COUNT]: 2, - [TALLY_META_TYPES.METRIC_ID]: 'Dolor Sit', - [TALLY_META_TYPES.PRODUCT]: 'Lorem Ipsum', - [TALLY_META_TYPES.TOTAL_MONTHLY]: { - [TALLY_META_TYPES.DATE]: '2019-09-05T00:00:00.000Z', - [TALLY_META_TYPES.HAS_DATA]: true, - [TALLY_META_TYPES.VALUE]: 25 - } - } - }, - status: 304 - } - } - } - }; - - expect(graphSelectors.graph(state, props, data)).toMatchSnapshot('fulfilled state'); - }); -}); diff --git a/src/redux/selectors/graphSelectors.js b/src/redux/selectors/graphSelectors.js deleted file mode 100644 index 60053ebc9..000000000 --- a/src/redux/selectors/graphSelectors.js +++ /dev/null @@ -1,117 +0,0 @@ -import { createSelector } from 'reselect'; -import { - rhsmConstants, - RHSM_API_RESPONSE_TALLY_DATA_TYPES as TALLY_DATA_TYPES, - RHSM_API_RESPONSE_TALLY_META_TYPES as TALLY_META_TYPES -} from '../../services/rhsm/rhsmConstants'; - -/** - * Return a combined state, props object. - * - * @private - * @param {object} state - * @param {object} props - * @param {object} data - * @param {Array} data.metrics - * @param {string} data.productId - * @returns {object} - */ -const statePropsFilter = (state, props, { metrics = [], productId } = {}) => { - const selectedMetrics = {}; - - metrics.forEach(metricId => { - selectedMetrics[metricId] = state.graph.tally?.[`${productId}_${metricId}`] || {}; - }); - - return { - ...selectedMetrics - }; -}; - -/** - * Create selector, transform combined state, props into a consumable object. - * - * @type {{ metrics: object }} - */ -const selector = createSelector([statePropsFilter], response => { - const metrics = response || {}; - const updatedResponseData = { pending: false, fulfilled: false, error: false, metrics: {} }; - const objEntries = Object.entries(metrics); - let isPending = false; - let isFulfilled = false; - let errorCount = 0; - - objEntries.forEach(([metric, metricValue]) => { - const { pending, cancelled, error, fulfilled, data: metricData, ...metricResponse } = metricValue; - const { [rhsmConstants.RHSM_API_RESPONSE_DATA]: data = [], [rhsmConstants.RHSM_API_RESPONSE_META]: meta = {} } = - metricData || {}; - const updatedPending = pending || cancelled || false; - - if (updatedPending) { - isPending = true; - } - - if (fulfilled) { - isFulfilled = true; - } - - if (error) { - errorCount += 1; - } - - updatedResponseData.metrics[metric] = { - ...metricResponse, - pending: updatedPending, - error, - fulfilled, - data: data.map( - ( - { [TALLY_DATA_TYPES.DATE]: date, [TALLY_DATA_TYPES.VALUE]: value, [TALLY_DATA_TYPES.HAS_DATA]: hasData }, - index - ) => ({ - x: index, - y: value, - date, - hasData - }) - ), - meta: { - count: meta[TALLY_META_TYPES.COUNT] || 0, - metricId: meta[TALLY_META_TYPES.METRIC_ID], - productId: meta[TALLY_META_TYPES.PRODUCT], - totalMonthly: { - date: meta[TALLY_META_TYPES.TOTAL_MONTHLY]?.[TALLY_META_TYPES.DATE], - hasData: meta[TALLY_META_TYPES.TOTAL_MONTHLY]?.[TALLY_META_TYPES.HAS_DATA], - value: meta[TALLY_META_TYPES.TOTAL_MONTHLY]?.[TALLY_META_TYPES.VALUE] - } - } - }; - }); - - if (errorCount === objEntries.length) { - updatedResponseData.error = true; - } else if (isPending) { - updatedResponseData.pending = true; - } else if (isFulfilled) { - updatedResponseData.fulfilled = true; - } - - return updatedResponseData; -}); - -/** - * Expose selector instance. For scenarios where a selector is reused across component instances. - * - * @param {object} data Pass additional data. - * @returns {{metrics: object}} - */ -const makeSelector = data => (state, props) => ({ - ...selector(state, props, data) -}); - -const graphSelectors = { - graph: selector, - makeGraph: makeSelector -}; - -export { graphSelectors as default, graphSelectors, selector, makeSelector }; diff --git a/src/redux/selectors/index.js b/src/redux/selectors/index.js index 7b249fd8a..5d9bdb679 100644 --- a/src/redux/selectors/index.js +++ b/src/redux/selectors/index.js @@ -1,6 +1,5 @@ import appMessagesSelectors from './appMessagesSelectors'; import guestsListSelectors from './guestsListSelectors'; -import graphSelectors from './graphSelectors'; import graphCardSelectors from './graphCardSelectors'; import inventoryListSelectors from './inventoryListSelectors'; import subscriptionsListSelectors from './subscriptionsListSelectors'; @@ -9,7 +8,6 @@ import userSelectors from './userSelectors'; const reduxSelectors = { appMessages: appMessagesSelectors, guestsList: guestsListSelectors, - graph: graphSelectors, graphCard: graphCardSelectors, inventoryList: inventoryListSelectors, subscriptionsList: subscriptionsListSelectors, diff --git a/src/services/common/helpers.js b/src/services/common/helpers.js index 35c1d7bab..fa64155ad 100644 --- a/src/services/common/helpers.js +++ b/src/services/common/helpers.js @@ -27,18 +27,18 @@ const camelCase = obj => { }; /** - * Apply response schema callback to data. + * Apply data to a callback, pass original data on error. * * @param {object} data - * @param {Function} schema + * @param {Function} callback * @returns {{data: *, error}} */ -const responseNormalize = (data, schema) => { +const passDataToCallback = (data, callback) => { let error; let updatedData = data; try { - updatedData = schema(data); + updatedData = callback(data); } catch (e) { error = e; } @@ -80,8 +80,8 @@ const schemaResponse = ({ casing, convert = true, id = null, response, schema } const serviceHelpers = { camelCase, - responseNormalize, + passDataToCallback, schemaResponse }; -export { serviceHelpers as default, serviceHelpers, camelCase, responseNormalize, schemaResponse }; +export { serviceHelpers as default, serviceHelpers, camelCase, passDataToCallback, schemaResponse }; diff --git a/src/services/config.js b/src/services/config.js index 66894eb9d..a915f7a9d 100644 --- a/src/services/config.js +++ b/src/services/config.js @@ -48,14 +48,15 @@ const responseCache = new LruCache({ * @param {boolean} config.cache * @param {boolean} config.cancel * @param {string} config.cancelId - * @param {*} config.errorSchema - * @param {*} config.responseSchema + * @param {Array} config.schema + * @param {Array} config.transform * @returns {Promise<*>} */ const serviceCall = async config => { await platformServices.getUser(); const updatedConfig = { ...config, cache: undefined, cacheResponse: config.cache }; + const responseTransformers = []; const axiosInstance = axios.create(); const sortedParams = (updatedConfig.params && Object.entries(updatedConfig.params).sort(([a], [b]) => a.localeCompare(b))) || []; @@ -90,41 +91,66 @@ const serviceCall = async config => { } } - axiosInstance.interceptors.response.use( - response => { - const updatedResponse = { ...response }; + if (updatedConfig.schema) { + responseTransformers.push(updatedConfig.schema); + } + + if (updatedConfig.transform) { + responseTransformers.push(updatedConfig.transform); + } + + responseTransformers.forEach(([successTransform, errorTransform]) => { + const transformers = [undefined, response => Promise.reject(response)]; - if (updatedConfig.responseSchema) { - const { data, error: normalizeError } = serviceHelpers.responseNormalize( + if (successTransform) { + transformers[0] = response => { + const updatedResponse = { ...response }; + const { data, error: normalizeError } = serviceHelpers.passDataToCallback( updatedResponse.data, - updatedConfig.responseSchema + successTransform ); if (!normalizeError) { updatedResponse.data = data; } - } - if (updatedConfig.cacheResponse === true) { - responseCache.set(cacheId, updatedResponse); - } + return updatedResponse; + }; + } - return updatedResponse; - }, - error => { - const updatedError = { ...error }; + if (errorTransform) { + transformers[1] = response => { + const updatedResponse = { ...response }; + const { data, error: normalizeError } = serviceHelpers.passDataToCallback( + updatedResponse?.response?.data, + errorTransform + ); - if (updatedConfig.errorSchema) { - const errorData = updatedError?.response?.data; - const { data, error: normalizeError } = serviceHelpers.responseNormalize(errorData, updatedConfig.errorSchema); if (!normalizeError) { - updatedError.response = { ...updatedError.response, data }; + updatedResponse.response = { ...updatedResponse.response, data }; } - } - return updatedError; + return Promise.reject(updatedResponse); + }; } - ); + + axiosInstance.interceptors.response.use(...transformers); + }); + + if (updatedConfig.cacheResponse === true) { + axiosInstance.interceptors.response.use( + response => { + const updatedResponse = { ...response }; + + if (updatedConfig.cacheResponse === true) { + responseCache.set(cacheId, updatedResponse); + } + + return updatedResponse; + }, + response => Promise.reject(response) + ); + } return axiosInstance(serviceConfig(updatedConfig)); }; diff --git a/src/services/rhsm/rhsmSchemas.js b/src/services/rhsm/rhsmSchemas.js index fa52b4171..15a11e43e 100644 --- a/src/services/rhsm/rhsmSchemas.js +++ b/src/services/rhsm/rhsmSchemas.js @@ -37,7 +37,9 @@ const tallyItem = Joi.object({ date: Joi.date().allow(null), has_data: Joi.boolean().optional().allow(null), value: Joi.number().allow(null).default(0) -}).unknown(true); +}) + .unknown(true) + .default(); /** * Tally response meta field. diff --git a/src/services/rhsm/rhsmServices.js b/src/services/rhsm/rhsmServices.js index cb8b85b6a..c89ed5c53 100644 --- a/src/services/rhsm/rhsmServices.js +++ b/src/services/rhsm/rhsmServices.js @@ -1,6 +1,7 @@ import { serviceCall } from '../config'; import { rhsmSchemas } from './rhsmSchemas'; import { helpers } from '../../common'; +import { rhsmTranformers } from './rhsmTranformers'; /** * @api {get} /api/rhsm-subscriptions/v1/version @@ -1469,7 +1470,6 @@ const getGraphReports = (id, params = {}, options = {}) => { * ], * "links": {}, * "meta": { - * "count": 31, * "granularity": "daily", * "has_cloudigrade_data": true, * "has_cloudigrade_mismatch": true, @@ -1492,8 +1492,8 @@ const getGraphReports = (id, params = {}, options = {}) => { * @param {object} options * @param {boolean} options.cancel * @param {string} options.cancelId - * @param {*} options.errorSchema - * @param {*} options.responseSchema + * @param {Array} options.schema An array of callbacks used to transform the response, ie. [SUCCESS SCHEMA, ERROR SCHEMA] + * @param {Array} options.transform An array of callbacks used to transform the response, ie. [SUCCESS TRANSFORM, ERROR TRANSFORM] * @returns {Promise<*>} */ const getGraphTally = (id, params = {}, options = {}) => { @@ -1501,8 +1501,8 @@ const getGraphTally = (id, params = {}, options = {}) => { cache = true, cancel = true, cancelId, - errorSchema = rhsmSchemas.errors, - responseSchema = rhsmSchemas.tally + schema = [rhsmSchemas.tally, rhsmSchemas.errors], + transform = [rhsmTranformers.tally] } = options; const updatedId = (typeof id === 'string' && [id]) || (Array.isArray(id) && id) || []; @@ -1517,8 +1517,8 @@ const getGraphTally = (id, params = {}, options = {}) => { cache, cancel, cancelId, - errorSchema, - responseSchema + schema, + transform }); }; diff --git a/src/services/rhsm/rhsmTranformers.js b/src/services/rhsm/rhsmTranformers.js new file mode 100644 index 000000000..cdbbdc5d6 --- /dev/null +++ b/src/services/rhsm/rhsmTranformers.js @@ -0,0 +1,40 @@ +import { + RHSM_API_RESPONSE_TALLY_DATA_TYPES as TALLY_DATA_TYPES, + RHSM_API_RESPONSE_TALLY_META_TYPES as TALLY_META_TYPES, + rhsmConstants +} from './rhsmConstants'; + +const rhsmTally = response => { + const updatedResponse = {}; + const { [rhsmConstants.RHSM_API_RESPONSE_DATA]: data = [], [rhsmConstants.RHSM_API_RESPONSE_META]: meta = {} } = + response || {}; + + updatedResponse.data = data.map( + ( + { [TALLY_DATA_TYPES.DATE]: date, [TALLY_DATA_TYPES.VALUE]: value, [TALLY_DATA_TYPES.HAS_DATA]: hasData }, + index + ) => ({ + x: index, + y: value, + date, + hasData + }) + ); + + updatedResponse.meta = { + count: meta[TALLY_META_TYPES.COUNT], + metricId: meta[TALLY_META_TYPES.METRIC_ID], + productId: meta[TALLY_META_TYPES.PRODUCT], + totalMonthlyDate: meta[TALLY_META_TYPES.TOTAL_MONTHLY]?.[TALLY_META_TYPES.DATE], + totalMonthlyHasData: meta[TALLY_META_TYPES.TOTAL_MONTHLY]?.[TALLY_META_TYPES.HAS_DATA], + totalMonthlyValue: meta[TALLY_META_TYPES.TOTAL_MONTHLY]?.[TALLY_META_TYPES.VALUE] + }; + + return updatedResponse; +}; + +const rhsmTranformers = { + tally: rhsmTally +}; + +export { rhsmTranformers as default, rhsmTranformers, rhsmTally };