diff --git a/.travis.yml b/.travis.yml index 087970eb9..df3f33518 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,22 +1,25 @@ language: node_js -sudo: required notifications: email: false node_js: -- '16' + - "16" install: - npm ci jobs: include: - - stage: Lint, tests - script: npm run verify && npm run test:ct && npm run coverage - - stage: Release tag - if: fork = false - script: npx semantic-release -after_success: -- curl -sSL https://raw.githubusercontent.com/RedHatInsights/insights-frontend-builder-common/master/src/bootstrap.sh | bash -s + - stage: Lint + script: npm run build && npm run lint + - stage: Test + script: npm run test && npm run test:ct + after_success: npm run coverage + - stage: Deploy + if: (fork = false) AND (branch IN (master, master-stable, prod-beta, prod-stable)) + script: npm run build && curl -sSL https://raw.githubusercontent.com/RedHatInsights/insights-frontend-builder-common/master/src/bootstrap.sh | bash -s + - stage: Tag + if: (fork = false) AND (branch = master) + script: npx semantic-release env: - global: + global: - REPO="git@github.com:RedHatInsights/insights-inventory-frontend-build" - REPO_DIR="insights-inventory-frontend-build" - - BRANCH=${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} \ No newline at end of file + - BRANCH=${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH} diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f843372..99eb5fb9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,152 @@ +## [1.14.8](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.7...v1.14.8) (2023-04-14) + + +### Bug Fixes + +* **LastSeenFilter:** Add edge cases for new filter ([f53155a](https://github.com/RedHatInsights/insights-inventory-frontend/commit/f53155a89bb874c9c5d6665a9f6dce23b029ecb9)) + + +### Reverts + +* Revert "ESSNTL(4056): Adds checks against edge cases for last seen filter (#1787)" ([1f0b70c](https://github.com/RedHatInsights/insights-inventory-frontend/commit/1f0b70c15749126a7d8a605c8e890fa6d0881df6)), closes [#1787](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1787) + +## [1.14.8](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.7...v1.14.8) (2023-04-13) + + +### Reverts + +* Revert "ESSNTL(4056): Adds checks against edge cases for last seen filter (#1787)" ([1f0b70c](https://github.com/RedHatInsights/insights-inventory-frontend/commit/1f0b70c15749126a7d8a605c8e890fa6d0881df6)), closes [#1787](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1787) + +## [1.14.7](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.6...v1.14.7) (2023-04-11) + + +### Bug Fixes + +* **inventory groups:** changed the column order to allign with the app ([#1828](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1828)) ([3124e53](https://github.com/RedHatInsights/insights-inventory-frontend/commit/3124e53dd63208a5c8a7a29e1e604b4850f6f204)) + +## [1.14.6](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.5...v1.14.6) (2023-04-06) + + +### Bug Fixes + +* **ESSNTL-3729:** Fix add button behavior ([#1825](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1825)) ([cdf9848](https://github.com/RedHatInsights/insights-inventory-frontend/commit/cdf9848dd9059052133cab8bf649c53ce2829109)) + +## [1.14.5](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.4...v1.14.5) (2023-04-03) + + +### Bug Fixes + +* **RHIF-232:** flickering inventory table is fixed, api reqs are reduced to 1 ([97af8a3](https://github.com/RedHatInsights/insights-inventory-frontend/commit/97af8a327d04e4aa4c676f5376502312bf44cbeb)) + +## [1.14.4](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.3...v1.14.4) (2023-04-03) + + +### Bug Fixes + +* **ESSNTL-3728:** Fix links, show missing tags filter ([#1824](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1824)) ([ebfad9c](https://github.com/RedHatInsights/insights-inventory-frontend/commit/ebfad9cdafcdb2d68176de9d5290a3db52579d2f)) + +## [1.14.3](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.2...v1.14.3) (2023-04-03) + + +### Bug Fixes + +* **ESSNTL-3727:** Hide Group filter ([#1823](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1823)) ([7657a7e](https://github.com/RedHatInsights/insights-inventory-frontend/commit/7657a7e33e74008a5121ea3253c823d92b761290)) + +## [1.14.2](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.1...v1.14.2) (2023-03-31) + + +### Bug Fixes + +* **ESSNTL-3727:** Fix minor issues for groups/%id view ([#1820](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1820)) ([1fa8d01](https://github.com/RedHatInsights/insights-inventory-frontend/commit/1fa8d013cf45c291891b58f9158eed6e95de7dd2)) + +## [1.14.1](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.14.0...v1.14.1) (2023-03-30) + + +### Bug Fixes + +* **ESSNTL-4196:** Tie URLs to chrome isBeta ([#1817](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1817)) ([7c686e5](https://github.com/RedHatInsights/insights-inventory-frontend/commit/7c686e5a2d104197ac05122d3cf532b899ed66a2)) + +# [1.14.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.13.0...v1.14.0) (2023-03-30) + + +### Features + +* **ESSNTL-3728:** Enable multiple hosts addition to group ([#1798](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1798)) ([c9a1d4e](https://github.com/RedHatInsights/insights-inventory-frontend/commit/c9a1d4e1899df65004878eec1574613e355dab7a)) + +# [1.13.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.12.3...v1.13.0) (2023-03-29) + + +### Features + +* **ESSNTL-3729:** Add new actions to kebab and new modal ([#1794](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1794)) ([011f64c](https://github.com/RedHatInsights/insights-inventory-frontend/commit/011f64c998573f9b8e9012c0c8c70f63e2d08532)) + +## [1.12.3](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.12.2...v1.12.3) (2023-03-28) + + +### Bug Fixes + +* **RHCLOUD-24793:** Show ROS tab is azure or aws cloud provider ([#1800](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1800)) ([2331f03](https://github.com/RedHatInsights/insights-inventory-frontend/commit/2331f0364a73c1916c19b864c04a4e512a10cd6a)) + +## [1.12.2](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.12.1...v1.12.2) (2023-03-22) + +## [1.12.1](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.12.0...v1.12.1) (2023-03-22) + + +### Bug Fixes + +* Fix the CI error related to lastSeen filter ([#1802](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1802)) ([a19f6aa](https://github.com/RedHatInsights/insights-inventory-frontend/commit/a19f6aa92e7fa31d8b4bf5356784f2922f213ddc)) + +# [1.12.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.11.0...v1.12.0) (2023-03-15) + + +### Features + +* **ESSNTL-4195:** Inventory table - group filter ([#1776](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1776)) ([358400e](https://github.com/RedHatInsights/insights-inventory-frontend/commit/358400ec23e1f64933cd2dfea3cd92fc568459b8)) + +# [1.11.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.10.1...v1.11.0) (2023-03-15) + + +### Features + +* **ESSNTL-4196:** Show group detail info tab ([#1792](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1792)) ([f0f421b](https://github.com/RedHatInsights/insights-inventory-frontend/commit/f0f421b0b5b2f577c3fd594f663c699ffcd44358)) + +## [1.10.1](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.10.0...v1.10.1) (2023-03-14) + + +### Bug Fixes + +* **ESSNTL-3760:** handle insights disconnected hosts for patch, advisor, vuln tabs ([#1791](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1791)) ([f8d7afd](https://github.com/RedHatInsights/insights-inventory-frontend/commit/f8d7afd9fde1f1a85aa1ff2033a6cdceb3019235)) + +# [1.10.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.9.2...v1.10.0) (2023-03-14) + + +### Features + +* **ESSNTL-3727:** Display group systems ([#1790](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1790)) ([08408ad](https://github.com/RedHatInsights/insights-inventory-frontend/commit/08408addd3fb27a333e2c376e9290b9049757860)) + +## [1.9.2](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.9.1...v1.9.2) (2023-03-13) + + +### Bug Fixes + +* **ESSNTL-4404:** global filters and tags ([#1788](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1788)) ([b10ce31](https://github.com/RedHatInsights/insights-inventory-frontend/commit/b10ce310766b6b463d82b5ed675328b411a28a14)) + +## [1.9.1](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.9.0...v1.9.1) (2023-03-13) + +# [1.9.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.8.0...v1.9.0) (2023-03-13) + + +### Features + +* **ESSNTL-3737, -3735:** Rename and delete group ([#1780](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1780)) ([bdf4c6a](https://github.com/RedHatInsights/insights-inventory-frontend/commit/bdf4c6ac30688e2f76f1e2e164e9e1438e255485)) + +# [1.8.0](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.7.2...v1.8.0) (2023-03-07) + + +### Features + +* **ESSNTL-4056:** Add lastSeen filter ([#1781](https://github.com/RedHatInsights/insights-inventory-frontend/issues/1781)) ([7bb6ac8](https://github.com/RedHatInsights/insights-inventory-frontend/commit/7bb6ac85fcb5fc7c31514066c2ef174868213687)) + ## [1.7.2](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.7.1...v1.7.2) (2023-03-07) ## [1.7.1](https://github.com/RedHatInsights/insights-inventory-frontend/compare/v1.7.0...v1.7.1) (2023-03-06) diff --git a/config/setupTests.js b/config/setupTests.js index 5c2d52cef..a0e7943f6 100644 --- a/config/setupTests.js +++ b/config/setupTests.js @@ -15,6 +15,34 @@ jest.mock('react-router-dom', () => ({ }) })); +jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ + __esModule: true, + default: () => ({ + updateDocumentTitle: jest.fn(), + auth: { + getUser: () => Promise.resolve({ + identity: { + account_number: '0', + type: 'User', + user: { + is_org_admin: true + } + }, + entitlements: { + hybrid_cloud: { is_entitled: true }, + insights: { is_entitled: true }, + openshift: { is_entitled: true }, + smart_management: { is_entitled: false } + } + }) + }, + appAction: jest.fn(), + appObjectId: jest.fn(), + on: jest.fn(), + getUserPermissions: () => Promise.resolve(['inventory:*:*']) + }) +})); + configure({ adapter: new Adapter() }); global.insights = { diff --git a/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json b/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json index e52cc1577..2f9ce9b03 100644 --- a/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json +++ b/cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json @@ -10,28 +10,7 @@ "account": "irure ea exercitation adipisicing velit", "org_id": "non", "created_at": "1994-07-28T22:00:00.0Z", - "host_ids": [ - "eEfb7FAa-1f0b-Bde3-a4FF-fDdCd5faA764", - "bb7417faE7f9eCdacEDDd0Fcae9Cf4BB", - "2095BB72Aa6E1d2D1DC48bdecF1085eb", - "E645A3Fb42B77c4eBf9faEfCad8f6F5a", - "7fb4fa758Cbc0A861C2Cb21695aeA9d6", - "f6BE4AafA6bF543693Fa3F1fadFaA4E9", - "D608C33f-5e6D-BBEF-Cab5-0FFE8eB1bAc4", - "Dd4De9b6-7f3a-ED3d-2a84-D60e4Af2Cd9d", - "ae08E8dd-FFdB-cBC1-BA2A-d0aaC61C1B55", - "dcaD88bD4CeaaDC8bceD5d730ffba4cF", - "5a9F9CDAE74F3116a9c848Eeb1C65EA0", - "f53eDCBe3Fb957BE5dBec9322f030F94", - "c6fbBE38-30D7-D7e9-C8C7-72F4a61Bea9c", - "20E5EedA1e3f2aC2dCaADDCa4BFEED5B", - "adEcDf6ac4A999ccecdbCe7E9e01e23C", - "Bf0E2e8C-7e96-f0C8-9fea-1bD5fa9E18ce", - "6349ADdf-8d6B-bF1e-F5AE-13f0B7ca4acE", - "DBD5E149-C967-12b3-Cc6B-6c9220bA32e1", - "4EC19BbD-b556-ED9E-33E7-Eb5Be40AaeDe", - "CE05B39b3FdDad8dDE2b5DebB7CABe7D" - ] + "host_ids": [] } ], "total": 1 diff --git a/cypress/fixtures/groups/Ba8B79ab5adC8E41e255D5f8aDb8f1F3.json b/cypress/fixtures/groups/Ba8B79ab5adC8E41e255D5f8aDb8f1F3.json new file mode 100644 index 000000000..c499d9599 --- /dev/null +++ b/cypress/fixtures/groups/Ba8B79ab5adC8E41e255D5f8aDb8f1F3.json @@ -0,0 +1,37 @@ +{ + "count": 1, + "page": 1, + "per_page": 50, + "results": [ + { + "name": "ea velit incididunt", + "updated_at": "1998-04-17T22:00:00.0Z", + "id": "620f9ae75A8F6b83d78F3B55Af1c4b2C", + "account": "irure ea exercitation adipisicing velit", + "org_id": "non", + "created_at": "1994-07-28T22:00:00.0Z", + "host_ids": [ + "00fcC027-F4e3-aB4D-3eB9-392B09E4E9eC", + "82c882beA274db74bA79Dad9695CDdcD", + "374ECeB4-4aA6-637a-A5d8-eBD40A51EED8", + "12f6cdf2-fBcA-EF8A-3cC1-739627825F5B", + "37F4D8D9-A1Bd-cEE7-1Bcc-A03A20589B1B", + "ab29a822dA3259bf4a80A586Bd2e16E0", + "0ed9cfebEb3bfdB9A6F51757008dFB2F", + "4ddCF8Ee4a5DF968CBCaaaed16F5c55f", + "3cfF3e49-35Af-fD3E-62eb-c4eBDa0Ac3fF", + "8ce37289-4B8f-cECF-a3db-94f9F9ACB45a", + "Fd60B4f79D74507bDE8be6913caFDa8e", + "BbEAB14B-9A1f-eDFB-9c3B-eF0C6b6CB4dF", + "14FA22F08BBFf6f9738cBED71aFf66eE", + "fcF870cc-8C3C-8ba9-FcdA-dff79Ba0e2dE", + "38B60BDf17c9E6C9Cdd11840aCb4e804", + "C306bEc5EAF6BfD025fa6f48b3BeAF43", + "83CE42Eadccd8b2e5E5Aca1eaBAB55aa", + "4da9f2C5d6C1Ce6EE2f3D7BBC974E7c3", + "CaaE8C2e-C67c-41b3-5EB8-5Fd233eC9FdC" + ] + } + ], + "total": 1 +} diff --git a/cypress/fixtures/hosts.json b/cypress/fixtures/hosts.json new file mode 100644 index 000000000..cd4c5c50b --- /dev/null +++ b/cypress/fixtures/hosts.json @@ -0,0 +1,2505 @@ +{ + "count": 19, + "page": 1, + "per_page": 50, + "results": [ + { + "org_id": "velit dolore cillum ut veniam", + "ip_addresses": [ + "nulla eni", + "adipisicing Duis", + "commodo", + "c", + "ad ", + "dolore dolor", + "laboris eiusmod aliqua labore", + "veniam dolore dolor", + "amet dolore", + "sint", + "anim", + "irure", + "in", + "et lab", + "sed veniam", + "ut Excepteur", + "nisi occaecat sint amet", + "officia laborum dolor", + "commodo", + "anim dolor ullamco qui deserunt" + ], + "mac_addresses": null, + "facts": [ + { + "namespace": "et ali" + }, + { + "namespace": "aliqua ipsum laboris" + }, + { + "namespace": "dolore magna quis ipsum" + }, + { + "namespace": "eu proident aliqua et officia" + }, + { + "namespace": "sed labore tempor dolor" + }, + { + "namespace": "labore occaecat dol" + }, + { + "namespace": "sunt qui volupta" + }, + { + "namespace": "cupidatat Duis et velit dolor" + }, + { + "namespace": "voluptate occaecat" + }, + { + "namespace": "cillum adipisi" + }, + { + "namespace": "commodo" + }, + { + "namespace": "amet dolor aute" + }, + { + "namespace": "ex dese" + }, + { + "namespace": "consectetur mollit culpa dolore do" + }, + { + "namespace": "commodo ipsum cupidatat est anim" + }, + { + "namespace": "consectetur aliquip dolore cillum" + }, + { + "namespace": "consectetur ut nulla" + }, + { + "namespace": "volu" + }, + { + "namespace": "mollit" + }, + { + "namespace": "deserunt sit adipisicing in dolore" + } + ], + "reporter": "dolor in dolor officia", + "per_reporter_staleness": { + "aliquip__": { + "check_in_succeeded": false, + "last_check_in": "1987-10-30T00:00:00.0Z", + "stale_timestamp": "1977-07-20T23:00:00.0Z" + } + }, + "fqdn": null, + "updated": "1987-02-23T23:00:00.0Z", + "bios_uuid": null, + "subscription_manager_id": "dolor", + "provider_type": null, + "stale_timestamp": null, + "satellite_id": null, + "id": "dolor", + "stale_warning_timestamp": null, + "account": "Ut labore reprehenderit velit est", + "ansible_host": null, + "insights_id": "commodo nulla", + "provider_id": null, + "display_name": null, + "created": "2006-03-24T23:00:00.0Z", + "culled_timestamp": null + }, + { + "org_id": "consectetur elit", + "stale_timestamp": "2013-06-07T22:00:00.0Z", + "facts": [ + { + "namespace": "adipisicing" + }, + { + "namespace": "occaecat sunt" + }, + { + "namespace": "Ut labore est velit enim" + }, + { + "namespace": "cupidatat aliqua tempor amet" + }, + { + "namespace": "consequat enim Ut" + }, + { + "namespace": "incididunt" + }, + { + "namespace": "nulla do" + }, + { + "namespace": "proident tempor enim ut quis" + }, + { + "namespace": "laborum" + }, + { + "namespace": "aliqua deserunt" + }, + { + "namespace": "velit" + }, + { + "namespace": "et incididunt laboris non anim" + }, + { + "namespace": "do" + }, + { + "namespace": "est et ea " + }, + { + "namespace": "in" + }, + { + "namespace": "minim" + }, + { + "namespace": "fugiat Excepteur" + }, + { + "namespace": "dolore mollit qui" + }, + { + "namespace": "ad" + }, + { + "namespace": "ut sint" + } + ], + "ip_addresses": [ + "ullamco", + "culpa officia eiusmod voluptate quis", + "commodo non Excepteur dolore", + "consequat", + "cillum labor", + "nostrud amet deserunt dolor", + "reprehenderit", + "ea in nisi", + "in", + "fugiat adipisicing", + "sit est in qui", + "ea i", + "exercitation adipisic", + "sunt exercitation", + "fugiat sunt est dolore sint", + "eiusmod nulla aute", + "do deserunt", + "commodo nostrud cupidatat Excepteur id", + "non irure cillum minim", + "tempor nulla culpa non sunt" + ], + "display_name": null, + "updated": "1966-08-09T23:00:00.0Z", + "per_reporter_staleness": { + "eiusmodc2": { + "last_check_in": "1943-10-27T00:00:00.0Z", + "check_in_succeeded": true, + "stale_timestamp": "2012-12-31T23:00:00.0Z" + }, + "do_39f": { + "last_check_in": "1950-02-01T23:00:00.0Z", + "stale_timestamp": "1970-04-16T23:00:00.0Z", + "check_in_succeeded": false + }, + "labore7": { + "check_in_succeeded": true, + "last_check_in": "1981-06-13T22:00:00.0Z", + "stale_timestamp": "1945-09-29T22:00:00.0Z" + } + }, + "provider_type": null, + "stale_warning_timestamp": "2007-11-12T00:00:00.0Z", + "insights_id": null, + "reporter": null, + "satellite_id": "consequat occaecat Ut", + "subscription_manager_id": "magna ullamco in Ut", + "account": "dolore voluptate", + "mac_addresses": [ + "cupidatat nisi officia magna do", + "commodo ad", + "sint est id", + "irure eiusmod", + "ea magna aute occaecat dolor", + "aute voluptate", + "esse", + "nisi et", + "et aute qui nostrud in", + "dolore ut", + "ut veniam", + "mollit in eu aliqua", + "mollit in ut Lorem", + "amet", + "Duis qui sit adipisicing sunt", + "dolor", + "do sed", + "adipisicing in cupidatat", + "anim in consectetur sit", + "enim minim ex labore" + ], + "ansible_host": null, + "id": "aute", + "created": "1990-07-14T22:00:00.0Z", + "fqdn": null, + "provider_id": null, + "bios_uuid": "anim nulla ex eiusmod voluptate", + "culled_timestamp": null + }, + { + "org_id": "deserunt sit irure", + "updated": "1953-10-25T00:00:00.0Z", + "ip_addresses": [ + "exercitation id qui Lorem", + "ad Ut", + "incididunt aliquip Ut", + "voluptate nost", + "la", + "Duis consectetur do enim occaecat", + "laborum aliquip velit", + "mollit culpa", + "magna ex incididunt eu sed", + "adipisicing officia", + "magna proident pariatur ullamco", + "fugiat officia ullamco", + "Duis", + "ea ex quis id", + "eiusmod ex aliquip mollit pari", + "dolor ipsum laboris", + "sunt aliqua magna", + "dolor anim", + "in reprehenderit ut aute", + "" + ], + "id": "anim commodo", + "display_name": null, + "insights_id": null, + "provider_type": null, + "ansible_host": null, + "subscription_manager_id": "exercitation", + "reporter": "adipisicing veniam velit", + "created": "1962-06-25T23:00:00.0Z", + "account": null, + "group_name": "abc", + "mac_addresses": null, + "provider_id": "aute ut sit", + "facts": [ + { + "namespace": "consequat esse dolor" + }, + { + "namespace": "aliquip amet eiusmod ad tempor" + }, + { + "namespace": "fugi" + }, + { + "namespace": "nulla" + }, + { + "namespace": "qui proident et consequat mollit" + }, + { + "namespace": "reprehenderit eiusmod dolor" + }, + { + "namespace": "elit sed" + }, + { + "namespace": "non dolore in elit aute" + }, + { + "namespace": "f" + }, + { + "namespace": "quis" + }, + { + "namespace": "proident mollit enim" + }, + { + "namespace": "aliqua ipsum consectetur laborum ea" + }, + { + "namespace": "incididunt non fugiat eiusmod Ut" + }, + { + "namespace": "officia" + }, + { + "namespace": "laboris quis fugiat sint" + }, + { + "namespace": "labore cupida" + }, + { + "namespace": "quis aliqua d" + }, + { + "namespace": "minim ex" + }, + { + "namespace": "minim non in ut sunt" + }, + { + "namespace": "dolore consectetur do et minim" + } + ], + "stale_warning_timestamp": "1967-03-03T23:00:00.0Z", + "satellite_id": "magna cupidatat occaecat", + "bios_uuid": null, + "stale_timestamp": "1966-06-15T23:00:00.0Z", + "per_reporter_staleness": { + "qui_d0": { + "last_check_in": "1987-01-23T23:00:00.0Z", + "stale_timestamp": "2006-08-07T22:00:00.0Z", + "check_in_succeeded": false + } + }, + "culled_timestamp": null, + "fqdn": null + }, + { + "org_id": "fugiat in quis ea", + "stale_warning_timestamp": null, + "satellite_id": null, + "provider_id": null, + "created": "2008-11-15T00:00:00.0Z", + "insights_id": "dolor aliquip labo", + "display_name": "culpa ut Lorem aliquip", + "per_reporter_staleness": { + "nostruda36": { + "stale_timestamp": "2003-05-19T22:00:00.0Z", + "last_check_in": "1999-01-15T23:00:00.0Z", + "check_in_succeeded": false + }, + "ind7": { + "check_in_succeeded": true, + "stale_timestamp": "2015-04-26T22:00:00.0Z", + "last_check_in": "1966-07-10T23:00:00.0Z" + }, + "magna_73": { + "stale_timestamp": "2002-10-22T00:00:00.0Z", + "last_check_in": "2008-06-19T22:00:00.0Z", + "check_in_succeeded": true + } + }, + "reporter": null, + "updated": "1974-10-05T23:00:00.0Z", + "provider_type": "tempor cupidatat deserunt fugiat", + "bios_uuid": "qui nostrud aliqua consectetur", + "ip_addresses": [ + "reprehenderit Excepteur aliqua incididunt", + "dolor dolore ullamco fugiat", + "fugiat", + "eu sed ut Ut anim", + "occaecat", + "incididunt officia eu labore pro", + "esse tempor quis mollit cillum", + "exercitation dolore nostrud anim labo", + "elit cillum", + "incididunt sit qui", + "dolor", + "ut enim nulla ", + "aliquip id fugiat", + "ullamco id ut in", + "labore est laboris id do", + "proident sit Lorem", + "mollit", + "ipsum", + "quis", + "cillum esse" + ], + "ansible_host": "", + "stale_timestamp": null, + "subscription_manager_id": "ea sunt", + "facts": [ + { + "namespace": "Lorem enim" + }, + { + "namespace": "ipsum velit est enim dolore" + }, + { + "namespace": "Duis ea" + }, + { + "namespace": "elit ipsum" + }, + { + "namespace": "Duis dolore in" + }, + { + "namespace": "aliquip" + }, + { + "namespace": "consequat Lorem occaecat" + }, + { + "namespace": "cillum" + }, + { + "namespace": "Ut ad" + }, + { + "namespace": "nisi" + }, + { + "namespace": "eiusmod ea do ad adipisicing" + }, + { + "namespace": "in id incididunt" + }, + { + "namespace": "aliqua" + }, + { + "namespace": "Lorem fugiat" + }, + { + "namespace": "dolore laboris" + }, + { + "namespace": "ipsum" + }, + { + "namespace": "id sint culpa ea" + }, + { + "namespace": "in labore ullamco" + }, + { + "namespace": "nostrud dolore aliquip dolor" + }, + { + "namespace": "irure" + } + ], + "account": "commodo velit ad do", + "culled_timestamp": null, + "mac_addresses": [ + "commodo", + "minim", + "Duis eiusmod ut", + "pariatur", + "consequat mollit aliquip ullamco dolore", + "eu Ut Lorem ut fugiat", + "nisi ullamco", + "anim voluptate", + "Duis", + "aute", + "ali", + "magna", + "nostrud", + "consequat dolore do sint", + "anim in dolor pariatur incididunt", + "ex Lorem sed in", + "qui Duis cupidatat nisi exercitation", + "velit elit nostrud", + "qui Excepteur fugiat reprehe", + "nu" + ], + "fqdn": null, + "id": "ea qui dolor voluptate" + }, + { + "org_id": "ut do pariatur consequat", + "updated": "1945-11-22T00:00:00.0Z", + "per_reporter_staleness": { + "culpae": { + "check_in_succeeded": false, + "stale_timestamp": "1948-10-13T00:00:00.0Z", + "last_check_in": "1961-04-09T23:00:00.0Z" + }, + "et_5e3": { + "stale_timestamp": "1979-04-12T22:00:00.0Z", + "last_check_in": "1944-09-23T22:00:00.0Z", + "check_in_succeeded": false + }, + "eiusmod_8": { + "check_in_succeeded": true, + "stale_timestamp": "2008-09-21T22:00:00.0Z", + "last_check_in": "2009-05-16T22:00:00.0Z" + }, + "eube": { + "stale_timestamp": "2016-03-19T23:00:00.0Z", + "last_check_in": "1985-10-19T00:00:00.0Z", + "check_in_succeeded": true + }, + "Duis3c": { + "stale_timestamp": "1971-06-27T23:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "1987-08-17T22:00:00.0Z" + } + }, + "stale_timestamp": "1995-03-17T23:00:00.0Z", + "facts": [ + { + "namespace": "Excepteur in quis" + }, + { + "namespace": "enim quis" + }, + { + "namespace": "Lorem sit" + }, + { + "namespace": "reprehenderit ea nulla anim incididunt" + }, + { + "namespace": "veniam ut tempor anim laboris" + }, + { + "namespace": "quis deserunt" + }, + { + "namespace": "ex Lorem ut adipisicing" + }, + { + "namespace": "aute elit" + }, + { + "namespace": "sed culpa" + }, + { + "namespace": "ad mollit non nisi occaecat" + }, + { + "namespace": "amet ut do" + }, + { + "namespace": "esse sed" + }, + { + "namespace": "laboris" + }, + { + "namespace": "sit" + }, + { + "namespace": "volupta" + }, + { + "namespace": "sunt ipsum cupidatat commodo" + }, + { + "namespace": "enim aliquip cillum" + }, + { + "namespace": "ad Duis" + }, + { + "namespace": "consectetur aute veniam esse" + }, + { + "namespace": "cillum commodo consequat fugiat in" + } + ], + "satellite_id": "adipisicing fugiat irur", + "provider_id": "tempor", + "culled_timestamp": "2008-09-27T22:00:00.0Z", + "created": "1955-09-28T23:00:00.0Z", + "fqdn": null, + "display_name": "in consec", + "insights_id": null, + "mac_addresses": [ + "do", + "nisi pariatu", + "dolore magna", + "in", + "eiusm", + "aliquip ut nisi", + "proident", + "occaecat ", + "tempor", + "consec", + "ad incididunt aute", + "quis anim", + "consectetur cupidatat", + "ad do", + "sed", + "nulla cillum Duis sed", + "dolor ea Ut", + "", + "incididunt nulla sed officia consectet", + "in amet aliquip Excepteur magna" + ], + "account": null, + "reporter": "sit amet Ut", + "subscription_manager_id": "officia", + "provider_type": "veniam ipsum commodo in", + "ansible_host": null, + "id": "consectetur", + "stale_warning_timestamp": "1988-07-25T22:00:00.0Z", + "bios_uuid": null, + "ip_addresses": [ + "ipsum ", + "minim reprehenderit ad et", + "est laborum", + "Ut in", + "esse culpa", + "laboris non", + "sunt amet dolor cillum", + "nostrud eiusmod", + "veniam non dolor aliqua", + "et consequat", + "aliqua nulla consequat", + "esse eiusmod est", + "non sed incididunt sunt aliquip", + "id commodo", + "nisi qui deserunt", + "Lorem sit qui dolor", + "ullamco", + "Duis dolore dolore temp", + "enim ut in ipsum Excepteur", + "ut dolor consequat aliquip ea" + ] + }, + { + "org_id": "irure magna est ipsum commodo", + "account": null, + "per_reporter_staleness": { + "tempor4f": { + "check_in_succeeded": true, + "last_check_in": "1983-06-05T22:00:00.0Z", + "stale_timestamp": "1949-05-04T22:00:00.0Z" + }, + "nisi260": { + "stale_timestamp": "2011-06-10T22:00:00.0Z", + "last_check_in": "1977-09-11T23:00:00.0Z", + "check_in_succeeded": false + } + }, + "updated": "1997-10-13T00:00:00.0Z", + "bios_uuid": null, + "facts": [ + { + "namespace": "dolore culpa ullamco" + }, + { + "namespace": "commodo est eu nulla ut" + }, + { + "namespace": "sed exercitation in" + }, + { + "namespace": "labore qui fugiat sunt" + }, + { + "namespace": "id aliquip" + }, + { + "namespace": "deserunt" + }, + { + "namespace": "culpa enim" + }, + { + "namespace": "commodo" + }, + { + "namespace": "irure incididunt nostrud proident" + }, + { + "namespace": "ullamco s" + }, + { + "namespace": "mollit incididunt aliquip Lorem" + }, + { + "namespace": "et" + }, + { + "namespace": "ut do ir" + }, + { + "namespace": "culpa adipisicing" + }, + { + "namespace": "consectetur ad magna sit enim" + }, + { + "namespace": "cupidatat" + }, + { + "namespace": "d" + }, + { + "namespace": "sed dolor ad labore veniam" + }, + { + "namespace": "ut" + }, + { + "namespace": "Ut" + } + ], + "id": "quis enim et", + "culled_timestamp": null, + "fqdn": "cupidatat nisi ut", + "stale_warning_timestamp": null, + "reporter": "dolor ex ullamco", + "mac_addresses": [ + "cupidatat", + "exercitation ut sed officia", + "reprehenderit do", + "incididunt tempor Ut consequat exerci", + "dolor eu", + "do aliqua", + "ipsum in", + "culpa dolore", + "et amet nostrud elit", + "velit dolore anim", + "officia ipsum", + "adipisicing veniam nostrud", + "Excepteur", + "pariatur voluptate Lorem", + "esse aliquip", + "aute officia eiusmod Duis", + "elit ut minim consequat", + "cillum ad mollit", + "tempor et cillum aute in", + "eu nisi" + ], + "provider_type": "laboris id labore occaecat aliq", + "subscription_manager_id": "proident ut nulla minim fugiat", + "ip_addresses": null, + "satellite_id": "reprehenderit incididunt ea dolor", + "display_name": null, + "stale_timestamp": null, + "ansible_host": null, + "created": "2016-06-02T22:00:00.0Z", + "insights_id": null, + "provider_id": "ipsum Ut id tempor cupid" + }, + { + "org_id": "t", + "facts": [ + { + "namespace": "in in sed" + }, + { + "namespace": "nisi incididunt ullamco quis" + }, + { + "namespace": "elit tempor sunt" + }, + { + "namespace": "elit" + }, + { + "namespace": "deserunt proident no" + }, + { + "namespace": "laboris nostrud" + }, + { + "namespace": "Lorem" + }, + { + "namespace": "proident officia laboris" + }, + { + "namespace": "sit Lorem" + }, + { + "namespace": "amet velit" + }, + { + "namespace": "mollit Duis" + }, + { + "namespace": "magna Lorem eiusmod mo" + }, + { + "namespace": "o" + }, + { + "namespace": "esse dolor" + }, + { + "namespace": "ad enim" + }, + { + "namespace": "dolore adipisicing Duis" + }, + { + "namespace": "minim nulla est ea" + }, + { + "namespace": "est elit" + }, + { + "namespace": "et" + }, + { + "namespace": "dolore adipisicin" + } + ], + "bios_uuid": "ea ipsum eu voluptate", + "stale_timestamp": "2008-01-15T23:00:00.0Z", + "insights_id": "aliquip labore", + "mac_addresses": null, + "display_name": "ven", + "created": "1995-09-16T22:00:00.0Z", + "per_reporter_staleness": { + "ea9e": { + "last_check_in": "1999-10-20T00:00:00.0Z", + "check_in_succeeded": false, + "stale_timestamp": "1955-07-02T23:00:00.0Z" + }, + "et_4f": { + "last_check_in": "1952-04-02T23:00:00.0Z", + "stale_timestamp": "1973-06-27T23:00:00.0Z", + "check_in_succeeded": true + } + }, + "fqdn": null, + "stale_warning_timestamp": null, + "ansible_host": "non minim sint et", + "subscription_manager_id": null, + "provider_id": "aute tempor", + "ip_addresses": [ + "ut sunt", + "nisi", + "proident exercitation labore elit consequat", + "mollit", + "incididunt qui", + "eiusmod veniam qui magna ut", + "in irure", + "dolore proident reprehenderit", + "ipsu", + "veniam adipisicing officia", + "dolor et dolore", + "enim dolor", + "exercitation laborum id deserunt", + "quis sed nostrud volu", + "amet nulla", + "ex", + "minim amet", + "esse est voluptate", + "labore velit", + "cupidatat" + ], + "reporter": null, + "satellite_id": null, + "account": "aliquip", + "provider_type": "in", + "id": "in", + "updated": "2004-12-06T23:00:00.0Z", + "culled_timestamp": null + }, + { + "org_id": "sit sint", + "provider_id": "est ad do", + "provider_type": "in", + "created": "1956-01-15T23:00:00.0Z", + "ip_addresses": null, + "culled_timestamp": "1977-08-01T23:00:00.0Z", + "satellite_id": null, + "updated": "1971-03-27T23:00:00.0Z", + "stale_timestamp": "2017-05-15T22:00:00.0Z", + "bios_uuid": "Excepteur non", + "subscription_manager_id": null, + "reporter": null, + "account": null, + "id": "adipisicing Ut non ut", + "fqdn": "Lorem ex est a", + "stale_warning_timestamp": null, + "ansible_host": null, + "per_reporter_staleness": { + "tempor5d1": { + "stale_timestamp": "1985-04-23T22:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "2003-04-02T22:00:00.0Z" + }, + "quis_cc": { + "check_in_succeeded": false, + "stale_timestamp": "2013-07-13T22:00:00.0Z", + "last_check_in": "1958-06-16T23:00:00.0Z" + } + }, + "facts": [ + { + "namespace": "nisi" + }, + { + "namespace": "non" + }, + { + "namespace": "et dolore" + }, + { + "namespace": "est laboris sed exercitation Excepteur" + }, + { + "namespace": "dolor" + }, + { + "namespace": "cupidatat dolore" + }, + { + "namespace": "do in aliqua cupidata" + }, + { + "namespace": "ullamco" + }, + { + "namespace": "cupidatat" + }, + { + "namespace": "enim labore" + }, + { + "namespace": "et" + }, + { + "namespace": "i" + }, + { + "namespace": "sint id cillum adipisicing" + }, + { + "namespace": "ut" + }, + { + "namespace": "e" + }, + { + "namespace": "quis" + }, + { + "namespace": "qui" + }, + { + "namespace": "Lorem magna sit tem" + }, + { + "namespace": "laboris aute ad minim" + }, + { + "namespace": "commodo do deserunt nostrud" + } + ], + "display_name": null, + "mac_addresses": null, + "insights_id": "in id fugiat dolor quis" + }, + { + "org_id": "est sunt nulla voluptate r", + "ip_addresses": [ + "nostrud occaecat dolor", + "dolore", + "ipsum vel", + "velit culpa commodo", + "aliqua", + "cillum ", + "tempor est do eiusmod elit", + "laboris incididunt dolor dolore laborum", + "velit elit commodo", + "aliquip sint sunt mollit ea", + "labore pariatur anim", + "dolore aute ut ipsum reprehenderit", + "dolor aute ea", + "dolore et ipsum", + "Lorem eiusmod tempor", + "ex ea", + "dolor pariatur", + "ex Duis", + "pariatur", + "reprehenderit quis cupidatat" + ], + "display_name": "pariatur Duis exercitation occaecat", + "facts": [ + { + "namespace": "cillum fugiat" + }, + { + "namespace": "ea nisi ut aut" + }, + { + "namespace": "quis" + }, + { + "namespace": "non sunt" + }, + { + "namespace": "dol" + }, + { + "namespace": "consequat aliquip esse ex" + }, + { + "namespace": "cupidatat pariatur" + }, + { + "namespace": "id sit cupidatat ut oc" + }, + { + "namespace": "aliqua esse" + }, + { + "namespace": "do" + }, + { + "namespace": "aliqua" + }, + { + "namespace": "ut id" + }, + { + "namespace": "ea labore in sed dolor" + }, + { + "namespace": "dolor labore consectetur quis" + }, + { + "namespace": "Excepteur" + }, + { + "namespace": "labore" + }, + { + "namespace": "in" + }, + { + "namespace": "veniam consectetur magna nisi fug" + }, + { + "namespace": "in sunt magna et aliqua" + }, + { + "namespace": "reprehenderit Ut" + } + ], + "stale_timestamp": null, + "provider_type": "est laboris", + "account": "ut culpa", + "updated": "1962-06-27T23:00:00.0Z", + "mac_addresses": null, + "provider_id": null, + "bios_uuid": null, + "ansible_host": null, + "stale_warning_timestamp": "1992-12-08T23:00:00.0Z", + "created": "2010-03-15T23:00:00.0Z", + "insights_id": null, + "per_reporter_staleness": { + "dolor_fc": { + "stale_timestamp": "1948-05-23T22:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "1969-11-22T00:00:00.0Z" + }, + "cillumd81": { + "last_check_in": "1983-11-26T00:00:00.0Z", + "stale_timestamp": "2002-02-08T23:00:00.0Z", + "check_in_succeeded": true + } + }, + "id": "mollit pariatur elit laborum", + "satellite_id": "ut magna in anim", + "fqdn": null, + "culled_timestamp": "1989-12-30T00:00:00.0Z", + "reporter": "do i", + "subscription_manager_id": null + }, + { + "org_id": "ipsum ", + "account": "do officia aute ut laborum", + "fqdn": "dolore nulla dolor", + "id": "elit magna culpa", + "bios_uuid": null, + "mac_addresses": [ + "quis esse eu sit minim", + "fugiat", + "dolore voluptate", + "Lorem Duis ullamco", + "eu aliqua", + "aliqua dolore", + "ut null", + "Excepteur esse dolore et ullamco", + "in minim", + "dolor", + "exerc", + "officia", + "minim proident velit enim pariatur", + "commodo laborum", + "pariatur", + "sint mollit Excepteur eiusmod officia", + "ullamco aliquip sed", + "dolore et", + "dolore esse", + "dolore Excepteur eiusmod minim" + ], + "ansible_host": null, + "reporter": "aliqua proident amet quis sin", + "satellite_id": null, + "stale_timestamp": null, + "created": "2013-09-10T22:00:00.0Z", + "updated": "1976-07-14T23:00:00.0Z", + "display_name": null, + "ip_addresses": null, + "insights_id": null, + "facts": [ + { + "namespace": "id" + }, + { + "namespace": "cupidatat non l" + }, + { + "namespace": "dolore" + }, + { + "namespace": "Lor" + }, + { + "namespace": "labore nisi" + }, + { + "namespace": "ullamco in dese" + }, + { + "namespace": "dolor fugiat laborum aliquip qui" + }, + { + "namespace": "cillum non" + }, + { + "namespace": "molli" + }, + { + "namespace": "Ut" + }, + { + "namespace": "Duis dolore incididunt est dolor" + }, + { + "namespace": "officia ut id pr" + }, + { + "namespace": "enim reprehenderit c" + }, + { + "namespace": "sed esse commodo" + }, + { + "namespace": "fugiat sit" + }, + { + "namespace": "cupidatat pariatur nisi Excepteur" + }, + { + "namespace": "consequat ipsum nostrud voluptate dolore" + }, + { + "namespace": "laboris sed" + }, + { + "namespace": "incididunt mollit amet" + }, + { + "namespace": "ut labore et proident" + } + ], + "culled_timestamp": "1955-12-25T00:00:00.0Z", + "stale_warning_timestamp": null, + "per_reporter_staleness": { + "Excepteur_a90": { + "check_in_succeeded": true, + "last_check_in": "1952-03-05T23:00:00.0Z", + "stale_timestamp": "2013-07-04T22:00:00.0Z" + } + }, + "subscription_manager_id": "in ea ut tempor", + "provider_type": "Ut incididunt sit officia mollit", + "provider_id": null + }, + { + "org_id": "aliqua labore velit veniam dolore", + "stale_warning_timestamp": null, + "mac_addresses": null, + "per_reporter_staleness": { + "labore_84": { + "stale_timestamp": "1952-07-20T23:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "2006-09-08T22:00:00.0Z" + }, + "voluptate_1": { + "stale_timestamp": "1990-08-20T22:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "1957-05-01T23:00:00.0Z" + } + }, + "display_name": "sit dolor eiusmod", + "satellite_id": null, + "ansible_host": null, + "updated": "1980-04-01T23:00:00.0Z", + "insights_id": null, + "subscription_manager_id": null, + "id": "aute", + "facts": [ + { + "namespace": "in" + }, + { + "namespace": "sed mollit nisi ad" + }, + { + "namespace": "in cillum non" + }, + { + "namespace": "mollit aliquip nisi cillum" + }, + { + "namespace": "laboris amet cupidatat ut" + }, + { + "namespace": "adipisicing est" + }, + { + "namespace": "magna cillum" + }, + { + "namespace": "fugiat in" + }, + { + "namespace": "in incididunt Duis minim" + }, + { + "namespace": "dolore in elit " + }, + { + "namespace": "in eu elit" + }, + { + "namespace": "dolore do est" + }, + { + "namespace": "ut cillum deserunt minim dolore" + }, + { + "namespace": "commodo " + }, + { + "namespace": "adipisi" + }, + { + "namespace": "quis ex pariatur" + }, + { + "namespace": "culpa consequat Ut consectetur" + }, + { + "namespace": "exercitation est ad" + }, + { + "namespace": "anim adipisicing occaecat reprehenderit veniam" + }, + { + "namespace": "labore cupidatat Duis" + } + ], + "account": "Duis Excepteur sint magna", + "ip_addresses": [ + "sunt adipisicin", + "sunt Duis irure", + "aliquip enim et ut ali", + "proident aliquip", + "aliquip laborum adipisicing", + "fugiat anim qui", + "ut proident", + "tempor dolore", + "cillum", + "quis", + "incididunt dolore in deserun", + "reprehenderit esse Ut", + "Ut mollit dolor", + "veniam fugiat laborum minim ips", + "officia veniam in reprehenderit ipsum", + "Excepteur cupidatat Lorem ea", + "Ut mollit fugiat culpa", + "commodo qui velit", + "adipisicing est in ut sit", + "fugiat labore nulla est laboris" + ], + "provider_type": null, + "bios_uuid": null, + "reporter": "ad Excep", + "fqdn": "officia non ", + "culled_timestamp": "1971-03-30T23:00:00.0Z", + "provider_id": "off", + "created": "2008-08-15T22:00:00.0Z", + "stale_timestamp": "2001-07-05T22:00:00.0Z" + }, + { + "org_id": "irure anim labore ut", + "account": "aliqua adipisicing", + "id": "voluptate velit", + "culled_timestamp": null, + "subscription_manager_id": null, + "ansible_host": "conse", + "stale_warning_timestamp": "1987-03-16T23:00:00.0Z", + "updated": "1977-06-12T23:00:00.0Z", + "reporter": null, + "insights_id": "dolore voluptate in Ut", + "bios_uuid": "in occaecat", + "per_reporter_staleness": { + "ullamco1": { + "last_check_in": "1964-07-29T23:00:00.0Z", + "stale_timestamp": "1997-05-19T22:00:00.0Z", + "check_in_succeeded": true + }, + "qui_c48": { + "last_check_in": "1976-03-01T23:00:00.0Z", + "check_in_succeeded": false, + "stale_timestamp": "2009-11-25T00:00:00.0Z" + } + }, + "provider_id": "in", + "stale_timestamp": "1985-10-31T00:00:00.0Z", + "display_name": null, + "created": "2000-02-21T23:00:00.0Z", + "fqdn": "ex quis cupida", + "facts": [ + { + "namespace": "non" + }, + { + "namespace": "aute" + }, + { + "namespace": "amet Excepteur" + }, + { + "namespace": "culpa ex" + }, + { + "namespace": "eiusmod pr" + }, + { + "namespace": "sit ad" + }, + { + "namespace": "mollit pariatur veniam dolore" + }, + { + "namespace": "nulla id" + }, + { + "namespace": "sit dolor sunt non" + }, + { + "namespace": "ipsum occaecat" + }, + { + "namespace": "minim" + }, + { + "namespace": "labore quis" + }, + { + "namespace": "Ut cupidatat commodo est" + }, + { + "namespace": "est ad" + }, + { + "namespace": "nulla" + }, + { + "namespace": "sunt veniam exercit" + }, + { + "namespace": "ex laborum dolor do" + }, + { + "namespace": "cupidatat eiusmod nisi" + }, + { + "namespace": "cillum quis qui irure ipsum" + }, + { + "namespace": "eu" + } + ], + "satellite_id": "minim ad Duis tempor cillum", + "mac_addresses": [ + "nisi eiusmod", + "eiusmod non", + "minim anim nostr", + "culpa Ut", + "ad eu commodo laborum sed", + "eu labore officia", + "ex sunt aliquip quis", + "consequat irure esse minim", + "culpa in Ut deserunt", + "commodo aliquip", + "cillum incididunt dolor cupidatat sit", + "ut", + "cillum dolore", + "enim nostr", + "nostrud commodo", + "labore dolore", + "in", + "exercitation aliqua", + "aliqua", + "consectetur" + ], + "ip_addresses": [ + "ipsum sunt", + "esse eiusmod dolore tempor", + "consequat", + "amet est", + "cupidatat", + "do aute", + "aliquip consectetur sunt", + "et", + "eiusmod", + "non laborum cillum nostrud in", + "est cupidatat elit dolor", + "nostrud", + "qui ea laborum enim", + "amet id proident culpa reprehenderit", + "fugiat Lorem deserunt do", + "consequat Duis aliqua in", + "labor", + "volupta", + "aliquip pariatur voluptate o", + "veniam ut dolore" + ], + "provider_type": null + }, + { + "org_id": "nostrud ullamco sed dolor", + "provider_id": "officia do", + "satellite_id": null, + "ip_addresses": [ + "officia commodo ipsum laboris", + "mollit ex veniam dolor", + "ipsu", + "pariatur ut", + "velit Lorem laborum non", + "nisi labore", + "nulla cupidatat aute", + "aute", + "reprehenderit", + "eu amet", + "magna nulla ea ad", + "dolore", + "magna exercitation", + "aliqua", + "sed dolor adipisicing", + "culpa quis non", + "ad esse", + "ad ex occaecat minim anim", + "eu in cupidatat consectetur irure", + "occaecat eiusmo" + ], + "insights_id": "exercitation et in nostrud", + "ansible_host": null, + "facts": [ + { + "namespace": "cupidatat in aliquip pariatur sed" + }, + { + "namespace": "quis ea culpa aute" + }, + { + "namespace": "do" + }, + { + "namespace": "ad dolor reprehenderit et dolore" + }, + { + "namespace": "enim" + }, + { + "namespace": "magna adipisicing ex aute qui" + }, + { + "namespace": "sunt pa" + }, + { + "namespace": "laboris" + }, + { + "namespace": "dolor elit cupidatat" + }, + { + "namespace": "et" + }, + { + "namespace": "et amet" + }, + { + "namespace": "elit ea" + }, + { + "namespace": "in proident" + }, + { + "namespace": "deserunt do mollit esse" + }, + { + "namespace": "volupta" + }, + { + "namespace": "in minim Ut sed" + }, + { + "namespace": "aute incididu" + }, + { + "namespace": "cupidatat" + }, + { + "namespace": "proident elit" + }, + { + "namespace": "dolore reprehenderit sint" + } + ], + "fqdn": "ad", + "bios_uuid": null, + "per_reporter_staleness": { + "tempor_18": { + "check_in_succeeded": true, + "stale_timestamp": "2016-10-13T00:00:00.0Z", + "last_check_in": "1957-02-05T23:00:00.0Z" + }, + "eiusmod_2": { + "stale_timestamp": "1965-12-23T00:00:00.0Z", + "last_check_in": "1955-12-13T00:00:00.0Z", + "check_in_succeeded": true + }, + "deserunt_fd": { + "check_in_succeeded": false, + "last_check_in": "1946-08-20T22:00:00.0Z", + "stale_timestamp": "1993-01-13T23:00:00.0Z" + } + }, + "account": null, + "created": "1962-09-26T23:00:00.0Z", + "reporter": null, + "provider_type": null, + "updated": "1971-07-15T23:00:00.0Z", + "stale_timestamp": "1998-01-11T23:00:00.0Z", + "id": "elit laboris velit ullamco non", + "mac_addresses": [ + "qui", + "tempor irure", + "incid", + "mollit voluptate", + "sint aliqua", + "proident", + "i", + "laborum ut cillum in", + "do qui", + "incididunt aute", + "in", + "commodo Lor", + "dolore aliqua laboris", + "tempor dolor do ut eu", + "magna officia in enim ut", + "non culpa", + "quis anim fugiat", + "nisi pariatur culpa consectetur", + "id sunt elit Duis", + "Ut eu" + ], + "culled_timestamp": "2008-02-13T23:00:00.0Z", + "stale_warning_timestamp": null, + "subscription_manager_id": null, + "display_name": "nostrud dolor cillum elit velit" + }, + { + "org_id": "amet cillum laboris velit enim", + "created": "1975-10-10T00:00:00.0Z", + "fqdn": "officia id", + "id": "dolore aut", + "bios_uuid": null, + "stale_warning_timestamp": "2011-11-15T00:00:00.0Z", + "provider_id": "in aliqua ut Lorem", + "per_reporter_staleness": { + "dolore_8": { + "stale_timestamp": "2000-07-18T22:00:00.0Z", + "check_in_succeeded": true, + "last_check_in": "1971-05-12T23:00:00.0Z" + }, + "eiusmod8": { + "last_check_in": "2009-10-10T00:00:00.0Z", + "stale_timestamp": "1955-05-29T23:00:00.0Z", + "check_in_succeeded": false + }, + "Loremddf": { + "stale_timestamp": "1948-01-23T23:00:00.0Z", + "last_check_in": "1950-08-29T23:00:00.0Z", + "check_in_succeeded": true + } + }, + "provider_type": null, + "display_name": null, + "mac_addresses": [ + "ex", + "dolor dolor", + "reprehenderit pariatur", + "aliqua sed Excepteur sit Lorem", + "est consequat", + "minim ", + "anim", + "commodo esse sint adipisicing", + "et culpa enim sint dolor", + "deserunt eu", + "ullamco sit", + "nostrud", + "Ut culpa est", + "sint magna", + "Excepteur nulla consectetur", + "Excepteur occaecat est", + "Lorem ex fugiat", + "ex exercitation voluptate", + "cillum", + "Ut amet ex deserunt" + ], + "account": null, + "ansible_host": "sed do consequa", + "satellite_id": "dolore", + "insights_id": null, + "reporter": "eiusmod mollit sit proi", + "ip_addresses": null, + "stale_timestamp": null, + "culled_timestamp": "1944-11-17T00:00:00.0Z", + "updated": "1955-12-02T23:00:00.0Z", + "facts": [ + { + "namespace": "dolor commodo nulla" + }, + { + "namespace": "sunt in ad ipsum" + }, + { + "namespace": "sit nostrud tempor non" + }, + { + "namespace": "eu laboris co" + }, + { + "namespace": "consequat sint laboris officia nulla" + }, + { + "namespace": "qui tempor enim" + }, + { + "namespace": "qui eu dolore consectetur" + }, + { + "namespace": "proident exercitation id in" + }, + { + "namespace": "fugiat sunt nostrud id" + }, + { + "namespace": "ea consequat" + }, + { + "namespace": "laborum sunt quis" + }, + { + "namespace": "irure fugiat id mollit tempor" + }, + { + "namespace": "id anim" + }, + { + "namespace": "sunt aliq" + }, + { + "namespace": "ex esse in" + }, + { + "namespace": "anim non est Duis" + }, + { + "namespace": "magna pariatur laboris nisi" + }, + { + "namespace": "exercitation labore dolor pariatur voluptate" + }, + { + "namespace": "Excepteu" + }, + { + "namespace": "cillum" + } + ], + "subscription_manager_id": null + }, + { + "org_id": "ex commodo ", + "provider_type": "ad qui Duis ", + "mac_addresses": [ + "in es", + "esse dolor", + "dolore et", + "cillum sit", + "occaecat", + "pariatur irure", + "proident consectetur culpa irure", + "in elit fugiat ut", + "tempor laborum est voluptate", + "dolore dolor n", + "dolor fugiat aute mollit", + "nulla ut ad sed sunt", + "do officia", + "anim", + "sed adipisicing", + "adipisicing amet sit aliqua", + "laboris anim nostrud do", + "laborum exercitation", + "o", + "commodo fugiat" + ], + "stale_warning_timestamp": null, + "insights_id": "cupidatat ut", + "culled_timestamp": null, + "display_name": null, + "updated": "2002-09-04T22:00:00.0Z", + "bios_uuid": null, + "ip_addresses": [ + "velit", + "aute consectetur voluptate adipisicing", + "aliqua", + "consequat magna et commodo exercitation", + "deserunt fugiat esse", + "sit mollit est", + "veniam est", + "culpa occaecat id", + "deserunt enim", + "adipisicing", + "est exercitation ullamco", + "officia culpa aliqua", + "fugiat anim eu voluptate Ut", + "in dolor reprehenderit Duis enim", + "pariatur", + "Duis sed cillum tempor in", + "do", + "d", + "id laborum commodo", + "velit" + ], + "satellite_id": "pariatur consequat Lorem enim officia", + "provider_id": "mollit", + "facts": [ + { + "namespace": "deserunt ipsum " + }, + { + "namespace": "aliqua est" + }, + { + "namespace": "proident" + }, + { + "namespace": "magna irure consectetur" + }, + { + "namespace": "consectetur non commodo ut" + }, + { + "namespace": "in qui aute magna in" + }, + { + "namespace": "Duis reprehenderit deserunt est" + }, + { + "namespace": "in exercitation nulla" + }, + { + "namespace": "cillum nulla" + }, + { + "namespace": "deserunt elit fugiat est" + }, + { + "namespace": "non" + }, + { + "namespace": "magna" + }, + { + "namespace": "a" + }, + { + "namespace": "eu" + }, + { + "namespace": "magna nostrud incididunt" + }, + { + "namespace": "mollit qui" + }, + { + "namespace": "in tempor et pariatur" + }, + { + "namespace": "tempor" + }, + { + "namespace": "in" + }, + { + "namespace": "proident dese" + } + ], + "created": "1979-10-29T00:00:00.0Z", + "per_reporter_staleness": { + "dolor81b": { + "check_in_succeeded": true, + "last_check_in": "1949-01-14T23:00:00.0Z", + "stale_timestamp": "1988-04-18T22:00:00.0Z" + } + }, + "subscription_manager_id": "in dolor nostrud occaecat i", + "fqdn": "veniam ipsum", + "account": null, + "stale_timestamp": null, + "reporter": "ex ut ad nostrud", + "id": "eu quis nostrud", + "ansible_host": null + }, + { + "org_id": "irure consectetur cillum Excepteur", + "id": "reprehenderit et non dolore", + "provider_id": null, + "account": null, + "per_reporter_staleness": { + "in0b": { + "stale_timestamp": "1975-09-10T23:00:00.0Z", + "check_in_succeeded": true, + "last_check_in": "1984-03-26T22:00:00.0Z" + }, + "mollit9e": { + "stale_timestamp": "1960-11-03T23:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "1962-02-02T23:00:00.0Z" + }, + "cupidatat1e": { + "stale_timestamp": "1999-02-10T23:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "1984-09-05T22:00:00.0Z" + }, + "pariatur32e": { + "stale_timestamp": "2001-04-20T22:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "2006-01-12T23:00:00.0Z" + } + }, + "ansible_host": "deserunt velit", + "satellite_id": "cupidatat aute dolor fugiat nulla", + "created": "1991-09-26T22:00:00.0Z", + "facts": [ + { + "namespace": "quis ut ut irure" + }, + { + "namespace": "quis ut con" + }, + { + "namespace": "qui" + }, + { + "namespace": "non ea irure consequat" + }, + { + "namespace": "do" + }, + { + "namespace": "dolore" + }, + { + "namespace": "sint dolor qui" + }, + { + "namespace": "do tempor aute" + }, + { + "namespace": "sint eiusmod consequat fugia" + }, + { + "namespace": "voluptate laboris sed consequat do" + }, + { + "namespace": "pariatur tempor voluptate proident aliqua" + }, + { + "namespace": "adipisicing sunt consectetur" + }, + { + "namespace": "consequat ut sed et" + }, + { + "namespace": "esse in quis" + }, + { + "namespace": "pariatur Lorem ut" + }, + { + "namespace": "exercitation " + }, + { + "namespace": "reprehenderit eu sed id esse" + }, + { + "namespace": "in ad quis nulla" + }, + { + "namespace": "dolore sed magna laboris" + }, + { + "namespace": "ullamco aute" + } + ], + "culled_timestamp": null, + "stale_timestamp": null, + "insights_id": "tempor d", + "reporter": "velit occaecat", + "updated": "1960-03-18T23:00:00.0Z", + "provider_type": "aute sunt exercitation", + "display_name": "enim do", + "ip_addresses": null, + "mac_addresses": [ + "qui", + "dolor non in sed ipsum", + "cillum dolor", + "cillum voluptate id ex officia", + "magna", + "in est consectetur sed", + "sit fugiat ea aute veniam", + "voluptate", + "voluptate", + "Excepteur laboris Lorem Ut", + "sunt sed nostrud sit qui", + "in eiusmod", + "si", + "in Ut cupidatat eu", + "aute ut", + "velit", + "occaecat", + "eu velit pariatur", + "sed adipisicing consequat", + "laborum mollit" + ], + "subscription_manager_id": null, + "bios_uuid": "minim enim cillum quis", + "fqdn": null, + "stale_warning_timestamp": "1998-10-05T22:00:00.0Z" + }, + { + "org_id": "Duis magna do", + "provider_id": null, + "facts": [ + { + "namespace": "sunt mollit ea" + }, + { + "namespace": "ut Duis eu cupidatat ea" + }, + { + "namespace": "amet in ad" + }, + { + "namespace": "ad esse est" + }, + { + "namespace": "quis nisi pariatur" + }, + { + "namespace": "est ex sunt" + }, + { + "namespace": "dolor Lorem cons" + }, + { + "namespace": "dolore" + }, + { + "namespace": "officia elit eu velit" + }, + { + "namespace": "dolor qui" + }, + { + "namespace": "consectetur magna proident voluptate" + }, + { + "namespace": "eu eiusmod mollit sit" + }, + { + "namespace": "proident cupidatat aute culpa id" + }, + { + "namespace": "officia dolor consequat esse" + }, + { + "namespace": "ut proident elit in minim" + }, + { + "namespace": "consectet" + }, + { + "namespace": "ad amet eu tempor" + }, + { + "namespace": "elit in eu exercitation non" + }, + { + "namespace": "Excepteur tempor et Lorem exercitati" + }, + { + "namespace": "eiusmod mollit in pariatur" + } + ], + "updated": "1946-11-14T00:00:00.0Z", + "reporter": null, + "id": "dolore amet", + "ip_addresses": [ + "incididunt", + "adipisicing", + "velit nulla", + "elit ex in minim", + "consectetur quis", + "", + "mollit ullamco id", + "voluptate ut occaecat", + "sint", + "Lorem", + "in ea ", + "ut consequa", + "velit non a", + "qui dolor Duis fugiat", + "tempor pariatur elit ut laborum", + "pariatur", + "esse", + "sint", + "sint in id elit velit", + "Duis quis proident" + ], + "stale_warning_timestamp": "1973-04-13T23:00:00.0Z", + "provider_type": "nulla magna", + "per_reporter_staleness": { + "nulla_8": { + "check_in_succeeded": true, + "stale_timestamp": "1946-08-27T22:00:00.0Z", + "last_check_in": "1965-01-17T23:00:00.0Z" + }, + "Excepteur_b": { + "stale_timestamp": "1994-04-14T22:00:00.0Z", + "last_check_in": "1944-06-27T22:00:00.0Z", + "check_in_succeeded": false + } + }, + "display_name": null, + "insights_id": "et irure", + "culled_timestamp": "2010-12-13T00:00:00.0Z", + "stale_timestamp": "1950-11-27T00:00:00.0Z", + "mac_addresses": null, + "ansible_host": "do culpa dolor veniam ullamco", + "fqdn": null, + "satellite_id": "amet", + "created": "1954-10-15T00:00:00.0Z", + "bios_uuid": "ipsum est in dolor occaecat", + "account": "labori", + "subscription_manager_id": "sed ani" + }, + { + "org_id": "dolor", + "satellite_id": null, + "insights_id": null, + "bios_uuid": "minim labore", + "updated": "1991-03-27T23:00:00.0Z", + "subscription_manager_id": null, + "ip_addresses": null, + "per_reporter_staleness": { + "aliquip6": { + "stale_timestamp": "1993-05-22T22:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "1989-03-07T23:00:00.0Z" + } + }, + "fqdn": "ea cillum laborum", + "display_name": "reprehenderit et ipsum dolore culpa", + "id": "cillum et laborum", + "stale_timestamp": null, + "stale_warning_timestamp": null, + "facts": [ + { + "namespace": "Ut" + }, + { + "namespace": "laborum ipsum" + }, + { + "namespace": "ipsum quis occaecat do" + }, + { + "namespace": "dolore sed consequ" + }, + { + "namespace": "mollit" + }, + { + "namespace": "non reprehenderit nisi exercitation" + }, + { + "namespace": "nisi" + }, + { + "namespace": "quis deserunt commodo sit ex" + }, + { + "namespace": "reprehenderit exercitation" + }, + { + "namespace": "ullamco cupidatat tempor" + }, + { + "namespace": "pariatur" + }, + { + "namespace": "labore ma" + }, + { + "namespace": "elit" + }, + { + "namespace": "labore in culpa mollit" + }, + { + "namespace": "esse cupidatat aliquip nisi" + }, + { + "namespace": "pariatur ut est" + }, + { + "namespace": "id" + }, + { + "namespace": "non eiusmod o" + }, + { + "namespace": "ex no" + }, + { + "namespace": "aute dolor dolore sunt" + } + ], + "mac_addresses": [ + "velit officia esse", + "in e", + "", + "ad fugiat nisi elit Duis", + "labore mollit", + "reprehenderit", + "ipsum voluptate", + "fugiat sed velit quis ", + "qui nostrud est fugiat", + "ullamco", + "dolore in Ut aliquip", + "sed dolor id culpa", + "est ipsum sed Excepteur", + "id", + "cillum enim culpa", + "dolor ea", + "laboris", + "dolor fugiat", + "ullamco ip", + "dolor dolore nos" + ], + "ansible_host": null, + "account": null, + "provider_id": null, + "provider_type": null, + "culled_timestamp": null, + "reporter": "laboris q", + "created": "2003-04-02T22:00:00.0Z" + }, + { + "org_id": "voluptate dolor sit", + "display_name": null, + "per_reporter_staleness": { + "inf97": { + "last_check_in": "1982-12-23T00:00:00.0Z", + "stale_timestamp": "1987-07-05T22:00:00.0Z", + "check_in_succeeded": false + }, + "sit_a": { + "stale_timestamp": "1966-09-06T23:00:00.0Z", + "last_check_in": "1948-02-09T23:00:00.0Z", + "check_in_succeeded": true + } + }, + "insights_id": null, + "satellite_id": null, + "account": "occaecat sunt", + "fqdn": null, + "subscription_manager_id": null, + "updated": "2010-04-17T22:00:00.0Z", + "facts": [ + { + "namespace": "minim" + }, + { + "namespace": "velit labore cillum ea" + }, + { + "namespace": "sunt anim laboris" + }, + { + "namespace": "sint proident" + }, + { + "namespace": "sed nulla irure sint" + }, + { + "namespace": "repreh" + }, + { + "namespace": "commodo quis anim " + }, + { + "namespace": "consectetur Lorem reprehenderit" + }, + { + "namespace": "sunt" + }, + { + "namespace": "irure eu do" + }, + { + "namespace": "minim Lorem" + }, + { + "namespace": "reprehenderit" + }, + { + "namespace": "qui" + }, + { + "namespace": "laboris culpa non dolore sit" + }, + { + "namespace": "sunt est Excepteur" + }, + { + "namespace": "do culpa" + }, + { + "namespace": "Ut" + }, + { + "namespace": "ipsum" + }, + { + "namespace": "nulla" + }, + { + "namespace": "cillum" + } + ], + "ip_addresses": [ + "velit sed do", + "esse fugiat pariatu", + "Excepteur proident exercita", + "veniam nisi", + "non qui", + "reprehenderit sed quis", + "Excepteur qui in", + "sit Excepteur ea qui irure", + "id enim veniam deserunt tempor", + "dolore elit culpa do aliquip", + "Excepteur esse dolore id eu", + "dolore", + "sunt non in qui", + "sed", + "dolor", + "proident nostrud Duis dolor", + "par", + "dolore aute quis nulla nisi", + "eiusmod laborum in", + "ullamco proident dolore ex id" + ], + "ansible_host": null, + "stale_timestamp": "2002-07-30T22:00:00.0Z", + "created": "1967-03-04T23:00:00.0Z", + "stale_warning_timestamp": null, + "provider_type": null, + "reporter": "ipsum aliqua", + "provider_id": "velit dolore", + "bios_uuid": null, + "mac_addresses": [ + "irure non reprehenderit aute", + "occaecat", + "sit", + "dolore ad Ut moll", + "in aliqua non ea esse", + "labore", + "proident ut", + "commodo dolore pariatur ipsum ex", + "quis mollit consequat ex est", + "amet dolor eu sed Ut", + "non Lorem Duis aliqua", + "ad nisi", + "cillum eiusmod nisi", + "labore amet deserunt veniam", + "ipsu", + "sunt", + "", + "dolore elit amet", + "eu ipsum mollit ut amet", + "commodo labore fugiat" + ], + "culled_timestamp": null, + "id": "dolor culpa" + }, + { + "org_id": "esse irure reprehenderit ad", + "bios_uuid": null, + "created": "2012-03-24T23:00:00.0Z", + "satellite_id": null, + "fqdn": "irure do ullamco", + "insights_id": "cillum in laboris ex ad", + "subscription_manager_id": null, + "per_reporter_staleness": { + "adipisicing_c": { + "last_check_in": "1965-03-17T23:00:00.0Z", + "stale_timestamp": "1946-12-22T00:00:00.0Z", + "check_in_succeeded": false + }, + "etc18": { + "check_in_succeeded": false, + "last_check_in": "2015-08-11T22:00:00.0Z", + "stale_timestamp": "1964-09-05T23:00:00.0Z" + }, + "dolore_27": { + "stale_timestamp": "2002-03-24T23:00:00.0Z", + "check_in_succeeded": false, + "last_check_in": "2016-11-28T00:00:00.0Z" + }, + "nulla1d": { + "last_check_in": "1947-11-16T00:00:00.0Z", + "stale_timestamp": "2002-11-22T00:00:00.0Z", + "check_in_succeeded": false + } + }, + "id": "pariatur aute ut", + "updated": "1991-03-03T23:00:00.0Z", + "provider_type": "dolor", + "culled_timestamp": "1995-09-17T22:00:00.0Z", + "ip_addresses": [ + "quis no", + "ea ullamco deserunt ", + "proident", + "incididunt commo", + "nostrud pariatur deserunt esse", + "ullamco", + "Duis", + "consequat Excepteur in aliquip nulla", + "velit nostrud exe", + "Ut Excepteur non consectetur", + "Lorem sed minim laboris Duis", + "eiusmod ad ullamco culpa", + "sit", + "qui", + "est officia cupidatat Ut non", + "officia", + "in ut", + "reprehenderit est", + "eiusmod Duis aliquip", + "enim reprehenderit eu" + ], + "facts": [ + { + "namespace": "veniam in Ut" + }, + { + "namespace": "incididunt fugiat ipsum minim" + }, + { + "namespace": "quis" + }, + { + "namespace": "ex qui cillum" + }, + { + "namespace": "nostrud" + }, + { + "namespace": "sed velit" + }, + { + "namespace": "quis eli" + }, + { + "namespace": "ex veniam in fugiat" + }, + { + "namespace": "proident commodo officia" + }, + { + "namespace": "aliqua" + }, + { + "namespace": "minim" + }, + { + "namespace": "ipsum aliquip cupidatat" + }, + { + "namespace": "fugiat in" + }, + { + "namespace": "sint ea mollit" + }, + { + "namespace": "voluptate" + }, + { + "namespace": "esse commodo" + }, + { + "namespace": "nulla" + }, + { + "namespace": "dolor ullamco quis velit esse" + }, + { + "namespace": "nostrud" + }, + { + "namespace": "proident" + } + ], + "ansible_host": null, + "provider_id": "aliquip Ut eni", + "reporter": null, + "account": null, + "display_name": null, + "stale_warning_timestamp": "1958-08-14T23:00:00.0Z", + "stale_timestamp": null, + "mac_addresses": null + } + ], + "total": 630 +} diff --git a/cypress/support/interceptors.js b/cypress/support/interceptors.js index 97bdbbed2..173d54c45 100644 --- a/cypress/support/interceptors.js +++ b/cypress/support/interceptors.js @@ -1,21 +1,38 @@ /* eslint-disable camelcase */ import { DEFAULT_ROW_COUNT } from '@redhat-cloud-services/frontend-components-utilities'; + +// fixtures generated by prism mock server import fixtures from '../fixtures/groups.json'; import groupsSecondPage from '../fixtures/groupsSecondPage.json'; import groupDetailFixtures from '../fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json'; +import hostsFixtures from '../fixtures/hosts.json'; +export { hostsFixtures, groupDetailFixtures }; export const groupsInterceptors = { 'successful with some items': () => - cy.intercept('GET', '/api/inventory/v1/groups*', fixtures).as('getGroups'), + cy + .intercept('GET', '/api/inventory/v1/groups*', { + statusCode: 200, + body: fixtures + }) + .as('getGroups'), 'successful with some items second page': () => - cy.intercept('GET', '/api/inventory/v1/groups?*page=2&perPage=50*', groupsSecondPage).as('getGroupsSecond'), + cy + .intercept('GET', '/api/inventory/v1/groups?*page=2&perPage=50*', { + statusCode: 200, + body: groupsSecondPage + }) + .as('getGroupsSecond'), 'successful empty': () => cy .intercept('GET', '/api/inventory/v1/groups*', { - count: 0, - page: 1, - per_page: DEFAULT_ROW_COUNT, - total: 0 + statusCode: 200, + body: { + count: 0, + page: 1, + per_page: DEFAULT_ROW_COUNT, + total: 0 + } }) .as('getGroups'), 'failed with server error': () => { @@ -27,11 +44,10 @@ export const groupsInterceptors = { ); }, 'long responding': () => { - cy.intercept('GET', '/api/inventory/v1/groups*', (req) => { - req.reply({ - body: fixtures, - delay: 42000000 // milliseconds - }); + cy.intercept('GET', '/api/inventory/v1/groups*', { + statusCode: 200, + body: fixtures, + delay: 42000000 // milliseconds }).as('getGroups'); } }; @@ -39,27 +55,71 @@ export const groupsInterceptors = { export const groupDetailInterceptors = { successful: () => cy - .intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', groupDetailFixtures) + .intercept( + 'GET', + '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', + { + statusCode: 200, + body: groupDetailFixtures + } + ) + .as('getGroupDetail'), + 'successful with hosts': () => + cy + .intercept( + 'GET', + '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', + { + statusCode: 200, + body: { + ...groupDetailFixtures, + results: [{ + ...groupDetailFixtures.results[0], + host_ids: ['host-1', 'host-2'] + }] + } + } + ) .as('getGroupDetail'), empty: () => cy - .intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', { statusCode: 404 }) + .intercept( + 'GET', + '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', + { statusCode: 404 } + ) .as('getGroupDetail'), 'failed with server error': () => { Cypress.on('uncaught:exception', () => { return false; }); - cy.intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', { statusCode: 500 }).as( - 'getGroupDetail' - ); + cy.intercept( + 'GET', + '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', + { statusCode: 500 } + ).as('getGroupDetail'); }, 'long responding': () => { - cy.intercept('GET', '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', (req) => { - req.reply({ + cy.intercept( + 'GET', + '/api/inventory/v1/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C', + { + statusCode: 200, body: groupDetailFixtures, delay: 42000000 // milliseconds - }); - }).as('getGroupDetail'); + } + ) + .as('getGroupDetail'); + }, + 'patch successful': () => { + cy + .intercept('PATCH', '/api/inventory/v1/groups/*', { statusCode: 200 }) + .as('patchGroup'); + }, + 'delete successful': () => { + cy + .intercept('DELETE', '/api/inventory/v1/groups/*', { statusCode: 204 }) + .as('deleteGroup'); } }; @@ -75,3 +135,63 @@ export const deleteGroupsInterceptors = { }).as('deleteGroups'); } }; + +export const hostsInterceptors = { + successful: () => { + cy.intercept('GET', '/api/inventory/v1/hosts*', { + statusCode: 200, + body: hostsFixtures + }).as('getHosts'); + }, + 'successful empty': () => { + cy.intercept('GET', '/api/inventory/v1/hosts*', { + statusCode: 200, + body: { + count: 0, + page: 1, + per_page: DEFAULT_ROW_COUNT, + total: 0, + results: [] + } + }).as('getHosts'); + }, + 'failed with server error': () => { + Cypress.on('uncaught:exception', () => { + return false; + }); + cy.intercept('GET', '/api/inventory/v1/hosts*', { statusCode: 500 }).as( + 'getHosts' + ); + } +}; + +export const systemProfileInterceptors = { + 'operating system, successful empty': () => { + cy.intercept('GET', '/api/inventory/v1/system_profile/operating_system', { + statusCode: 200, + body: { + results: [] + } + }).as('getSystemProfile'); + } +}; + +export const featureFlagsInterceptors = { + successful: () => { + cy.intercept('GET', '/feature_flags*', { + statusCode: 200, + body: { + toggles: [ + { + name: 'hbi.ui.inventory-groups', + enabled: true, + variant: { + name: 'disabled', + enabled: true + } + } + ] + } + }).as('getFeatureFlag'); + } +}; diff --git a/cypress/support/utils.js b/cypress/support/utils.js new file mode 100644 index 000000000..80ca08455 --- /dev/null +++ b/cypress/support/utils.js @@ -0,0 +1,24 @@ +import { ROW } from '@redhat-cloud-services/frontend-components-utilities'; + +export const ORDER_TO_URL = { + ascending: 'ASC', + descending: 'DESC' +}; + +export const selectRowN = (number) => { + cy.get(ROW).eq(number).find('.pf-c-table__check').click(); +}; + +export const checkSelectedNumber = (number, selector = '#toggle-checkbox-text') => { + if (number === 0) { + cy.get(selector).should('not.exist'); + } else { + cy.get(selector).should('have.text', `${number} selected`); + } +}; + +export const unleashDummyConfig = { + url: 'http://localhost:8002/feature_flags', + clientKey: 'abc', + appName: 'abc' +}; diff --git a/doc/props_table.md b/doc/props_table.md index 4c7c8fd76..dbc0e28c3 100644 --- a/doc/props_table.md +++ b/doc/props_table.md @@ -90,7 +90,9 @@ Function called when table is refreshed. *object* -Props passed to table component. +Props passed to table component. In addition it is used to pass `actionResolver` props. +That will give you access to the row data, that way you can map through each row and enable/disable kebab action depending on the value. You cannot use both `actions` and `actionResolver` props - choose one. +Example in [Patchman UI](https://github.com/RedHatInsights/patchman-ui/blob/master/src/SmartComponents/Systems/Systems.js#L191) ## paginationProps diff --git a/package-lock.json b/package-lock.json index 59aecfd12..fa52f3ef0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "insights-inventory-frontend", - "version": "1.7.2", + "version": "1.14.8", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "insights-inventory-frontend", - "version": "1.7.2", + "version": "1.14.8", "dependencies": { "@data-driven-forms/common": "^3.20.0", "@data-driven-forms/pf4-component-mapper": "^3.20.0", @@ -14,7 +14,7 @@ "@patternfly/react-core": "^4.265.2", "@patternfly/react-icons": "^4.93.6", "@patternfly/react-table": "^4.111.45", - "@redhat-cloud-services/frontend-components": "^3.9.25", + "@redhat-cloud-services/frontend-components": "^3.9.33", "@redhat-cloud-services/frontend-components-notifications": "^3.2.12", "@redhat-cloud-services/frontend-components-utilities": "^3.3.13", "@redhat-cloud-services/host-inventory-client": "1.0.116", @@ -3612,6 +3612,20 @@ "@octokit/openapi-types": "^16.0.0" } }, + "node_modules/@openshift/dynamic-plugin-sdk": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@openshift/dynamic-plugin-sdk/-/dynamic-plugin-sdk-2.0.1.tgz", + "integrity": "sha512-fiSPxk8ghs/aEp7UasDBhjdXrQ5/IQl+QuCB8FHz6IhAkN5mB/aQ7GcBHfW+ITK4g0eb6ydb4x2IaKP8iZeBJw==", + "dependencies": { + "lodash-es": "^4.17.21", + "semver": "^7.3.7", + "uuid": "^8.3.2", + "yup": "^0.32.11" + }, + "peerDependencies": { + "react": "^17.0.2" + } + }, "node_modules/@patternfly/patternfly": { "version": "4.224.2", "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.224.2.tgz", @@ -3842,14 +3856,14 @@ "dev": true }, "node_modules/@redhat-cloud-services/frontend-components": { - "version": "3.9.25", - "resolved": "https://registry.npmjs.org/@redhat-cloud-services/frontend-components/-/frontend-components-3.9.25.tgz", - "integrity": "sha512-ynppJJrMtkgzt5/uUF9rR51HeE5WkC5kCkNzIBqAFHHMsGueGazc9y5g1nzPkl7jgBajsTg7tePCLCvKP/HEnw==", + "version": "3.9.33", + "resolved": "https://registry.npmjs.org/@redhat-cloud-services/frontend-components/-/frontend-components-3.9.33.tgz", + "integrity": "sha512-DtqxppMpkmtyLUtRjMIUgUqYPBOztYQgucbmU4BdKCIuugaFrBwMisMxaf2tUKCdZ/fY4lvYUFJghjDEWfdbDA==", "dependencies": { "@redhat-cloud-services/frontend-components-utilities": "^3.2.25", - "@redhat-cloud-services/types": "^0.0.15", - "@scalprum/core": "^0.2.3", - "@scalprum/react-core": "^0.2.4", + "@redhat-cloud-services/types": "^0.0.17", + "@scalprum/core": "^0.4.0", + "@scalprum/react-core": "^0.4.0", "sanitize-html": "^2.7.2" }, "peerDependencies": { @@ -3859,9 +3873,9 @@ "classnames": "^2.2.5", "lodash": "^4.17.15", "prop-types": "^15.6.2", - "react": "^16.14.0 || ^17.0.0", + "react": "^16.14.0 || ^17.0.0 || ^18.0.0", "react-content-loader": "^6.2.0", - "react-dom": "^16.14.0 || ^17.0.0", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0", "react-redux": ">=7.0.0", "react-router-dom": "^5.0.0 || ^6.0.0" } @@ -4104,11 +4118,6 @@ "react-router-dom": "^5.0.0 || ^6.0.0" } }, - "node_modules/@redhat-cloud-services/frontend-components-utilities/node_modules/@redhat-cloud-services/types": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@redhat-cloud-services/types/-/types-0.0.17.tgz", - "integrity": "sha512-3V9mmarS3jD5fBksMwh+XCEAMUIzqSOxDkH6OcIVu6w/gaBBOWHh34Jwn4CxKlu+WStxVV/rxm3oFGpsWqljvg==" - }, "node_modules/@redhat-cloud-services/frontend-components-utilities/node_modules/axios": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", @@ -4154,21 +4163,25 @@ } }, "node_modules/@redhat-cloud-services/types": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@redhat-cloud-services/types/-/types-0.0.15.tgz", - "integrity": "sha512-1aqJcgQZq4uih+LxRpVJQblt2x4o/hlrqSZMYFhWyTLgnVNhJ8Y7B5pwoVjpA5PCE1fBNahrydVwugEKMsDDtg==" + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@redhat-cloud-services/types/-/types-0.0.17.tgz", + "integrity": "sha512-3V9mmarS3jD5fBksMwh+XCEAMUIzqSOxDkH6OcIVu6w/gaBBOWHh34Jwn4CxKlu+WStxVV/rxm3oFGpsWqljvg==" }, "node_modules/@scalprum/core": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@scalprum/core/-/core-0.2.3.tgz", - "integrity": "sha512-bL7YjXWSgtAw44ha+goEF/cCWUu1BELB0qo4Y8hlfmn0+FMnoIHcY0gD1OOotz7Oy74r5+DRxi5Wra40DTG8Qg==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@scalprum/core/-/core-0.4.1.tgz", + "integrity": "sha512-Ff8G2Mhc6ORPx+5C/B6vYYyGL2mBmQ8jR1I0yhgmYClzZU4gzQalZrSIwBDozGCoYmdKggF+hPCxojFwgE227g==", + "dependencies": { + "@openshift/dynamic-plugin-sdk": "^2.0.1" + } }, "node_modules/@scalprum/react-core": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@scalprum/react-core/-/react-core-0.2.8.tgz", - "integrity": "sha512-+qGfiA6FkXAx4x53fHmv7Q3oZcEQK0NChgaVeKGaZfG+LSNa1ozgkd4oSWueAMG3XV3St0QbAxzAtRQNFRyqNQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@scalprum/react-core/-/react-core-0.4.1.tgz", + "integrity": "sha512-R5gtrnqbeR6qRDUddZAtJUDUYOU+HjMbTROAYP6ryFzFnwbDBPY1DtNx4n8458yaZBRRiPYfkJEWvWzui1D0hw==", "dependencies": { - "@scalprum/core": "^0.2.3", + "@openshift/dynamic-plugin-sdk": "^2.0.1", + "@scalprum/core": "^0.4.1", "lodash": "^4.17.0" }, "peerDependencies": { @@ -5722,6 +5735,11 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "node_modules/@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" + }, "node_modules/@types/mdast": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -15959,6 +15977,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "node_modules/lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -16848,6 +16871,11 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "node_modules/nanoclone": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", + "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" + }, "node_modules/nanoid": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", @@ -21527,6 +21555,11 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, + "node_modules/property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, "node_modules/proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -24635,6 +24668,11 @@ "node": ">=0.6" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/totalist": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", @@ -26312,6 +26350,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "0.32.11", + "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", + "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/lodash": "^4.14.175", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "nanoclone": "^0.2.1", + "property-expr": "^2.0.4", + "toposort": "^2.0.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", @@ -28912,6 +28967,17 @@ "@octokit/openapi-types": "^16.0.0" } }, + "@openshift/dynamic-plugin-sdk": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@openshift/dynamic-plugin-sdk/-/dynamic-plugin-sdk-2.0.1.tgz", + "integrity": "sha512-fiSPxk8ghs/aEp7UasDBhjdXrQ5/IQl+QuCB8FHz6IhAkN5mB/aQ7GcBHfW+ITK4g0eb6ydb4x2IaKP8iZeBJw==", + "requires": { + "lodash-es": "^4.17.21", + "semver": "^7.3.7", + "uuid": "^8.3.2", + "yup": "^0.32.11" + } + }, "@patternfly/patternfly": { "version": "4.224.2", "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-4.224.2.tgz", @@ -29060,14 +29126,14 @@ "dev": true }, "@redhat-cloud-services/frontend-components": { - "version": "3.9.25", - "resolved": "https://registry.npmjs.org/@redhat-cloud-services/frontend-components/-/frontend-components-3.9.25.tgz", - "integrity": "sha512-ynppJJrMtkgzt5/uUF9rR51HeE5WkC5kCkNzIBqAFHHMsGueGazc9y5g1nzPkl7jgBajsTg7tePCLCvKP/HEnw==", + "version": "3.9.33", + "resolved": "https://registry.npmjs.org/@redhat-cloud-services/frontend-components/-/frontend-components-3.9.33.tgz", + "integrity": "sha512-DtqxppMpkmtyLUtRjMIUgUqYPBOztYQgucbmU4BdKCIuugaFrBwMisMxaf2tUKCdZ/fY4lvYUFJghjDEWfdbDA==", "requires": { "@redhat-cloud-services/frontend-components-utilities": "^3.2.25", - "@redhat-cloud-services/types": "^0.0.15", - "@scalprum/core": "^0.2.3", - "@scalprum/react-core": "^0.2.4", + "@redhat-cloud-services/types": "^0.0.17", + "@scalprum/core": "^0.4.0", + "@scalprum/react-core": "^0.4.0", "sanitize-html": "^2.7.2" } }, @@ -29253,11 +29319,6 @@ "react-content-loader": "^6.2.0" }, "dependencies": { - "@redhat-cloud-services/types": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@redhat-cloud-services/types/-/types-0.0.17.tgz", - "integrity": "sha512-3V9mmarS3jD5fBksMwh+XCEAMUIzqSOxDkH6OcIVu6w/gaBBOWHh34Jwn4CxKlu+WStxVV/rxm3oFGpsWqljvg==" - }, "axios": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", @@ -29302,21 +29363,25 @@ } }, "@redhat-cloud-services/types": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@redhat-cloud-services/types/-/types-0.0.15.tgz", - "integrity": "sha512-1aqJcgQZq4uih+LxRpVJQblt2x4o/hlrqSZMYFhWyTLgnVNhJ8Y7B5pwoVjpA5PCE1fBNahrydVwugEKMsDDtg==" + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@redhat-cloud-services/types/-/types-0.0.17.tgz", + "integrity": "sha512-3V9mmarS3jD5fBksMwh+XCEAMUIzqSOxDkH6OcIVu6w/gaBBOWHh34Jwn4CxKlu+WStxVV/rxm3oFGpsWqljvg==" }, "@scalprum/core": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@scalprum/core/-/core-0.2.3.tgz", - "integrity": "sha512-bL7YjXWSgtAw44ha+goEF/cCWUu1BELB0qo4Y8hlfmn0+FMnoIHcY0gD1OOotz7Oy74r5+DRxi5Wra40DTG8Qg==" + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@scalprum/core/-/core-0.4.1.tgz", + "integrity": "sha512-Ff8G2Mhc6ORPx+5C/B6vYYyGL2mBmQ8jR1I0yhgmYClzZU4gzQalZrSIwBDozGCoYmdKggF+hPCxojFwgE227g==", + "requires": { + "@openshift/dynamic-plugin-sdk": "^2.0.1" + } }, "@scalprum/react-core": { - "version": "0.2.8", - "resolved": "https://registry.npmjs.org/@scalprum/react-core/-/react-core-0.2.8.tgz", - "integrity": "sha512-+qGfiA6FkXAx4x53fHmv7Q3oZcEQK0NChgaVeKGaZfG+LSNa1ozgkd4oSWueAMG3XV3St0QbAxzAtRQNFRyqNQ==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@scalprum/react-core/-/react-core-0.4.1.tgz", + "integrity": "sha512-R5gtrnqbeR6qRDUddZAtJUDUYOU+HjMbTROAYP6ryFzFnwbDBPY1DtNx4n8458yaZBRRiPYfkJEWvWzui1D0hw==", "requires": { - "@scalprum/core": "^0.2.3", + "@openshift/dynamic-plugin-sdk": "^2.0.1", + "@scalprum/core": "^0.4.1", "lodash": "^4.17.0" } }, @@ -30583,6 +30648,11 @@ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==" }, + "@types/lodash": { + "version": "4.14.191", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.191.tgz", + "integrity": "sha512-BdZ5BCCvho3EIXw6wUCXHe7rS53AIDPLE+JzwgT+OsJk53oBfbSmZZ7CX4VaRoN78N+TJpFi9QPlfIVNmJYWxQ==" + }, "@types/mdast": { "version": "3.0.10", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.10.tgz", @@ -38350,6 +38420,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" + }, "lodash.camelcase": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", @@ -39027,6 +39102,11 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "nanoclone": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", + "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==" + }, "nanoid": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.4.tgz", @@ -42432,6 +42512,11 @@ } } }, + "property-expr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.5.tgz", + "integrity": "sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA==" + }, "proto-list": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", @@ -44825,6 +44910,11 @@ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true }, + "toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "totalist": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/totalist/-/totalist-1.1.0.tgz", @@ -46074,6 +46164,20 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, + "yup": { + "version": "0.32.11", + "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz", + "integrity": "sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg==", + "requires": { + "@babel/runtime": "^7.15.4", + "@types/lodash": "^4.14.175", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "nanoclone": "^0.2.1", + "property-expr": "^2.0.4", + "toposort": "^2.0.2" + } + }, "zwitch": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", diff --git a/package.json b/package.json index 8ed65fee6..5de25b6c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "insights-inventory-frontend", - "version": "1.7.2", + "version": "1.14.8", "private": false, "engines": { "node": ">=15.0.0", @@ -13,7 +13,7 @@ "@patternfly/react-core": "^4.265.2", "@patternfly/react-icons": "^4.93.6", "@patternfly/react-table": "^4.111.45", - "@redhat-cloud-services/frontend-components": "^3.9.25", + "@redhat-cloud-services/frontend-components": "^3.9.33", "@redhat-cloud-services/frontend-components-notifications": "^3.2.12", "@redhat-cloud-services/frontend-components-utilities": "^3.3.13", "@redhat-cloud-services/host-inventory-client": "1.0.116", diff --git a/src/Utilities/ChromeLoader.js b/src/Utilities/ChromeLoader.js new file mode 100644 index 000000000..246cf5bc2 --- /dev/null +++ b/src/Utilities/ChromeLoader.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; + +const ChromeLoader = ({ children }) => { + const chrome = useChrome(); + + return <> + {React.Children.map(children, child => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { chrome }); + } + + return child; + })} + ; +}; + +ChromeLoader.propTypes = { + children: PropTypes.any +}; + +export default ChromeLoader; diff --git a/src/Utilities/DeleteModal.js b/src/Utilities/DeleteModal.js index fb37bfc20..318f154d5 100644 --- a/src/Utilities/DeleteModal.js +++ b/src/Utilities/DeleteModal.js @@ -27,7 +27,7 @@ const DeleteModal = ({ handleModalToggle, isModalOpen, currentSytems, onConfirm return handleModalToggle(false)} diff --git a/src/Utilities/__snapshots__/DeleteModal.test.js.snap b/src/Utilities/__snapshots__/DeleteModal.test.js.snap index bf491e42a..6c38f8f4e 100644 --- a/src/Utilities/__snapshots__/DeleteModal.test.js.snap +++ b/src/Utilities/__snapshots__/DeleteModal.test.js.snap @@ -7,7 +7,7 @@ exports[`EntityTable DOM should render correctly 1`] = ` aria-describedby="" aria-label="" aria-labelledby="" - className="ins-c-inventory__table--remove" + className="ins-c-inventory__table--remove sentry-mask data-hj-suppress" hasNoBodyWrapper={false} isOpen={false} onClose={[Function]} @@ -103,7 +103,7 @@ exports[`EntityTable DOM should render correctly with multiple systems - count 1 aria-describedby="" aria-label="" aria-labelledby="" - className="ins-c-inventory__table--remove" + className="ins-c-inventory__table--remove sentry-mask data-hj-suppress" hasNoBodyWrapper={false} isOpen={true} onClose={[Function]} @@ -200,7 +200,7 @@ exports[`EntityTable DOM should render correctly with multiple systems - count 2 aria-describedby="" aria-label="" aria-labelledby="" - className="ins-c-inventory__table--remove" + className="ins-c-inventory__table--remove sentry-mask data-hj-suppress" hasNoBodyWrapper={false} isOpen={true} onClose={[Function]} @@ -297,7 +297,7 @@ exports[`EntityTable DOM should render correctly with one system 1`] = ` aria-describedby="" aria-label="" aria-labelledby="" - className="ins-c-inventory__table--remove" + className="ins-c-inventory__table--remove sentry-mask data-hj-suppress" hasNoBodyWrapper={false} isOpen={true} onClose={[Function]} @@ -394,7 +394,7 @@ exports[`EntityTable DOM should render correctly with one system 2`] = ` aria-describedby="" aria-label="" aria-labelledby="" - className="ins-c-inventory__table--remove" + className="ins-c-inventory__table--remove sentry-mask data-hj-suppress" hasNoBodyWrapper={false} isOpen={true} onClose={[Function]} diff --git a/src/Utilities/constants.js b/src/Utilities/constants.js index ed6147e0f..aeec8cfe0 100644 --- a/src/Utilities/constants.js +++ b/src/Utilities/constants.js @@ -10,6 +10,14 @@ export const REGISTERED_CHIP = 'registered_with'; export const OS_CHIP = 'operating_system'; export const RHCD_FILTER_KEY = 'rhc_client_id'; export const UPDATE_METHOD_KEY = 'system_update_method'; +export const LAST_SEEN_CHIP = 'last_seen'; +export const HOST_GROUP_CHIP = 'host_group'; + +export function subtractDate(days) { + const date = new Date(); + date.setDate(date.getDate() - days); + return date.toISOString(); +} export const staleness = [ { label: 'Fresh', value: 'fresh' }, @@ -17,6 +25,34 @@ export const staleness = [ { label: 'Stale warning', value: 'stale_warning' }, { label: 'Unknown', value: 'unknown' } ]; + +export const currentDate = new Date().toISOString(); +export const lastSeenItems = [ + { + value: { updatedStart: subtractDate(1), updatedEnd: currentDate, mark: 'last24' }, + label: 'Within the last 24 hours' }, + { + value: { updatedEnd: subtractDate(1), mark: '24more' }, + label: 'More than 1 day ago' + + }, + { + value: { updatedEnd: subtractDate(7), mark: '7more' }, + label: 'More than 7 days ago' + }, + { + value: { updatedEnd: subtractDate(15), mark: '15more' }, + label: 'More than 15 days ago' + }, + { + value: { updatedEnd: subtractDate(30), mark: '30more' }, + label: 'More than 30 days ago' + }, + { + value: { mark: 'custom' }, + label: 'Custom' + } +]; export const registered = [ { label: 'insights-client', value: 'puptoo', idName: 'Insights id', idValue: 'insights_id' }, { label: 'subscription-manager', value: 'rhsm-conduit', @@ -81,7 +117,8 @@ export function reduceFilters(filters = []) { }; } - const foundKey = ['staleFilter', 'registeredWithFilter', 'osFilter', 'rhcdFilter', 'updateMethodFilter', ''] + const foundKey = ['staleFilter', 'registeredWithFilter', 'osFilter', 'rhcdFilter', 'updateMethodFilter', + 'lastSeenFilter', ''] .find(item => Object.keys(oneFilter).includes(item)); return { @@ -105,7 +142,16 @@ export const reloadWrapper = (event, callback) => { export const isEmpty = (check) => !check || check?.length === 0; -export const generateFilter = (status, source, tagsFilter, filterbyName, operatingSystem, rhcdFilter, updateMethodFilter) => ([ +export const generateFilter = (status, + source, + tagsFilter, + filterbyName, + operatingSystem, + rhcdFilter, + updateMethodFilter, + hostGroup, + lastSeenFilter +) => ([ !isEmpty(status) && { staleFilter: Array.isArray(status) ? status : [status] }, @@ -131,8 +177,15 @@ export const generateFilter = (status, source, tagsFilter, filterbyName, operati !isEmpty(rhcdFilter) && { rhcdFilter: Array.isArray(rhcdFilter) ? rhcdFilter : [rhcdFilter] }, + !isEmpty(lastSeenFilter) && { + lastSeenFilter: Array.isArray(lastSeenFilter) + ? lastSeenItems.filter((item)=> item.value.mark === lastSeenFilter[0])[0].value : [lastSeenFilter] + }, !isEmpty(updateMethodFilter) && { updateMethodFilter: Array.isArray(updateMethodFilter) ? updateMethodFilter : [updateMethodFilter] + }, + !isEmpty(hostGroup) && { + hostGroupFilter: Array.isArray(hostGroup) ? hostGroup : [hostGroup] } ].filter(Boolean)); diff --git a/src/api/api.js b/src/api/api.js index 494259848..5a0eeae55 100644 --- a/src/api/api.js +++ b/src/api/api.js @@ -98,7 +98,9 @@ export const filtersReducer = (acc, filter = {}) => ({ ...'registeredWithFilter' in filter && { registeredWithFilter: filter.registeredWithFilter }, ...'osFilter' in filter && { osFilter: filter.osFilter }, ...'rhcdFilter' in filter && { rhcdFilter: filter.rhcdFilter }, - ...'updateMethodFilter' in filter && { updateMethodFilter: filter.updateMethodFilter } + ...'lastSeenFilter' in filter && { lastSeenFilter: filter.lastSeenFilter }, + ...'updateMethodFilter' in filter && { updateMethodFilter: filter.updateMethodFilter }, + ...'groupHostFilter' in filter && { groupHostFilter: filter.groupHostFilter } }); export async function getEntities(items, { @@ -109,9 +111,10 @@ export async function getEntities(items, { page, orderBy, orderDirection, - fields = { system_profile: ['operating_system'] }, + fields = { system_profile: ['operating_system', /* needed by inventory groups */ 'system_update_method'] }, ...options }, showTags) { + if (hasItems && items?.length > 0) { let data = await hosts.apiHostGetHostById( items, @@ -180,8 +183,8 @@ export async function getEntities(items, { orderDirection, filters.staleFilter, [ - ...constructTags(filters.tagFilters), - ...options.tags || [] + ...constructTags(filters?.tagFilters), + ...options?.globalFilter?.tags || [] ], filters?.registeredWithFilter, undefined, @@ -189,9 +192,12 @@ export async function getEntities(items, { { cancelToken: controller && controller.token, query: { + ...(options?.globalFilter?.filter && generateFilter(options.globalFilter.filter)), ...(options.filter && Object.keys(options.filter).length && generateFilter(options.filter)), ...(calculateSystemProfile(filters)), - ...(fields && Object.keys(fields).length && generateFilter(fields, 'fields')) + ...(fields && Object.keys(fields).length && generateFilter(fields, 'fields')), + ...filters?.lastSeenFilter?.updatedStart && { updated_start: filters.lastSeenFilter.updatedStart }, + ...filters?.lastSeenFilter?.updatedEnd && { updated_end: filters.lastSeenFilter.updatedEnd } } } ) diff --git a/src/components/GeneralInfo/SystemCard/SystemCard.js b/src/components/GeneralInfo/SystemCard/SystemCard.js index 76a589e1c..504faf2b5 100644 --- a/src/components/GeneralInfo/SystemCard/SystemCard.js +++ b/src/components/GeneralInfo/SystemCard/SystemCard.js @@ -170,6 +170,7 @@ class SystemCardCore extends Component { inputOuiaId="input-edit-display-name" onCancel={ this.onCancel } onSubmit={ this.onSubmit(setDisplayName, entity && entity.display_name) } + className ='sentry-mask data-hj-suppress' /> ); diff --git a/src/components/GroupSystems/GroupSystems.cy.js b/src/components/GroupSystems/GroupSystems.cy.js new file mode 100644 index 000000000..a9286371a --- /dev/null +++ b/src/components/GroupSystems/GroupSystems.cy.js @@ -0,0 +1,365 @@ +import { mount } from '@cypress/react'; +import { + changePagination, + checkEmptyState, + checkPaginationTotal, + checkPaginationValues, + checkTableHeaders, + CHIP, + CHIP_GROUP, + DROPDOWN_ITEM, + DROPDOWN_TOGGLE, + hasChip, + MODAL, + PAGINATION_VALUES, + SORTING_ORDERS, + TEXT_INPUT, + TOOLBAR, + TOOLBAR_FILTER +} from '@redhat-cloud-services/frontend-components-utilities'; +import FlagProvider from '@unleash/proxy-client-react'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { + featureFlagsInterceptors, + groupsInterceptors, + hostsInterceptors, + systemProfileInterceptors +} from '../../../cypress/support/interceptors'; +import { getStore } from '../../store'; +import GroupSystems from './GroupSystems'; +import fixtures from '../../../cypress/fixtures/hosts.json'; +import { + checkSelectedNumber as checkSelectedNumber_, + ORDER_TO_URL, + selectRowN, + unleashDummyConfig +} from '../../../cypress/support/utils'; +import _ from 'lodash'; + +const GROUP_NAME = 'foobar'; +const ROOT = 'div[id="group-systems-table"]'; +const TABLE_HEADERS = ['Name', 'Tags', 'OS', 'Update method', 'Last seen']; +const SORTABLE_HEADERS = ['Name', 'OS', 'Last seen']; +const DEFAULT_ROW_COUNT = 50; + +const checkSelectedNumber = (number) => + checkSelectedNumber_(number, '#bulk-select-systems-toggle-checkbox-text'); + +const mountTable = () => + mount( + + + + + + + + ); + +before(() => { + cy.window().then( + (window) => + (window.insights = { + chrome: { + isProd: false, + auth: { + getUser: () => { + return Promise.resolve({}); + } + } + } + }) + ); +}); + +describe('renders correctly', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + it('the root container is rendered', () => { + cy.get(ROOT); + }); + + it('renders toolbar', () => { + cy.get(TOOLBAR).should('have.length', 1); + }); + + it('renders table header', () => { + checkTableHeaders(TABLE_HEADERS); + }); +}); + +describe('defaults', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.wait('@getHosts'); + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + it(`pagination is set to ${DEFAULT_ROW_COUNT}`, () => { + cy.get('.pf-c-options-menu__toggle-text') + .find('b') + .eq(0) + .should('have.text', `1 - ${DEFAULT_ROW_COUNT}`); + }); + + it('name filter is a default filter', () => { + cy.get(TOOLBAR_FILTER).find(TEXT_INPUT).should('exist'); + }); +}); + +describe('pagination', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.wait('@getHosts'); + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + it('shows correct total number of groups', () => { + checkPaginationTotal(fixtures.total); + }); + + it('values are expected ones', () => { + checkPaginationValues(PAGINATION_VALUES); + }); + + it('can change page limit', () => { + PAGINATION_VALUES.forEach((el) => { + changePagination(el).then(() => { + cy.wait('@getHosts') + .its('request.url') + .should('include', `per_page=${el}`); + }); + }); + }); + + it('can change page', () => { + cy.get('button[data-action=next]').eq(0).click(); // click "next page" button + cy.wait('@getHosts').its('request.url').should('include', `page=2`); + }); +}); + +describe('sorting', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.wait('@getHosts'); + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + const checkSorting = (label, order, dataField) => { + // get appropriate locators + const header = `th[data-label="${label}"]`; + if (order === 'ascending') { + cy.get(header).find('button').click(); + } else { + cy.get(header).find('button').click(); + cy.wait('@getHosts'); // TODO: implement debounce for sorting feature + cy.get(header).find('button').click(); + } + + cy.wait('@getHosts') + .its('request.url') + .should('include', `order_how=${ORDER_TO_URL[order]}`) + .and('include', `order_by=${dataField}`); + }; + + _.zip( + ['display_name', 'operating_system', 'updated'], + SORTABLE_HEADERS + ).forEach(([category, label]) => { + SORTING_ORDERS.forEach((order) => { + it(`${order} by ${label}`, () => { + checkSorting(label, order, category); + }); + }); + }); +}); + +describe('filtering', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.wait('@getHosts'); + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + const applyNameFilter = () => + cy.get('.ins-c-primary-toolbar__filter').find('input').type('lorem'); + it('renders filter chip', () => { + applyNameFilter(); + hasChip('Display name', 'lorem'); + cy.wait('@getHosts'); + }); + + it('sends correct request', () => { + applyNameFilter(); + cy.wait('@getHosts') + .its('request.url') + .should('include', 'hostname_or_id=lorem'); + }); + + it('can remove the chip or reset filters', () => { + applyNameFilter(); + cy.wait('@getHosts') + .its('request.url') + .should('contain', 'hostname_or_id=lorem'); + cy.get(CHIP_GROUP) + .find(CHIP) + .ouiaId('close', 'button') + .each(() => { + cy.get(CHIP_GROUP).find(CHIP).ouiaId('close', 'button'); + }); + cy.get('button').contains('Reset filters').click(); + cy.wait('@getHosts') + .its('request.url') + .should('not.contain', 'hostname_or_id'); + + cy.get(CHIP_GROUP).should('not.exist'); + + cy.wait('@getHosts'); // TODO: reset filters shouldn't trigger this second extra call + }); + + it('should not contain group filter', () => { + cy.get('button[data-ouia-component-id="ConditionalFilter"]').click(); + cy.get(DROPDOWN_ITEM).should('not.contain', 'Group'); + }); + // TODO: add more filter cases +}); + +describe('selection and bulk selection', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.wait('@getHosts'); + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + it('can select and deselect systems', () => { + const middleRow = Math.ceil(DEFAULT_ROW_COUNT / 4); + selectRowN(middleRow); + checkSelectedNumber(1); + selectRowN(Math.ceil(middleRow / 2)); + checkSelectedNumber(2); + selectRowN(middleRow); + checkSelectedNumber(1); + }); + + /* it('can select all in dropdown toggle', () => { + cy.get(DROPDOWN_TOGGLE).eq(0).click(); // open selection dropdown + cy.get('.pf-c-dropdown__menu > li').eq(2).click(); + checkSelectedNumber(fixtures.total); + }); */ + + /* it('can select all by clicking checkbox', () => { + cy.get('#toggle-checkbox').eq(0).click(); + checkSelectedNumber(fixtures.total); + cy.get('#toggle-checkbox').eq(0).click(); + checkSelectedNumber(0); + }); */ + + it('can select page in dropdown toggle', () => { + cy.get(DROPDOWN_TOGGLE).eq(0).click(); // open selection dropdown + cy.get('.pf-c-dropdown__menu > li').eq(1).click(); + checkSelectedNumber(fixtures.count); + }); + + it('can select page by clicking checkbox', () => { + cy.get('#bulk-select-systems-toggle-checkbox').eq(0).click(); + checkSelectedNumber(fixtures.count); + cy.get('#bulk-select-systems-toggle-checkbox').eq(0).click(); + checkSelectedNumber(0); + }); + + it('can select none', () => { + selectRowN(1); + cy.get(DROPDOWN_TOGGLE).eq(0).click(); // open selection dropdown + cy.get('.pf-c-dropdown__menu > li').eq(1).click(); + checkSelectedNumber(0); + }); +}); + +describe('actions', () => { + beforeEach(() => { + cy.intercept('*', { statusCode: 200 }); + hostsInterceptors.successful(); + featureFlagsInterceptors.successful(); // make Groups col available + + mountTable(); + + cy.wait('@getHosts'); + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + }); + + it('can open systems add modal', () => { + cy.get('button').contains('Add systems').click(); + cy.get(MODAL).find('h1').contains('Add systems'); + + cy.wait('@getHosts'); + }); +}); + +describe('edge cases', () => { + it('no groups match', () => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors['successful empty'](); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + groupsInterceptors['successful with some items'](); + mountTable(); + + cy.wait('@getHosts'); + + checkEmptyState('No matching systems found', true); + checkPaginationTotal(0); + }); + + it('failed request', () => { + cy.intercept('*', { statusCode: 200, body: { results: [] } }); + hostsInterceptors['failed with server error'](); + featureFlagsInterceptors.successful(); + systemProfileInterceptors['operating system, successful empty'](); + mountTable(); + + cy.wait('@getHosts'); + cy.get('.pf-c-empty-state').find('h4').contains('Something went wrong'); + }); +}); diff --git a/src/components/GroupSystems/GroupSystems.js b/src/components/GroupSystems/GroupSystems.js new file mode 100644 index 000000000..83c5ac840 --- /dev/null +++ b/src/components/GroupSystems/GroupSystems.js @@ -0,0 +1,164 @@ +import { Button } from '@patternfly/react-core'; +import { fitContent, TableVariant } from '@patternfly/react-table'; +import difference from 'lodash/difference'; +import map from 'lodash/map'; +import PropTypes from 'prop-types'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { clearFilters, selectEntity } from '../../store/inventory-actions'; +import AddSystemsToGroupModal from '../InventoryGroups/Modals/AddSystemsToGroupModal'; +import InventoryTable from '../InventoryTable/InventoryTable'; +import { Link } from 'react-router-dom'; + +export const bulkSelectConfig = (dispatch, selectedNumber, noneSelected, pageSelected, rowsNumber) => ({ + count: selectedNumber, + id: 'bulk-select-systems', + items: [ + { + title: 'Select none (0)', + onClick: () => dispatch(selectEntity(-1, false)), + props: { isDisabled: noneSelected } + }, + { + title: `${pageSelected ? 'Deselect' : 'Select'} page (${ + rowsNumber + } items)`, + onClick: () => dispatch(selectEntity(0, !pageSelected)) + } + // TODO: Implement "select all" + ], + onSelect: (value) => { + dispatch(selectEntity(0, value)); + }, + checked: selectedNumber > 0 && pageSelected // TODO: support partial selection (dash sign) in FEC BulkSelect +}); + +export const prepareColumns = (initialColumns, hideGroupColumn) => { + // hides the "groups" column + const columns = hideGroupColumn ? initialColumns.filter(({ key }) => key !== 'groups') : initialColumns; + + // additionally insert the "update method" column + columns.splice(columns.length - 2 /* must be the 3rd col from the end */, 0, { + key: 'update_method', + title: 'Update method', + sortKey: 'update_method', + transforms: [fitContent], + renderFunc: (value, hostId, systemData) => + systemData?.system_profile?.system_update_method || 'N/A', + props: { + // TODO: remove isStatic when the sorting is supported by API + isStatic: true, + width: 10 + } + }); + + columns[columns.findIndex(({ key }) => key === 'display_name')].renderFunc = + (value, hostId) => ( +
+ + {value} + +
+ ); + + // map columns to the speicifc order + return [ + 'display_name', + 'groups', + 'tags', + 'system_profile', + 'update_method', + 'updated' + ].map((colKey) => columns.find(({ key }) => key === colKey)) + .filter(Boolean); // eliminate possible undefined's +}; + +const GroupSystems = ({ groupName, groupId }) => { + const dispatch = useDispatch(); + + const selected = useSelector( + (state) => state?.entities?.selected || new Map() + ); + const rows = useSelector(({ entities }) => entities?.rows || []); + + const noneSelected = selected.size === 0; + const displayedIds = map(rows, 'id'); + const pageSelected = + difference(displayedIds, [...selected.keys()]).length === 0; + + const [isModalOpen, setIsModalOpen] = useState(false); + + const resetTable = () => { + dispatch(clearFilters()); + dispatch(selectEntity(-1, false)); + }; + + useEffect(() => { + return () => { + resetTable(); + }; + }, []); + + return ( +
+ { + isModalOpen && { + resetTable(); + setIsModalOpen(value); + } + } + groupId={groupId} + groupName={groupName} + /> + } + { + !isModalOpen && + prepareColumns(columns, true)} + hideFilters={{ hostGroupFilter: true }} + getEntities={async (items, config, showTags, defaultGetEntities) => + await defaultGetEntities( + items, + // filter systems by the group name + { + ...config, + filters: { + ...config.filters, + groupName: [groupName] // TODO: the param is not yet supported by `apiHostGetHostList` + } + }, + showTags + ) + } + tableProps={{ + isStickyHeader: true, + variant: TableVariant.compact, + canSelectAll: false + }} + bulkSelect={bulkSelectConfig(dispatch, selected.size, noneSelected, pageSelected, rows.length)} + showTags + > + + + } +
+ ); +}; + +GroupSystems.propTypes = { + groupName: PropTypes.string.isRequired, + groupId: PropTypes.string.isRequired +}; + +export default GroupSystems; diff --git a/src/components/GroupSystems/index.js b/src/components/GroupSystems/index.js new file mode 100644 index 000000000..9c7177334 --- /dev/null +++ b/src/components/GroupSystems/index.js @@ -0,0 +1,30 @@ +import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import NoSystemsEmptyState from '../InventoryGroupDetail/NoSystemsEmptyState'; +import GroupSystems from './GroupSystems'; + +const GroupSystemsWrapper = ({ groupName, groupId }) => { + const { uninitialized, loading, data } = useSelector((state) => state.groupDetail); + const hosts = data?.results?.[0]?.host_ids /* can be null */ || []; + + return uninitialized || loading ? ( + + + + + + ) : hosts.length > 0 ? ( + + ) : + ; +}; + +GroupSystemsWrapper.propTypes = { + groupName: PropTypes.string.isRequired, + groupId: PropTypes.string.isRequired +}; + +export default GroupSystemsWrapper; +export { GroupSystems }; diff --git a/src/components/GroupsTable/GroupsTable.cy.js b/src/components/GroupsTable/GroupsTable.cy.js index f13df86b1..701461d35 100644 --- a/src/components/GroupsTable/GroupsTable.cy.js +++ b/src/components/GroupsTable/GroupsTable.cy.js @@ -26,30 +26,14 @@ import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import fixtures from '../../../cypress/fixtures/groups.json'; import { groupsInterceptors as interceptors } from '../../../cypress/support/interceptors'; +import { checkSelectedNumber, ORDER_TO_URL, selectRowN } from '../../../cypress/support/utils'; import { getStore } from '../../store'; import GroupsTable from './GroupsTable'; -const ORDER_TO_URL = { - ascending: 'ASC', - descending: 'DESC' -}; - const DEFAULT_ROW_COUNT = 50; const TABLE_HEADERS = ['Name', 'Total systems', 'Last modified']; const ROOT = 'div[id="groups-table"]'; -export const checkSelectedNumber = (number) => { - if (number === 0) { - cy.get('#toggle-checkbox-text').should('not.exist'); - } else { - cy.get('#toggle-checkbox-text').should('have.text', `${number} selected`); - } -}; - -export const selectRowN = (number) => { - cy.get(ROW).eq(number).find('.pf-c-table__check').click(); -}; - const mountTable = () => mount( @@ -153,7 +137,7 @@ describe('sorting', () => { interceptors['successful with some items'](); mountTable(); - cy.wait('@getGroups'); // first initial call + cy.wait('@getGroups'); // first initial request }); const checkSorting = (label, order, dataField) => { @@ -187,7 +171,7 @@ describe('filtering', () => { interceptors['successful with some items'](); mountTable(); - cy.wait('@getGroups'); // first initial call + cy.wait('@getGroups'); // first initial request }); const applyNameFilter = () => @@ -201,16 +185,13 @@ describe('filtering', () => { }); it('sends correct request', () => { - applyNameFilter().then(() => { - cy.wait('@getGroups') - .its('request.url') - .should('include', 'hostname_or_id=lorem'); - }); + applyNameFilter(); + cy.wait('@getGroups').its('request.url').should('include', 'hostname_or_id=lorem'); }); it('can remove the chip or reset filters', () => { applyNameFilter(); - cy.wait('@getGroups'); + cy.wait('@getGroups').its('request.url').should('contain', 'hostname_or_id=lorem'); cy.get(CHIP_GROUP) .find(CHIP) .ouiaId('close', 'button') @@ -218,7 +199,7 @@ describe('filtering', () => { cy.get(CHIP_GROUP).find(CHIP).ouiaId('close', 'button'); }); cy.get('button').contains('Reset filters').click(); - cy.wait('@getGroups'); + cy.wait('@getGroups').its('request.url').should('not.contain', 'hostname_or_id'); cy.get(CHIP_GROUP).should('not.exist'); }); diff --git a/src/components/InventoryDetail/TopBar.js b/src/components/InventoryDetail/TopBar.js index 408deaf61..25496197b 100644 --- a/src/components/InventoryDetail/TopBar.js +++ b/src/components/InventoryDetail/TopBar.js @@ -135,6 +135,7 @@ const TopBar = ({ } { isModalOpen && ( setIsModalOpen(!isModalOpen)} isModalOpen={isModalOpen} currentSytems={entity} diff --git a/src/components/InventoryGroupDetail/GroupDetailHeader.js b/src/components/InventoryGroupDetail/GroupDetailHeader.js index 9ef4e4830..ec8886392 100644 --- a/src/components/InventoryGroupDetail/GroupDetailHeader.js +++ b/src/components/InventoryGroupDetail/GroupDetailHeader.js @@ -1,33 +1,108 @@ -import { Breadcrumb, BreadcrumbItem, Skeleton } from '@patternfly/react-core'; +import { + Breadcrumb, + BreadcrumbItem, + Dropdown, + DropdownItem, + DropdownToggle, + Flex, + FlexItem, + Skeleton +} from '@patternfly/react-core'; import { PageHeader, PageHeaderTitle } from '@redhat-cloud-services/frontend-components'; -import React from 'react'; -import { useSelector } from 'react-redux'; -import { Link } from 'react-router-dom'; +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link, useHistory } from 'react-router-dom'; import { routes } from '../../Routes'; import PropTypes from 'prop-types'; +import DeleteGroupModal from '../InventoryGroups/Modals/DeleteGroupModal'; +import RenameGroupModal from '../InventoryGroups/Modals/RenameGroupModal'; +import { fetchGroupDetail } from '../../store/inventory-actions'; const GroupDetailHeader = ({ groupId }) => { - const { uninitialized, loading, data } = useSelector((state) => state.groupDetail); - - const nameOrId = uninitialized || loading ? ( - - ) : ( - // in case of error, render just id from URL - data?.results?.[0]?.name || groupId + const dispatch = useDispatch(); + const { uninitialized, loading, data } = useSelector( + (state) => state.groupDetail ); + const [dropdownOpen, setDropdownOpen] = useState(false); + const [renameModalOpen, setRenameModalOpen] = useState(false); + const [deleteModalOpen, setDeleteModalOpen] = useState(false); + + const name = data?.results?.[0]?.name; + const title = + uninitialized || loading ? ( + + ) : ( + name || groupId // in case of error, render just id from URL + ); + + const history = useHistory(); + return ( + setRenameModalOpen(false)} + modalState={{ + id: groupId, + name: name || groupId + }} + reloadData={() => dispatch(fetchGroupDetail(groupId))} + /> + setDeleteModalOpen(false)} + modalState={{ + id: groupId, + name: name || groupId + }} + reloadData={() => history.push('/groups')} + /> Groups - {nameOrId} + {title} - + + + + + + setDropdownOpen(!dropdownOpen)} + autoFocus={false} + isOpen={dropdownOpen} + toggle={ + setDropdownOpen(isOpen)} + toggleVariant="secondary" + isDisabled={uninitialized || loading} + > + Group actions + + } + dropdownItems={[ + setRenameModalOpen(true)} + > + Rename + , + setDeleteModalOpen(true)} + > + Delete + + ]} + /> + + ); }; diff --git a/src/components/InventoryGroupDetail/GroupDetailInfo.cy.js b/src/components/InventoryGroupDetail/GroupDetailInfo.cy.js new file mode 100644 index 000000000..9e6b91a30 --- /dev/null +++ b/src/components/InventoryGroupDetail/GroupDetailInfo.cy.js @@ -0,0 +1,39 @@ +import { mount } from '@cypress/react'; +import React from 'react'; +import { GroupDetailInfo } from './GroupDetailInfo'; + +const mountPage = (params) => + mount( + + ); + +describe('group detail information page', () => { + beforeEach(() => { + mountPage({ chrome: { isBeta: () => false } }); + }); + + it('title is rendered', () => { + cy.get('div[class="pf-c-card__title pf-c-title pf-m-lg card-title"]').should('have.text', 'User access configuration'); + }); + + it('button is present', () => { + cy.get('a[class="pf-c-button pf-m-secondary"]').should('have.text', 'Manage access'); + }); + + it('card text is present', () => { + cy.get('div[class="pf-c-card__body"]').should + ('have.text', 'Manage your inventory group user access configuration under Identity & Access Management > User Access.'); + }); + + describe('links', () => { + it('in stable environment', () => { + mountPage({ chrome: { isBeta: () => false } }); + cy.get('a').should('have.attr', 'href', '/iam/user-access'); + }); + + it('in beta environment', () => { + mountPage({ chrome: { isBeta: () => true } }); + cy.get('a').should('have.attr', 'href', '/beta/iam/user-access'); + }); + }); +}); diff --git a/src/components/InventoryGroupDetail/GroupDetailInfo.js b/src/components/InventoryGroupDetail/GroupDetailInfo.js index 01a935077..78914050d 100644 --- a/src/components/InventoryGroupDetail/GroupDetailInfo.js +++ b/src/components/InventoryGroupDetail/GroupDetailInfo.js @@ -1,20 +1,46 @@ -import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; -import { InvalidObject } from '@redhat-cloud-services/frontend-components'; +import { + Button, + Card, + CardTitle, + CardBody, + CardHeader, + CardActions } from '@patternfly/react-core'; import React from 'react'; -import { useSelector } from 'react-redux'; +import PropTypes from 'prop-types'; +import ChromeLoader from '../../Utilities/ChromeLoader'; -const GroupDetailInfo = () => { - const { uninitialized, loading } = useSelector((state) => state.groupDetail); - - // TODO: implement according to mocks +const GroupDetailInfo = ({ chrome }) => { + const path = `${chrome.isBeta() ? '/beta' : ''}/iam/user-access`; return ( - - - {uninitialized || loading ? : } - - + + + + + + + User access configuration + + + + Manage your inventory group user access configuration under + Identity & Access Management {'>'} User Access. + + ); }; -export default GroupDetailInfo; +GroupDetailInfo.propTypes = { + chrome: PropTypes.object +}; + +const GroupDetailInfoWithChrome = () => ( + + + +); + +export { GroupDetailInfo }; +export default GroupDetailInfoWithChrome; diff --git a/src/components/InventoryGroupDetail/GroupDetailSystems.js b/src/components/InventoryGroupDetail/GroupDetailSystems.js deleted file mode 100644 index e9470ce71..000000000 --- a/src/components/InventoryGroupDetail/GroupDetailSystems.js +++ /dev/null @@ -1,22 +0,0 @@ -import { EmptyState, EmptyStateBody, Spinner } from '@patternfly/react-core'; -import React from 'react'; -import { useSelector } from 'react-redux'; -import NoSystemsEmptyState from './NoSystemsEmptyState'; - -const GroupDetailSystems = () => { - const { uninitialized, loading } = useSelector((state) => state.groupDetail); - - // TODO: integrate the inventory table - - return (uninitialized || loading ? - - - - - - : - - ); -}; - -export default GroupDetailSystems; diff --git a/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js b/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js index b487e971b..db3ddd65d 100644 --- a/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js +++ b/src/components/InventoryGroupDetail/InventoryGroupDetail.cy.js @@ -1,9 +1,10 @@ import { mount } from '@cypress/react'; +import { DROPDOWN, DROPDOWN_ITEM, MODAL } from '@redhat-cloud-services/frontend-components-utilities'; import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import groupDetailFixtures from '../../../cypress/fixtures/groups/620f9ae75A8F6b83d78F3B55Af1c4b2C.json'; -import { groupDetailInterceptors as interceptors } from '../../../cypress/support/interceptors'; +import { groupDetailInterceptors as interceptors, groupsInterceptors } from '../../../cypress/support/interceptors'; import { getStore } from '../../store'; import InventoryGroupDetail from './InventoryGroupDetail'; @@ -46,7 +47,8 @@ describe('group detail page', () => { .should('have.text', groupDetailFixtures.results[0].name); }); - it('skeletons rendered while fetching data', () => { + /* TODO: fix this test (affected the execution of the next tests and made them flaky) + it('skeletons rendered while fetching data', () => { // TODO: after each hook fails for some reason for this particular test Cypress.on('uncaught:exception', () => { return false; @@ -59,4 +61,39 @@ describe('group detail page', () => { cy.get('h1').find('.pf-c-skeleton'); cy.get('.pf-c-empty-state').find('.pf-c-spinner'); }); + */ + + it('can open rename group modal', () => { + interceptors.successful(); + interceptors['patch successful'](); + groupsInterceptors['successful with some items'](); // intercept modal validation requests + mountPage(); + + cy.wait('@getGroupDetail'); + + cy.get(DROPDOWN).click(); + cy.get(DROPDOWN_ITEM).contains('Rename').click(); + + cy.get(MODAL).find('input').type('1'); + cy.get(MODAL).find('button[type=submit]').click(); + + cy.wait('@patchGroup').its('request.body') + .should('deep.equal', { name: `${groupDetailFixtures.results[0].name}1` }); + cy.wait('@getGroupDetail'); // the page is refreshed after submition + }); + + it('can open delete group modal', () => { + interceptors.successful(); + interceptors['delete successful'](); + mountPage(); + cy.wait('@getGroupDetail'); + + cy.get(DROPDOWN).click(); + cy.get(DROPDOWN_ITEM).contains('Delete').click(); + + cy.get(`div[class="pf-c-check"]`).click(); + cy.get(`button[type="submit"]`).click(); + cy.wait('@deleteGroup').its('request.url') + .should('contain', groupDetailFixtures.results[0].id); + }); }); diff --git a/src/components/InventoryGroupDetail/InventoryGroupDetail.js b/src/components/InventoryGroupDetail/InventoryGroupDetail.js index 816d81043..bc414da7b 100644 --- a/src/components/InventoryGroupDetail/InventoryGroupDetail.js +++ b/src/components/InventoryGroupDetail/InventoryGroupDetail.js @@ -1,19 +1,17 @@ -import React, { lazy, Suspense, useEffect } from 'react'; -import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; -import { useDispatch, useSelector } from 'react-redux'; -import { fetchGroupDetail } from '../../store/inventory-actions'; import { Bullseye, PageSection, Spinner, Tab, - Tabs, - TabTitleText + Tabs } from '@patternfly/react-core'; -import { useState } from 'react'; -import GroupDetailHeader from './GroupDetailHeader'; -import GroupDetailSystems from './GroupDetailSystems'; +import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; import PropTypes from 'prop-types'; +import React, { lazy, Suspense, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchGroupDetail } from '../../store/inventory-actions'; +import GroupSystems from '../GroupSystems'; +import GroupDetailHeader from './GroupDetailHeader'; const GroupDetailInfo = lazy(() => import('./GroupDetailInfo')); @@ -21,6 +19,7 @@ const InventoryGroupDetail = ({ groupId }) => { const dispatch = useDispatch(); const { data } = useSelector((state) => state.groupDetail); const chrome = useChrome(); + const groupName = data?.results?.[0]?.name; useEffect(() => { dispatch(fetchGroupDetail(groupId)); @@ -29,7 +28,7 @@ const InventoryGroupDetail = ({ groupId }) => { useEffect(() => { // if available, change ID to the group's name in the window title chrome?.updateDocumentTitle?.( - `${data?.name || groupId} - Inventory Groups | Red Hat Insights` + `${groupName || groupId} - Inventory Groups | Red Hat Insights` ); }, [data]); @@ -50,16 +49,16 @@ const InventoryGroupDetail = ({ groupId }) => { > Systems} + title='Systems' aria-label="Group systems tab" > - + Group info} + title='Group info' aria-label="Group info tab" > {activeTabKey === 1 && ( // helps to lazy load the component diff --git a/src/components/InventoryGroupDetail/NoSystemsEmptyState.js b/src/components/InventoryGroupDetail/NoSystemsEmptyState.js index decc11181..6ab6100c6 100644 --- a/src/components/InventoryGroupDetail/NoSystemsEmptyState.js +++ b/src/components/InventoryGroupDetail/NoSystemsEmptyState.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Button, EmptyState, @@ -10,14 +10,24 @@ import { import { ExternalLinkAltIcon, PlusCircleIcon } from '@patternfly/react-icons'; import { global_palette_black_600 as globalPaletteBlack600 } from '@patternfly/react-tokens/dist/js/global_palette_black_600'; +import AddSystemsToGroupModal from '../InventoryGroups/Modals/AddSystemsToGroupModal'; +import PropTypes from 'prop-types'; + +const NoSystemsEmptyState = ({ groupId, groupName }) => { + const [isModalOpen, setIsModalOpen] = useState(false); -const NoSystemsEmptyState = () => { return ( + No systems added @@ -25,13 +35,14 @@ const NoSystemsEmptyState = () => { <EmptyStateBody> To manage systems more effectively, add systems to the group. </EmptyStateBody> - <Button variant="primary" onClick={() => {}}>Add systems</Button> + <Button variant="primary" onClick={() => setIsModalOpen(true)}> + Add systems + </Button> <EmptyStateSecondaryActions> <Button variant="link" icon={<ExternalLinkAltIcon />} iconPosition="right" - // TODO: component={(props) => <a href='' {...props} />} > Learn more about system groups </Button> @@ -39,4 +50,8 @@ const NoSystemsEmptyState = () => { </EmptyState> );}; +NoSystemsEmptyState.propTypes = { + groupId: PropTypes.string, + groupName: PropTypes.string +}; export default NoSystemsEmptyState; diff --git a/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js b/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js index 10a77cc0e..ecf4a1527 100644 --- a/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js +++ b/src/components/InventoryGroupDetail/__tests__/GroupDetailHeader.test.js @@ -3,6 +3,7 @@ import { render } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; import GroupDetailHeader from '../GroupDetailHeader'; +import { DROPDOWN } from '@redhat-cloud-services/frontend-components-utilities/CypressUtils/selectors'; jest.mock('react-redux', () => { return { @@ -17,12 +18,14 @@ jest.mock('react-redux', () => { } ] } - }) + }), + useDispatch: () => {} }; }); describe('group detail header', () => { let getByRole; + let container; beforeEach(() => { const rendered = render( @@ -31,6 +34,7 @@ describe('group detail header', () => { </MemoryRouter> ); getByRole = rendered.getByRole; + container = rendered.container; }); it('renders title and breadcrumbs', () => { @@ -41,4 +45,9 @@ describe('group detail header', () => { expect(getByRole('navigation')).toHaveClass('pf-c-breadcrumb'); expect(getByRole('navigation')).toHaveTextContent('group-name-1'); }); + + it('renders the actions dropdown', () => { + expect(container.querySelector('#group-header-dropdown')).toHaveTextContent('Group actions'); + expect(container.querySelector(DROPDOWN)).toBeVisible(); + }); }); diff --git a/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js b/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js index 44f42fa57..2b59832e3 100644 --- a/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js +++ b/src/components/InventoryGroupDetail/__tests__/InventoryGroupDetail.test.js @@ -2,25 +2,11 @@ import '@testing-library/jest-dom'; import { render } from '@testing-library/react'; import React from 'react'; import { MemoryRouter } from 'react-router-dom'; +import { getStore } from '../../../store'; import InventoryGroupDetail from '../InventoryGroupDetail'; +import { Provider } from 'react-redux'; -jest.mock('react-redux', () => { - return { - ...jest.requireActual('react-redux'), - useSelector: () => ({ - uninitialized: false, - loading: false, - data: { - results: [ - { - name: 'group-name-1' - } - ] - } - }), - useDispatch: () => () => {} - }; -}); +jest.mock('../../../Utilities/useFeatureFlag'); describe('group detail page component', () => { let getByRole; @@ -29,7 +15,9 @@ describe('group detail page component', () => { beforeEach(() => { const rendered = render( <MemoryRouter> - <InventoryGroupDetail groupId="group-id-2" /> + <Provider store={getStore()}> + <InventoryGroupDetail groupId="group-id-2" /> + </Provider> </MemoryRouter> ); getByRole = rendered.getByRole; diff --git a/src/components/InventoryGroups/Modals/AddHostToGroupModal.js b/src/components/InventoryGroups/Modals/AddHostToGroupModal.js new file mode 100644 index 000000000..36ea5541d --- /dev/null +++ b/src/components/InventoryGroups/Modals/AddHostToGroupModal.js @@ -0,0 +1,94 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import Modal from './Modal'; +import { addHostToGroup } from '../utils/api'; +import apiWithToast from '../utils/apiWithToast'; +import { useDispatch, useSelector } from 'react-redux'; +import { CreateGroupButton } from '../SmallComponents/CreateGroupButton'; +import { fetchGroups } from '../../../store/inventory-actions'; +import { addHostSchema } from './ModalSchemas/schemes'; +import CreateGroupModal from './CreateGroupModal'; + +const AddHostToGroupModal = ({ + isModalOpen, + setIsModalOpen, + modalState, + reloadData +}) => { + + const dispatch = useDispatch(); + //we have to fetch groups to make them available in state + useEffect(() => { + dispatch(fetchGroups()); + }, []); + + const groups = useSelector(({ groups }) => groups?.data?.results); + + const [isCreateGroupModalOpen, setIsCreateGroupModalOpen] = useState(false); + const handleAddDevices = (values) => { + const { group } = values; + const statusMessages = { + onSuccess: { + title: 'Success', + description: `System(s) have been added to ${group.name} successfully` + }, + onError: { title: 'Error', description: `Failed to add ${modalState.name} to ${group.name}` } + }; + + apiWithToast( + dispatch, + () => addHostToGroup(group.groupId, modalState.id), + statusMessages + ); + }; + + return ( + <> + <Modal + isModalOpen={isModalOpen} + closeModal={() => setIsModalOpen(false)} + title="Add to group" + submitLabel="Add" + schema={addHostSchema(modalState.name, groups)} + additionalMappers={{ + 'create-group-btn': { + component: CreateGroupButton, + closeModal: () => { + setIsCreateGroupModalOpen(true); + setIsModalOpen(false); + } + } + }} + initialValues={modalState} + onSubmit={handleAddDevices} + reloadData={reloadData} + /> + {isCreateGroupModalOpen && ( + <CreateGroupModal + isModalOpen={isCreateGroupModalOpen} + setIsModalOpen={setIsCreateGroupModalOpen} + reloadData={() => console.log('data reloaded')} + //modal before prop tells create group modal that it should + //reopen add host modal when user closes create group modal + modalBefore={true} + setterOfModalBefore={setIsModalOpen} + /> + )} + </> + ); +}; + +AddHostToGroupModal.propTypes = { + modalState: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + groupName: PropTypes.string + }), + isModalOpen: PropTypes.bool, + setIsModalOpen: PropTypes.func, + reloadData: PropTypes.func, + setIsCreateGroupModalOpen: PropTypes.func, + deviceIds: PropTypes.array +}; + +export default AddHostToGroupModal; diff --git a/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.cy.js b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.cy.js new file mode 100644 index 000000000..c9dd8e37b --- /dev/null +++ b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.cy.js @@ -0,0 +1,184 @@ +import { mount } from '@cypress/react'; +import { + checkTableHeaders, + DROPDOWN_ITEM, + MODAL, + ouiaId, + TABLE +} from '@redhat-cloud-services/frontend-components-utilities'; +import FlagProvider from '@unleash/proxy-client-react'; +import _ from 'lodash'; +import React from 'react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { + featureFlagsInterceptors, + groupDetailInterceptors, + hostsFixtures, + hostsInterceptors +} from '../../../../cypress/support/interceptors'; +import { + selectRowN, + unleashDummyConfig +} from '../../../../cypress/support/utils'; +import { getStore } from '../../../store'; +import AddSystemsToGroupModal from './AddSystemsToGroupModal'; + +const TABLE_HEADERS = [ + 'Name', + 'Group', + 'Tags', + 'OS', + 'Update method', + 'Last seen' +]; + +const AVAILABLE_FILTER_NAMES = [ + 'Name', + 'Status', + 'Operating System', + 'Data Collector', + 'RHC status', + 'Last seen', + 'Group', + 'Tags' +]; + +const ALERT = '[data-ouia-component-type="PF4/Alert"]'; + +before(() => { + cy.window().then( + (window) => + (window.insights = { + chrome: { + isProd: false, + auth: { + getUser: () => { + return Promise.resolve({}); + } + } + } + }) + ); +}); + +const mountModal = () => + mount( + <FlagProvider config={unleashDummyConfig}> + <Provider store={getStore()}> + <MemoryRouter> + <AddSystemsToGroupModal + isModalOpen={true} + groupId="620f9ae75A8F6b83d78F3B55Af1c4b2C" + setIsModalOpen={() => {}} // TODO: test that the func is called on close + /> + </MemoryRouter> + </Provider> + </FlagProvider> + ); + +describe('test data', () => { + it('at least one system is already in a group', () => { + const alreadyInGroup = hostsFixtures.results.filter( + // eslint-disable-next-line camelcase + ({ group_name }) => !_.isEmpty(group_name) + ); + expect(alreadyInGroup.length).to.be.gte(1); + }); + + it('the first system in group has specific id', () => { + const alreadyInGroup = hostsFixtures.results.filter( + // eslint-disable-next-line camelcase + ({ group_name }) => !_.isEmpty(group_name) + ); + expect(alreadyInGroup[0].id).to.eq('anim commodo'); + }); +}); + +describe('AddSystemsToGroupModal', () => { + beforeEach(() => { + cy.viewport(1920, 1080); // to accomadate the inventory table + cy.intercept('*', { statusCode: 200 }); + hostsInterceptors.successful(); // default hosts list + featureFlagsInterceptors.successful(); // to enable the Group column + }); + + it('renders correct header and buttons', () => { + mountModal(); + + cy.wait('@getHosts'); + cy.get('h1').contains('Add systems'); + cy.get('button').contains('Add systems'); + cy.get('button').contains('Cancel'); + }); + + it('renders the inventory table', () => { + mountModal(); + + cy.wait('@getHosts'); + cy.get(ouiaId('PrimaryToolbar')); + cy.get(TABLE); + cy.get('#options-menu-bottom-pagination'); + checkTableHeaders(TABLE_HEADERS); + }); + + it('can add systems that are not yet in group', () => { + groupDetailInterceptors['patch successful'](); + groupDetailInterceptors['successful with hosts'](); + mountModal(); + + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + cy.get('button').contains('Add systems').should('be.disabled'); + selectRowN(1); + cy.get('button').contains('Add systems').click(); + cy.wait('@getGroupDetail'); // requests the current hosts list + cy.wait('@patchGroup') + .its('request.body') + .should('deep.equal', { + // eslint-disable-next-line camelcase + host_ids: ['host-1', 'host-2', 'dolor'] // sends the merged list of hosts + }); + }); + + it('can add systems that are already in group', () => { + groupDetailInterceptors['patch successful'](); + groupDetailInterceptors['successful with hosts'](); + mountModal(); + + cy.get('table[aria-label="Host inventory"]').should('have.attr', 'data-ouia-safe', 'true'); + const i = + hostsFixtures.results.findIndex( + // eslint-disable-next-line camelcase + ({ group_name }) => !_.isEmpty(group_name) + ) + 1; + selectRowN(i); + cy.get(ALERT); // check the alert is shown + cy.get('button').contains('Add systems').click(); + cy.get(MODAL).find('h1').contains('Add all selected systems to group?'); + cy.get('button') + .contains('Yes, add all systems to group') + .should('be.disabled'); + cy.get('input[name="confirmation"]').check(); + cy.get('button').contains('Yes, add all systems to group').click(); + cy.wait('@getGroupDetail'); + cy.wait('@patchGroup') + .its('request.body') + .should('deep.equal', { + // eslint-disable-next-line camelcase + host_ids: ['host-1', 'host-2', 'anim commodo'] // sends the merged list of hosts + }); + }); + + describe('filters', () => { + it('has correct list of filters', () => { + groupDetailInterceptors['successful with hosts'](); + mountModal(); + + cy.wait('@getHosts'); + cy.get('button[data-ouia-component-id="ConditionalFilter"]').click(); + cy.get(DROPDOWN_ITEM).each(($item, i) => { + expect($item.text()).to.equal(AVAILABLE_FILTER_NAMES[i]); + }); + }); + }); +}); diff --git a/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.js b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.js new file mode 100644 index 000000000..5d092d2a6 --- /dev/null +++ b/src/components/InventoryGroups/Modals/AddSystemsToGroupModal.js @@ -0,0 +1,169 @@ +import { + Alert, + Button, + Flex, + FlexItem, + Modal +} from '@patternfly/react-core'; +import { TableVariant } from '@patternfly/react-table'; +import difference from 'lodash/difference'; +import map from 'lodash/map'; +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchGroupDetail } from '../../../store/inventory-actions'; +import { bulkSelectConfig, prepareColumns } from '../../GroupSystems/GroupSystems'; +import InventoryTable from '../../InventoryTable/InventoryTable'; +import { addHostsToGroupById } from '../utils/api'; +import apiWithToast from '../utils/apiWithToast'; +import ConfirmSystemsAddModal from './ConfirmSystemsAddModal'; + +const AddSystemsToGroupModal = ({ + isModalOpen, + setIsModalOpen, + groupId, + groupName +}) => { + const dispatch = useDispatch(); + + const [confirmationModalOpen, setConfirmationModalOpen] = useState(false); + const [systemsSelectModalOpen, setSystemSelectModalOpen] = useState(true); + const selected = useSelector( + (state) => state?.entities?.selected || new Map() + ); + const rows = useSelector(({ entities }) => entities?.rows || []); + + const noneSelected = selected.size === 0; + const displayedIds = map(rows, 'id'); + const pageSelected = difference(displayedIds, [...selected.keys()]).length === 0; + + const alreadyHasGroup = [...selected].filter( + // eslint-disable-next-line camelcase + (entry) => { + return entry[1].group_name !== undefined && entry[1].group_name !== ''; + } + ); + const showWarning = alreadyHasGroup.length > 0; + + const handleSystemAddition = useCallback( + (hostIds) => { + const statusMessages = { + onSuccess: { + title: 'Success', + description: `${hostIds.length > 1 ? 'Systems' : 'System'} added to ${groupName || groupId}` + }, + onError: { + title: 'Error', + description: `Failed to add ${hostIds.length > 1 ? 'systems' : 'system'} to ${groupName || groupId}` + } + }; + return apiWithToast( + dispatch, + () => addHostsToGroupById(groupId, hostIds), + statusMessages + ); + }, + [isModalOpen] + ); + + return ( + isModalOpen && ( + <> + {/** confirmation modal */} + <ConfirmSystemsAddModal + isModalOpen={confirmationModalOpen} + onSubmit={async () => { + await handleSystemAddition([...selected.keys()]); + setTimeout(() => dispatch(fetchGroupDetail(groupId)), 500); // refetch data for this group + setIsModalOpen(false); + + }} + onBack={() => { + setConfirmationModalOpen(false); + setSystemSelectModalOpen(true); // switch back to the systems table modal + }} + onCancel={() => setIsModalOpen(false)} + hostsNumber={alreadyHasGroup.length} + /> + {/** hosts selection modal */} + <Modal + title="Add systems" + isOpen={systemsSelectModalOpen} + onClose={() => setIsModalOpen(false)} + footer={ + <Flex direction={{ default: 'column' }} style={{ width: '100%' }}> + {showWarning && ( + <FlexItem fullWidth={{ default: 'fullWidth' }}> + <Alert + variant="warning" + isInline + title="One or more of the selected systems + already belong to a group. Adding these systems + to a different group may impact system configuration." + /> + </FlexItem> + )} + <FlexItem> + <Button + key="confirm" + variant="primary" + onClick={async () => { + if (showWarning) { + setSystemSelectModalOpen(false); + setConfirmationModalOpen(true); // switch to the confirmation modal + } else { + await handleSystemAddition([ + ...selected.keys() + ]); + setTimeout( + () => + dispatch( + fetchGroupDetail(groupId) + ), + 500 + ); // refetch data for this group + setIsModalOpen(false); + } + }} + isDisabled={noneSelected} + > + Add systems + </Button> + <Button + key="cancel" + variant="link" + onClick={() => setIsModalOpen(false)} + > + Cancel + </Button> + </FlexItem> + </Flex> + } + variant="large" // required to accomodate the systems table + > + <InventoryTable + columns={(columns) => prepareColumns(columns, false)} + variant={TableVariant.compact} // TODO: this doesn't affect the table variant + tableProps={{ + isStickyHeader: false, + canSelectAll: false + }} + bulkSelect={bulkSelectConfig(dispatch, selected.size, noneSelected, pageSelected, rows.length)} + initialLoading={true} + showTags + /> + </Modal> + </> + ) + ); +}; + +AddSystemsToGroupModal.propTypes = { + isModalOpen: PropTypes.bool, + setIsModalOpen: PropTypes.func, + reloadData: PropTypes.func, + groupId: PropTypes.string, + groupName: PropTypes.string +}; + +export default AddSystemsToGroupModal; diff --git a/src/components/InventoryGroups/Modals/ConfirmSystemsAddModal.js b/src/components/InventoryGroups/Modals/ConfirmSystemsAddModal.js new file mode 100644 index 000000000..48682b155 --- /dev/null +++ b/src/components/InventoryGroups/Modals/ConfirmSystemsAddModal.js @@ -0,0 +1,74 @@ +import { FormSpy, useFormApi } from '@data-driven-forms/react-form-renderer'; +import { Button, Flex } from '@patternfly/react-core'; +import ExclamationTriangleIcon from '@patternfly/react-icons/dist/js/icons/exclamation-triangle-icon'; +import warningColor from '@patternfly/react-tokens/dist/esm/global_warning_color_100'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from './Modal'; +import { confirmSystemsAddSchema } from './ModalSchemas/schemes'; + +const ConfirmSystemsAddModal = ({ + isModalOpen, + onSubmit, + onBack, + onCancel, + hostsNumber +}) => ( + <Modal + isModalOpen={isModalOpen} + title={'Add all selected systems to group?'} + titleIconVariant={() => ( + <ExclamationTriangleIcon color={warningColor.value} /> + )} + closeModal={onCancel} + schema={confirmSystemsAddSchema(hostsNumber)} + reloadData={() => {}} + onSubmit={onSubmit} + customFormTemplate={({ formFields, schema }) => { + const { handleSubmit, getState } = useFormApi(); + const { submitting, valid } = getState(); + + return ( + <form onSubmit={handleSubmit}> + <Flex + direction={{ default: 'column' }} + spaceItems={{ default: 'spaceItemsLg' }} + > + {schema.title} + {formFields} + <FormSpy> + {() => ( + <Flex> + <Button + isDisabled={submitting || !valid} + type="submit" + color="primary" + variant="primary" + > + Yes, add all systems to group + </Button> + <Button variant="secondary" onClick={onBack}> + Back + </Button> + <Button variant="link" onClick={onCancel}> + Cancel + </Button> + </Flex> + )} + </FormSpy> + </Flex> + </form> + ); + }} + /> +); + +ConfirmSystemsAddModal.propTypes = { + isModalOpen: PropTypes.bool, + onSubmit: PropTypes.func, + onBack: PropTypes.func, + onCancel: PropTypes.func, + hostsNumber: PropTypes.number +}; + +export default ConfirmSystemsAddModal; diff --git a/src/components/InventoryGroups/Modals/CreateGroupModal.js b/src/components/InventoryGroups/Modals/CreateGroupModal.js index 0a9d2ea88..678e20d68 100644 --- a/src/components/InventoryGroups/Modals/CreateGroupModal.js +++ b/src/components/InventoryGroups/Modals/CreateGroupModal.js @@ -13,7 +13,9 @@ import awesomeDebouncePromise from 'awesome-debounce-promise'; const CreateGroupModal = ({ isModalOpen, setIsModalOpen, - reloadData + reloadData, + modalBefore = false, + setterOfModalBefore }) => { const dispatch = useDispatch(); @@ -46,11 +48,19 @@ const CreateGroupModal = ({ return createGroupSchema(d); }, []); + const onClose = () => { + if (modalBefore) { + setIsModalOpen(false); + setterOfModalBefore(true); + } else { + setIsModalOpen(false); + } + }; + return ( <Modal - data-testid="create-group-modal" isModalOpen={isModalOpen} - closeModal={() => setIsModalOpen(false)} + closeModal={onClose} title="Create group" submitLabel="Create" schema={schema} @@ -65,5 +75,7 @@ export default CreateGroupModal; CreateGroupModal.propTypes = { isModalOpen: PropTypes.bool, setIsModalOpen: PropTypes.func, - reloadData: PropTypes.func + reloadData: PropTypes.func, + modalBefore: PropTypes.bool, + setterOfModalBefore: PropTypes.func }; diff --git a/src/components/InventoryGroups/Modals/Modal.js b/src/components/InventoryGroups/Modals/Modal.js index 4f7380b2f..71bcd3300 100644 --- a/src/components/InventoryGroups/Modals/Modal.js +++ b/src/components/InventoryGroups/Modals/Modal.js @@ -16,7 +16,9 @@ const RepoModal = ({ variant, reloadData, size, - onSubmit + onSubmit, + customFormTemplate, + additionalMappers }) => { return ( <Modal @@ -29,7 +31,7 @@ const RepoModal = ({ > <FormRenderer schema={schema} - FormTemplate={(props) => ( + FormTemplate={customFormTemplate ? customFormTemplate : (props) => ( <FormTemplate {...props} submitLabel={submitLabel} @@ -40,7 +42,9 @@ const RepoModal = ({ /> )} initialValues={initialValues} - componentMapper={componentMapper} + componentMapper={additionalMappers + ? { ...additionalMappers, ...componentMapper } + : componentMapper} //reload comes from the table and fetches fresh data onSubmit={async (values) => { await onSubmit(values); @@ -48,6 +52,7 @@ const RepoModal = ({ closeModal(); }} onCancel={() => closeModal()} + subscription={{ values: true }} /> </Modal> ); @@ -66,7 +71,7 @@ RepoModal.propTypes = { size: PropTypes.string, additionalMappers: PropTypes.object, titleIconVariant: PropTypes.any, - validatorMapper: PropTypes.object + customFormTemplate: PropTypes.node }; export default RepoModal; diff --git a/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js b/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js index 1a5e306c1..7d52f187e 100644 --- a/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js +++ b/src/components/InventoryGroups/Modals/ModalSchemas/schemes.js @@ -1,6 +1,8 @@ +import React from 'react'; import validatorTypes from '@data-driven-forms/react-form-renderer/validator-types'; import componentTypes from '@data-driven-forms/react-form-renderer/component-types'; import { nameValidator } from '../../helpers/validate'; +import { Text } from '@patternfly/react-core'; export const createGroupSchema = (namePresenceValidator) => ({ fields: [ @@ -22,3 +24,57 @@ export const createGroupSchema = (namePresenceValidator) => ({ } ] }); + +export const confirmSystemsAddSchema = (hostsNumber) => ({ + fields: [ + { + component: componentTypes.PLAIN_TEXT, + name: 'warning-message', + label: `${hostsNumber} of the systems you selected already belong to a group. + Moving them to a different group will impact their configuration.` + }, + { + component: componentTypes.CHECKBOX, + name: 'confirmation', + label: 'I acknowledge that this action cannot be undone.', + validate: [{ type: validatorTypes.REQUIRED }] + } + ] +}); + +const createDescription = (systemName) => { + return ( + <Text> + Select a group to add <strong>{systemName}</strong> to, or create a new one. + </Text> + ); +}; + +//this is a custom schema that is passed via additional mappers to the Modal component +//it allows to create custom item types in the modal + +export const addHostSchema = (systemName, groups) => ({ + fields: [ + { + component: componentTypes.PLAIN_TEXT, + name: 'description', + label: createDescription(systemName) + }, + { + component: 'select', + name: 'group', + label: 'Select a group', + simpleValue: true, + isSearchable: true, // enables typeahead + isRequired: true, + isClearable: true, + placeholder: 'Type or click to select a group', + options: (groups || []).map(({ id, name }) => ({ + label: name, + value: { name, id } + })), + validate: [{ type: validatorTypes.REQUIRED }] + }, + { component: 'create-group-btn', name: 'create-group-btn', isRequired: true } + ] +}); diff --git a/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js b/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js index b0e270f81..21559fa45 100644 --- a/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js +++ b/src/components/InventoryGroups/Modals/RenameGroupModal.cy.js @@ -8,45 +8,9 @@ import { import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; import { getStore } from '../../../store'; +import groups from '../../../../cypress/fixtures/groups.json'; -const mockResponse = [ - { - count: 50, - page: 20, - per_page: 20, - total: 50, - results: [ - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group0', - updated_at: '2020-02-09T10:16:07.996Z' - }, - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group1', - updated_at: '2020-02-09T10:16:07.996Z' - }, - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group2', - updated_at: '2020-02-09T10:16:07.996Z' - }, - { - created_at: '2020-02-09T10:16:07.996Z', - host_ids: ['bA6deCFc19564430AB814bf8F70E8cEf'], - id: '3f01b55457674041b75e41829bcee1dca', - name: 'sre-group3', - updated_at: '2020-02-09T10:16:07.996Z' - } - ] - } -]; +const mockResponse = [groups]; describe('render Rename Group Modal', () => { before(() => { @@ -74,21 +38,21 @@ describe('render Rename Group Modal', () => { } }).as('rename'); + }); + + it('Input is fillable and firing a validation request that succeeds', () => { mount( <MemoryRouter> <Provider store={getStore()}> <RenameGroupModal isModalOpen={true} reloadData={() => console.log('data reloaded')} - modalState={{ id: '1', name: 'sre-group' }} + modalState={{ id: '1', name: 'Ut occaeca' }} /> </Provider> </MemoryRouter> ); - }); - - it('Input is fillable and firing a validation request that succeeds', () => { - cy.get(TEXT_INPUT).type('0'); + cy.get(TEXT_INPUT).type('t'); cy.wait('@validate').then((xhr) => { expect(xhr.request.url).to.contain('groups');} ); @@ -96,6 +60,17 @@ describe('render Rename Group Modal', () => { }); it('User can rename the group', () => { + mount( + <MemoryRouter> + <Provider store={getStore()}> + <RenameGroupModal + isModalOpen={true} + reloadData={() => console.log('data reloaded')} + modalState={{ id: '1', name: 'sre-group' }} + /> + </Provider> + </MemoryRouter> + ); cy.get(TEXT_INPUT).type('newname'); cy.get(`button[type="submit"]`).should('have.attr', 'aria-disabled', 'false'); cy.get(`button[type="submit"]`).click(); diff --git a/src/components/InventoryGroups/Modals/RenameGroupModal.js b/src/components/InventoryGroups/Modals/RenameGroupModal.js index 2fbcf9ba4..8c1767a70 100644 --- a/src/components/InventoryGroups/Modals/RenameGroupModal.js +++ b/src/components/InventoryGroups/Modals/RenameGroupModal.js @@ -45,7 +45,7 @@ const RenameGroupModal = ({ }, onError: { title: 'Error', description: 'Failed to rename group' } }; - apiWithToast(dispatch, () => updateGroupById(id, values), statusMessages); + apiWithToast(dispatch, () => updateGroupById(id, { name: values.name }), statusMessages); }; const schema = useMemo(() => { diff --git a/src/components/InventoryGroups/SmallComponents/CreateGroupButton.js b/src/components/InventoryGroups/SmallComponents/CreateGroupButton.js new file mode 100644 index 000000000..93097edce --- /dev/null +++ b/src/components/InventoryGroups/SmallComponents/CreateGroupButton.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Button, Text } from '@patternfly/react-core'; +import PropTypes from 'prop-types'; + +export const CreateGroupButton = ({ closeModal }) => ( + <> + <Text>Or</Text> + <Button variant="secondary" className="pf-u-w-50" onClick={closeModal}> + Create a new group + </Button> + </> +); + +CreateGroupButton.propTypes = { + closeModal: PropTypes.func +}; diff --git a/src/components/InventoryGroups/utils/api.js b/src/components/InventoryGroups/utils/api.js index 4e28e0031..2ae0ac650 100644 --- a/src/components/InventoryGroups/utils/api.js +++ b/src/components/InventoryGroups/utils/api.js @@ -1,15 +1,25 @@ +/* eslint-disable camelcase */ import { instance } from '@redhat-cloud-services/frontend-components-utilities/interceptors/interceptors'; import { INVENTORY_API_BASE } from '../../../api'; import { TABLE_DEFAULT_PAGINATION } from '../../../constants'; import PropTypes from 'prop-types'; +import union from 'lodash/union'; +import fixtureGroups from '../../../../cypress/fixtures/groups.json'; +import fixtureGroupsDetails from '../../../../cypress/fixtures/groups/Ba8B79ab5adC8E41e255D5f8aDb8f1F3.json'; -export const getGroups = (search = {}, pagination = { page: 1, perPage: TABLE_DEFAULT_PAGINATION }) => { - const parameters = new URLSearchParams({ - ...search, - ...pagination - }).toString(); +// eslint-disable-next-line camelcase +export const getGroups = (search = {}, pagination = { page: 1, per_page: TABLE_DEFAULT_PAGINATION }) => { + if (window.Cypress) { + const parameters = new URLSearchParams({ + ...search, + ...pagination + }).toString(); - return instance.get(`${INVENTORY_API_BASE}/groups?${parameters}` /* , { headers: { Prefer: 'code=404' } } */); + return instance.get(`${INVENTORY_API_BASE}/groups?${parameters}`); + } + + // FIXME: remove mock data when API is implemented + return Promise.resolve(fixtureGroups); }; export const createGroup = (payload) => { @@ -21,23 +31,46 @@ export const createGroup = (payload) => { }; export const validateGroupName = (name) => { - return instance.get(`${INVENTORY_API_BASE}/groups`) - .then((resp) => resp?.results.some((group) => group.name === name)); + if (window.Cypress) { + return instance.get(`${INVENTORY_API_BASE}/groups`) + .then((resp) => resp?.results.some((group) => group.name === name)); + } + + // FIXME: remove mock data when API is implemented + return Promise.resolve(fixtureGroups).then((resp) => resp?.results.some((group) => group.name === name)); }; export const getGroupDetail = (groupId) => { - return instance.get(`${INVENTORY_API_BASE}/groups/${groupId}`); + if (window.Cypress) { + return instance.get(`${INVENTORY_API_BASE}/groups/${groupId}`); + } + + // FIXME: remove mock data when API is implemented + return Promise.resolve(fixtureGroupsDetails); }; export const updateGroupById = (id, payload) => { - return instance.patch(`${INVENTORY_API_BASE}/groups/${id}`, { - name: payload.name - }); + return instance.patch(`${INVENTORY_API_BASE}/groups/${id}`, payload); }; export const deleteGroupsById = (ids = []) => { return instance.delete(`${INVENTORY_API_BASE}/groups/${ids.join(',')}`); +}; + +export const addHostsToGroupById = (id, hostIds) => { + // the current hosts must be fetched before merging with the new ones + return getGroupDetail(id).then((response) => + updateGroupById(id, { + // eslint-disable-next-line camelcase + host_ids: union(response.results[0].host_ids, hostIds) + }) + ); +}; +export const addHostToGroup = (groupId, newHostId) => { + return instance.post(`${INVENTORY_API_BASE}/groups/${groupId}/hosts/${newHostId}`, { + host_ids: newHostId + }); }; getGroups.propTypes = { diff --git a/src/components/InventoryTable/EntityTableToolbar.js b/src/components/InventoryTable/EntityTableToolbar.js index 95f6b486f..1d081566e 100644 --- a/src/components/InventoryTable/EntityTableToolbar.js +++ b/src/components/InventoryTable/EntityTableToolbar.js @@ -19,7 +19,9 @@ import { TAG_CHIP, arrayToSelection, RHCD_FILTER_KEY, - UPDATE_METHOD_KEY + UPDATE_METHOD_KEY, + LAST_SEEN_CHIP, + HOST_GROUP_CHIP } from '../../Utilities/index'; import { onDeleteFilter, onDeleteTag } from './helpers'; import { @@ -28,6 +30,7 @@ import { useRegisteredWithFilter, useTagsFilter, useRhcdFilter, + useLastSeenFilter, useUpdateMethodFilter, textFilterState, textFilterReducer, @@ -40,10 +43,16 @@ import { rhcdFilterReducer, rhcdFilterState, updateMethodFilterReducer, - updateMethodFilterState + updateMethodFilterState, + lastSeenFilterReducer, + lastSeenFilterState, + groupFilterReducer, + groupFilterState } from '../filters'; import useOperatingSystemFilter from '../filters/useOperatingSystemFilter'; import useFeatureFlag from '../../Utilities/useFeatureFlag'; +import useGroupFilter from '../filters/useGroupFilter'; +import { DatePicker, Split, SplitItem } from '@patternfly/react-core'; /** * Table toolbar used at top of inventory table. @@ -81,14 +90,18 @@ const EntityTableToolbar = ({ tagsFilterReducer, operatingSystemFilterReducer, rhcdFilterReducer, - updateMethodFilterReducer + lastSeenFilterReducer, + updateMethodFilterReducer, + groupFilterReducer ]), { ...textFilterState, ...stalenessFilterState, ...registeredWithFilterState, ...tagsFilterState, ...rhcdFilterState, - ...updateMethodFilterState + ...updateMethodFilterState, + ...lastSeenFilterState, + ...groupFilterState }); const filters = useSelector(({ entities: { activeFilters } }) => activeFilters); const allTagsLoaded = useSelector(({ entities: { allTagsLoaded } }) => allTagsLoaded); @@ -98,11 +111,15 @@ const EntityTableToolbar = ({ const [stalenessFilter, stalenessChip, staleFilter, setStaleFilter] = useStalenessFilter(reducer); const [registeredFilter, registeredChip, registeredWithFilter, setRegisteredWithFilter] = useRegisteredWithFilter(reducer); const [rhcdFilterConfig, rhcdFilterChips, rhcdFilterValue, setRhcdFilterValue] = useRhcdFilter(reducer); + const [lastSeenFilter, lastSeenChip, lastSeenFilterValue, setLastSeenFilterValue, + toValidator, onFromChange, onToChange, endDate, startDate, fromValidator, + setStartDate, setEndDate] = useLastSeenFilter(reducer); const [osFilterConfig, osFilterChips, osFilterValue, setOsFilterValue] = useOperatingSystemFilter(); const [updateMethodConfig, updateMethodChips, updateMethodValue, setUpdateMethodValue] = useUpdateMethodFilter(reducer); + const [hostGroupConfig, hostGroupChips, hostGroupValue, setHostGroupValue] = useGroupFilter(); const isUpdateMethodEnabled = useFeatureFlag('hbi.ui.system-update-method'); - + const groupsEnabled = useFeatureFlag('hbi.ui.inventory-groups'); const { tagsFilter, tagsChip, @@ -118,7 +135,7 @@ const EntityTableToolbar = ({ const debounceGetAllTags = useCallback(debounce((config, options) => { if (showTags && !hasItems && hasAccess) { dispatch(fetchAllTags(config, { - ...options?.pagination + ...options?.paginationhideFilters }, getTags)); } }, 800), [customFilters?.tags]); @@ -130,10 +147,13 @@ const EntityTableToolbar = ({ operatingSystem: !(hideFilters.all && hideFilters.operatingSystem !== false) && !hideFilters.operatingSystem, tags: !(hideFilters.all && hideFilters.tags !== false) && !hideFilters.tags, rhcdFilter: !(hideFilters.all && hideFilters.rhcdFilter !== false) && !hideFilters.rhcdFilter, + lastSeenFilter: !(hideFilters.all && hideFilters.lastSeen !== false) && !hideFilters.lastSeen, //hides the filter untill API is ready. JIRA: RHIF-169 updateMethodFilter: isUpdateMethodEnabled && !(hideFilters.all && hideFilters.updateMethodFilter !== false) - && !hideFilters.updateMethodFilter + && !hideFilters.updateMethodFilter, + hostGroupFilter: groupsEnabled && !(hideFilters.all && hideFilters.hostGroupFilter !== false) + && !hideFilters.hostGroupFilter }; /** @@ -171,7 +191,15 @@ const EntityTableToolbar = ({ */ useEffect(() => { const { - textFilter, tagFilters, staleFilter, registeredWithFilter, osFilter, rhcdFilter, updateMethodFilter + textFilter, + tagFilters, + staleFilter, + registeredWithFilter, + osFilter, + rhcdFilter, + lastSeenFilter, + updateMethodFilter, + groupFilter } = reduceFilters([...filters || [], ...customFilters?.filters || []]); debouncedRefresh(); @@ -182,6 +210,8 @@ const EntityTableToolbar = ({ enabledFilters.operatingSystem && setOsFilterValue(osFilter); enabledFilters.rhcdFilter && setRhcdFilterValue(rhcdFilter); enabledFilters.updateMethodFilter && setUpdateMethodValue(updateMethodFilter); + enabledFilters.lastSeenFilter && setLastSeenFilterValue(lastSeenFilter); + enabledFilters.hostGroupFilter && setHostGroupValue(groupFilter); }, []); /** @@ -190,7 +220,7 @@ const EntityTableToolbar = ({ * @param {*} debounced if debounce function should be used. */ const onSetTextFilter = (value, debounced = true) => { - const trimmedValue = value.trim(); + const trimmedValue = value?.trim(); const textualFilter = filters?.find(oneFilter => oneFilter.value === TEXT_FILTER); if (textualFilter) { @@ -261,12 +291,24 @@ const EntityTableToolbar = ({ } }, [rhcdFilterValue]); + useEffect(() => { + if (shouldReload && enabledFilters.lastSeenFilter) { + onSetFilter(lastSeenFilterValue, 'lastSeenFilter', debouncedRefresh); + } + }, [lastSeenFilterValue]); + useEffect(() => { if (shouldReload && enabledFilters.updateMethodFilter) { onSetFilter(updateMethodValue, 'updateMethodFilter', debouncedRefresh); } }, [updateMethodValue]); + useEffect(() => { + if (shouldReload && enabledFilters.hostGroupFilter) { + onSetFilter(hostGroupValue, 'hostGroupFilter', debouncedRefresh); + } + }, [hostGroupValue]); + /** * Mapper to simplify removing of any filter. */ @@ -285,7 +327,13 @@ const EntityTableToolbar = ({ ), [OS_CHIP]: (deleted) => setOsFilterValue(xor(osFilterValue, deleted.chips.map(({ value }) => value))), [RHCD_FILTER_KEY]: (deleted) => setRhcdFilterValue(onDeleteFilter(deleted, rhcdFilterValue)), - [UPDATE_METHOD_KEY]: (deleted) => setUpdateMethodValue(onDeleteFilter(deleted, updateMethodValue)) + [LAST_SEEN_CHIP]: (deleted) => + { + setLastSeenFilterValue(onDeleteFilter(deleted, [lastSeenFilterValue.mark])), + setStartDate(), + setEndDate(); + }, [UPDATE_METHOD_KEY]: (deleted) => setUpdateMethodValue(onDeleteFilter(deleted, updateMethodValue)), + [HOST_GROUP_CHIP]: (deleted) => setHostGroupValue(onDeleteFilter(deleted, hostGroupValue)) }; /** * Function to reset all filters with 'Reset Filter' is clicked @@ -297,7 +345,9 @@ const EntityTableToolbar = ({ enabledFilters.tags && setSelectedTags({}); enabledFilters.operatingSystem && setOsFilterValue([]); enabledFilters.rhcdFilter && setRhcdFilterValue([]); + enabledFilters.lastSeenFilter && setLastSeenFilterValue([]); enabledFilters.updateMethodFilter && setUpdateMethodValue([]); + enabledFilters.hostGroupFilter && setHostGroupValue([]); dispatch(setFilter([])); updateData({ page: 1, filters: [] }); }; @@ -316,6 +366,8 @@ const EntityTableToolbar = ({ ...!hasItems && enabledFilters.operatingSystem ? osFilterChips : [], ...!hasItems && enabledFilters.rhcdFilter ? rhcdFilterChips : [], ...!hasItems && enabledFilters.updateMethodFilter ? updateMethodChips : [], + ...!hasItems && enabledFilters.lastSeenFilter ? lastSeenChip : [], + ...!hasItems && enabledFilters.hostGroupFilter ? hostGroupChips : [], ...activeFiltersConfig?.filters || [] ], onDelete: (e, [deleted, ...restDeleted], isAll) => { @@ -341,6 +393,8 @@ const EntityTableToolbar = ({ ...enabledFilters.registeredWith ? [registeredFilter] : [], ...enabledFilters.rhcdFilter ? [rhcdFilterConfig] : [], ...enabledFilters.updateMethodFilter ? [updateMethodConfig] : [], + ...enabledFilters.lastSeenFilter ? [lastSeenFilter] : [], + ...enabledFilters.hostGroupFilter ? [hostGroupConfig] : [], ...showTags && enabledFilters.tags ? [tagsFilter] : [] ] : [], ...filterConfig?.items || [] @@ -384,7 +438,32 @@ const EntityTableToolbar = ({ ...paginationProps } : <Skeleton size={SkeletonSize.lg} />} > + {lastSeenFilterValue?.mark === 'custom' && + <Split> + <SplitItem> + <DatePicker + onChange={onFromChange} + aria-label="Start date" + validators={[fromValidator]} + placeholder='Start' + /> + </SplitItem> + <SplitItem style={{ padding: '6px 12px 0 12px' }}> + to + </SplitItem> + <SplitItem> + <DatePicker + value={endDate} + onChange={onToChange} + rangeStart={startDate} + validators={[toValidator]} + aria-label="End date" + placeholder='End' + /> + </SplitItem> + </Split>} { children } + </PrimaryToolbar> { (showTags || enabledFilters.tags || showTagModal) && <TagsModal @@ -430,7 +509,9 @@ EntityTableToolbar.propTypes = { stale: PropTypes.bool, operatingSystem: PropTypes.bool, rhcdFilter: PropTypes.bool, + lastSeen: PropTypes.bool, updateMethodFilter: PropTypes.bool, + hostGroupFilter: PropTypes.bool, all: PropTypes.bool }), paginationProps: PropTypes.object, diff --git a/src/components/InventoryTable/EntityTableToolbar.test.js b/src/components/InventoryTable/EntityTableToolbar.test.js index faa09fda1..8a6b2fcc0 100644 --- a/src/components/InventoryTable/EntityTableToolbar.test.js +++ b/src/components/InventoryTable/EntityTableToolbar.test.js @@ -14,6 +14,12 @@ import debounce from 'lodash/debounce'; jest.mock('lodash/debounce'); jest.mock('../../Utilities/useFeatureFlag'); +jest.mock('../../Utilities/constants', () => ({ + ...jest.requireActual('../../Utilities/constants'), + lastSeenItems: jest.fn().mockReturnValue([]) + +})); + describe('EntityTableToolbar', () => { let initialState; let stateWithActiveFilter; @@ -324,7 +330,7 @@ describe('EntityTableToolbar', () => { </Provider>); wrapper.find('.ins-c-chip-filters button.pf-m-link').last().simulate('click'); const actions = store.getActions(); - expect(actions.length).toBe(3); + expect(actions.length).toBe(4); expect(actions[actions.length - 2]).toMatchObject({ type: 'CLEAR_FILTERS' }); expect(onRefreshData).toHaveBeenCalledWith({ filters: [], page: 1 }); }); @@ -349,7 +355,7 @@ describe('EntityTableToolbar', () => { const wrapper = mount(<Provider store={store}> <EntityTableToolbar - hideFilters={{ all: true, name: false }} + hideFilters={{ all: true, name: false, group: true }} page={1} total={500} perPage={50} diff --git a/src/components/InventoryTable/InventoryTable.js b/src/components/InventoryTable/InventoryTable.js index 1565553a8..6dca45f4b 100644 --- a/src/components/InventoryTable/InventoryTable.js +++ b/src/components/InventoryTable/InventoryTable.js @@ -11,6 +11,7 @@ import AccessDenied from '../../Utilities/AccessDenied'; import { loadSystems } from '../../Utilities/sharedFunctions'; import isEqual from 'lodash/isEqual'; import { entitiesLoading } from '../../store/actions'; +import cloneDeep from 'lodash/cloneDeep'; /** * A helper function to store props and to always return the latest state. @@ -18,14 +19,17 @@ import { entitiesLoading } from '../../store/actions'; * to get the latest props and not the props at the time of when the function is * being wrapped in callback. */ -const propsCache = () => { +const inventoryCache = () => { let cache = {}; - const updateProps = (props) => { cache = props; }; + const updateProps = (props) => { cache = cloneDeep({ ...cache, props }); }; - const getProps = () => cache; + const updateParams = (params) => { cache = cloneDeep({ ...cache, params }); }; - return { updateProps, getProps }; + const getProps = () => cache.props; + const getParams = () => cache.params; + + return { updateProps, updateParams, getProps, getParams }; }; /** @@ -71,12 +75,10 @@ const InventoryTable = forwardRef(({ // eslint-disable-line react/display-name )); const page = useSelector(({ entities: { page: invPage } }) => ( hasItems ? propsPage : (invPage || 1) - ) - , shallowEqual); + ), shallowEqual); const perPage = useSelector(({ entities: { perPage: invPerPage } }) => ( hasItems ? propsPerPage : (invPerPage || 50) - ) - , shallowEqual); + ), shallowEqual); const total = useSelector(({ entities: { total: invTotal } }) => { if (hasItems) { return propsTotal !== undefined ? propsTotal : items?.length; @@ -112,7 +114,7 @@ const InventoryTable = forwardRef(({ // eslint-disable-line react/display-name const dispatch = useDispatch(); const store = useStore(); - const cache = useRef(propsCache()); + const cache = useRef(inventoryCache()); cache.current.updateProps({ page, perPage, @@ -134,7 +136,7 @@ const InventoryTable = forwardRef(({ // eslint-disable-line react/display-name const cachedProps = cache.current?.getProps() || {}; const currPerPage = options?.per_page || options?.perPage || cachedProps.perPage; - const params = { + const newParams = { page: cachedProps.page, per_page: currPerPage, items: cachedProps.items, @@ -146,25 +148,29 @@ const InventoryTable = forwardRef(({ // eslint-disable-line react/display-name ...options }; - if (onRefresh && !disableOnRefresh) { - dispatch(entitiesLoading()); - onRefresh(params, (options) => { + const cachedParams = cache.current.getParams(); + if (!isEqual(cachedParams, newParams)) { + cache.current.updateParams(newParams); + if (onRefresh && !disableOnRefresh) { + dispatch(entitiesLoading()); + onRefresh(newParams, (options) => { + dispatch( + loadSystems( + { ...newParams, ...options }, + cachedProps.showTags, + cachedProps.getEntities + ) + ); + }); + } else { dispatch( loadSystems( - { ...params, ...options }, + newParams, cachedProps.showTags, cachedProps.getEntities ) ); - }); - } else { - dispatch( - loadSystems( - params, - cachedProps.showTags, - cachedProps.getEntities - ) - ); + } } }; diff --git a/src/components/InventoryTable/InventoryTable.test.js b/src/components/InventoryTable/InventoryTable.test.js index 64d25be34..08eb6f2c0 100644 --- a/src/components/InventoryTable/InventoryTable.test.js +++ b/src/components/InventoryTable/InventoryTable.test.js @@ -220,7 +220,14 @@ describe('InventoryTable', () => { </Provider>); expect(wrapper.find(ConditionalFilter).props().items.map(({ label }) => label)).toEqual( - ['Status', 'Operating System', 'Data Collector', 'RHC status', 'System Update Method', 'Tags'] + ['Status', + 'Operating System', + 'Data Collector', + 'RHC status', + 'System Update Method', + 'Last seen', + 'Group', + 'Tags'] ); }); diff --git a/src/components/InventoryTable/TitleColumn.js b/src/components/InventoryTable/TitleColumn.js index ac41ddcf0..c327a8a2f 100644 --- a/src/components/InventoryTable/TitleColumn.js +++ b/src/components/InventoryTable/TitleColumn.js @@ -32,7 +32,7 @@ const onRowClick = (event, key, { loaded, onRowClick: rowClick, noDetail }) => { * @param {*} props additional props passed from `EntityTable` - holds any props passed to inventory table. */ const TitleColumn = (data, id, item, props) => ( - <div className="ins-composed-col"> + <div className="ins-composed-col sentry-mask data-hj-suppress"> <div key="os_release">{item?.os_release}</div> <div key="data" className={props?.noDetail ? 'ins-m-nodetail' : ''}> { props?.noDetail ? diff --git a/src/components/InventoryTable/__snapshots__/EntityTable.test.js.snap b/src/components/InventoryTable/__snapshots__/EntityTable.test.js.snap index 340c86783..3169e0c6e 100644 --- a/src/components/InventoryTable/__snapshots__/EntityTable.test.js.snap +++ b/src/components/InventoryTable/__snapshots__/EntityTable.test.js.snap @@ -102,7 +102,7 @@ exports[`EntityTable DOM should render correctly - compact 1`] = ` data-label="One" > <div - class="ins-composed-col" + class="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -283,7 +283,7 @@ exports[`EntityTable DOM should render correctly - is expandable 1`] = ` "cells": Array [ <React.Fragment> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -468,7 +468,7 @@ exports[`EntityTable DOM should render correctly - with actions 1`] = ` "cells": Array [ <React.Fragment> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -601,7 +601,7 @@ exports[`EntityTable DOM should render correctly - without checkbox 1`] = ` "cells": Array [ <React.Fragment> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -737,7 +737,7 @@ exports[`EntityTable DOM should render correctly 1`] = ` data-label="One" > <div - class="ins-composed-col" + class="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -808,7 +808,7 @@ exports[`EntityTable DOM sort by should render correctly - is expandable 1`] = ` "cells": Array [ <React.Fragment> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -887,7 +887,7 @@ exports[`EntityTable DOM sort by should render correctly - without checkbox 1`] "cells": Array [ <React.Fragment> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div @@ -967,7 +967,7 @@ exports[`EntityTable DOM sort by should render correctly 1`] = ` "cells": Array [ <React.Fragment> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div /> <div diff --git a/src/components/InventoryTable/__snapshots__/EntityTableToolbar.test.js.snap b/src/components/InventoryTable/__snapshots__/EntityTableToolbar.test.js.snap index 7a54d42c1..2e0e03e3e 100644 --- a/src/components/InventoryTable/__snapshots__/EntityTableToolbar.test.js.snap +++ b/src/components/InventoryTable/__snapshots__/EntityTableToolbar.test.js.snap @@ -127,6 +127,18 @@ exports[`EntityTableToolbar DOM should render correctly - no data 1`] = ` "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, ], } } @@ -304,6 +316,18 @@ exports[`EntityTableToolbar DOM should render correctly - with children 1`] = ` "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, ], } } @@ -498,6 +522,18 @@ exports[`EntityTableToolbar DOM should render correctly - with custom activeFilt "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, Object { "filterValues": Object { "className": "ins-c-tagfilter", @@ -710,6 +746,18 @@ exports[`EntityTableToolbar DOM should render correctly - with custom filters 1` "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, Object { "filterValues": Object { "isDisabled": false, @@ -912,6 +960,18 @@ exports[`EntityTableToolbar DOM should render correctly - with customFilters 1`] "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, Object { "filterValues": Object { "className": "ins-c-tagfilter", @@ -1137,6 +1197,18 @@ exports[`EntityTableToolbar DOM should render correctly - with default filters 1 "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, ], } } @@ -1319,6 +1391,18 @@ exports[`EntityTableToolbar DOM should render correctly - with default tag filte "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, ], } } @@ -1517,6 +1601,18 @@ exports[`EntityTableToolbar DOM should render correctly - with no access 1`] = ` "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": true, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, ], } } @@ -1707,6 +1803,18 @@ exports[`EntityTableToolbar DOM should render correctly - with tags 1`] = ` "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, Object { "filterValues": Object { "className": "ins-c-tagfilter", @@ -1927,6 +2035,18 @@ exports[`EntityTableToolbar DOM should render correctly 1`] = ` "type": "checkbox", "value": "rhc-status", }, + Object { + "filterValues": Object { + "isDisabled": false, + "items": [MockFunction], + "onChange": [Function], + "placeholder": "Filter by last seen", + "value": undefined, + }, + "label": "Last seen", + "type": "radio", + "value": "last_seen", + }, ], } } diff --git a/src/components/InventoryTable/__snapshots__/TitleColumn.test.js.snap b/src/components/InventoryTable/__snapshots__/TitleColumn.test.js.snap index b1ce57c68..930d3903c 100644 --- a/src/components/InventoryTable/__snapshots__/TitleColumn.test.js.snap +++ b/src/components/InventoryTable/__snapshots__/TitleColumn.test.js.snap @@ -3,7 +3,7 @@ exports[`TitleColumn should render correctly no detail with data 1`] = ` <Cmp> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div key="os_release" @@ -23,7 +23,7 @@ exports[`TitleColumn should render correctly no detail with data 1`] = ` exports[`TitleColumn should render correctly with NO data 1`] = ` <Cmp> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div key="os_release" @@ -45,7 +45,7 @@ exports[`TitleColumn should render correctly with NO data 1`] = ` exports[`TitleColumn should render correctly with data 1`] = ` <Cmp> <div - className="ins-composed-col" + className="ins-composed-col sentry-mask data-hj-suppress" > <div key="os_release" diff --git a/src/components/InventoryTable/helpers.js b/src/components/InventoryTable/helpers.js index 862c4d1d7..1b181d88a 100644 --- a/src/components/InventoryTable/helpers.js +++ b/src/components/InventoryTable/helpers.js @@ -19,6 +19,13 @@ export const buildCells = (item, columns, extra) => { }); }; +//returns an array of objects representing rows for a table. +//The function takes three parameters: "rows", "columns", and an object with several optional properties. +//The "rows" parameter is an array of objects, where each object represents a single row. +//The "columns" parameter is also an array of objects, where each object represents a single column in the table. +//The third parameter is an object with several optional properties, including "actions", +//"expandable", "noSystemsTable", and "extra". These properties are destructured from +//the object using object destructuring syntax. export const createRows = (rows = [], columns = [], { actions, expandable, noSystemsTable, ...extra } = {}) => { if (rows.length === 0) { return [{ @@ -32,6 +39,12 @@ export const createRows = (rows = [], columns = [], { actions, expandable, noSys }]; } + //If the "rows" parameter is not empty, the function maps over each row object in the "rows" + //array and creates an array of two objects for each row. The first object represents the + //row itself and contains the "cells" property, which is an array of objects representing + //each cell in the row. The "actionProps" property is also set to an object containing the + //"data-ouia-component-id" property, which is set to a string combining the row's "id" property + //and the string "-actions-kebab". return flatten(rows.map((oneItem, key) => ([{ ...oneItem, ...oneItem.children && expandable && { isOpen: !!oneItem.isOpen }, @@ -39,7 +52,13 @@ export const createRows = (rows = [], columns = [], { actions, expandable, noSys actionProps: { 'data-ouia-component-id': `${oneItem.id}-actions-kebab` } - }, oneItem.children && expandable && { + }, + //The second object represents the child row, which is only created if the "expandable" + //property is set to true and the row has a "children" property. This object has the + //"cells" property set to an array containing a single object representing the cell + //in the row. The "parent" property is set to the index of the parent row multiplied by 2, + //and the "fullWidth" property is set to true. + oneItem.children && expandable && { cells: [ { title: typeof oneItem.children === 'function' ? oneItem.children() : oneItem.children diff --git a/src/components/InventoryTable/hooks/useColumns.js b/src/components/InventoryTable/hooks/useColumns.js index d2a92e02b..e267efb70 100644 --- a/src/components/InventoryTable/hooks/useColumns.js +++ b/src/components/InventoryTable/hooks/useColumns.js @@ -17,6 +17,7 @@ const useColumns = (columnsProp, disableDefaultColumns, showTags, columnsCounter ) ); const disabledColumns = Array.isArray(disableDefaultColumns) ? disableDefaultColumns : []; + //condition for the newDefaultColumns should be removed after inventory groups is released const defaultColumnsFiltered = useMemo(() => (disableDefaultColumns === true) ? [] : defaultColumns(groupsEnabled).filter(({ key }) => isColumnEnabled(key, disabledColumns, showTags) diff --git a/src/components/filters/__snapshots__/useGroupFilter.test.js.snap b/src/components/filters/__snapshots__/useGroupFilter.test.js.snap new file mode 100644 index 000000000..eed91ba77 --- /dev/null +++ b/src/components/filters/__snapshots__/useGroupFilter.test.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`useGroupFilter with groups loaded should match snapshot 1`] = ` +Array [ + Object { + "filterValues": Object { + "items": Array [ + Object { + "label": "nisi ut consequat ad", + "value": "nisi ut consequat ad", + }, + Object { + "label": "nisi ut consequat ad1", + "value": "nisi ut consequat ad1", + }, + Object { + "label": "nisi ut consequat ad2", + "value": "nisi ut consequat ad2", + }, + ], + "onChange": [Function], + "value": Array [], + }, + "label": "Group", + "type": "checkbox", + "value": "group-host-filter", + }, + Array [], + Array [], + [Function], +] +`; + +exports[`useGroupFilter with groups loaded should return correct chips array, current value and value setter 1`] = ` +Array [ + Object { + "category": "Group", + "chips": Array [ + Object { + "name": "nisi ut consequat ad1", + "value": "nisi ut consequat ad1", + }, + ], + "type": "host_group", + }, +] +`; + +exports[`useGroupFilter with groups loaded should return empty state value if no groups obtained 1`] = ` +Array [ + Object { + "filterValues": Object { + "items": Array [], + "onChange": [Function], + "value": Array [], + }, + "label": "Group", + "type": "checkbox", + "value": "group-host-filter", + }, + Array [], + Array [], + [Function], +] +`; + +exports[`useGroupFilter with groups yet not loaded should return empty state value 1`] = ` +Array [ + Object { + "filterValues": Object { + "items": Array [], + "onChange": [Function], + "value": Array [], + }, + "label": "Group", + "type": "checkbox", + "value": "group-host-filter", + }, + Array [], + Array [], + [Function], +] +`; diff --git a/src/components/filters/index.js b/src/components/filters/index.js index 4c9fae5db..69e83b911 100644 --- a/src/components/filters/index.js +++ b/src/components/filters/index.js @@ -5,6 +5,8 @@ export * from './useTagsFilter'; export * from './useOperatingSystemFilter'; export * from './useRhcdFilter'; export * from './useUpdateMethodFilter'; +export * from './useLastSeenFilter'; +export * from './useGroupFilter'; export const filtersReducer = (reducersList) => (state, action) => reducersList.reduce((acc, curr) => ({ ...acc, ...curr?.(state, action) diff --git a/src/components/filters/useGroupFilter.js b/src/components/filters/useGroupFilter.js new file mode 100644 index 000000000..44a3d2b98 --- /dev/null +++ b/src/components/filters/useGroupFilter.js @@ -0,0 +1,80 @@ +/* eslint-disable camelcase */ +import union from 'lodash/union'; +import { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { fetchGroups } from '../../store/inventory-actions'; +import { HOST_GROUP_CHIP } from '../../Utilities/index'; + +//for attaching this filter to the redux +export const groupFilterState = { groupHostFilter: null }; +export const GROUP_FILTER = 'GROUP_FILTER'; +export const groupFilterReducer = (_state, { type, payload }) => ({ + ...type === GROUP_FILTER && { + groupHostFilter: payload + } +}); + +//receive the array of selected groups and return chips based on the name of selected groups +export const buildHostGroupChips = (selectedGroups = []) => { + //we use new Set to make sure that chips are unique + const chips = [...selectedGroups]?.map((group) => ({ name: group, value: group })); + return chips?.length > 0 + ? [ + { + category: 'Group', + type: HOST_GROUP_CHIP, + chips + } + ] + : []; +}; + +const useGroupFilter = (apiParams = []) => { + const dispatch = useDispatch(); + useEffect(() => { + dispatch(fetchGroups(apiParams)); + }, []); + //fetched values + const fetchedValues = useSelector(({ groups }) => groups?.data?.results); + //selected are the groups we selected + const [selected, setSelected] = useState([]); + //buildHostGroupsValues build an array of objects to populate dropdown + const [buildHostGroupsValues, setBuildHostGroupsValues] = useState([]); + //hostGroupValue is used for config items + useEffect(() => { + setBuildHostGroupsValues((fetchedValues || []).reduce((acc, group) => { + acc.push({ label: group.name, value: group.name }); + return acc; + }, [])); + }, [fetchedValues, selected]); + //this is used in the filter config as a way to select values onChange + const onHostGroupsChange = (event, selection, item) => { + setSelected(union(selection, item)); + }; + + const chips = useMemo(() => buildHostGroupChips(selected), [selected]); + //chips that is built for the filter config + + //hostGroupConfig is a config that we use in EntityTableToolbar.js + const hostGroupConfig = useMemo(() => ({ + label: 'Group', + value: 'group-host-filter', + type: 'checkbox', + filterValues: { + onChange: (event, value, item) => { + onHostGroupsChange(event, value, item); + }, + value: selected, + items: buildHostGroupsValues + } + }), [selected, buildHostGroupsValues]); + + //setSelectedValues is used for selecting and deleting values + const setSelectedValues = (currentValue = []) => { + setSelected(currentValue); + }; + + return [hostGroupConfig, chips, selected, setSelectedValues]; +}; + +export default useGroupFilter; diff --git a/src/components/filters/useGroupFilter.test.js b/src/components/filters/useGroupFilter.test.js new file mode 100644 index 000000000..0c7e6880a --- /dev/null +++ b/src/components/filters/useGroupFilter.test.js @@ -0,0 +1,176 @@ +/* eslint-disable camelcase */ +import { act, renderHook } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { createPromise as promiseMiddleware } from 'redux-promise-middleware'; +import { mockSystemProfile } from '../../__mocks__/hostApi'; +import useGroupFilter from './useGroupFilter'; + +describe('useGroupFilter', () => { + const mockStore = configureStore([promiseMiddleware()]); + beforeEach(() => { + mockSystemProfile.onGet().replyOnce(200); + }); + + describe('with groups yet not loaded', () => { + const wrapper = ({ children }) => ( + <Provider store={mockStore({})}> + {children} + </Provider> + ); + + it('should return empty state value', () => { + const { result } = renderHook(useGroupFilter, { wrapper }); + expect(result.current).toMatchSnapshot(); + }); + }); + + describe('with groups loaded', () => { + const wrapper = ({ children }) => ( + <Provider + store={mockStore({ + groups: { + data: { + page: 1, + count: 50, + results: [{ + created_at: '2019-02-18T23:00:00.0Z', + id: '4f5B88dBe1D4eB732B388abc1Baa4BAc', + updated_at: '1960-11-06T23:00:00.0Z', + org_id: 'nostrud in deserunt', + account: 'ullamco dolore pariatur sint', + host_ids: [ + '5f20569C-9E96-C794-887c-1Cb5D6e220b2', + 'D34e68beFc2d4fF1C19CF4CaF913d1b6', + '76D3AcBB-5B4C-8BB8-f3BB-69808CcFa84A', + '185dC76d-D6B8-059b-1bCb-685D2edd0CEa', + '50E0cAaDCace55e07AebC7Bcc40cfdAD', + 'be8e0A09AdEE9a23fe65972F43D31bDA', + '8B6764001Bf6d0bA4E6aEdCAf207bf71', + '80CffFbd-04C9-cBB6-5Bf5-Dc0eBbDBba70', + '55d8323C-dEbC-B10e-f88e-23ba37068C9f', + 'C5A8Fd55a0B6fa3A3a748cb0a4cc20BB', + '1dF767C34BF222E65BD46BcC3A62de4b', + '4d44747b1D8D1648EC5713e983984a6C', + 'b89A20e9da89Eb9862502748ca9aa0bf', + 'F24dc6A1548B40dF453FdBEa4DD5CbEB', + '2Ba0BCE6-F8cC-FAFE-Ed72-f7BF01dC60A4', + '1f255AB7-d49f-f8Ff-48eD-B9c6E4674Fe2', + '8277DD2f3Fc0aaE89D601400E86E4E86', + '3Be6b8FE-8B32-240d-9C0f-0c074baC5EBb', + '94c4bBCB31D0dfa37e955Dc94eAd3Dc0', + '2B0725BE87671E4DffE19F0f7fc8aFe7' + ], + name: 'nisi ut consequat ad' + }, + { + created_at: '2019-02-18T23:00:00.0Z', + id: '4f5B88dBe1D4eB732B388abc1Baa4BAc', + updated_at: '1960-11-06T23:00:00.0Z', + org_id: 'nostrud in deserunt', + account: 'ullamco dolore pariatur sint', + host_ids: [ + '5f20569C-9E96-C794-887c-1Cb5D6e220b2', + 'D34e68beFc2d4fF1C19CF4CaF913d1b6', + '76D3AcBB-5B4C-8BB8-f3BB-69808CcFa84A', + '185dC76d-D6B8-059b-1bCb-685D2edd0CEa', + '50E0cAaDCace55e07AebC7Bcc40cfdAD', + 'be8e0A09AdEE9a23fe65972F43D31bDA', + '8B6764001Bf6d0bA4E6aEdCAf207bf71', + '80CffFbd-04C9-cBB6-5Bf5-Dc0eBbDBba70', + '55d8323C-dEbC-B10e-f88e-23ba37068C9f', + 'C5A8Fd55a0B6fa3A3a748cb0a4cc20BB', + '1dF767C34BF222E65BD46BcC3A62de4b', + '4d44747b1D8D1648EC5713e983984a6C', + 'b89A20e9da89Eb9862502748ca9aa0bf', + 'F24dc6A1548B40dF453FdBEa4DD5CbEB', + '2Ba0BCE6-F8cC-FAFE-Ed72-f7BF01dC60A4', + '1f255AB7-d49f-f8Ff-48eD-B9c6E4674Fe2', + '8277DD2f3Fc0aaE89D601400E86E4E86', + '3Be6b8FE-8B32-240d-9C0f-0c074baC5EBb', + '94c4bBCB31D0dfa37e955Dc94eAd3Dc0', + '2B0725BE87671E4DffE19F0f7fc8aFe7' + ], + name: 'nisi ut consequat ad1' + }, + { + created_at: '2019-02-18T23:00:00.0Z', + id: '4f5B88dBe1D4eB732B388abc1Baa4BAc', + updated_at: '1960-11-06T23:00:00.0Z', + org_id: 'nostrud in deserunt', + account: 'ullamco dolore pariatur sint', + host_ids: [ + '5f20569C-9E96-C794-887c-1Cb5D6e220b2', + 'D34e68beFc2d4fF1C19CF4CaF913d1b6', + '76D3AcBB-5B4C-8BB8-f3BB-69808CcFa84A', + '185dC76d-D6B8-059b-1bCb-685D2edd0CEa', + '50E0cAaDCace55e07AebC7Bcc40cfdAD', + 'be8e0A09AdEE9a23fe65972F43D31bDA', + '8B6764001Bf6d0bA4E6aEdCAf207bf71', + '80CffFbd-04C9-cBB6-5Bf5-Dc0eBbDBba70', + '55d8323C-dEbC-B10e-f88e-23ba37068C9f', + 'C5A8Fd55a0B6fa3A3a748cb0a4cc20BB', + '1dF767C34BF222E65BD46BcC3A62de4b', + '4d44747b1D8D1648EC5713e983984a6C', + 'b89A20e9da89Eb9862502748ca9aa0bf', + 'F24dc6A1548B40dF453FdBEa4DD5CbEB', + '2Ba0BCE6-F8cC-FAFE-Ed72-f7BF01dC60A4', + '1f255AB7-d49f-f8Ff-48eD-B9c6E4674Fe2', + '8277DD2f3Fc0aaE89D601400E86E4E86', + '3Be6b8FE-8B32-240d-9C0f-0c074baC5EBb', + '94c4bBCB31D0dfa37e955Dc94eAd3Dc0', + '2B0725BE87671E4DffE19F0f7fc8aFe7' + ], + name: 'nisi ut consequat ad2' + } + ], + total: 100 + } + } + })} + > + {children} + </Provider> + ); + + it('should match snapshot', () => { + const { result } = renderHook(useGroupFilter, { wrapper }); + expect(result.current).toMatchSnapshot(); + }); + + it('should return correct chips array, current value and value setter', () => { + const { result } = renderHook(useGroupFilter, { wrapper }); + const [, chips, value, setValue] = result.current; + expect(chips.length).toBe(0); + expect(value.length).toBe(0); + act(() => { + setValue(['nisi ut consequat ad1']); + }); + const [, chipsUpdated, valueUpdated] = result.current; + expect(chipsUpdated.length).toBe(1); + expect(valueUpdated).toEqual(['nisi ut consequat ad1']); + expect(chipsUpdated).toMatchSnapshot(); + }); + + it('should return empty state value if no groups obtained', () => { + const wrapper = ({ children }) => ( + <Provider + store={mockStore({ + groups: { + data: { + page: 1, + count: 50, + results: [], + total: 100 + } + } + })} + > + {children} + </Provider> + ); + const { result } = renderHook(useGroupFilter, { wrapper }); + expect(result.current).toMatchSnapshot(); + }); + }); +}); diff --git a/src/components/filters/useLastSeenFilter.js b/src/components/filters/useLastSeenFilter.js new file mode 100644 index 000000000..353f3fd55 --- /dev/null +++ b/src/components/filters/useLastSeenFilter.js @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { LAST_SEEN_CHIP, lastSeenItems } from '../../Utilities/constants'; + +export const lastSeenFilterState = { lastSeenFilter: [] }; +export const LAST_SEEN_FILTER = 'LAST_SEEN_FILTER'; +export const lastSeenFilterReducer = (_state, { type, payload }) => ({ + ...(type === LAST_SEEN_FILTER && { + lastSeenFilter: payload + }) +}); + +export const useLastSeenFilter = ( + [state, dispatch] = [lastSeenFilterState] +) => { + let [lastSeenStateValue, setLastSeenValue] = useState({}); + const lastSeenValue = dispatch ? state.lastSeenFilter : [lastSeenStateValue]; + const setValue = dispatch + ? (newValue) => dispatch({ type: LAST_SEEN_FILTER, payload: newValue }) + : setLastSeenValue; + + const filter = { + label: 'Last seen', + value: 'last_seen', + type: 'radio', + filterValues: { + value: lastSeenValue, + onChange: (_e, value) => setValue(value), + items: lastSeenItems + } + }; + + const chip = + !Array.isArray(lastSeenValue) && lastSeenValue !== undefined + ? [ + { + category: 'Last seen', + type: LAST_SEEN_CHIP, + chips: lastSeenItems + .filter(({ value }) => value?.mark === lastSeenValue?.mark) + .map(({ label, ...props }) => ({ name: label, ...props })) + } + ] + : []; + + const [startDate, setStartDate] = useState(); + const [endDate, setEndDate] = useState(); + const todaysDate = new Date(); + + const manageStartDate = (apiStartDate, apiEndDate)=> { + if (isNaN(apiEndDate) && isNaN(apiStartDate)) { + setValue({ ...lastSeenValue, updatedStart: null, updatedEnd: null }); + } else if (apiStartDate > apiEndDate || isNaN(apiStartDate) || apiStartDate > todaysDate) { + setValue({ ...lastSeenValue, updatedStart: null, updatedEnd: apiEndDate.toISOString() }); + } else { + setValue({ ...lastSeenValue, updatedStart: apiStartDate.toISOString() }); + } + }; + + const manageEndDate = (apiStartDate, apiEndDate)=> { + if (isNaN(apiEndDate) && isNaN(apiStartDate)) { + setValue({ ...lastSeenValue, updatedStart: null, updatedEnd: null }); + } else if (apiStartDate > apiEndDate || isNaN(apiEndDate)) { + setValue({ ...lastSeenValue, updatedStart: apiStartDate.toISOString(), updatedEnd: null }); + } else { + setValue({ ...lastSeenValue, updatedEnd: apiEndDate.toISOString() }); + } + }; + + const toValidator = (date) => { + const newDate = new Date(date); + const minDate = new Date(startDate); + + if (minDate >= newDate) { + return 'Start date must be earlier than End date.'; + } else if (newDate > todaysDate) { + return `Date must be ${todaysDate.toISOString().split('T')[0]} or earlier`; + } else { + return ''; + } + }; + + const fromValidator = (date) => { + const minDate = new Date(1950, 1, 1); + const maxDate = new Date(endDate); + + if (date < minDate) { + return 'Date is before the allowable range.'; + } else if (date > maxDate) { + return `End date must be later than Start date.`; + } else if (date > todaysDate) { + return ' Start date must be earlier than End date.'; + } else { + return ''; + } + }; + + const onFromChange = (date) => { + const newToDate = new Date(endDate); + if (date > newToDate) { + setStartDate(); + return 'End date must be later than Start date.'; + } + + setStartDate(date); + const apiStartDate = new Date(date); + apiStartDate.setUTCHours(0); + manageStartDate(apiStartDate, newToDate); + }; + + const onToChange = (date) => { + if (startDate > new Date(date)) { + return 'Start date must be earlier than End date.'; + } else if (new Date(date) > todaysDate) { + return 'End date must be later than Start date.'; + } else { + setEndDate(date); + const apiEndDate = new Date(date); + apiEndDate.setUTCHours(23, 59); + manageEndDate(new Date(startDate), apiEndDate); + } + }; + + return [ + filter, + chip, + lastSeenValue, + setValue, + toValidator, + onFromChange, + onToChange, + endDate, + startDate, + fromValidator, + setStartDate, + setEndDate + ]; +}; diff --git a/src/components/filters/useTextFilter.js b/src/components/filters/useTextFilter.js index ca71d4e3e..f5dbb8558 100644 --- a/src/components/filters/useTextFilter.js +++ b/src/components/filters/useTextFilter.js @@ -23,7 +23,7 @@ export const useTextFilter = ([state, dispatch] = [textFilterState]) => { onChange: (_e, value) => setValue(value) } }; - const chip = value.length > 0 ? [{ + const chip = value?.length > 0 ? [{ category: 'Display name', type: TEXTUAL_CHIP, chips: [ diff --git a/src/constants.js b/src/constants.js index 3970ee0a5..adfa8c648 100644 --- a/src/constants.js +++ b/src/constants.js @@ -1,5 +1,5 @@ import PropTypes from 'prop-types'; -import { RHCD_FILTER_KEY, UPDATE_METHOD_KEY } from './Utilities/constants'; +import { RHCD_FILTER_KEY, UPDATE_METHOD_KEY, HOST_GROUP_CHIP } from './Utilities/constants'; export const tagsMapper = (acc, curr) => { let [namespace, keyValue] = curr.split('/'); @@ -65,7 +65,6 @@ export const generateFilters = (cells = [], filters = [], activeFilters = {}, on filters.map((filter, key) => { const activeKey = filter.index || key; const activeLabel = cells[activeKey] && (cells[activeKey].title || cells[activeKey]); - return ({ value: String(activeKey), label: activeLabel, @@ -123,9 +122,12 @@ export const getSearchParams = () => { const operatingSystem = searchParams.getAll('operating_system'); const rhcdFilter = searchParams.getAll(RHCD_FILTER_KEY); const updateMethodFilter = searchParams.getAll(UPDATE_METHOD_KEY); + const groupHostsFilter = searchParams.getAll(HOST_GROUP_CHIP); const page = searchParams.getAll('page'); const perPage = searchParams.getAll('per_page'); - return { status, source, tagsFilter, filterbyName, operatingSystem, rhcdFilter, updateMethodFilter, page, perPage }; + const lastSeenFilter = searchParams.getAll('last_seen'); + return { status, source, tagsFilter, filterbyName, operatingSystem, rhcdFilter, updateMethodFilter, lastSeenFilter, + page, perPage, groupHostsFilter }; }; export const TABLE_DEFAULT_PAGINATION = 50; // from UX table audit diff --git a/src/routes/InventoryTable.js b/src/routes/InventoryTable.js index 5f6f2e3ba..5df03787f 100644 --- a/src/routes/InventoryTable.js +++ b/src/routes/InventoryTable.js @@ -14,6 +14,8 @@ import flatMap from 'lodash/flatMap'; import { useWritePermissions, RHCD_FILTER_KEY, UPDATE_METHOD_KEY, generateFilter } from '../Utilities/constants'; import { InventoryTable as InventoryTableCmp } from '../components/InventoryTable'; import useChrome from '@redhat-cloud-services/frontend-components/useChrome'; +import AddHostToGroupModal from '../components/InventoryGroups/Modals/AddHostToGroupModal'; +import useFeatureFlag from '../Utilities/useFeatureFlag'; const reloadWrapper = (event, callback) => { event.payload.then(callback); @@ -48,8 +50,13 @@ const filterMapper = { flatMap(tagFilters, mapTags) ), rhcdFilter: ({ rhcdFilter }, searchParams) => rhcdFilter?.forEach(item => searchParams.append(RHCD_FILTER_KEY, item)), + lastSeenFilter: ({ lastSeenFilter }, searchParams) => + Object.keys(lastSeenFilter || {})?.forEach(item => item === 'mark' && + searchParams.append('last_seen', lastSeenFilter[item])), updateMethodFilter: ({ updateMethodFilter }, searchParams) => - updateMethodFilter?.forEach(item => searchParams.append(UPDATE_METHOD_KEY, item)) + updateMethodFilter?.forEach(item => searchParams.append(UPDATE_METHOD_KEY, item)), + groupHostFilter: ({ groupHostFilter }, searchParams) => groupHostFilter + ?.forEach(item => searchParams.append('host_group', item)) }; const calculateFilters = (searchParams, filters = []) => { @@ -58,7 +65,6 @@ const calculateFilters = (searchParams, filters = []) => { filterMapper?.[key]?.(filter, searchParams); }); }); - return searchParams; }; @@ -78,10 +84,12 @@ const Inventory = ({ operatingSystem, rhcdFilter, updateMethodFilter, + lastSeenFilter, page, perPage, initialLoading, - hasAccess + hasAccess, + groupHostsFilter }) => { const history = useHistory(); const chrome = useChrome(); @@ -89,18 +97,28 @@ const Inventory = ({ const [isModalOpen, handleModalToggle] = useState(false); const [currentSytem, activateSystem] = useState({}); const [filters, onSetfilters] = useState( - generateFilter(status, source, tagsFilter, filterbyName, operatingSystem, rhcdFilter, updateMethodFilter) + generateFilter( + status, + source, + tagsFilter, + filterbyName, + operatingSystem, + rhcdFilter, + updateMethodFilter, + groupHostsFilter, + lastSeenFilter) ); const [ediOpen, onEditOpen] = useState(false); + const [addHostGroupModalOpen, setAddHostGroupModalOpen] = useState(false); const [globalFilter, setGlobalFilter] = useState(); const writePermissions = useWritePermissions(); const rows = useSelector(({ entities }) => entities?.rows, shallowEqual); const loaded = useSelector(({ entities }) => entities?.loaded); const selected = useSelector(({ entities }) => entities?.selected); const dispatch = useDispatch(); + const groupsEnabled = useFeatureFlag('hbi.ui.inventory-groups'); const onSelectRows = (id, isSelected) => dispatch(actions.selectEntity(id, isSelected)); - const onRefresh = (options, callback) => { onSetfilters(options?.filters); const searchParams = new URLSearchParams(); @@ -149,10 +167,61 @@ const Inventory = ({ Array.isArray(perPage) ? perPage[0] : perPage )); } + + return () => { + dispatch(actions.clearEntitiesAction()); + }; }, []); const calculateSelected = () => selected ? selected.size : 0; + //This wrapping of table actions allows to pass feature flag status and receive a prepared array of actions + const tableActions = (groupsUiStatus, row) => { + const isGroupPresentForThisRow = (row) => { + return row && row?.groups?.title !== ''; + }; + + const standardActions = [ + { + title: 'Edit', + onClick: (_event, _index, data) => { + activateSystem(() => data); + onEditOpen(() => true); + } + }, + { + title: 'Delete', + onClick: (_event, _index, { id: systemId, display_name: displayName }) => { + activateSystem(() => ({ + id: systemId, + displayName + })); + handleModalToggle(() => true); + } + } + ]; + + const actionsBehindFeatureFlag = [ + { + title: 'Add to group', + onClick: (_event, _index, { id: systemId, display_name: displayName, group_name: groupName }) => { + activateSystem(() => ({ + id: systemId, + name: displayName, + groupName + })); + setAddHostGroupModalOpen(true); + } + }, + { + title: 'Remove from group', + isDisabled: isGroupPresentForThisRow(row) + } + ]; + + return [...(groupsUiStatus ? actionsBehindFeatureFlag : []), ...standardActions]; + }; + return ( <React.Fragment> <PageHeader className="pf-m-light"> @@ -171,26 +240,12 @@ const Inventory = ({ onRefresh={onRefresh} hasCheckbox={writePermissions} autoRefresh + ignoreRefresh initialLoading={initialLoading} + tableProps={ + (writePermissions && { + actionResolver: (row) => tableActions(groupsEnabled, row), canSelectAll: false })} {...(writePermissions && { - actions: [ - { - title: 'Delete', - onClick: (_event, _index, { id: systemId, display_name: displayName }) => { - activateSystem(() => ({ - id: systemId, - displayName - })); - handleModalToggle(() => true); - } - }, { - title: 'Edit', - onClick: (_event, _index, data) => { - activateSystem(() => data); - onEditOpen(() => true); - } - } - ], actionsConfig: { actions: [{ label: 'Delete', @@ -227,15 +282,13 @@ const Inventory = ({ } } })} - tableProps={{ - canSelectAll: false - }} onRowClick={(_e, id, app) => history.push(`/${id}${app ? `/${app}` : ''}`)} /> </GridItem> </Grid> </Main> <DeleteModal + className ='sentry-mask data-hj-suppress' handleModalToggle={handleModalToggle} isModalOpen={isModalOpen} currentSytems={currentSytem} @@ -263,7 +316,6 @@ const Inventory = ({ handleModalToggle(false); }} /> - <TextInputModal title="Edit display name" isOpen={ediOpen} @@ -274,6 +326,13 @@ const Inventory = ({ onEditOpen(false); }} /> + <AddHostToGroupModal + isModalOpen={addHostGroupModalOpen} + setIsModalOpen={setAddHostGroupModalOpen} + modalState={currentSytem} + //should be replaced with a fetch to update the values in the table + reloadData={() => console.log('data reloaded')} + /> </React.Fragment> ); }; @@ -289,7 +348,9 @@ Inventory.propTypes = { initialLoading: PropTypes.bool, rhcdFilter: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), updateMethodFilter: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), - hasAccess: PropTypes.bool + hasAccess: PropTypes.bool, + groupHostsFilter: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), + lastSeenFilter: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]) }; Inventory.defaultProps = { diff --git a/src/routes/InventoryTable.test.js b/src/routes/InventoryTable.test.js index ad3166c63..ea7f6ca7f 100644 --- a/src/routes/InventoryTable.test.js +++ b/src/routes/InventoryTable.test.js @@ -27,15 +27,6 @@ jest.mock('@redhat-cloud-services/frontend-components-utilities/RBACHook', () => usePermissionsWithContext: () => ({ hasAccess: true }) })); -jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => ({ - __esModule: true, - default: () => ({ - updateDocumentTitle: jest.fn(), - appAction: jest.fn(), - appObjectId: jest.fn(), - on: jest.fn() - }) -})); jest.mock('../Utilities/useFeatureFlag'); describe('InventoryTable', () => { @@ -258,8 +249,12 @@ describe('InventoryTable', () => { expect(wrapper.find('DropdownMenu')).toHaveLength(1); await act(async () => { - wrapper.find('DropdownItem').first().find('button').simulate('click'); + const dropdownItems = wrapper.find('DropdownItem'); + + const deleteDropdown = dropdownItems.at(1); + deleteDropdown.find('button').simulate('click'); }); + wrapper.update(); expect(wrapper.find(DeleteModal).props().isModalOpen).toEqual(true); diff --git a/src/store/action-types.js b/src/store/action-types.js index a3911afcf..779b1cceb 100644 --- a/src/store/action-types.js +++ b/src/store/action-types.js @@ -67,3 +67,4 @@ export const CLEAR_FILTERS = 'CLEAR_FILTERS'; export const TOGGLE_TAG_MODAL = 'TOGGLE_TAG_MODAL'; export const CONFIG_CHANGED = 'CONFIG_CHANGED'; export const TOGGLE_DRAWER = 'TOGGLE_INVENTORY_DRAWER'; +export const CLEAR_ENTITIES = 'CLEAR_ENTITIES'; diff --git a/src/store/actions.js b/src/store/actions.js index 0167d550b..202699fbe 100644 --- a/src/store/actions.js +++ b/src/store/actions.js @@ -1,4 +1,5 @@ -import { ACTION_TYPES, CLEAR_NOTIFICATIONS, SET_INVENTORY_FILTER, SET_PAGINATION } from './action-types'; +import { ACTION_TYPES, CLEAR_NOTIFICATIONS, SET_INVENTORY_FILTER, SET_PAGINATION, + CLEAR_ENTITIES } from './action-types'; import { hosts, getEntitySystemProfile } from '../api'; export * from './system-issues-actions'; export * from './inventory-actions'; @@ -77,3 +78,8 @@ export const editAnsibleHost = (id, value, origValue) => ({ } } }); + +export const clearEntitiesAction = () => ({ + type: CLEAR_ENTITIES, + payload: [] +}); diff --git a/src/store/actions.test.js b/src/store/actions.test.js index e3bf3a6d0..ff71dc4a0 100644 --- a/src/store/actions.test.js +++ b/src/store/actions.test.js @@ -2,7 +2,7 @@ import { editAnsibleHost, editDisplayName, fetchGroups, systemProfile } from './actions'; import { hosts } from '../api'; import mockedData from '../__mocks__/mockedData.json'; -import mockedGroups from '../__mocks__/mockedGroups.json'; +import fixturesGroups from '../../cypress/fixtures/groups.json'; import MockAdapter from 'axios-mock-adapter'; const mocked = new MockAdapter(hosts.axios); @@ -65,10 +65,10 @@ describe('editAnsibleHost', () => { describe('fetchGroups', () => { it('should call correct endpoint', async () => { mocked.onGet(new RegExp('/api/inventory/v1/groups*')).reply(() => { - return [200, mockedGroups]; + return [200, fixturesGroups]; }); const { type, payload } = await fetchGroups(); expect(type).toBe('GROUPS'); - expect(await payload).toEqual(mockedGroups); + expect(await payload).toEqual(fixturesGroups); }); }); diff --git a/src/store/entities.js b/src/store/entities.js index c90947e26..8e16d9f2d 100644 --- a/src/store/entities.js +++ b/src/store/entities.js @@ -9,7 +9,8 @@ import { ENTITIES_LOADING, CLEAR_FILTERS, TOGGLE_TAG_MODAL, - CONFIG_CHANGED + CONFIG_CHANGED, + CLEAR_ENTITIES } from './action-types'; import { mergeArraysByKey } from '@redhat-cloud-services/frontend-components-utilities/helpers'; import { DateFormat } from '@redhat-cloud-services/frontend-components/DateFormat'; @@ -21,6 +22,8 @@ import InsightsDisconnected from '../Utilities/InsightsDisconnected'; import OperatingSystemFormatter from '../Utilities/OperatingSystemFormatter'; import { Tooltip } from '@patternfly/react-core'; import { verifyCulledInsightsClient } from '../Utilities/sharedFunctions'; +import { fitContent } from '@patternfly/react-table'; +import isEmpty from 'lodash/isEmpty'; export const defaultState = { loaded: false, @@ -46,9 +49,11 @@ export const defaultColumns = (groupsEnabled = false) => ([ ...(groupsEnabled ? [{ key: 'groups', sortKey: 'groups', - title: 'Groups', + title: 'Group', props: { width: 10 }, - renderFunc: () => <React.Fragment>N/A</React.Fragment> + // eslint-disable-next-line camelcase + renderFunc: (value, systemId, { group_name }) => isEmpty(group_name) ? 'N/A' : group_name, + transforms: [fitContent] }] : []), { key: 'tags', @@ -94,7 +99,8 @@ export const defaultColumns = (groupsEnabled = false) => ([ } > <DateFormat date={ value } /> </CullingInformation> : new Date(value).toLocaleString(); }, - props: { width: 10 } + props: { width: 10 }, + transforms: [fitContent] } ]); @@ -118,6 +124,10 @@ function clearFilters(state) { }; } +const clearEntities = () => { + return defaultState; +}; + // eslint-disable-next-line camelcase function entitiesLoaded(state, { payload: { results, per_page: perPage, page, count, total, loaded, filters }, meta }) { // Older requests should not rewrite the state @@ -170,7 +180,7 @@ function selectEntity(state, { payload }) { function versionsLoaded(state, { payload: { results } }) { return { ...state, - operatingSystems: results.map(entry => { + operatingSystems: (results || []).map(entry => { const { name, major, minor } = entry.value; const versionStringified = `${major}.${minor}`; return { label: `${name} ${versionStringified}`, value: versionStringified }; @@ -300,5 +310,6 @@ export default { [CLEAR_FILTERS]: clearFilters, [ENTITIES_LOADING]: (state, { payload: { isLoading } }) => ({ ...state, loaded: !isLoading }), [TOGGLE_TAG_MODAL]: toggleTagModalReducer, - [CONFIG_CHANGED]: (state, { payload }) => ({ ...state, invConfig: payload }) + [CONFIG_CHANGED]: (state, { payload }) => ({ ...state, invConfig: payload }), + [CLEAR_ENTITIES]: clearEntities }; diff --git a/src/store/inventory-actions.js b/src/store/inventory-actions.js index 57052c366..3c75b91d7 100644 --- a/src/store/inventory-actions.js +++ b/src/store/inventory-actions.js @@ -37,11 +37,14 @@ export const loadEntities = (items = [], { filters, ...config }, { showTags } = ...filters.length === 0 && { registeredWithFilter: [] }, ...(isFilterDisabled('stale') && { staleFilter: undefined }), ...(isFilterDisabled('registeredWith') && { registeredWithFilter: undefined }), - ...(isFilterDisabled('operating_system') && { osFilter: undefined }) + ...(isFilterDisabled('operating_system') && { osFilter: undefined }), + ...(isFilterDisabled('host_group')) && { groupHostFilter: undefined } }) : { ...(isFilterDisabled('stale') && { staleFilter: undefined }), + ...(isFilterDisabled('last_seen') && { lastSeenFilter: undefined }), ...(isFilterDisabled('registeredWith') && { registeredWithFilter: undefined }), - ...(isFilterDisabled('operating_system') && { osFilter: undefined }) + ...(isFilterDisabled('operating_system') && { osFilter: undefined }), + ...(isFilterDisabled('host_group')) && { groupHostFilter: undefined } }; const orderBy = config.orderBy || 'updated';