diff --git a/src/redux/actions/__tests__/rhsmActions.test.js b/src/redux/actions/__tests__/rhsmActions.test.js index 798ec6579..af8f98af0 100644 --- a/src/redux/actions/__tests__/rhsmActions.test.js +++ b/src/redux/actions/__tests__/rhsmActions.test.js @@ -63,7 +63,7 @@ describe('RhsmActions', () => { dispatcher(store.dispatch).then(() => { const response = store.getState().inventory; - expect(response.hostsInventoryGuests.fulfilled).toBe(true); + expect(response.hostsGuests.fulfilled).toBe(true); done(); }); }); diff --git a/src/redux/common/reduxHelpers.js b/src/redux/common/reduxHelpers.js index e42335968..d33c964c2 100644 --- a/src/redux/common/reduxHelpers.js +++ b/src/redux/common/reduxHelpers.js @@ -274,6 +274,7 @@ const generatedPromiseActionReducer = (types = [], state = {}, action = {}) => { ...expandMetaTypes(action.meta) }; + // Automatically apply data and state to a contextual ID if meta.id exists. const setId = data => (action.meta && action.meta.id && { [action.meta.id]: { ...baseState, ...data } }) || { ...baseState, ...data }; diff --git a/src/redux/reducers/__tests__/__snapshots__/inventoryReducer.test.js.snap b/src/redux/reducers/__tests__/__snapshots__/inventoryReducer.test.js.snap index a21384fd7..ecb782847 100644 --- a/src/redux/reducers/__tests__/__snapshots__/inventoryReducer.test.js.snap +++ b/src/redux/reducers/__tests__/__snapshots__/inventoryReducer.test.js.snap @@ -3,8 +3,7 @@ exports[`InventoryReducer should handle all defined error types: rejected types GET_HOSTS_INVENTORY_GUESTS_RHSM 1`] = ` Object { "result": Object { - "hostsInventory": Object {}, - "hostsInventoryGuests": Object { + "hostsGuests": Object { "error": true, "errorMessage": "MESSAGE", "fulfilled": false, @@ -15,6 +14,7 @@ Object { "pending": false, "status": 0, }, + "hostsInventory": Object {}, }, "type": "GET_HOSTS_INVENTORY_GUESTS_RHSM_REJECTED", } @@ -23,6 +23,7 @@ Object { exports[`InventoryReducer should handle all defined error types: rejected types GET_HOSTS_INVENTORY_RHSM 1`] = ` Object { "result": Object { + "hostsGuests": Object {}, "hostsInventory": Object { "error": true, "errorMessage": "MESSAGE", @@ -34,7 +35,6 @@ Object { "pending": false, "status": 0, }, - "hostsInventoryGuests": Object {}, }, "type": "GET_HOSTS_INVENTORY_RHSM_REJECTED", } @@ -43,8 +43,7 @@ Object { exports[`InventoryReducer should handle all defined fulfilled types: fulfilled types GET_HOSTS_INVENTORY_GUESTS_RHSM 1`] = ` Object { "result": Object { - "hostsInventory": Object {}, - "hostsInventoryGuests": Object { + "hostsGuests": Object { "data": Object { "test": "success", }, @@ -59,6 +58,7 @@ Object { "pending": false, "status": 0, }, + "hostsInventory": Object {}, }, "type": "GET_HOSTS_INVENTORY_GUESTS_RHSM_FULFILLED", } @@ -67,6 +67,7 @@ Object { exports[`InventoryReducer should handle all defined fulfilled types: fulfilled types GET_HOSTS_INVENTORY_RHSM 1`] = ` Object { "result": Object { + "hostsGuests": Object {}, "hostsInventory": Object { "data": Object { "test": "success", @@ -82,7 +83,6 @@ Object { "pending": false, "status": 0, }, - "hostsInventoryGuests": Object {}, }, "type": "GET_HOSTS_INVENTORY_RHSM_FULFILLED", } @@ -91,8 +91,7 @@ Object { exports[`InventoryReducer should handle all defined pending types: pending types GET_HOSTS_INVENTORY_GUESTS_RHSM 1`] = ` Object { "result": Object { - "hostsInventory": Object {}, - "hostsInventoryGuests": Object { + "hostsGuests": Object { "error": false, "errorMessage": "", "fulfilled": false, @@ -102,6 +101,7 @@ Object { "metaQuery": undefined, "pending": true, }, + "hostsInventory": Object {}, }, "type": "GET_HOSTS_INVENTORY_GUESTS_RHSM_PENDING", } @@ -110,6 +110,7 @@ Object { exports[`InventoryReducer should handle all defined pending types: pending types GET_HOSTS_INVENTORY_RHSM 1`] = ` Object { "result": Object { + "hostsGuests": Object {}, "hostsInventory": Object { "error": false, "errorMessage": "", @@ -120,7 +121,6 @@ Object { "metaQuery": undefined, "pending": true, }, - "hostsInventoryGuests": Object {}, }, "type": "GET_HOSTS_INVENTORY_RHSM_PENDING", } diff --git a/src/redux/reducers/inventoryReducer.js b/src/redux/reducers/inventoryReducer.js index 6c624dcfb..d68169ded 100644 --- a/src/redux/reducers/inventoryReducer.js +++ b/src/redux/reducers/inventoryReducer.js @@ -9,7 +9,7 @@ import { reduxHelpers } from '../common/reduxHelpers'; */ const initialState = { hostsInventory: {}, - hostsInventoryGuests: {} + hostsGuests: {} }; /** @@ -23,7 +23,7 @@ const inventoryReducer = (state = initialState, action) => reduxHelpers.generatedPromiseActionReducer( [ { ref: 'hostsInventory', type: rhsmTypes.GET_HOSTS_INVENTORY_RHSM }, - { ref: 'hostsInventoryGuests', type: rhsmTypes.GET_HOSTS_INVENTORY_GUESTS_RHSM } + { ref: 'hostsGuests', type: rhsmTypes.GET_HOSTS_INVENTORY_GUESTS_RHSM } ], state, action diff --git a/src/redux/selectors/__tests__/__snapshots__/guestListSelectors.test.js.snap b/src/redux/selectors/__tests__/__snapshots__/guestListSelectors.test.js.snap new file mode 100644 index 000000000..6b57a7f13 --- /dev/null +++ b/src/redux/selectors/__tests__/__snapshots__/guestListSelectors.test.js.snap @@ -0,0 +1,152 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`GuestsListSelectors should handle pending state on a product ID: pending 1`] = ` +Object { + "error": false, + "fulfilled": false, + "listData": Array [], + "pending": true, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should map a fulfilled product ID response to an aggregated output: fulfilled 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "displayName": "db.lorem.com", + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "17 days ago", + "subscriptionManagerId": "adafd9d5-5b00-42fa-a6c9-75801d45cc6d", + }, + Object { + "displayName": "db.ipsum.com", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "subscriptionManagerId": "b101a72f-1859-4489-acb8-d6d31c2578c4", + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should pass minimal data on a product ID without a product ID provided: no product id error 1`] = ` +Object { + "error": false, + "fulfilled": false, + "listData": Array [], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should pass minimal data on missing a reducer response: missing reducer error 1`] = ` +Object { + "error": false, + "fulfilled": false, + "listData": Array [], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should populate data from the in memory cache: cached data: ERROR, query mismatch 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "displayName": "db.ipsum.com", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "subscriptionManagerId": "b101a72f-1859-4489-acb8-d6d31c2578c4", + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should populate data from the in memory cache: cached data: cache used and pending 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "displayName": "db.lorem.com", + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "17 days ago", + "subscriptionManagerId": "adafd9d5-5b00-42fa-a6c9-75801d45cc6d", + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should populate data from the in memory cache: cached data: initial fulfilled 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "displayName": "db.lorem.com", + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "17 days ago", + "subscriptionManagerId": "adafd9d5-5b00-42fa-a6c9-75801d45cc6d", + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should populate data from the in memory cache: cached data: updated and fulfilled 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "displayName": "db.ipsum.com", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "subscriptionManagerId": "b101a72f-1859-4489-acb8-d6d31c2578c4", + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should populate data on a product ID when the api response is missing expected properties: data populated, missing properties 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "displayName": null, + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "in a year", + "subscriptionManagerId": "adafd9d5-5b00-42fa-a6c9-75801d45cc6d", + }, + Object { + "displayName": "db.example.com", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`GuestsListSelectors should return specific selectors: selectors 1`] = ` +Object { + "guestsList": [Function], + "makeGuestsList": [Function], +} +`; diff --git a/src/redux/selectors/__tests__/__snapshots__/inventoryListSelectors.test.js.snap b/src/redux/selectors/__tests__/__snapshots__/inventoryListSelectors.test.js.snap new file mode 100644 index 000000000..61eca7fe1 --- /dev/null +++ b/src/redux/selectors/__tests__/__snapshots__/inventoryListSelectors.test.js.snap @@ -0,0 +1,184 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InventoryListSelectors should handle pending state on a product ID: pending 1`] = ` +Object { + "error": false, + "fulfilled": false, + "listData": Array [], + "pending": true, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should map a fulfilled product ID response to an aggregated output: fulfilled 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "cores": 2, + "displayName": "db.lorem.com", + "hardwareType": "physical", + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "17 days ago", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + Object { + "cores": 2, + "displayName": "db.ipsum.com", + "hardwareType": "physical", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should pass minimal data on a product ID without a product ID provided: no product id error 1`] = ` +Object { + "error": false, + "fulfilled": false, + "listData": Array [], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should pass minimal data on missing a reducer response: missing reducer error 1`] = ` +Object { + "error": false, + "fulfilled": false, + "listData": Array [], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should populate data from the in memory cache: cached data: ERROR, query mismatch 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "cores": 2, + "displayName": "db.ipsum.com", + "hardwareType": "physical", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should populate data from the in memory cache: cached data: cache used and pending 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "cores": 2, + "displayName": "db.lorem.com", + "hardwareType": "physical", + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "17 days ago", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should populate data from the in memory cache: cached data: initial fulfilled 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "cores": 2, + "displayName": "db.lorem.com", + "hardwareType": "physical", + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "17 days ago", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should populate data from the in memory cache: cached data: updated and fulfilled 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "cores": 2, + "displayName": "db.ipsum.com", + "hardwareType": "physical", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should populate data on a product ID when the api response is missing expected properties: data populated, missing properties 1`] = ` +Object { + "error": false, + "fulfilled": true, + "listData": Array [ + Object { + "cores": 2, + "displayName": null, + "hardwareType": null, + "insightsId": "d6214a0b-b344-4778-831c-d53dcacb2da3", + "lastSeen": "in a year", + "numberOfGuests": null, + "sockets": 1, + "subscriptionManagerId": null, + }, + Object { + "cores": null, + "displayName": "db.example.com", + "hardwareType": "physical", + "insightsId": "9358e312-1c9f-42f4-8910-dcef6e970852", + "lastSeen": "in a month", + "numberOfGuests": null, + "sockets": null, + "subscriptionManagerId": null, + }, + ], + "pending": false, + "status": undefined, +} +`; + +exports[`InventoryListSelectors should return specific selectors: selectors 1`] = ` +Object { + "inventoryList": [Function], + "makeInventoryList": [Function], +} +`; diff --git a/src/redux/selectors/__tests__/guestListSelectors.test.js b/src/redux/selectors/__tests__/guestListSelectors.test.js new file mode 100644 index 000000000..229b5a2b0 --- /dev/null +++ b/src/redux/selectors/__tests__/guestListSelectors.test.js @@ -0,0 +1,234 @@ +import guestsListSelectors from '../guestsListSelectors'; +import { rhsmApiTypes } from '../../../types/rhsmApiTypes'; + +describe('GuestsListSelectors', () => { + it('should return specific selectors', () => { + expect(guestsListSelectors).toMatchSnapshot('selectors'); + }); + + it('should pass minimal data on missing a reducer response', () => { + const state = {}; + expect(guestsListSelectors.guestsList(state)).toMatchSnapshot('missing reducer error'); + }); + + it('should pass minimal data on a product ID without a product ID provided', () => { + const props = { + viewId: 'test', + productId: undefined, + listQuery: {} + }; + const state = { + inventory: { + hostsGuests: { + fulfilled: true, + metaId: undefined, + metaQuery: {}, + data: { [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [] } + } + } + }; + + expect(guestsListSelectors.guestsList(state, props)).toMatchSnapshot('no product id error'); + }); + + it('should handle pending state on a product ID', () => { + const props = { + viewId: 'test', + productId: 'Lorem Ipsum ID pending state' + }; + const state = { + inventory: { + hostsGuests: { + 'Lorem Ipsum ID pending state': { + pending: true, + metaId: 'Lorem Ipsum ID pending state', + metaQuery: {}, + data: { [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [] } + } + } + } + }; + + expect(guestsListSelectors.guestsList(state, props)).toMatchSnapshot('pending'); + }); + + it('should populate data on a product ID when the api response is missing expected properties', () => { + const props = { + viewId: 'test', + productId: 'Lorem Ipsum missing expected properties', + listQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + } + }; + const state = { + inventory: { + hostsGuests: { + 'Lorem Ipsum missing expected properties': { + fulfilled: true, + metaId: 'Lorem Ipsum missing expected properties', + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + }, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.ID]: + 'd6214a0b-b344-4778-831c-d53dcacb2da3', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.SUBSCRIPTION_ID]: + 'adafd9d5-5b00-42fa-a6c9-75801d45cc6d' + }, + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.ID]: + '9358e312-1c9f-42f4-8910-dcef6e970852', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.NAME]: 'db.example.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.LAST_SEEN]: '2019-09-04T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(guestsListSelectors.guestsList(state, props)).toMatchSnapshot('data populated, missing properties'); + }); + + it('should map a fulfilled product ID response to an aggregated output', () => { + const props = { + viewId: 'test', + productId: 'Lorem Ipsum fulfilled aggregated output', + listQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + } + }; + const state = { + inventory: { + hostsGuests: { + 'Lorem Ipsum fulfilled aggregated output': { + fulfilled: true, + metaId: 'Lorem Ipsum fulfilled aggregated output', + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + }, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.ID]: + 'd6214a0b-b344-4778-831c-d53dcacb2da3', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.NAME]: 'db.lorem.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.SUBSCRIPTION_ID]: + 'adafd9d5-5b00-42fa-a6c9-75801d45cc6d', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.LAST_SEEN]: '2019-07-03T00:00:00.000Z' + }, + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.ID]: + '9358e312-1c9f-42f4-8910-dcef6e970852', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.NAME]: 'db.ipsum.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.SUBSCRIPTION_ID]: + 'b101a72f-1859-4489-acb8-d6d31c2578c4', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.LAST_SEEN]: '2019-09-04T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(guestsListSelectors.guestsList(state, props)).toMatchSnapshot('fulfilled'); + }); + + it('should populate data from the in memory cache', () => { + const props = { + viewId: 'cache-test', + productId: 'Lorem Ipsum ID cached', + listQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + } + }; + const stateInitialFulfilled = { + inventory: { + hostsGuests: { + 'Lorem Ipsum ID cached': { + fulfilled: true, + metaId: 'Lorem Ipsum ID cached', + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + }, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.ID]: + 'd6214a0b-b344-4778-831c-d53dcacb2da3', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.NAME]: 'db.lorem.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.SUBSCRIPTION_ID]: + 'adafd9d5-5b00-42fa-a6c9-75801d45cc6d', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.LAST_SEEN]: '2019-07-03T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(guestsListSelectors.guestsList(stateInitialFulfilled, props)).toMatchSnapshot( + 'cached data: initial fulfilled' + ); + + const statePending = { + inventory: { + hostsGuests: { + 'Lorem Ipsum ID cached': { + ...stateInitialFulfilled.inventory.hostsGuests['Lorem Ipsum ID cached'], + pending: true + } + } + } + }; + + expect(guestsListSelectors.guestsList(statePending, props)).toMatchSnapshot('cached data: cache used and pending'); + + const stateFulfilled = { + inventory: { + hostsGuests: { + 'Lorem Ipsum ID cached': { + ...stateInitialFulfilled.inventory.hostsGuests['Lorem Ipsum ID cached'], + fulfilled: true, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.ID]: + '9358e312-1c9f-42f4-8910-dcef6e970852', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.NAME]: 'db.ipsum.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.SUBSCRIPTION_ID]: + 'b101a72f-1859-4489-acb8-d6d31c2578c4', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES.LAST_SEEN]: '2019-09-04T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(guestsListSelectors.guestsList(stateFulfilled, props)).toMatchSnapshot('cached data: updated and fulfilled'); + + const stateFulfilledQueryMismatch = { + graph: { + reportCapacity: { + 'Lorem Ipsum ID cached': { + ...stateInitialFulfilled.inventory.hostsGuests['Lorem Ipsum ID cached'], + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.NONE + } + } + } + } + }; + + expect(guestsListSelectors.guestsList(stateFulfilledQueryMismatch, props)).toMatchSnapshot( + 'cached data: ERROR, query mismatch' + ); + }); +}); diff --git a/src/redux/selectors/__tests__/inventoryListSelectors.test.js b/src/redux/selectors/__tests__/inventoryListSelectors.test.js new file mode 100644 index 000000000..6a7c895d4 --- /dev/null +++ b/src/redux/selectors/__tests__/inventoryListSelectors.test.js @@ -0,0 +1,237 @@ +import inventoryListSelectors from '../inventoryListSelectors'; +import { rhsmApiTypes } from '../../../types/rhsmApiTypes'; + +describe('InventoryListSelectors', () => { + it('should return specific selectors', () => { + expect(inventoryListSelectors).toMatchSnapshot('selectors'); + }); + + it('should pass minimal data on missing a reducer response', () => { + const state = {}; + expect(inventoryListSelectors.inventoryList(state)).toMatchSnapshot('missing reducer error'); + }); + + it('should pass minimal data on a product ID without a product ID provided', () => { + const props = { + viewId: 'test', + productId: undefined, + listQuery: {} + }; + const state = { + inventory: { + hostsInventory: { + fulfilled: true, + metaId: undefined, + metaQuery: {}, + data: { [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [] } + } + } + }; + + expect(inventoryListSelectors.inventoryList(state, props)).toMatchSnapshot('no product id error'); + }); + + it('should handle pending state on a product ID', () => { + const props = { + viewId: 'test', + productId: 'Lorem Ipsum ID pending state' + }; + const state = { + inventory: { + hostsInventory: { + 'Lorem Ipsum ID pending state': { + pending: true, + metaId: 'Lorem Ipsum ID pending state', + metaQuery: {}, + data: { [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [] } + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(state, props)).toMatchSnapshot('pending'); + }); + + it('should populate data on a product ID when the api response is missing expected properties', () => { + const props = { + viewId: 'test', + productId: 'Lorem Ipsum missing expected properties', + listQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + } + }; + const state = { + inventory: { + hostsInventory: { + 'Lorem Ipsum missing expected properties': { + fulfilled: true, + metaId: 'Lorem Ipsum missing expected properties', + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + }, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.ID]: 'd6214a0b-b344-4778-831c-d53dcacb2da3', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.CORES]: 2, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.SOCKETS]: 1 + }, + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.ID]: '9358e312-1c9f-42f4-8910-dcef6e970852', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.NAME]: 'db.example.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.HARDWARE]: 'physical', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN]: '2019-09-04T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(state, props)).toMatchSnapshot('data populated, missing properties'); + }); + + it('should map a fulfilled product ID response to an aggregated output', () => { + const props = { + viewId: 'test', + productId: 'Lorem Ipsum fulfilled aggregated output', + listQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + } + }; + const state = { + inventory: { + hostsInventory: { + 'Lorem Ipsum fulfilled aggregated output': { + fulfilled: true, + metaId: 'Lorem Ipsum fulfilled aggregated output', + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + }, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.ID]: 'd6214a0b-b344-4778-831c-d53dcacb2da3', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.NAME]: 'db.lorem.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.CORES]: 2, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.SOCKETS]: 1, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.HARDWARE]: 'physical', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN]: '2019-07-03T00:00:00.000Z' + }, + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.ID]: '9358e312-1c9f-42f4-8910-dcef6e970852', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.NAME]: 'db.ipsum.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.CORES]: 2, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.SOCKETS]: 1, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.HARDWARE]: 'physical', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN]: '2019-09-04T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(state, props)).toMatchSnapshot('fulfilled'); + }); + + it('should populate data from the in memory cache', () => { + const props = { + viewId: 'cache-test', + productId: 'Lorem Ipsum ID cached', + listQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + } + }; + const stateInitialFulfilled = { + inventory: { + hostsInventory: { + 'Lorem Ipsum ID cached': { + fulfilled: true, + metaId: 'Lorem Ipsum ID cached', + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.PREMIUM + }, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.ID]: 'd6214a0b-b344-4778-831c-d53dcacb2da3', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.NAME]: 'db.lorem.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.CORES]: 2, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.SOCKETS]: 1, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.HARDWARE]: 'physical', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN]: '2019-07-03T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(stateInitialFulfilled, props)).toMatchSnapshot( + 'cached data: initial fulfilled' + ); + + const statePending = { + inventory: { + hostsInventory: { + 'Lorem Ipsum ID cached': { + ...stateInitialFulfilled.inventory.hostsInventory['Lorem Ipsum ID cached'], + pending: true + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(statePending, props)).toMatchSnapshot( + 'cached data: cache used and pending' + ); + + const stateFulfilled = { + inventory: { + hostsInventory: { + 'Lorem Ipsum ID cached': { + ...stateInitialFulfilled.inventory.hostsInventory['Lorem Ipsum ID cached'], + fulfilled: true, + data: { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA]: [ + { + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.ID]: '9358e312-1c9f-42f4-8910-dcef6e970852', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.NAME]: 'db.ipsum.com', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.CORES]: 2, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.SOCKETS]: 1, + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.HARDWARE]: 'physical', + [rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN]: '2019-09-04T00:00:00.000Z' + } + ] + } + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(stateFulfilled, props)).toMatchSnapshot( + 'cached data: updated and fulfilled' + ); + + const stateFulfilledQueryMismatch = { + graph: { + reportCapacity: { + 'Lorem Ipsum ID cached': { + ...stateInitialFulfilled.inventory.hostsInventory['Lorem Ipsum ID cached'], + metaQuery: { + [rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES]: rhsmApiTypes.RHSM_API_QUERY_SLA_TYPES.NONE + } + } + } + } + }; + + expect(inventoryListSelectors.inventoryList(stateFulfilledQueryMismatch, props)).toMatchSnapshot( + 'cached data: ERROR, query mismatch' + ); + }); +}); diff --git a/src/redux/selectors/guestsListSelectors.js b/src/redux/selectors/guestsListSelectors.js new file mode 100644 index 000000000..9d5820581 --- /dev/null +++ b/src/redux/selectors/guestsListSelectors.js @@ -0,0 +1,129 @@ +import { createSelector } from 'reselect'; +import moment from 'moment'; +import _isEqual from 'lodash/isEqual'; +import _camelCase from 'lodash/camelCase'; +import { rhsmApiTypes } from '../../types/rhsmApiTypes'; +import { reduxHelpers } from '../common/reduxHelpers'; +import { getCurrentDate } from '../../common/dateHelpers'; + +/** + * Selector cache. + * + * @private + * @type {{dataId: {string}, data: {object}}} + */ +const selectorCache = { dataId: null, data: {} }; + +/** + * Return a combined state, props object. + * + * @private + * @param {object} state + * @param {object} props + * @returns {object} + */ +const statePropsFilter = (state, props = {}) => ({ + ...state.inventory?.hostsGuests?.[props.productId], + ...{ + viewId: props.viewId, + productId: props.productId, + query: props.listQuery + } +}); + +/** + * Create selector, transform combined state, props into a consumable object. + * + * @type {{pending: boolean, fulfilled: boolean, listData: object, error: boolean, status: (*|number)}} + */ +const selector = createSelector([statePropsFilter], response => { + const { viewId = null, productId = null, query = {}, metaId, metaQuery = {}, ...responseData } = response || {}; + + const updatedResponseData = { + error: responseData.error || false, + fulfilled: false, + pending: responseData.pending || responseData.cancelled || false, + listData: [], + status: responseData.status + }; + + const responseMetaQuery = { ...metaQuery }; + + const cache = + (viewId && productId && selectorCache.data[`${viewId}_${productId}_${JSON.stringify(query)}`]) || undefined; + + Object.assign(updatedResponseData, { ...cache }); + + if (viewId && selectorCache.dataId !== viewId) { + selectorCache.dataId = viewId; + selectorCache.data = {}; + } + + if (responseData.fulfilled && productId === metaId && _isEqual(query, responseMetaQuery)) { + const inventory = responseData.data; + const listData = inventory?.[rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA] || []; + + updatedResponseData.listData.length = 0; + + // Populate expected API response values with undefined + const [hostsSchema = {}] = reduxHelpers.setResponseSchemas([ + rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_GUESTS_DATA_TYPES + ]); + + // Apply "display logic" then return a custom value for entries + const customInventoryValue = ({ key, value }) => { + switch (key) { + case rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN: + return moment.utc(value).startOf('day').from(getCurrentDate()) || null; + default: + return value ?? null; + } + }; + + // Generate reflected properties + listData.forEach(value => { + const generateReflectedData = ({ dataObj, keyPrefix = '', customValue = null }) => { + const updatedDataObj = {}; + + Object.keys(dataObj).forEach(dataObjKey => { + const casedDataObjKey = _camelCase(`${keyPrefix} ${dataObjKey}`).trim(); + + if (typeof customValue === 'function') { + updatedDataObj[casedDataObjKey] = customValue({ data: dataObj, key: dataObjKey, value: value[dataObjKey] }); + } else { + updatedDataObj[casedDataObjKey] = value[dataObjKey]; + } + }); + + updatedResponseData.listData.push(updatedDataObj); + }; + + generateReflectedData({ dataObj: { ...hostsSchema, ...value }, customValue: customInventoryValue }); + }); + + // Update response and cache + updatedResponseData.fulfilled = true; + selectorCache.data[`${viewId}_${productId}_${JSON.stringify(query)}`] = { + ...updatedResponseData + }; + } + + return updatedResponseData; +}); + +/** + * Expose selector instance. For scenarios where a selector is reused across component instances. + * + * @param {object} defaultProps + * @returns {{pending: boolean, fulfilled: boolean, graphData: object, error: boolean, status: (*|number)}} + */ +const makeSelector = defaultProps => (state, props) => ({ + ...selector(state, props, defaultProps) +}); + +const guestsListSelectors = { + guestsList: selector, + makeGuestsList: makeSelector +}; + +export { guestsListSelectors as default, guestsListSelectors, selector, makeSelector }; diff --git a/src/redux/selectors/index.js b/src/redux/selectors/index.js index 1010bce18..65bceff05 100644 --- a/src/redux/selectors/index.js +++ b/src/redux/selectors/index.js @@ -1,9 +1,13 @@ +import guestsListSelectors from './guestsListSelectors'; import graphCardSelectors from './graphCardSelectors'; +import inventoryListSelectors from './inventoryListSelectors'; import userSelectors from './userSelectors'; import viewSelectors from './viewSelectors'; const reduxSelectors = { + guestsList: guestsListSelectors, graphCard: graphCardSelectors, + inventoryList: inventoryListSelectors, user: userSelectors, view: viewSelectors }; diff --git a/src/redux/selectors/inventoryListSelectors.js b/src/redux/selectors/inventoryListSelectors.js new file mode 100644 index 000000000..79620151a --- /dev/null +++ b/src/redux/selectors/inventoryListSelectors.js @@ -0,0 +1,127 @@ +import { createSelector } from 'reselect'; +import moment from 'moment'; +import _isEqual from 'lodash/isEqual'; +import _camelCase from 'lodash/camelCase'; +import { rhsmApiTypes } from '../../types/rhsmApiTypes'; +import { reduxHelpers } from '../common/reduxHelpers'; +import { getCurrentDate } from '../../common/dateHelpers'; + +/** + * Selector cache. + * + * @private + * @type {{dataId: {string}, data: {object}}} + */ +const selectorCache = { dataId: null, data: {} }; + +/** + * Return a combined state, props object. + * + * @private + * @param {object} state + * @param {object} props + * @returns {object} + */ +const statePropsFilter = (state, props = {}) => ({ + ...state.inventory?.hostsInventory?.[props.productId], + ...{ + viewId: props.viewId, + productId: props.productId, + query: props.listQuery + } +}); + +/** + * Create selector, transform combined state, props into a consumable object. + * + * @type {{pending: boolean, fulfilled: boolean, listData: object, error: boolean, status: (*|number)}} + */ +const selector = createSelector([statePropsFilter], response => { + const { viewId = null, productId = null, query = {}, metaId, metaQuery = {}, ...responseData } = response || {}; + + const updatedResponseData = { + error: responseData.error || false, + fulfilled: false, + pending: responseData.pending || responseData.cancelled || false, + listData: [], + status: responseData.status + }; + + const responseMetaQuery = { ...metaQuery }; + + const cache = + (viewId && productId && selectorCache.data[`${viewId}_${productId}_${JSON.stringify(query)}`]) || undefined; + + Object.assign(updatedResponseData, { ...cache }); + + if (viewId && selectorCache.dataId !== viewId) { + selectorCache.dataId = viewId; + selectorCache.data = {}; + } + + if (responseData.fulfilled && productId === metaId && _isEqual(query, responseMetaQuery)) { + const inventory = responseData.data; + const listData = inventory?.[rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA] || []; + + updatedResponseData.listData.length = 0; + + // Populate expected API response values with undefined + const [hostsSchema = {}] = reduxHelpers.setResponseSchemas([rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES]); + + // Apply "display logic" then return a custom value for entries + const customInventoryValue = ({ key, value }) => { + switch (key) { + case rhsmApiTypes.RHSM_API_RESPONSE_INVENTORY_DATA_TYPES.LAST_SEEN: + return moment.utc(value).startOf('day').from(getCurrentDate()) || null; + default: + return value ?? null; + } + }; + + // Generate reflected properties + listData.forEach(value => { + const generateReflectedData = ({ dataObj, keyPrefix = '', customValue = null }) => { + const updatedDataObj = {}; + + Object.keys(dataObj).forEach(dataObjKey => { + const casedDataObjKey = _camelCase(`${keyPrefix} ${dataObjKey}`).trim(); + + if (typeof customValue === 'function') { + updatedDataObj[casedDataObjKey] = customValue({ data: dataObj, key: dataObjKey, value: value[dataObjKey] }); + } else { + updatedDataObj[casedDataObjKey] = value[dataObjKey]; + } + }); + + updatedResponseData.listData.push(updatedDataObj); + }; + + generateReflectedData({ dataObj: { ...hostsSchema, ...value }, customValue: customInventoryValue }); + }); + + // Update response and cache + updatedResponseData.fulfilled = true; + selectorCache.data[`${viewId}_${productId}_${JSON.stringify(query)}`] = { + ...updatedResponseData + }; + } + + return updatedResponseData; +}); + +/** + * Expose selector instance. For scenarios where a selector is reused across component instances. + * + * @param {object} defaultProps + * @returns {{pending: boolean, fulfilled: boolean, graphData: object, error: boolean, status: (*|number)}} + */ +const makeSelector = defaultProps => (state, props) => ({ + ...selector(state, props, defaultProps) +}); + +const inventoryListSelectors = { + inventoryList: selector, + makeInventoryList: makeSelector +}; + +export { inventoryListSelectors as default, inventoryListSelectors, selector, makeSelector }; diff --git a/src/services/rhsmServices.js b/src/services/rhsmServices.js index ae6e5f24d..71abc0bad 100644 --- a/src/services/rhsmServices.js +++ b/src/services/rhsmServices.js @@ -934,6 +934,7 @@ const getGraphCapacity = (id, params = {}) => }); /** + * @apiMock {DelayResponse} 1000 * @api {get} /api/rhsm-subscriptions/v1/hosts/products/:product_id Get RHSM hosts/systems table/inventory data * @apiDescription Retrieve hosts/systems table/inventory data. * @@ -960,7 +961,7 @@ const getGraphCapacity = (id, params = {}) => * "cores": 4, * "sockets": 6, * "hardware_type": "physical", - * "last_seen": "2020-07-01T00:00:00Z" + * "last_seen": "2020-06-20T00:00:00Z" * } * ], * "links": { @@ -1003,6 +1004,7 @@ const getHostsInventory = (id, params = {}) => }); /** + * @apiMock {DelayResponse} 500 * @api {get} /api/rhsm-subscriptions/v1/hosts/:hypervisor_uuid/guests Get RHSM hosts/systems table/inventory guests data * @apiDescription Retrieve hosts/systems table/inventory guests data. *