diff --git a/.env b/.env index bd0decc7..3c1a6120 100644 --- a/.env +++ b/.env @@ -10,6 +10,8 @@ REACT_APP_UI_BRAND=false REACT_APP_AUTH_TOKEN=csrftoken REACT_APP_AUTH_HEADER=X-CSRFToken REACT_APP_AJAX_TIMEOUT=60000 +REACT_APP_TOAST_NOTIFICATIONS_TIMEOUT=8000 +REACT_APP_POLL_INTERVAL=120000 REACT_APP_CONFIG_SERVICE_LOCALES_DEFAULT_LNG=en REACT_APP_CONFIG_SERVICE_LOCALES_DEFAULT_LNG_DESC=English diff --git a/.eslintrc b/.eslintrc index b468faab..752138c6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "plugins": [ "import", "jest", + "jsdoc", "json", "prettier", "react" @@ -21,7 +22,8 @@ "airbnb", "airbnb/hooks", "prettier", - "plugin:jest/recommended" + "plugin:jest/recommended", + "plugin:jsdoc/recommended" ], "globals": { "mountHook": "readonly", @@ -57,6 +59,17 @@ "import/no-named-as-default-member": 0, "jest/no-done-callback": 0, "jest/prefer-to-have-length": 0, + "jsdoc/require-param-description": 0, + "jsdoc/require-property-description": 0, + "jsdoc/require-returns-description": 0, + "jsdoc/tag-lines": [ + "warn", + "always", + { + "count": 0, + "noEndLines": true + } + ], "max-len": [ "error", { diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 20f387d9..3feb1737 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -1,7 +1,7 @@ name: Build on: push: - branches: [master, main] + branches: [master, main, dev**] tags: - "*" pull_request: diff --git a/package.json b/package.json index 68fae1fe..f9fd3a92 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "!src/components/app.js", "!src/components/**/index.js", "!src/common/index.js", + "!src/hooks/index.js", "!src/redux/index.js", "!src/redux/store.js", "!src/redux/middleware/**", @@ -79,9 +80,10 @@ "scripts": { "api:build": "run-s -l build:pre build:js build:post", "api:dev": "sh ./scripts/api.sh -p 5000 -t dev", - "api:docs": "sh ./scripts/api.sh -t docs", + "api:specs": "sh ./scripts/api.sh -t specs", "api:review": "sh ./scripts/api.sh -p 9443 -t review", "api:stage": "sh ./scripts/api.sh -p 5001 -t stage", + "api:stop": "sh ./scripts/api.sh -t stopApi", "api:update": "npm run api:build; sh ./scripts/api.sh -t update", "build": "run-s -l build:pre build:docs test:docs build:template-css build:js build:post test:integration", "build:clean": "bash ./scripts/clean.sh", @@ -91,12 +93,13 @@ "build:post": "bash ./scripts/post.sh; bash ./scripts/clean.sh", "build:pre": "bash ./scripts/pre.sh", "build:brand": "run-s -l 'build:pre -b' 'build:docs -b' test:docs build:template-css build:js build:post test:integration", - "build:template-css": "sass --no-source-map --load-path ./src --load-path ./node_modules ./src/styles/index.scss ./src/styles/.css/index.css", + "build:template-css": "sass --no-source-map --load-path ./src --load-path ./node_modules ./src/styles/template.scss ./src/styles/.css/index.css", "release": "standard-version", - "start": "open http://localhost:5050/docs/api; run-p -l api:dev start:js", + "start": "run-p -l api:dev start:js", + "start:specs": "open http://localhost:5050/docs/api; run-s api:specs", "start:js": "react-scripts start", "start:dev": "npm start", - "start:review": "open https://localhost:9443/login/; run-s build && run-s -l api:review;", + "start:review": "open https://127.0.0.1:9443/login/; run-s build && run-s -l api:review;", "start:stage": "open https://localhost:5001/login/; run-s api:build && run-p -l api:stage start:ui-stage", "start:ui-stage": "sh -ac '. ./.env;. ./.env.staging; react-app-rewired start'", "test": "run-s test:lint test:ci", @@ -111,6 +114,12 @@ "test:unit": "react-scripts test --env=jsdom" }, "dependencies": { + "@patternfly/patternfly": "4.196.7", + "@patternfly/react-core": "4.221.3", + "@patternfly/react-icons": "4.72.3", + "@patternfly/react-styles": "4.71.3", + "@patternfly/react-table": "4.71.37", + "@patternfly/react-tokens": "4.73.3", "axios": "^0.27.2", "bootstrap": "^5.1.3", "classnames": "^2.3.1", @@ -120,7 +129,7 @@ "i18next-xhr-backend": "^2.0.1", "js-cookie": "^3.0.1", "lodash": "^4.17.21", - "moment": "^2.29.3", + "moment": "^2.29.4", "npm-run-all": "^4.1.5", "patternfly": "3.59.5", "patternfly-react": "2.40.0", @@ -154,10 +163,9 @@ "eslint-plugin-json": "^3.1.0", "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-prettier": "^3.4.0", - "eslint-plugin-react": "^7.30.0", + "eslint-plugin-react": "^7.32.2", "eslint-plugin-react-hooks": "^4.6.0", "express": "^4.18.1", - "gettext-extractor": "^3.5.4", "htmlhint": "^1.1.4", "iso-639-1": "^2.1.15", "jest": "26.6.0", diff --git a/public/locales/en.json b/public/locales/en.json index 0967ef42..5679bd44 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -1 +1,205 @@ -{} +{ + "about-modal": { + "browser-os": "Browser OS", + "browser-version": "Browser Version", + "copy-button": "Copy application information", + "copy-confirmation": "Application information copied", + "copyright": "Copyright (c) {{year}} Red Hat Inc.", + "server-version": "Server Version", + "ui-version": "UI Version", + "username": "Username" + }, + "form-dialog": { + "confirmation_body_add-source": "{{context}}", + "confirmation_body_add-source_edit": "{{context}}", + "confirmation_heading_add-source": "Are you sure you want to cancel adding this source?", + "confirmation_heading_add-source_edit": "Are you sure you want to cancel updating this source?", + "confirmation_title_add-source": "Cancel Add Source", + "confirmation_title_add-source_edit": "Cancel Updating Source", + "confirmation_body_add-source_exit": "The wizard is in a pending state and will continue adding this source.", + "confirmation_body_add-source_exit_edit": "The wizard is in a pending state and will continue updating this source.", + "confirmation_heading_add-source_exit": "Are you sure you want to exit this wizard?", + "confirmation_title_add-source_exit": "Exit Wizard", + "confirmation_title_delete-source": "Delete Source", + "confirmation_heading_delete-source": "Are you sure you want to delete the source <0>{{name}}?", + "empty-state_description_add-source": "{{context}}", + "empty-state_description_add-source_error": "There are errors on a previous step. Use the Back button to review your settings and try again.", + "empty-state_description_add-source_pending": "Please wait while source {{name}} is being created.", + "empty-state_description_add-source_pending_edit": "Please wait while source {{name}} is being updated.", + "empty-state_title_add-source": "{{name}} was created.", + "empty-state_title_add-source_edit": "{{name}} was updated.", + "empty-state_title_add-source_error": "Error Creating Source", + "empty-state_title_add-source_error_edit": "Error Updating Source", + "empty-state_title_add-source_pending": "Creating Source...", + "empty-state_title_add-source_pending_edit": "Updating Source...", + "label": "{{context}}", + "label_add": "Add", + "label_add-credential": "Add a credential", + "label_cancel": "Cancel", + "label_close": "Close", + "label_delete": "Delete", + "label_no": "No", + "label_option_disableSsl": "Disable SSL", + "label_option_SSLv23": "SSLv23", + "label_option_TLSv1": "TLSv1", + "label_option_TLSv1_1": "TLSv1.1", + "label_option_TLSv1_2": "TLSv1.2", + "label_option_sshKey": "SSH Key", + "label_option_usernamePassword": "Username and Password", + "label_option_network": "Network Credential", + "label_option_satellite": "Satellite Credential", + "label_option_vcenter": "VCenter Credential", + "label_placeholder_add-credential": "Add Credential", + "label_placeholder_add-source_credential": "Select a credential", + "label_placeholder_add-source_credential_add": "Add a credential", + "label_placeholder_add-source_credential_multi": "Select one or more credentials", + "label_placeholder_add-source_credential_multi_add": "Add one or more credentials", + "label_submit": "Submit", + "label_submit_add-source": "Save", + "label_submit_confirmation": "Confirm", + "label_submit_create-credential": "Save", + "label_submit_create-scan": "Scan", + "label_submit_merge-reports": "Merge", + "label_yes": "Yes", + "title_add-source": "Add Source", + "title_add-source_edit": "Edit Source", + "title_add-source_step": "Source Type", + "title_add-source_step_two": "Credentials", + "title_add-source_step_three": "Confirm" + }, + "modal": { + "aria-label-default": "Application modal" + }, + "refresh-time-button": { + "refreshed": "Refreshed just now", + "refreshed_load": "Refreshed {{refresh}}" + }, + "table": { + "header_credentials": "Credentials", + "header_description": "Description", + "header_failed": "Failed systems", + "header_failed_sources": "Failed hosts", + "header_scan": "Scan", + "header_scan-jobs": "Previous scans", + "header_sources": "Sources", + "header_success": "Successful systems", + "header_success_sources": "Ok hosts", + "header_unreachable": "Unreachable systems", + "header_unreachable_sources": "Unreachable hosts", + "label_network-range": "Network Range", + "label": "{{context}}", + "label_action_scan": "Start scan", + "label_action_scan_canceled": "Retry scan", + "label_action_scan_cancelled": "Retry scan", + "label_action_scan_completed": "Run scan", + "label_action_scan_created": "Pause scan", + "label_action_scan_download": "Download", + "label_action_scan_failed": "Retry scan", + "label_action_scan_running": "Pause scan", + "label_action_scan_paused": "Resume scan", + "label_action_scan_pending": "Cancel scan", + "label_add": "Add", + "label_delete": "Delete", + "label_edit": "Edit", + "label_merge-reports": "Merge reports", + "label_scan": "Scan", + "label_status": "{{context}}", + "label_status_scans": "Scan created", + "label_status_completed": "Last completed", + "label_status_failed": "Failed", + "label_status_canceled": "Canceled", + "label_status_cancelled": "Canceled", + "label_status_created": "In progress", + "label_status_pending": "In progress", + "label_status_running": "In progress", + "label_status_paused": "Paused", + "label_status_completed_sources": "Last connected", + "label_status_failed_sources": "Connection failed", + "label_status_canceled_sources": "Connection canceled", + "label_status_cancelled_sources": "Connection canceled", + "label_status_created_sources": "Connection in progress", + "label_status_pending_sources": "Connection in progress", + "label_status_running_sources": "Connection in progress", + "label_status_paused_sources": "Connection paused", + "label_status_completed_scans": "Last scanned", + "label_status_failed_scans": "Scan failed", + "label_status_canceled_scans": "Scan canceled", + "label_status_cancelled_scans": "Scan canceled", + "label_status_created_scans": "Scan in progress", + "label_status_pending_scans": "Scan in progress", + "label_status_running_scans": "Scan in progress", + "label_status_paused_scans": "Scan paused", + "label_status_cell": "<0/> <1>{{count}}", + "label_status_tooltip": "{{count}}", + "label_status_tooltip_failed": "{{count}} Failed System", + "label_status_tooltip_failed_other": "{{count}} Failed Systems", + "label_status_tooltip_failed_sources": "{{count}} Failed Authentication", + "label_status_tooltip_failed_sources_other": "{{count}} Failed Authentications", + "label_status_tooltip_idCard": "{{count}} Credential", + "label_status_tooltip_idCard_other": "{{count}} Credentials", + "label_status_tooltip_scans": "{{count}} Previous scan", + "label_status_tooltip_scans_other": "{{count}} Previous scans", + "label_status_tooltip_sources": "{{count}} Source", + "label_status_tooltip_sources_other": "{{count}} Sources", + "label_status_tooltip_success": "{{count}} Successful System", + "label_status_tooltip_success_other": "{{count}} Successful Systems", + "label_status_tooltip_success_sources": "{{count}} Successful Authentication", + "label_status_tooltip_success_sources_other": "{{count}} Successful Authentications", + "label_status_tooltip_unreachable": "{{count}} Unreachable System", + "label_status_tooltip_unreachable_other": "{{count}} Unreachable Systems", + "tooltip_merge-reports": "Merge selected scan results into a single report" + }, + "toast-notifications": { + "description": "{{context}}", + "description_add-source_hidden": "Source {{name}} was created", + "description_add-source_hidden_edit": "Source {{name}} was updated", + "description_add-source_hidden_error": "{{message}}", + "description_add-source_hidden_error_edit": "{{message}}", + "description_deleted-source": "Deleted source {{name}}.", + "description_error": "Application error", + "description_scan-report_canceled": "Scan <0>{{name}} stopped", + "description_scan-report_download": "Report <0>{{name}} downloaded", + "description_scan-report_paused": "Scan <0>{{name}} paused", + "description_scan-report_restart": "Scan <0>{{name}} resumed", + "description_scan-report_play": "Scan <0>{{name}} started", + "description_warning": "Application warning", + "title": "Notification", + "title_add-source_hidden": "Success creating source", + "title_add-source_hidden_edit": "Success updating source", + "title_add-source_hidden_error": "Error creating source", + "title_add-source_hidden_error_edit": "Error updating source", + "title_deleted-source": "Success deleting source", + "title_error": "Error", + "title_warning": "Warning" + }, + "view": { + "empty-state_description_credentials": "Credentials contain authentication information needed to scan a source. A credential includes a username and a password or SSH key. {{name}} uses SSH to connect to servers on the network and uses credentials to access those servers.", + "empty-state_description_scans": "Select a Source to scan from the Sources page.", + "empty-state_description_scans_zero": "$t(view.empty-state_description_sources)", + "empty-state_description_sources": "Begin by adding one or more sources. A source contains a collection of network information, including systems management solution information, IP addresses, or host names, in addition to SSH ports and credentials.", + "empty-state_filter_description": "The active filters are hiding all items.", + "empty-state_filter_title": "No Results Match the Filter Criteria", + "empty-state_label_clear": "Clear Filters", + "empty-state_label_sources": "Add Source", + "empty-state_label_source-navigate": "Go to Sources", + "empty-state_label_source-navigate_zero": "Add Source", + "empty-state_title": "Welcome to {{name}}", + "empty-state_title_scans": "No scans exist yet", + "empty-state_title_scans_zero": "Welcome to {{name}}", + "error_authentication": "Login error", + "error_credentials": "Credentials error", + "error_scan-hosts": "Scan hosts error", + "error_scan-jobs": "Scan jobs error", + "error_scans": "Scans error", + "error_sources": "Sources error", + "error-message_authentication": "{{message}} Please <0>login to continue.", + "error-message_credentials": "Error retrieving credentials: {{message}}", + "error-message_scan-hosts": "Error retrieving scan results: {{message}}", + "error-message_scan-jobs": "Error retrieving scan jobs: {{message}}", + "error-message_scans": "Error retrieving scans: {{message}}", + "error-message_sources": "Error retrieving sources: {{message}}", + "loading": "Loading...", + "loading_authentication": "Logging in...", + "loading_credentials": "Loading credentials..." + } +} diff --git a/scripts/api.sh b/scripts/api.sh index ae50da21..5441cf15 100644 --- a/scripts/api.sh +++ b/scripts/api.sh @@ -87,7 +87,7 @@ startDB() { local CONTAINER="postgres:9.6.10" local NAME=$1 - local DATA="$(pwd)/.container/postgres" + local DATA="$(pwd)/.container/${NAME}/postgres" local DATA_VOLUME="/var/lib/postgresql/data" local STORE_DATA=$2 @@ -110,8 +110,8 @@ startDB() checkContainerRunning $NAME - if [ ! -z "$(docker ps | grep $CONTAINER)" ]; then - echo " Container: $(docker ps | grep $CONTAINER | cut -c 1-80)" + if [ ! -z "$(docker ps | grep $NAME)" ]; then + echo " Container: $(docker ps | grep $NAME | cut -c 1-80)" echo " QPC DB running:" printf " To stop: $ ${GREEN}docker stop ${NAME}${NOCOLOR}\n" fi @@ -148,7 +148,7 @@ buildApp() reviewApi() { local NAME="qpc-review" - local DB_NAME="qpc-db" + local DB_NAME="qpc-db-review" local PORT=$1 local IS_BUILT=$2 local CONTAINER=$3 @@ -162,17 +162,17 @@ reviewApi() startDB $DB_NAME - if [ -z "$(docker ps | grep $CONTAINER)" ]; then + if [ -z "$(docker ps | grep $NAME)" ]; then printf "\n" echo "Starting API..." docker run -d --rm -p $PORT:443 -e QPC_DBMS_HOST=$DB_NAME --link $DB_NAME:qpc-link --name $NAME $CONTAINER >/dev/null fi - checkContainerRunning $CONTAINER + checkContainerRunning $NAME - if [ ! -z "$(docker ps | grep $CONTAINER)" ]; then - echo " Container: $(docker ps | grep $CONTAINER | cut -c 1-80)" - echo " QPC API running: https://localhost:${PORT}/" + if [ ! -z "$(docker ps | grep $NAME)" ]; then + echo " Container: $(docker ps | grep $NAME | cut -c 1-80)" + echo " QPC API running: https://127.0.0.1:${PORT}/" printf " To stop: $ ${GREEN}docker stop ${NAME}${NOCOLOR}\n" fi @@ -185,7 +185,7 @@ reviewApi() stageApi() { local NAME="qpc-stage" - local DB_NAME="qpc-db" + local DB_NAME="qpc-db-stage" local PORT=$1 local UPDATE=$2 local IS_BUILT=$3 @@ -209,23 +209,22 @@ stageApi() fi if [ ! "$UPDATE" = true ]; then - startDB $DB_NAME true + startDB $DB_NAME - if [ -z "$(docker ps | grep $CONTAINER)" ]; then + if [ -z "$(docker ps | grep $NAME)" ]; then printf "\n" echo "Starting API..." docker run -d --rm -p $PORT:443 -v $BUILD_DIR:$CLIENT_VOLUME -v $BUILD_DIR:$TEMPLATE_CLIENT_VOLUME -v $TEMPLATE_DIR:$TEMPLATE_REGISTRATION_VOLUME -e QPC_DBMS_HOST=$DB_NAME --link $DB_NAME:qpc-link --name $NAME $CONTAINER >/dev/null fi - checkContainerRunning $CONTAINER + checkContainerRunning $NAME - if [ ! -z "$(docker ps | grep $CONTAINER)" ]; then - echo " Container: $(docker ps | grep $CONTAINER | cut -c 1-80)" + if [ ! -z "$(docker ps | grep $NAME)" ]; then + echo " Container: $(docker ps | grep $NAME | cut -c 1-80)" echo " QPC API running: https://localhost:${PORT}/" printf " To stop: $ ${GREEN}docker stop ${NAME}${NOCOLOR}\n" fi - runDocs exit 0 fi } @@ -264,7 +263,6 @@ devApi() printf " To stop: $ ${GREEN}docker stop ${NAME}${NOCOLOR}\n" fi - runDocs exit 0 fi } @@ -272,7 +270,7 @@ devApi() # # Serve swagger spec # -runDocs() +runSpecs() { local GITREPO=$1 @@ -284,6 +282,14 @@ runDocs() } # # +# Stop QPC containers +# +stopApi() +{ + docker stop $(docker ps --filter name="qpc*") +} +# +# # main() # { @@ -340,10 +346,12 @@ runDocs() stageApi $PORT $UPDATE $BUILT $QPC_IMAGE_CONTAINER;; dev ) devApi $PORT "$FILE" $UPDATE $MOCK_IMAGE_CONTAINER;; - docs ) - runDocs true;; + specs ) + runSpecs true;; gitApi ) gitApi;; + stopApi ) + stopApi;; update ) devApi $PORT "$FILE" true stageApi $PORT true diff --git a/src/common/__tests__/__snapshots__/helpers.test.js.snap b/src/common/__tests__/__snapshots__/helpers.test.js.snap index c7f01dd7..01f409df 100644 --- a/src/common/__tests__/__snapshots__/helpers.test.js.snap +++ b/src/common/__tests__/__snapshots__/helpers.test.js.snap @@ -52,6 +52,18 @@ exports[`Helpers should handle http status less than 400 message from response: exports[`Helpers should handle http status less than 400 message from response: 400 message 1`] = `"Request success XXX"`; +exports[`Helpers should handle use aggregate error, or fallback: emulated aggregate error 1`] = ` +Object { + "aggregated": [AggregateError: testing aggregated], + "errors": Array [ + [Error: lorem ipsum], + [Error: dolor sit], + ], + "isEmulated": true, + "name": "AggregateError", +} +`; + exports[`Helpers should handle view related selectors and props updates: createViewQueryObject 1`] = ` Object { "page": 1, @@ -62,8 +74,10 @@ Object { exports[`Helpers should have specific functions: helpers 1`] = ` Object { "DEV_MODE": false, + "POLL_INTERVAL": 120000, "PROD_MODE": false, "TEST_MODE": true, + "TOAST_NOTIFICATIONS_TIMEOUT": 8000, "UI_BRAND": false, "UI_NAME": "Quipucords", "UI_SENTENCE_START_NAME": "The Quipucords tool", @@ -75,6 +89,7 @@ Object { "devModeNormalizeCount": [Function], "downloadData": [Function], "generateId": [Function], + "getCurrentDate": [Function], "getMessageFromResults": [Function], "getStatusFromResults": [Function], "getTimeDisplayHowLongAgo": [Function], @@ -93,6 +108,12 @@ Object { } `; +exports[`Helpers should return a predictable current date: current date 1`] = ` +Object { + "currentDate": 2022-06-01T00:00:00.000Z, +} +`; + exports[`Helpers should support displaying the ui version: ui version 1`] = `"0.0.0.0000000"`; exports[`Helpers should support icon references: scanStatusIcon 1`] = ` diff --git a/src/common/__tests__/helpers.test.js b/src/common/__tests__/helpers.test.js index b9080d51..9cbe77cf 100644 --- a/src/common/__tests__/helpers.test.js +++ b/src/common/__tests__/helpers.test.js @@ -5,6 +5,21 @@ describe('Helpers', () => { expect(helpers).toMatchSnapshot('helpers'); }); + it('should handle use aggregate error, or fallback', () => { + const aggregateError = window.AggregateError; + window.AggregateError = undefined; + const aggregated = helpers.aggregatedError( + [new Error('lorem ipsum'), new Error('dolor sit')], + 'testing aggregated' + ); + expect({ + aggregated, + ...aggregated + }).toMatchSnapshot('emulated aggregate error'); + + window.AggregateError = aggregateError; + }); + it('should support generated strings and flags', () => { expect(helpers.generateId()).toBe('generatedid-'); expect(helpers.generateId('lorem')).toBe('lorem-'); @@ -175,4 +190,9 @@ describe('Helpers', () => { expect(helpers.ipAddressValue('0.0.0.1.5')).toBe(1); expect(Number.isNaN(helpers.ipAddressValue('lorem'))).toBe(true); }); + + it('should return a predictable current date', () => { + const currentDate = helpers.getCurrentDate(); + expect({ currentDate }).toMatchSnapshot('current date'); + }); }); diff --git a/src/common/helpers.js b/src/common/helpers.js index 7ba541d3..633996c4 100644 --- a/src/common/helpers.js +++ b/src/common/helpers.js @@ -421,7 +421,7 @@ const getStatusFromResults = results => { /** * Return a callback for determining a timestamp. * - * @returns {Function} + * @type {Function} */ const getTimeStampFromResults = process.env.REACT_APP_ENV !== 'test' @@ -431,7 +431,7 @@ const getTimeStampFromResults = /** * Return a callback for determine time offset. * - * @returns {Function} + * @type {Function} */ const getTimeDisplayHowLongAgo = process.env.REACT_APP_ENV !== 'test' @@ -496,12 +496,12 @@ const TEST_MODE = process.env.REACT_APP_ENV === 'test'; const UI_BRAND = process.env.REACT_APP_UI_BRAND === 'true'; /** - * UI coded name. + * UI coded name, brand dependent. * See dotenv config files for updating. * * @type {string} */ -const UI_NAME = process.env.REACT_APP_UI_NAME; +const UI_NAME = UI_BRAND === true ? process.env.REACT_APP_UI_BRAND_NAME : process.env.REACT_APP_UI_NAME; /** * UI cased sentence start name. @@ -509,7 +509,8 @@ const UI_NAME = process.env.REACT_APP_UI_NAME; * * @type {string} */ -const UI_SENTENCE_START_NAME = process.env.REACT_APP_UI_SENTENCE_START_NAME; +const UI_SENTENCE_START_NAME = + UI_BRAND === true ? process.env.REACT_APP_UI_BRAND_SENTENCE_START_NAME : process.env.REACT_APP_UI_SENTENCE_START_NAME; /** * UI short name. @@ -517,7 +518,8 @@ const UI_SENTENCE_START_NAME = process.env.REACT_APP_UI_SENTENCE_START_NAME; * * @type {string} */ -const UI_SHORT_NAME = process.env.REACT_APP_UI_SHORT_NAME; +const UI_SHORT_NAME = + UI_BRAND === true ? process.env.REACT_APP_UI_BRAND_SHORT_NAME : process.env.REACT_APP_UI_SHORT_NAME; /** * UI packaged application version, with generated hash. @@ -527,6 +529,27 @@ const UI_SHORT_NAME = process.env.REACT_APP_UI_SHORT_NAME; */ const UI_VERSION = process.env.REACT_APP_UI_VERSION; +/** + * Timeout for toast alert notifications. + * + * @type {number|undefined} + */ +const TOAST_NOTIFICATIONS_TIMEOUT = Number.parseInt(process.env.REACT_APP_TOAST_NOTIFICATIONS_TIMEOUT, 10) || undefined; + +/** + * Global poll interval + * + * @type {number|undefined} + */ +const POLL_INTERVAL = Number.parseInt(process.env.REACT_APP_POLL_INTERVAL, 10) || undefined; + +/** + * Return a consistent current date. + * + * @returns {string|Date} + */ +const getCurrentDate = () => (TEST_MODE && moment.utc('20220601').toDate()) || moment.utc().toDate(); + const helpers = { aggregatedError, copyClipboard, @@ -550,13 +573,16 @@ const helpers = { isIpAddress, ipAddressValue, DEV_MODE, + POLL_INTERVAL, PROD_MODE, TEST_MODE, + TOAST_NOTIFICATIONS_TIMEOUT, UI_BRAND, UI_NAME, UI_SENTENCE_START_NAME, UI_SHORT_NAME, - UI_VERSION + UI_VERSION, + getCurrentDate }; export { helpers as default, helpers }; diff --git a/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.js.snap b/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.js.snap index 5e36f0e9..091e82c9 100644 --- a/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.js.snap +++ b/src/components/aboutModal/__tests__/__snapshots__/aboutModal.test.js.snap @@ -2,50 +2,58 @@ exports[`AboutModal Component should contain brand: brand 1`] = ` } - show={true} - trademarkText="Copyright (c) 2019 - 2022 Red Hat Inc." + trademark="t(about-modal.copyright, {\\"year\\":\\"2022\\"})" >
- - - + + + + t(about-modal.ui-version) + + + 0.0.0.0000000 + + +
@@ -55,7 +63,7 @@ exports[`AboutModal Component should contain brand: brand 1`] = ` exports[`AboutModal Component should have a copy event that updates state: post copy event 1`] = ` Object { "copied": null, - "timer": 14, + "timer": 13, } `; @@ -71,7 +79,6 @@ exports[`AboutModal Component should render a connected component with default p exports[`AboutModal Component should render a non-connected component: hidden modal 1`] = ` `; diff --git a/src/components/aboutModal/aboutModal.js b/src/components/aboutModal/aboutModal.js index a48e37cd..8e88cd47 100644 --- a/src/components/aboutModal/aboutModal.js +++ b/src/components/aboutModal/aboutModal.js @@ -1,14 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; +import moment from 'moment'; import { detect } from 'detect-browser'; -import { AboutModal as PfAboutModal, Button, Icon } from 'patternfly-react'; -import { connectTranslate, reduxActions, reduxTypes, store } from '../../redux'; -import helpers from '../../common/helpers'; +import { + AboutModal as PfAboutModal, + Button, + ButtonVariant, + TextContent, + TextList, + TextListItem +} from '@patternfly/react-core'; +import { CheckIcon, PasteIcon } from '@patternfly/react-icons'; +import { connect, reduxActions, reduxTypes, store } from '../../redux'; +import { helpers } from '../../common'; +import { translate } from '../i18n/i18n'; import logoImg from '../../styles/images/logo.svg'; import titleImg from '../../styles/images/title.svg'; import logoImgBrand from '../../styles/images/logo-brand.svg'; import titleImgBrand from '../../styles/images/title-brand.svg'; +/** + * About modal, display application information. + */ class AboutModal extends React.Component { selectElement = React.createRef(); @@ -66,6 +79,7 @@ class AboutModal extends React.Component { const { copied } = this.state; const { show, serverVersion, t, uiBrand, uiName, uiShortName, uiVersion, username } = this.props; const browser = detect(); + const currentYear = moment.utc(helpers.getCurrentDate()).format('YYYY'); const props = { show, @@ -78,47 +92,69 @@ class AboutModal extends React.Component { if (uiBrand) { props.logo = logoImgBrand; props.productTitle = {uiName}; - props.trademarkText = 'Copyright (c) 2019 - 2022 Red Hat Inc.'; + props.trademarkText = t('about-modal.copyright', { year: currentYear }); } return ( - -
- - {username && ( - - )} - {browser && ( - - )} - {browser && ( - - )} - {serverVersion && ( - - )} - {uiVersion && ( - - )} - + +
+ + + {username && ( + + {t('about-modal.username')} + {username || ''} + + )} + {browser && ( + + {t('about-modal.browser-version')} + {`${browser.name} ${browser.version}`} + + )} + {browser && ( + + {t('about-modal.browser-os')} + {browser.os || ''} + + )} + {serverVersion && ( + + {t('about-modal.server-version')} + {serverVersion} + + )} + {uiVersion && ( + + {t('about-modal.ui-version')} + {uiVersion} + + )} + +
@@ -126,9 +162,14 @@ class AboutModal extends React.Component { } } +/** + * Prop types. + * + * @type {{uiShortName: string, serverVersion: string, t: Function, resetTimer: number, show: boolean, + * uiVersion: string, getStatus: Function, uiName: string, uiBrand: boolean, username: string}} + */ AboutModal.propTypes = { getStatus: PropTypes.func, - getUser: PropTypes.func, resetTimer: PropTypes.number, serverVersion: PropTypes.string, show: PropTypes.bool.isRequired, @@ -140,12 +181,17 @@ AboutModal.propTypes = { username: PropTypes.string }; +/** + * Default props. + * + * @type {{uiShortName: string, serverVersion: null, t: translate, resetTimer: number, uiVersion: string, + * getStatus: Function, uiName: string, uiBrand: boolean, username: null}} + */ AboutModal.defaultProps = { getStatus: helpers.noop, - getUser: helpers.noop, resetTimer: 3000, serverVersion: null, - t: helpers.noopTranslate, + t: translate, uiBrand: helpers.UI_BRAND, uiName: helpers.UI_NAME, uiShortName: helpers.UI_SHORT_NAME, @@ -163,6 +209,6 @@ const mapStateToProps = state => ({ username: state.user.session.username }); -const ConnectedAboutModal = connectTranslate(mapStateToProps, mapDispatchToProps)(AboutModal); +const ConnectedAboutModal = connect(mapStateToProps, mapDispatchToProps)(AboutModal); export { ConnectedAboutModal as default, ConnectedAboutModal, AboutModal }; diff --git a/src/components/addCredentialType/__tests__/__snapshots__/addCredentialType.test.js.snap b/src/components/addCredentialType/__tests__/__snapshots__/addCredentialType.test.js.snap new file mode 100644 index 00000000..3155d747 --- /dev/null +++ b/src/components/addCredentialType/__tests__/__snapshots__/addCredentialType.test.js.snap @@ -0,0 +1,70 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ToolbarFieldGranularity Component should export select options: fieldOptions 1`] = ` +Array [ + Object { + "title": [Function], + "value": "network", + }, + Object { + "title": [Function], + "value": "satellite", + }, + Object { + "title": [Function], + "value": "vcenter", + }, +] +`; + +exports[`ToolbarFieldGranularity Component should handle opening the credential dialog through redux state with hook: dispatch, hook 1`] = ` +Array [ + Array [ + Array [ + Object { + "credentialType": "dolor sit", + "type": "CREATE_CREDENTIAL_SHOW", + }, + ], + ], +] +`; + +exports[`ToolbarFieldGranularity Component should render a basic component: basic 1`] = ` + +`; diff --git a/src/components/addCredentialType/__tests__/addCredentialType.test.js b/src/components/addCredentialType/__tests__/addCredentialType.test.js new file mode 100644 index 00000000..029f1a1c --- /dev/null +++ b/src/components/addCredentialType/__tests__/addCredentialType.test.js @@ -0,0 +1,35 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AddCredentialType, fieldOptions, useOnSelect } from '../addCredentialType'; +import { store } from '../../../redux/store'; + +describe('ToolbarFieldGranularity Component', () => { + let mockDispatch; + + beforeEach(() => { + mockDispatch = jest.spyOn(store, 'dispatch').mockImplementation((type, data) => ({ type, data })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render a basic component', () => { + const props = {}; + const component = shallow(); + + expect(component).toMatchSnapshot('basic'); + }); + + it('should export select options', () => { + expect(fieldOptions).toMatchSnapshot('fieldOptions'); + }); + + it('should handle opening the credential dialog through redux state with hook', () => { + const options = {}; + const onSelect = useOnSelect(options); + + onSelect({ value: 'dolor sit' }); + expect(mockDispatch.mock.calls).toMatchSnapshot('dispatch, hook'); + }); +}); diff --git a/src/components/addCredentialType/addCredentialType.js b/src/components/addCredentialType/addCredentialType.js new file mode 100644 index 00000000..8ef8f84e --- /dev/null +++ b/src/components/addCredentialType/addCredentialType.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { DropdownSelect, ButtonVariant, SelectPosition } from '../dropdownSelect/dropdownSelect'; +import { reduxTypes, storeHooks } from '../../redux'; +import { translate } from '../i18n/i18n'; + +/** + * Select field options. + * + * @type {{title: Function|string, value: string}[]} + */ +const fieldOptions = [ + { title: () => translate('form-dialog.label', { context: ['option', 'network'] }), value: 'network' }, + { title: () => translate('form-dialog.label', { context: ['option', 'satellite'] }), value: 'satellite' }, + { title: () => translate('form-dialog.label', { context: ['option', 'vcenter'] }), value: 'vcenter' } +]; + +/** + * On select create credential + * + * @param {object} options + * @param {Function} options.useDispatch + * @returns {Function} + */ +const useOnSelect = ({ useDispatch: useAliasDispatch = storeHooks.reactRedux.useDispatch } = {}) => { + const dispatch = useAliasDispatch(); + + return ({ value = null }) => { + dispatch([ + { + type: reduxTypes.credentials.CREATE_CREDENTIAL_SHOW, + credentialType: value + } + ]); + }; +}; + +/** + * Display available credential types. + * + * @param {object} props + * @param {Array} props.options + * @param {string} props.placeholder + * @param {Function} props.t + * @param {Function} props.useOnSelect + * @param {object} props.props + * @returns {React.ReactNode} + */ +const AddCredentialType = ({ options, placeholder, t, useOnSelect: useAliasOnSelect, ...props } = {}) => { + const onSelect = useAliasOnSelect(); + + return ( + + ); +}; + +/** + * Prop types + * + * @type {{useOnSelect: Function, options: Array, placeholder: string}} + */ +AddCredentialType.propTypes = { + options: PropTypes.arrayOf( + PropTypes.shape({ + title: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), + value: PropTypes.any, + selected: PropTypes.bool + }) + ), + placeholder: PropTypes.string, + t: PropTypes.func, + useOnSelect: PropTypes.func +}; + +/** + * Default props + * + * @type {{useOnSelect: Function, t: translate, options: {title: (Function|string), value: string}[], + * placeholder: null}} + */ +AddCredentialType.defaultProps = { + options: fieldOptions, + placeholder: null, + t: translate, + useOnSelect +}; + +export { AddCredentialType as default, AddCredentialType, fieldOptions, useOnSelect, ButtonVariant, SelectPosition }; diff --git a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizard.test.js.snap b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizard.test.js.snap index ab5fa92e..db1356ca 100644 --- a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizard.test.js.snap +++ b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizard.test.js.snap @@ -1,121 +1,131 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`AddSourceWizard Component should allow cancelling the wizard: cancel 1`] = ` +Array [ + Array [ + Object { + "source": Object { + "source_type": "network", + }, + "type": "VALID_SOURCE_WIZARD_STEPONE", + }, + ], + Array [ + Object { + "body": "t(form-dialog.confirmation_body_add-source, {\\"context\\":\\" \\"})", + "cancelButtonText": "t(form-dialog.label, {\\"context\\":\\"no\\"})", + "confirmButtonText": "t(form-dialog.label, {\\"context\\":\\"yes\\"})", + "heading": "t(form-dialog.confirmation_heading_add-source, {\\"context\\":\\"\\"})", + "onConfirm": [Function], + "title": "t(form-dialog.confirmation_title_add-source, {\\"context\\":\\"\\"})", + "type": "CONFIRMATION_MODAL_SHOW", + "variant": "medium", + }, + ], +] +`; + +exports[`AddSourceWizard Component should allow closing the wizard on fulfillment: fulfill 1`] = ` +Array [ + Array [ + Object { + "source": Object { + "source_type": "network", + }, + "type": "VALID_SOURCE_WIZARD_STEPONE", + }, + ], + Array [ + Array [ + Object { + "type": "CONFIRMATION_MODAL_HIDE", + }, + Object { + "type": "UPDATE_SOURCE_HIDE", + }, + ], + ], + Array [ + Object { + "type": "UPDATE_SOURCES", + }, + ], +] +`; + exports[`AddSourceWizard Component should display update steps: update 1`] = ` - - - , - , - ] - } - /> - - - - - - - - - - - - - - - - - + onNext={[Function]} + steps={ + Array [ + Object { + "canJumpTo": false, + "component": , + "enableNext": false, + "id": 1, + "name": "t(form-dialog.title_add-source_step, {\\"context\\":\\"two\\"})", + "nextButtonText": "t(form-dialog.label_submit, {\\"context\\":\\"add-source\\"})", + }, + Object { + "canJumpTo": false, + "component": , + "enableNext": false, + "hideBackButton": false, + "hideCancelButton": false, + "id": 2, + "name": "t(form-dialog.title_add-source_step, {\\"context\\":\\"three\\"})", + "nextButtonText": "t(form-dialog.label, {\\"context\\":\\"close\\"})", + }, + ] + } + title="t(form-dialog.title_add-source, {\\"context\\":\\"edit\\"})" + variant="medium" +/> `; -exports[`AddSourceWizard Component should not display a wizard 1`] = `null`; - -exports[`AddSourceWizard Component should render a connected component: connected 1`] = ``; +exports[`AddSourceWizard Component should render a basic component: basic 1`] = ` +, + "enableNext": false, + "id": 1, + "name": "t(form-dialog.title_add-source, {\\"context\\":\\"step\\"})", + }, + Object { + "canJumpTo": false, + "component": , + "enableNext": false, + "id": 2, + "name": "t(form-dialog.title_add-source_step, {\\"context\\":\\"two\\"})", + "nextButtonText": "t(form-dialog.label_submit, {\\"context\\":\\"add-source\\"})", + }, + Object { + "canJumpTo": false, + "component": , + "enableNext": false, + "hideBackButton": false, + "hideCancelButton": false, + "id": 3, + "name": "t(form-dialog.title_add-source_step, {\\"context\\":\\"three\\"})", + "nextButtonText": "t(form-dialog.label, {\\"context\\":\\"close\\"})", + }, + ] + } + title="t(form-dialog.title, {\\"context\\":\\"add-source\\"})" + variant="medium" +/> +`; diff --git a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardContext.test.js.snap b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardContext.test.js.snap new file mode 100644 index 00000000..82bc6879 --- /dev/null +++ b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardContext.test.js.snap @@ -0,0 +1,74 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddSourceWizardContext should apply a hook for retrieving data from a selector: dispatch confirmations on hide wizard 1`] = ` +Array [ + Array [ + Object { + "alertType": "danger", + "header": "t(toast-notifications.title_add-source_hidden, {\\"context\\":\\"error\\"})", + "message": "t(toast-notifications.description_add-source_hidden, {\\"context\\":\\"error\\"})", + "type": "TOAST_ADD", + }, + ], + Array [ + Object { + "alertType": "success", + "header": "t(toast-notifications.title_add-source_hidden, {\\"context\\":\\"\\"})", + "message": "t(toast-notifications.description_add-source_hidden, {\\"context\\":\\"\\",\\"name\\":\\"lorem ipsum\\"})", + "type": "TOAST_ADD", + }, + ], +] +`; + +exports[`AddSourceWizardContext should apply a hook for retrieving data from a selector: error response 1`] = ` +Object { + "data": Object { + "messages": Object {}, + }, + "error": true, + "show": false, +} +`; + +exports[`AddSourceWizardContext should apply a hook for retrieving data from a selector: error show response 1`] = ` +Object { + "data": Object { + "messages": Object {}, + }, + "error": true, +} +`; + +exports[`AddSourceWizardContext should apply a hook for retrieving data from a selector: success response 1`] = ` +Object { + "fulfilled": true, +} +`; + +exports[`AddSourceWizardContext should apply a hook for retrieving data from a selector: success show response 1`] = ` +Object { + "fulfilled": true, + "show": false, + "source": Object { + "name": "lorem ipsum", + }, +} +`; + +exports[`AddSourceWizardContext should handle an onShowAddSourceWizard event: onColumnSort event, dispatch 1`] = ` +Array [ + Array [ + Object { + "type": "CREATE_SOURCE_SHOW", + }, + ], +] +`; + +exports[`AddSourceWizardContext should return specific properties: specific properties 1`] = ` +Object { + "useGetAddSource": [Function], + "useOnShowAddSourceWizard": [Function], +} +`; diff --git a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepOne.test.js.snap b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepOne.test.js.snap index cb48a2b6..d8bc8386 100644 --- a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepOne.test.js.snap +++ b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepOne.test.js.snap @@ -6,56 +6,65 @@ exports[`AddSourceWizardStepOne Component should render a non-connected componen
-

- Select source type -

-
- -
-
- -
+ Select source type +
- +
+ +
+
+ +
+
+ +
+
diff --git a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepThree.test.js.snap b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepThree.test.js.snap index 61619fd8..e2e106a2 100644 --- a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepThree.test.js.snap +++ b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepThree.test.js.snap @@ -10,41 +10,71 @@ exports[`AccountWizardStepResults Component should render a wizard results step fulfilled={false} name={null} pending={false} + t={[Function]} > -
- - - - + className="pf-c-empty-state__icon" + color="#c9190b" + noVerticalAlign={false} + size="sm" + > + + + + + <h3 + className="pf-c-title pf-m-lg" + data-ouia-component-id="OUIA-Generated-Title-2" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + > + t(form-dialog.empty-state_title_add-source, {"context":"error","name":null}) + </h3> + + +
+ t(form-dialog.empty-state_description_add-source_error, {"context":" ","name":null}) +
+
+
-

- Error - Creating - Source -

-

- There are errors on a previous step. Use the Back button to review your settings and try again. -

- + `; @@ -56,41 +86,71 @@ exports[`AccountWizardStepResults Component should render a wizard results step fulfilled={false} name={null} pending={false} + t={[Function]} > -
- - - - + className="pf-c-empty-state__icon" + color="#c9190b" + noVerticalAlign={false} + size="sm" + > + + + + + <h3 + className="pf-c-title pf-m-lg" + data-ouia-component-id="OUIA-Generated-Title-1" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + > + t(form-dialog.empty-state_title_add-source_error, {"context":"edit","name":null}) + </h3> + + +
+ t(form-dialog.empty-state_description_add-source_error, {"context":" ","name":null}) +
+
+
-

- Error - Updating - Source -

-

- There are errors on a previous step. Use the Back button to review your settings and try again. -

- + `; @@ -101,39 +161,71 @@ exports[`AccountWizardStepResults Component should render a wizard results step fulfilled={true} name="Dolor" pending={false} + t={[Function]} > -
- - - - + className="pf-c-empty-state__icon" + color="#3e8635" + noVerticalAlign={false} + size="sm" + > + + + + + <h3 + className="pf-c-title pf-m-lg" + data-ouia-component-id="OUIA-Generated-Title-6" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + > + t(form-dialog.empty-state_title_add-source, {"context":"","name":"Dolor"}) + </h3> + + +
+ t(form-dialog.empty-state_description_add-source, {"context":" ","name":"Dolor"}) +
+
+
-

- - Dolor - - was - created - . -

- + `; @@ -144,39 +236,71 @@ exports[`AccountWizardStepResults Component should render a wizard results step fulfilled={true} name="Dolor" pending={false} + t={[Function]} > -
- - - - + className="pf-c-empty-state__icon" + color="#3e8635" + noVerticalAlign={false} + size="sm" + > + + + + + <h3 + className="pf-c-title pf-m-lg" + data-ouia-component-id="OUIA-Generated-Title-5" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + > + t(form-dialog.empty-state_title_add-source, {"context":"edit","name":"Dolor"}) + </h3> + + +
+ t(form-dialog.empty-state_description_add-source, {"context":" ","name":"Dolor"}) +
+
+
-

- - Dolor - - was - updated - . -

- + `; @@ -187,39 +311,65 @@ exports[`AccountWizardStepResults Component should render a wizard results step fulfilled={false} name="Dolor" pending={true} + t={[Function]} > -
-
- -

- Creating - Source... -

-

- Please wait while source - - Dolor - - is being - created - . -

-
+ className="pf-c-empty-state__content" + > + + + + + <h3 + className="pf-c-title pf-m-lg" + data-ouia-component-id="OUIA-Generated-Title-4" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + > + t(form-dialog.empty-state_title_add-source, {"context":"pending","name":"Dolor"}) + </h3> + + +
+ t(form-dialog.empty-state_description_add-source_pending, {"context":" ","name":"Dolor"}) +
+
+
+ + `; @@ -230,38 +380,64 @@ exports[`AccountWizardStepResults Component should render a wizard results step fulfilled={false} name="Dolor" pending={true} + t={[Function]} > -
-
- -

- Updating - Source... -

-

- Please wait while source - - Dolor - - is being - updated - . -

-
+ className="pf-c-empty-state__content" + > + + + + + <h3 + className="pf-c-title pf-m-lg" + data-ouia-component-id="OUIA-Generated-Title-3" + data-ouia-component-type="PF4/Title" + data-ouia-safe={true} + > + t(form-dialog.empty-state_title_add-source_pending, {"context":"edit","name":"Dolor"}) + </h3> + + +
+ t(form-dialog.empty-state_description_add-source_pending, {"context":"edit","name":"Dolor"}) +
+
+
+ + `; diff --git a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepTwo.test.js.snap b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepTwo.test.js.snap index 0c74553a..57df83e2 100644 --- a/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepTwo.test.js.snap +++ b/src/components/addSourceWizard/__tests__/__snapshots__/addSourceWizardStepTwo.test.js.snap @@ -109,49 +109,82 @@ exports[`AddSourceWizardStepTwo Component should display different forms for sou class="input-group" > @@ -251,49 +284,83 @@ exports[`AddSourceWizardStepTwo Component should display different forms for sou class="input-group" > @@ -312,91 +379,51 @@ exports[`AddSourceWizardStepTwo Component should display different forms for sou class="col-sm-9" > + - Disable SSL - - - + + + + @@ -425,6 +452,31 @@ exports[`AddSourceWizardStepTwo Component should display different forms for sou `; +exports[`AddSourceWizardStepTwo Component should export select options: options 1`] = ` +Array [ + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"SSLv23\\"})", + "value": "SSLv23", + }, + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"TLSv1\\"})", + "value": "TLSv1", + }, + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"TLSv1_1\\"})", + "value": "TLSv1_1", + }, + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"TLSv1_2\\"})", + "value": "TLSv1_2", + }, + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"disableSsl\\"})", + "value": "disableSsl", + }, +] +`; + exports[`AddSourceWizardStepTwo Component should render a non-connected component: non-connected 1`] = `
diff --git a/src/components/addSourceWizard/__tests__/addSourceWizard.test.js b/src/components/addSourceWizard/__tests__/addSourceWizard.test.js index 9ccbbfaa..dabd1906 100644 --- a/src/components/addSourceWizard/__tests__/addSourceWizard.test.js +++ b/src/components/addSourceWizard/__tests__/addSourceWizard.test.js @@ -1,54 +1,95 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; -import { mount, shallow } from 'enzyme'; -import { ConnectedAddSourceWizard, AddSourceWizard } from '../addSourceWizard'; +import { store } from '../../../redux'; +import { AddSourceWizard } from '../addSourceWizard'; +import { Wizard } from '../../wizard/wizard'; describe('AddSourceWizard Component', () => { + let mockDispatch; + + beforeEach(() => { + mockDispatch = jest.spyOn(store, 'dispatch').mockImplementation((type, data) => ({ type, data })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + const generateEmptyStore = (obj = {}) => configureMockStore()(obj); - it('should render a connected component', () => { - const store = generateEmptyStore({ addSourceWizard: { show: true } }); - const component = shallow( - - - - ); + it('should render a basic component', async () => { + const props = { + useGetAddSource: () => ({ + show: true + }) + }; - expect(component.find(ConnectedAddSourceWizard)).toMatchSnapshot('connected'); + const component = await shallowHookComponent(); + expect(component).toMatchSnapshot('basic'); }); - it('should display update steps', () => { + it('should display update steps', async () => { const props = { - show: true, - edit: true + useGetAddSource: () => ({ + show: true, + edit: true + }) }; - const component = shallow(); + const component = await shallowHookComponent(); expect(component).toMatchSnapshot('update'); }); - it('should not display a wizard', () => { + it('should not display a wizard', async () => { const props = { - show: false + useGetAddSource: () => ({ + show: false + }) }; - const component = mount(); - expect(component.render()).toMatchSnapshot(); + const component = await mountHookComponent(); + expect(component.find(Wizard).props()?.isOpen).toBe(false); }); - it('should have specific events defined', () => { + it('should allow cancelling the wizard', async () => { + const mockStore = generateEmptyStore({ + addSourceWizard: {} + }); const props = { - show: false + useGetAddSource: () => ({ + show: true + }) }; - const component = mount(); - const componentInstance = component.instance(); + const component = await mountHookComponent( + + + + ); + + component.find('button.pf-c-button.pf-m-link').first().simulate('click'); + expect(mockDispatch.mock.calls).toMatchSnapshot('cancel'); + }); + + it('should allow closing the wizard on fulfillment', async () => { + const mockStore = generateEmptyStore({ + addSourceWizard: {} + }); + const props = { + useGetAddSource: () => ({ + show: true, + fulfilled: true + }) + }; + + const component = await mountHookComponent( + + + + ); - expect(componentInstance.onCancel).toBeDefined(); - expect(componentInstance.onNext).toBeDefined(); - expect(componentInstance.onBack).toBeDefined(); - expect(componentInstance.onSubmit).toBeDefined(); - expect(componentInstance.onStep).toBeDefined(); + component.find('button.pf-c-button.pf-m-link').first().simulate('click'); + expect(mockDispatch.mock.calls).toMatchSnapshot('fulfill'); }); }); diff --git a/src/components/addSourceWizard/__tests__/addSourceWizardContext.test.js b/src/components/addSourceWizard/__tests__/addSourceWizardContext.test.js new file mode 100644 index 00000000..81cb2a4f --- /dev/null +++ b/src/components/addSourceWizard/__tests__/addSourceWizardContext.test.js @@ -0,0 +1,78 @@ +import { context, useGetAddSource, useOnShowAddSourceWizard } from '../addSourceWizardContext'; +import apiTypes from '../../../constants/apiConstants'; +import { store } from '../../../redux'; + +describe('AddSourceWizardContext', () => { + it('should return specific properties', () => { + expect(context).toMatchSnapshot('specific properties'); + }); + + it('should apply a hook for retrieving data from a selector', async () => { + const mockDispatch = jest.spyOn(store, 'dispatch').mockImplementation((type, data) => ({ type, data })); + + const { result: errorResponse } = await mountHook(() => + useGetAddSource({ + useSelector: () => ({ + error: true, + show: false, + data: { + messages: {} + } + }) + }) + ); + + expect(errorResponse).toMatchSnapshot('error response'); + + const { result: errorShowResponse } = await mountHook(() => + useGetAddSource({ + useSelector: () => ({ + error: true, + data: { + messages: {} + } + }) + }) + ); + + expect(errorShowResponse).toMatchSnapshot('error show response'); + + const { result: successResponse } = await mountHook(() => + useGetAddSource({ + useSelector: () => ({ + fulfilled: true + }) + }) + ); + + expect(successResponse).toMatchSnapshot('success response'); + + const { result: successShowResponse } = await mountHook(() => + useGetAddSource({ + useSelector: () => ({ + fulfilled: true, + show: false, + source: { + [apiTypes.API_SUBMIT_SOURCE_NAME]: 'lorem ipsum' + } + }) + }) + ); + + expect(successShowResponse).toMatchSnapshot('success show response'); + expect(mockDispatch.mock.calls).toMatchSnapshot('dispatch confirmations on hide wizard'); + mockDispatch.mockClear(); + }); + + it('should handle an onShowAddSourceWizard event', () => { + const mockDispatch = jest.fn(); + const onShowAddSourceWizard = useOnShowAddSourceWizard({ + useDispatch: () => mockDispatch + }); + + onShowAddSourceWizard(); + + expect(mockDispatch.mock.calls).toMatchSnapshot('onColumnSort event, dispatch'); + mockDispatch.mockClear(); + }); +}); diff --git a/src/components/addSourceWizard/__tests__/addSourceWizardStepTwo.test.js b/src/components/addSourceWizard/__tests__/addSourceWizardStepTwo.test.js index 905d03fe..9e8722bf 100644 --- a/src/components/addSourceWizard/__tests__/addSourceWizardStepTwo.test.js +++ b/src/components/addSourceWizard/__tests__/addSourceWizardStepTwo.test.js @@ -1,6 +1,6 @@ import React from 'react'; import { mount } from 'enzyme'; -import { AddSourceWizardStepTwo } from '../addSourceWizardStepTwo'; +import { AddSourceWizardStepTwo, sslProtocolOptions } from '../addSourceWizardStepTwo'; describe('AddSourceWizardStepTwo Component', () => { it('should render a non-connected component', () => { @@ -13,6 +13,15 @@ describe('AddSourceWizardStepTwo Component', () => { expect(component.render()).toMatchSnapshot('non-connected'); }); + it('should export select options', () => { + expect( + sslProtocolOptions.map(({ title, ...option }) => ({ + ...option, + title: (typeof title === 'function' && title()) || title + })) + ).toMatchSnapshot('options'); + }); + it('should display different forms for source types', () => { const props = { type: 'network' diff --git a/src/components/addSourceWizard/addSourceWizard.js b/src/components/addSourceWizard/addSourceWizard.js index a20d0bed..d3c00bc3 100644 --- a/src/components/addSourceWizard/addSourceWizard.js +++ b/src/components/addSourceWizard/addSourceWizard.js @@ -1,246 +1,165 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Button, Icon, Wizard } from 'patternfly-react'; -import { connect, reduxActions, reduxTypes, store } from '../../redux'; +import { ModalVariant } from '@patternfly/react-core'; +import { reduxActions, reduxTypes, storeHooks } from '../../redux'; import { addSourceWizardSteps, editSourceWizardSteps } from './addSourceWizardConstants'; -import helpers from '../../common/helpers'; +import { Wizard } from '../wizard/wizard'; +import { useGetAddSource } from './addSourceWizardContext'; import apiTypes from '../../constants/apiConstants'; - -class AddSourceWizard extends React.Component { - state = { - activeStepIndex: 0 +import { EMPTY_CONTEXT, translate } from '../i18n/i18n'; + +/** + * Add a source with a Wizard. + * + * @fires onCancel + * @fires onSubmit + * @param {object} props + * @param {Function} props.addSource + * @param {Array} props.addSteps + * @param {Array} props.editSteps + * @param {Function} props.t + * @param {Function} props.updateSource + * @param {Function} props.useDispatch + * @param {Function} props.useGetAddSource + * @returns {React.ReactNode} + */ +const AddSourceWizard = ({ + addSource, + addSteps, + editSteps, + t, + updateSource, + useDispatch: useAliasDispatch, + useGetAddSource: useAliasGetAddSource +}) => { + const dispatch = useAliasDispatch(); + const { edit, errorStatus, fulfilled, pending, source, stepOneValid, stepTwoValid, show } = useAliasGetAddSource(); + + /** + * If value is a function, run it and pass state for step validation. + * + * @param {*} value + * @returns {*} + */ + const isFunctionRun = value => { + if (typeof value === 'function') { + return value({ fulfilled, pending, stepOneValid, stepTwoValid }); + } + return value; }; - onCancel = () => { - const { edit, errorStatus, fulfilled, pending } = this.props; - + const updatedSteps = ((edit && editSteps) || addSteps).map(step => { + const updatedStep = {}; + Object.entries(step).forEach(([key, value]) => { + updatedStep[key] = isFunctionRun(value); + }); + return updatedStep; + }); + + /** + * On close, or cancel the wizard process dispatch notifications, close the wizard. + * + * @event onCancel + */ + const onCancel = () => { const closeWizard = () => { - this.setState({ activeStepIndex: 0 }, () => { - store.dispatch({ + dispatch([ + { type: reduxTypes.confirmationModal.CONFIRMATION_MODAL_HIDE - }); - - store.dispatch({ + }, + { type: reduxTypes.sources.UPDATE_SOURCE_HIDE - }); - - if (fulfilled) { - store.dispatch({ - type: reduxTypes.sources.UPDATE_SOURCES - }); } - }); + ]); + + if (fulfilled) { + dispatch({ + type: reduxTypes.sources.UPDATE_SOURCES + }); + } }; if (fulfilled || errorStatus >= 500 || errorStatus === 0) { closeWizard(); - } else if (pending) { - store.dispatch({ - type: reduxTypes.confirmationModal.CONFIRMATION_MODAL_SHOW, - title: `Exit Wizard`, - heading: 'Are you sure you want to exit this wizard?', - body: `The wizard is in a pending state and will continue ${edit ? 'updating' : 'adding'} this source.`, - cancelButtonText: 'No', - confirmButtonText: 'Yes', - onConfirm: closeWizard - }); - } else { - store.dispatch({ - type: reduxTypes.confirmationModal.CONFIRMATION_MODAL_SHOW, - title: 'Cancel Add Source', - heading: `Are you sure you want to cancel ${edit ? 'updating' : 'adding'} this source?`, - cancelButtonText: 'No', - confirmButtonText: 'Yes', - onConfirm: closeWizard - }); + return; } - }; - - onNext = () => { - const { activeStepIndex } = this.state; - const { addSteps, edit, editSteps } = this.props; - const wizardStepsLength = edit ? editSteps.length : addSteps.length; - if (activeStepIndex < wizardStepsLength - 1) { - this.setState({ activeStepIndex: activeStepIndex + 1 }); - } + dispatch({ + type: reduxTypes.confirmationModal.CONFIRMATION_MODAL_SHOW, + title: t('form-dialog.confirmation_title_add-source', { context: [pending && 'exit', edit && 'edit'] }), + heading: t('form-dialog.confirmation_heading_add-source', { context: [pending && 'exit', edit && 'edit'] }), + body: t('form-dialog.confirmation_body_add-source', { + context: [pending && 'exit', edit && 'edit', !pending && EMPTY_CONTEXT] + }), + cancelButtonText: t('form-dialog.label', { context: 'no' }), + confirmButtonText: t('form-dialog.label', { context: 'yes' }), + onConfirm: closeWizard, + variant: ModalVariant.medium + }); }; - onBack = () => { - const { activeStepIndex } = this.state; - - if (activeStepIndex >= 1) { - this.setState({ activeStepIndex: activeStepIndex - 1 }); - } - }; - - onSubmit = () => { - const { addSource, edit, source, stepOneValid, stepTwoValid, updateSource } = this.props; - const { activeStepIndex } = this.state; - + /** + * On save, submit, the wizard form. + * + * @event onSubmit + */ + const onSubmit = () => { if (stepOneValid && stepTwoValid) { - this.setState({ activeStepIndex: activeStepIndex + 1 }, () => { - let addUpdateSourcePromise; - - if (edit) { - addUpdateSourcePromise = updateSource(source[apiTypes.API_SUBMIT_SOURCE_ID], source); - } else { - addUpdateSourcePromise = addSource(source, { scan: true }); - } - - addUpdateSourcePromise.then( - () => { - const { props } = this; - - if (!props.show) { - store.dispatch({ - type: reduxTypes.toastNotifications.TOAST_ADD, - alertType: 'success', - message: `Source ${source[apiTypes.API_SUBMIT_SOURCE_NAME]} was ${ - (props.edit && 'updated') || 'created' - }` - }); - } - }, - () => { - const { props } = this; - - if (!props.show) { - store.dispatch({ - type: reduxTypes.toastNotifications.TOAST_ADD, - alertType: 'error', - header: `Error ${(props.edit && 'updating') || 'creating'} source`, - message: props.errorMessage - }); - } - } - ); - }); + if (edit) { + updateSource(source[apiTypes.API_SUBMIT_SOURCE_ID], source)(dispatch); + } else { + addSource(source, { scan: true })(dispatch); + } } }; - onStep = () => { - // ToDo: wizard step map/breadcrumb/trail click, or leave disabled - }; - - renderWizardSteps() { - const { activeStepIndex } = this.state; - const { addSteps, edit, editSteps } = this.props; - const wizardSteps = edit ? editSteps : addSteps; - const activeStep = wizardSteps[activeStepIndex]; - - return wizardSteps.map((step, stepIndex) => ( - - )); - } - - render() { - const { addSteps, edit, editSteps, error, errorStatus, fulfilled, pending, show, stepOneValid, stepTwoValid } = - this.props; - const { activeStepIndex } = this.state; - const wizardSteps = edit ? editSteps : addSteps; - - if (!show) { - return null; - } - - return ( - - - - - - - {wizardSteps.map((step, stepIndex) => ( - - {wizardSteps[stepIndex].page} - - ))} - - - - - - - {activeStepIndex < wizardSteps.length - 2 && ( - - )} - {activeStepIndex === wizardSteps.length - 2 && ( - - )} - {activeStepIndex === wizardSteps.length - 1 && ( - - )} - - - ); - } -} + return ( + + ); +}; +/** + * Prop types + * + * @type {{editSteps: Array, edit: boolean, pending: boolean, stepOneValid: boolean, show: boolean, + * errorMessage: string, fulfilled: boolean, stepTwoValid: boolean, addSteps: Array, source: object, + * addSource: Function, updateSource: Function, t: Function, useDispatch: Function, + * errorStatus: number}} + */ AddSourceWizard.propTypes = { addSource: PropTypes.func, addSteps: PropTypes.array, - updateSource: PropTypes.func, - show: PropTypes.bool.isRequired, - edit: PropTypes.bool, editSteps: PropTypes.array, - error: PropTypes.bool, - errorMessage: PropTypes.string, // eslint-disable-line - errorStatus: PropTypes.number, - fulfilled: PropTypes.bool, - pending: PropTypes.bool, - source: PropTypes.object, - stepOneValid: PropTypes.bool, - stepTwoValid: PropTypes.bool + t: PropTypes.func, + updateSource: PropTypes.func, + useDispatch: PropTypes.func, + useGetAddSource: PropTypes.func }; +/** + * Default props + * + * @type {{editSteps: *[], edit: boolean, pending: boolean, stepOneValid: boolean, errorMessage: null, + * fulfilled: boolean, stepTwoValid: boolean, addSteps: *[], source: {}, addSource: Function, + * updateSource: Function, t: translate, useDispatch: Function, errorStatus: null}} + */ AddSourceWizard.defaultProps = { - addSource: helpers.noop, + addSource: reduxActions.sources.addSource, addSteps: addSourceWizardSteps, - updateSource: helpers.noop, - edit: false, editSteps: editSourceWizardSteps, - error: false, - errorMessage: null, - errorStatus: null, - fulfilled: false, - pending: false, - source: {}, - stepOneValid: false, - stepTwoValid: false + t: translate, + updateSource: reduxActions.sources.updateSource, + useDispatch: storeHooks.reactRedux.useDispatch, + useGetAddSource }; -const mapDispatchToProps = dispatch => ({ - addSource: (data, query) => dispatch(reduxActions.sources.addSource(data, query)), - updateSource: (id, data) => dispatch(reduxActions.sources.updateSource(id, data)) -}); - -const mapStateToProps = state => ({ ...state.addSourceWizard }); - -const ConnectedAddSourceWizard = connect(mapStateToProps, mapDispatchToProps)(AddSourceWizard); - -export { ConnectedAddSourceWizard as default, ConnectedAddSourceWizard, AddSourceWizard }; +export { AddSourceWizard as default, AddSourceWizard }; diff --git a/src/components/addSourceWizard/addSourceWizardConstants.js b/src/components/addSourceWizard/addSourceWizardConstants.js index 70c9e882..99a8ab2f 100644 --- a/src/components/addSourceWizard/addSourceWizardConstants.js +++ b/src/components/addSourceWizard/addSourceWizardConstants.js @@ -2,45 +2,69 @@ import React from 'react'; import AddSourceWizardStepOne from './addSourceWizardStepOne'; import AddSourceWizardStepTwo from './addSourceWizardStepTwo'; import AddSourceWizardStepThree from './addSourceWizardStepThree'; +import { translate } from '../i18n/i18n'; +/** + * Add a source, wizard steps. Applies state for wizard behavior. + * + * @type {{component: React.ReactNode, enableNext: Function|*, name: Function|*, canJumpTo: Function|boolean, + * nextButtonText: Function|*, id: number|string, hideBackButton: Function|boolean, + * hideCancelButton: Function|boolean}[]} + */ const addSourceWizardSteps = [ { - step: 1, - label: '1', - title: 'Type', - page: , - subSteps: [] + id: 1, + component: , + canJumpTo: ({ fulfilled, pending }) => fulfilled === false && pending === false, + enableNext: ({ stepOneValid }) => stepOneValid === true, + name: () => translate('form-dialog.title', { context: ['add-source', 'step'] }) }, { - step: 2, - label: '2', - title: 'Credentials', - page: , - subSteps: [] + id: 2, + component: , + canJumpTo: ({ fulfilled, pending, stepOneValid }) => + fulfilled === false && pending === false && stepOneValid === true, + enableNext: ({ stepOneValid, stepTwoValid }) => (stepOneValid && stepTwoValid) || false, + name: () => translate('form-dialog.title', { context: ['add-source', 'step', 'two'] }), + nextButtonText: () => translate('form-dialog.label', { context: ['submit', 'add-source'] }) }, { - step: 3, - label: '3', - title: 'Results', - page: , - subSteps: [] + id: 3, + component: , + canJumpTo: ({ stepOneValid, stepTwoValid }) => (stepOneValid && stepTwoValid) || false, + enableNext: ({ fulfilled, pending }) => fulfilled === true || pending === true, + hideBackButton: ({ fulfilled, pending }) => fulfilled === true || pending === true, + hideCancelButton: ({ fulfilled, pending }) => fulfilled === true || pending === true, + name: () => translate('form-dialog.title', { context: ['add-source', 'step', 'three'] }), + nextButtonText: () => translate('form-dialog.label', { context: ['close'] }) } ]; +/** + * Edit a source, wizard steps. Applies state for wizard behavior. + * + * @type {{component: React.ReactNode, enableNext: Function|*, name: Function|*, canJumpTo: Function|boolean, + * nextButtonText: Function|*, id: number|string, hideBackButton: Function|boolean, + * hideCancelButton: Function|boolean}[]} + */ const editSourceWizardSteps = [ { - step: 2, - label: '1', - title: 'Credentials', - page: , - subSteps: [] + id: 1, + component: , + canJumpTo: ({ fulfilled, pending }) => fulfilled === false && pending === false, + enableNext: ({ stepTwoValid }) => stepTwoValid || false, + name: () => translate('form-dialog.title', { context: ['add-source', 'step', 'two'] }), + nextButtonText: () => translate('form-dialog.label', { context: ['submit', 'add-source'] }) }, { - step: 3, - label: '2', - title: 'Results', - page: , - subSteps: [] + id: 2, + component: , + canJumpTo: ({ stepTwoValid }) => stepTwoValid || false, + enableNext: ({ fulfilled, pending }) => fulfilled === true || pending === true, + hideBackButton: ({ fulfilled, pending }) => fulfilled === true || pending === true, + hideCancelButton: ({ fulfilled, pending }) => fulfilled === true || pending === true, + name: () => translate('form-dialog.title', { context: ['add-source', 'step', 'three'] }), + nextButtonText: () => translate('form-dialog.label', { context: ['close'] }) } ]; diff --git a/src/components/addSourceWizard/addSourceWizardContext.js b/src/components/addSourceWizard/addSourceWizardContext.js new file mode 100644 index 00000000..fbd7c7a2 --- /dev/null +++ b/src/components/addSourceWizard/addSourceWizardContext.js @@ -0,0 +1,67 @@ +import { useShallowCompareEffect } from 'react-use'; +import { AlertVariant } from '@patternfly/react-core'; +import { reduxTypes, storeHooks } from '../../redux'; +import apiTypes from '../../constants/apiConstants'; +import { translate } from '../i18n/i18n'; + +/** + * Return an updated source. Display relative toast messaging after wizard closes. + * + * @param {object} options + * @param {Function} options.t + * @param {Function} options.useDispatch + * @param {Function} options.useSelector + * @returns {{}} + */ +const useGetAddSource = ({ + t = translate, + useDispatch: useAliasDispatch = storeHooks.reactRedux.useDispatch, + useSelector: useAliasSelector = storeHooks.reactRedux.useSelector +} = {}) => { + const selectorResponse = useAliasSelector(({ addSourceWizard }) => addSourceWizard, {}); + const dispatch = useAliasDispatch(); + const { edit, error, errorMessage, fulfilled, show, source } = selectorResponse; + + useShallowCompareEffect(() => { + if (show === false && (fulfilled || error)) { + dispatch({ + type: reduxTypes.toastNotifications.TOAST_ADD, + alertType: (error && AlertVariant.danger) || AlertVariant.success, + header: t('toast-notifications.title_add-source_hidden', { + context: [error && 'error', edit && 'edit'] + }), + message: t('toast-notifications.description_add-source_hidden', { + context: [error && 'error', edit && 'edit'], + message: errorMessage, + name: source?.[apiTypes.API_SUBMIT_SOURCE_NAME] + }) + }); + } + }, [dispatch, edit, error, fulfilled, show, source, t]); + + return selectorResponse; +}; + +/** + * An onShowAddSourceWizard callback for adding a source. + * + * @param {object} options + * @param {Function} options.useDispatch + * @returns {Function} + */ +const useOnShowAddSourceWizard = ({ useDispatch: useAliasDispatch = storeHooks.reactRedux.useDispatch } = {}) => { + const dispatch = useAliasDispatch(); + + return () => { + dispatch({ + type: reduxTypes.sources.CREATE_SOURCE_SHOW + }); + }; +}; + +const context = { + useGetAddSource, + useOnShowAddSourceWizard +}; + +export { context as default, context, useGetAddSource, useOnShowAddSourceWizard }; diff --git a/src/components/addSourceWizard/addSourceWizardStepOne.js b/src/components/addSourceWizard/addSourceWizardStepOne.js index bbfb02f9..41a7b9a2 100644 --- a/src/components/addSourceWizard/addSourceWizardStepOne.js +++ b/src/components/addSourceWizard/addSourceWizardStepOne.js @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Form, Radio } from 'patternfly-react'; import { connect, store, reduxSelectors, reduxTypes } from '../../redux'; +import { FormField } from '../formField/formField'; import { FormState } from '../formState/formState'; import apiTypes from '../../constants/apiConstants'; @@ -22,33 +23,34 @@ class AddSourceWizardStepOne extends React.Component { {({ values, handleOnEvent, handleOnSubmit }) => ( -

Select source type

- - - Network Range - - - Satellite - - - vCenter Server - - + + + + Network Range + + + Satellite + + + vCenter Server + + + )}
diff --git a/src/components/addSourceWizard/addSourceWizardStepThree.js b/src/components/addSourceWizard/addSourceWizardStepThree.js index a1b2154f..057096d4 100644 --- a/src/components/addSourceWizard/addSourceWizardStepThree.js +++ b/src/components/addSourceWizard/addSourceWizardStepThree.js @@ -1,57 +1,71 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Icon, Spinner } from 'patternfly-react'; +import { EmptyState, EmptyStateBody, EmptyStateIcon, Spinner, Title } from '@patternfly/react-core'; +import { OutlinedCheckCircleIcon, ExclamationCircleIcon } from '@patternfly/react-icons'; +import { global_success_color_100 as green, global_danger_color_100 as red } from '@patternfly/react-tokens'; import { connect, reduxSelectors } from '../../redux'; +import { EMPTY_CONTEXT, translate } from '../i18n/i18n'; -const AddSourceWizardStepThree = ({ add, error, fulfilled, pending, name }) => ( +/** + * Add source wizard, step three, confirmation. + * + * @param {object} props + * @param {boolean} props.add + * @param {boolean} props.error + * @param {boolean} props.fulfilled + * @param {boolean} props.pending + * @param {string} props.name + * @param {Function} props.t + * @returns {React.ReactNode} + */ +const AddSourceWizardStepThree = ({ add, error, fulfilled, pending, name, t }) => ( - {error && ( -
-
- -
-

Error {add ? 'Creating' : 'Updating'} Source

-

- There are errors on a previous step. Use the Back button to review your settings and try again. -

-
- )} - {fulfilled && ( -
-
- -
-

- {name} was {add ? 'created' : 'updated'}. -

-
- )} - {pending && ( -
- -

{add ? 'Creating' : 'Updating'} Source...

-

- Please wait while source {name} is being {add ? 'created' : 'updated'}. -

-
- )} + + {error && } + {fulfilled && } + {pending && } + + {t('form-dialog.empty-state_title_add-source', { + context: [(error && 'error') || (pending && 'pending'), !add && 'edit'], + name + })} + + + {t(`form-dialog.empty-state_description_add-source`, { + context: [(error && 'error') || (pending && 'pending'), (!add && pending && 'edit') || EMPTY_CONTEXT], + name + })} + +
); +/** + * Prop types + * + * @type {{add: boolean, t: Function, pending: boolean, fulfilled: boolean, name: string, error: boolean}} + */ AddSourceWizardStepThree.propTypes = { add: PropTypes.bool, error: PropTypes.bool, fulfilled: PropTypes.bool, pending: PropTypes.bool, - name: PropTypes.string + name: PropTypes.string, + t: PropTypes.func }; +/** + * Default props + * + * @type {{add: boolean, t: translate, pending: boolean, fulfilled: boolean, name: null, error: boolean}} + */ AddSourceWizardStepThree.defaultProps = { add: false, error: false, fulfilled: false, pending: false, - name: null + name: null, + t: translate }; const makeMapStateToProps = () => { diff --git a/src/components/addSourceWizard/addSourceWizardStepTwo.js b/src/components/addSourceWizard/addSourceWizardStepTwo.js index 3d1a45e8..12e141a2 100644 --- a/src/components/addSourceWizard/addSourceWizardStepTwo.js +++ b/src/components/addSourceWizard/addSourceWizardStepTwo.js @@ -1,14 +1,35 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Button, Form, Icon } from 'patternfly-react'; +import { Button, ButtonVariant } from '@patternfly/react-core'; +import { PlusIcon } from '@patternfly/react-icons'; +import { Form } from 'patternfly-react'; import { connect, store, reduxActions, reduxSelectors, reduxTypes } from '../../redux'; import { helpers } from '../../common/helpers'; import apiTypes from '../../constants/apiConstants'; import { dictionary, sslProtocolDictionary } from '../../constants/dictionaryConstants'; import { FormField, fieldValidation } from '../formField/formField'; import { FormState } from '../formState/formState'; -import DropdownSelect from '../dropdownSelect/dropdownSelect'; +import { DropdownSelect, SelectVariant } from '../dropdownSelect/dropdownSelect'; +import { translate } from '../i18n/i18n'; + +/** + * Generate ssl protocol options. + * + * @type {{title: string|Function, value: *}[]} + */ +const sslProtocolOptions = Object.keys(sslProtocolDictionary) + .map(type => ({ + title: () => translate('form-dialog.label', { context: ['option', type] }), + value: type + })) + .concat({ + title: () => translate('form-dialog.label', { context: ['option', 'disableSsl'] }), + value: 'disableSsl' + }); +/** + * Add a source with a Wizard, step two, apply credentials. + */ class AddSourceWizardStepTwo extends React.Component { static hostValid(value) { return ( @@ -50,6 +71,7 @@ class AddSourceWizardStepTwo extends React.Component { getCredentials(); } + // ToDo: future, exported hook from addCredentialType can be leveraged here onAddCredential = () => { const { type } = this.props; @@ -275,16 +297,11 @@ class AddSourceWizardStepTwo extends React.Component { } renderCredentials({ errors, touched, handleOnEventCustom }) { - const { availableCredentials, credentials, stepTwoErrorMessages, type } = this.props; + const { availableCredentials, credentials, stepTwoErrorMessages, t, type } = this.props; const multiselectCredentials = type === 'network'; const sourceCredentials = availableCredentials.filter(cred => cred.type === type); - const titleAddSelect = availableCredentials.length ? 'Select' : 'Add'; - const title = multiselectCredentials - ? `${titleAddSelect} one or more credentials` - : `${titleAddSelect} a credential`; - const onChangeCredential = event => { handleOnEventCustom({ name: 'credentials', @@ -300,20 +317,31 @@ class AddSourceWizardStepTwo extends React.Component { > - + +
+

+ Confirm +

+
- - +
+ +
diff --git a/src/components/confirmationModal/__tests__/confirmationModal.test.js b/src/components/confirmationModal/__tests__/confirmationModal.test.js index 68913aad..df6bab04 100644 --- a/src/components/confirmationModal/__tests__/confirmationModal.test.js +++ b/src/components/confirmationModal/__tests__/confirmationModal.test.js @@ -1,7 +1,7 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; -import { mount, shallow } from 'enzyme'; +import { shallow } from 'enzyme'; import { ConnectedConfirmationModal, ConfirmationModal } from '../confirmationModal'; describe('Confirmation Modal Component', () => { @@ -28,7 +28,7 @@ describe('Confirmation Modal Component', () => { expect(component.find(ConnectedConfirmationModal)).toMatchSnapshot('connected'); }); - it('should display a confirmation modal', () => { + it('should display a confirmation modal', async () => { const onCancel = jest.fn(); const props = { show: true, @@ -41,21 +41,55 @@ describe('Confirmation Modal Component', () => { onCancel }; - const component = mount(); - + const component = await mountHookComponent(); expect(component.render()).toMatchSnapshot('show'); - component.find('button[className="btn btn-default"]').simulate('click'); + component.find('button[className="pf-c-button pf-m-secondary"]').simulate('click'); expect(onCancel).toHaveBeenCalledTimes(1); }); - it('should NOT display a confirmation modal', () => { + it('should NOT display a confirmation modal', async () => { const props = { show: false }; - const component = mount(); - + const component = await mountHookComponent(); expect(component.render()).toMatchSnapshot('hidden'); }); + + it('should allow passed children, or specific props', async () => { + const props = { + show: true, + heading: 'Lorem ipsum', + children: 'hello world' + }; + + const component = await mountHookComponent(); + expect(component.find('.pf-c-modal-box__body').render()).toMatchSnapshot('heading'); + + component.setProps({ + heading: null, + body: 'Dolor sit' + }); + + expect(component.find('.pf-c-modal-box__body').render()).toMatchSnapshot('body'); + + component.setProps({ + body: null + }); + + expect(component.find('.pf-c-modal-box__body').render()).toMatchSnapshot('children'); + }); + + it('should allow custom content', async () => { + const props = { + show: true, + isActions: false, + isClose: false, + isContentOnly: true + }; + + const component = await mountHookComponent(lorem ipsum); + expect(component.render()).toMatchSnapshot('custom'); + }); }); diff --git a/src/components/confirmationModal/confirmationModal.js b/src/components/confirmationModal/confirmationModal.js index f96c996a..d0892dde 100644 --- a/src/components/confirmationModal/confirmationModal.js +++ b/src/components/confirmationModal/confirmationModal.js @@ -1,19 +1,54 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { MessageDialog, Icon } from 'patternfly-react'; +import { + Alert, + AlertVariant as ConfirmationVariant, + Button, + ButtonVariant, + ModalVariant, + Title +} from '@patternfly/react-core'; +import { Modal } from '../modal/modal'; import { connect, store, reduxTypes } from '../../redux'; -import helpers from '../../common/helpers'; +import { translate } from '../i18n/i18n'; +/** + * Display a confirmation modal with actions. + * + * @param {object} props + * @param {React.ReactNode} props.body + * @param {React.ReactNode} props.children + * @param {string} props.cancelButtonText + * @param {string} props.confirmButtonText + * @param {React.ReactNode} props.heading + * @param {string} props.icon + * @param {boolean} props.isClose + * @param {boolean} props.isContentOnly + * @param {boolean} props.isActions + * @param {Function} props.onCancel + * @param {Function} props.onConfirm + * @param {boolean} props.show + * @param {Function} props.t + * @param {string} props.title + * @param {string} props.variant + * @returns {React.ReactNode} + */ const ConfirmationModal = ({ - show, - title, - heading, body, - icon, - confirmButtonText, + children, cancelButtonText, + confirmButtonText, + heading, + icon, + isClose, + isContentOnly, + isActions, + onCancel, onConfirm, - onCancel + show, + t, + title, + variant }) => { const cancel = () => { if (onCancel) { @@ -25,47 +60,112 @@ const ConfirmationModal = ({ } }; + const setActions = () => { + const actions = []; + + if (!isActions) { + return actions; + } + + if (onConfirm) { + actions.push( + + ); + } + + actions.push( + + ); + + return actions; + }; + + const updatedChildren = + body || heading ? ( + +

{body}

+
+ ) : ( + children + ); + return ( - {heading}

} - secondaryContent={

{body}

} - /> + {title || t('form-dialog.label', { context: ['submit', 'confirmation'] })} + ) + } + isContentOnly={isContentOnly} + isOpen={show} + onClose={cancel} + showClose={isClose} + variant={variant} + > + {updatedChildren || ''} + ); }; +/** + * Prop types + * + * @type {{isClose: boolean, isActions: boolean, heading: React.ReactNode, icon: string, body: React.ReactNode, title: string, + * show: boolean, t: Function, children: React.ReactNode, onCancel: Function, onConfirm: Function, variant: string, + * isContentOnly: boolean, cancelButtonText: string, confirmButtonText: string}} + */ ConfirmationModal.propTypes = { - show: PropTypes.bool.isRequired, - title: PropTypes.string, - heading: PropTypes.node, - icon: PropTypes.node, body: PropTypes.node, - confirmButtonText: PropTypes.string, cancelButtonText: PropTypes.string, + children: PropTypes.node, + confirmButtonText: PropTypes.string, + heading: PropTypes.node, + icon: PropTypes.oneOf([...Object.values(ConfirmationVariant)]), + isActions: PropTypes.bool, + isClose: PropTypes.bool, + isContentOnly: PropTypes.bool, + show: PropTypes.bool.isRequired, + onCancel: PropTypes.func, onConfirm: PropTypes.func, - onCancel: PropTypes.func + t: PropTypes.func, + title: PropTypes.string, + variant: PropTypes.oneOf([...Object.values(ModalVariant)]) }; +/** + * Default props. + * + * @type {{isClose: boolean, isActions: boolean, heading: null, icon: ConfirmationVariant.warning, title: null, body: null, + * t: translate, children: null, onCancel: null, onConfirm: null, variant: null, isContentOnly: boolean, + * confirmButtonText: null, cancelButtonText: null}} + */ ConfirmationModal.defaultProps = { - title: 'Confirm', + children: null, + title: null, heading: null, body: null, - icon: , - confirmButtonText: 'Confirm', - cancelButtonText: '', - onConfirm: helpers.noop, - onCancel: null + icon: ConfirmationVariant.warning, + isActions: true, + isClose: true, + isContentOnly: false, + confirmButtonText: null, + cancelButtonText: null, + onConfirm: null, + onCancel: null, + t: translate, + variant: null }; const mapStateToProps = state => ({ ...state.confirmationModal }); const ConnectedConfirmationModal = connect(mapStateToProps)(ConfirmationModal); -export { ConnectedConfirmationModal as default, ConnectedConfirmationModal, ConfirmationModal }; +export { ConnectedConfirmationModal as default, ConnectedConfirmationModal, ConfirmationModal, ConfirmationVariant }; diff --git a/src/components/contextIcon/__tests__/__snapshots__/contextIcon.test.js.snap b/src/components/contextIcon/__tests__/__snapshots__/contextIcon.test.js.snap new file mode 100644 index 00000000..a1ad1def --- /dev/null +++ b/src/components/contextIcon/__tests__/__snapshots__/contextIcon.test.js.snap @@ -0,0 +1,209 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, canceled 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, cancelled 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, completed 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, created 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, download 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, failed 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, idCard 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, network 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, paused 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, pencil 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, pending 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, running 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, satellite 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, scans 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, sources 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, success 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, trash 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, unknown 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, unreachable 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: icon variant, vcenter 1`] = ` + +`; + +exports[`ContextIcon Component should export, and handle icon variants: variants 1`] = ` +Object { + "canceled": "failed", + "cancelled": "failed", + "completed": "success", + "created": "pending", + "download": "download", + "failed": "failed", + "idCard": "idCard", + "network": "network", + "paused": "paused", + "pencil": "pencil", + "pending": "pending", + "running": "pending", + "satellite": "satellite", + "scans": "scans", + "sources": "sources", + "success": "success", + "trash": "trash", + "unknown": "unknown", + "unreachable": "unreachable", + "vcenter": "vcenter", +} +`; + +exports[`ContextIcon Component should render a basic component with default props: basic 1`] = ` + +`; diff --git a/src/components/contextIcon/__tests__/__snapshots__/contextIconAction.test.js.snap b/src/components/contextIcon/__tests__/__snapshots__/contextIconAction.test.js.snap new file mode 100644 index 00000000..1e60c279 --- /dev/null +++ b/src/components/contextIcon/__tests__/__snapshots__/contextIconAction.test.js.snap @@ -0,0 +1,95 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, canceled 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, cancelled 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, completed 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, created 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, failed 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, paused 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, pending 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, play 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: icon variant, running 1`] = ` + +`; + +exports[`ContextIconAction Component should export, and handle icon variants: variants 1`] = ` +Object { + "canceled": "failed", + "cancelled": "failed", + "completed": "completed", + "created": "running", + "failed": "failed", + "paused": "paused", + "pending": "pending", + "play": "play", + "running": "running", +} +`; + +exports[`ContextIconAction Component should render a basic component with default props: basic 1`] = ` + +`; diff --git a/src/components/contextIcon/__tests__/contextIcon.test.js b/src/components/contextIcon/__tests__/contextIcon.test.js new file mode 100644 index 00000000..e998def5 --- /dev/null +++ b/src/components/contextIcon/__tests__/contextIcon.test.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ContextIcon, ContextIconVariant } from '../contextIcon'; + +describe('ContextIcon Component', () => { + it('should render a basic component with default props', () => { + const component = shallow(); + expect(component).toMatchSnapshot('basic'); + }); + + it('should export, and handle icon variants', () => { + expect(ContextIconVariant).toMatchSnapshot('variants'); + + Object.entries(ContextIconVariant).forEach(([key, value]) => { + const component = shallow(); + expect(component).toMatchSnapshot(`icon variant, ${key}`); + }); + }); +}); diff --git a/src/components/contextIcon/__tests__/contextIconAction.test.js b/src/components/contextIcon/__tests__/contextIconAction.test.js new file mode 100644 index 00000000..55e5a668 --- /dev/null +++ b/src/components/contextIcon/__tests__/contextIconAction.test.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ContextIconAction, ContextIconActionVariant } from '../contextIconAction'; + +describe('ContextIconAction Component', () => { + it('should render a basic component with default props', () => { + const component = shallow(); + expect(component).toMatchSnapshot('basic'); + }); + + it('should export, and handle icon variants', () => { + expect(ContextIconActionVariant).toMatchSnapshot('variants'); + + Object.entries(ContextIconActionVariant).forEach(([key, value]) => { + const component = shallow(); + expect(component).toMatchSnapshot(`icon variant, ${key}`); + }); + }); +}); diff --git a/src/components/contextIcon/contextIcon.js b/src/components/contextIcon/contextIcon.js new file mode 100644 index 00000000..5071ff4a --- /dev/null +++ b/src/components/contextIcon/contextIcon.js @@ -0,0 +1,165 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Spinner } from '@patternfly/react-core'; +import { + CheckCircleIcon, + DisconnectedIcon, + DownloadIcon, + ErrorCircleOIcon, + ExclamationTriangleIcon, + IdCardIcon, + PencilAltIcon, + PficonNetworkRangeIcon, + PficonSatelliteIcon, + PficonVcenterIcon, + TrashIcon, + UnknownIcon, + IconSize, + ClipboardCheckIcon, + CrosshairsIcon +} from '@patternfly/react-icons'; +import { + global_Color_dark_100 as gray, + global_success_color_100 as green, + global_warning_color_100 as yellow, + global_danger_color_100 as red +} from '@patternfly/react-tokens'; + +/** + * Context icon colors, for consistency + * + * @type {{ red: object, gray: object, green: object, yellow: object }} + */ +const ContextIconColors = { + gray, + green, + yellow, + red +}; + +/** + * Context icon variants + * + * @type {{running: string, canceled: string, paused: string, unreachable: string, success: string, created: string, + * pending: string, cancelled: string, completed: string, failed: string}} + */ +const ContextIconVariant = { + completed: 'success', + success: 'success', + failed: 'failed', + canceled: 'failed', + cancelled: 'failed', + created: 'pending', + pending: 'pending', + running: 'pending', + paused: 'paused', + download: 'download', + idCard: 'idCard', + network: 'network', + pencil: 'pencil', + satellite: 'satellite', + scans: 'scans', + sources: 'sources', + trash: 'trash', + unknown: 'unknown', + unreachable: 'unreachable', + vcenter: 'vcenter' +}; + +/** + * Emulate pf icon sizing for custom SVGs + * + * @param {string} size + * @returns {string} em measurement + */ +const svgSize = size => { + if (!Number.isNaN(Number.parseFloat(size))) { + return size; + } + + switch (size) { + case 'md': + return '1.5em'; + case 'lg': + return '2em'; + case 'xl': + return '3em'; + case 'sm': + default: + return '1em'; + } +}; + +/** + * Return an icon from context/symbol + * + * @param {object} props + * @param {string} props.symbol + * @param {object} props.props + * @returns {React.ReactNode} + */ +const ContextIcon = ({ symbol, ...props }) => { + switch (symbol) { + case ContextIconVariant.download: + return ; + case ContextIconVariant.failed: + return ; + case ContextIconVariant.idCard: + return ; + case ContextIconVariant.network: + return ; + case ContextIconVariant.paused: + return ; + case ContextIconVariant.pencil: + return ; + case ContextIconVariant.pending: + const updatedSize = { style: { display: 'inline-block' } }; + + if (props.size) { + updatedSize.size = undefined; + updatedSize.style.height = svgSize(props.size); + updatedSize.style.width = svgSize(props.size); + } + + return ; + case ContextIconVariant.satellite: + return ; + case ContextIconVariant.scans: + return ; + case ContextIconVariant.sources: + return ; + case ContextIconVariant.success: + return ; + case ContextIconVariant.trash: + return ; + case ContextIconVariant.unreachable: + return ; + case ContextIconVariant.vcenter: + return ; + case ContextIconVariant.unknown: + default: + return ; + } +}; + +/** + * Prop types + * + * @type {{symbol: string}} + */ +ContextIcon.propTypes = { + symbol: PropTypes.oneOf([...Object.values(ContextIconVariant)]), + size: PropTypes.oneOf([...Object.values(IconSize)]) +}; + +/** + * Default props + * + * @type {{symbol: null}} + */ +ContextIcon.defaultProps = { + symbol: null, + size: IconSize.sm +}; + +export { ContextIcon as default, ContextIcon, ContextIconColors, ContextIconVariant }; diff --git a/src/components/contextIcon/contextIconAction.js b/src/components/contextIcon/contextIconAction.js new file mode 100644 index 00000000..ed9eea41 --- /dev/null +++ b/src/components/contextIcon/contextIconAction.js @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { PauseIcon, PlayIcon, RedoIcon, StopIcon } from '@patternfly/react-icons'; + +/** + * Context icon variants + * + * @type {{running: string, play: string, canceled: string, paused: string, created: string, pending: string, + * cancelled: string, completed: string, failed: string}} + */ +const ContextIconActionVariant = { + completed: 'completed', + failed: 'failed', + canceled: 'failed', + cancelled: 'failed', + pending: 'pending', + created: 'running', + running: 'running', + paused: 'paused', + play: 'play' +}; + +/** + * Return an action icon from context/symbol + * + * @param {object} props + * @param {string} props.symbol + * @param {object} props.props + * @returns {React.ReactNode} + */ +const ContextIconAction = ({ symbol, ...props }) => { + switch (symbol) { + case ContextIconActionVariant.completed: + case ContextIconActionVariant.failed: + return ; + case ContextIconActionVariant.running: + return ; + case ContextIconActionVariant.paused: + return ; + case ContextIconActionVariant.pending: + return ; + default: + return ; + } +}; + +/** + * Prop types + * + * @type {{symbol: string}} + */ +ContextIconAction.propTypes = { + symbol: PropTypes.oneOf([...Object.values(ContextIconActionVariant)]) +}; + +/** + * Default props + * + * @type {{symbol: null}} + */ +ContextIconAction.defaultProps = { + symbol: null +}; + +export { ContextIconAction as default, ContextIconAction, ContextIconActionVariant }; diff --git a/src/components/createCredentialDialog/__tests__/__snapshots__/createCredentialDialog.test.js.snap b/src/components/createCredentialDialog/__tests__/__snapshots__/createCredentialDialog.test.js.snap index 7cc8ee11..812eed9d 100644 --- a/src/components/createCredentialDialog/__tests__/__snapshots__/createCredentialDialog.test.js.snap +++ b/src/components/createCredentialDialog/__tests__/__snapshots__/createCredentialDialog.test.js.snap @@ -1,48 +1,77 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`CreateCredentialDialog Component should export select options: options 1`] = ` +Array [ + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"sshKey\\"})", + "value": "sshKey", + }, + Object { + "title": "t(form-dialog.label_option, {\\"context\\":\\"usernamePassword\\"})", + "value": "usernamePassword", + }, +] +`; + exports[`CreateCredentialDialog Component should render a connected component: connected 1`] = ` diff --git a/src/components/createCredentialDialog/__tests__/createCredentialDialog.test.js b/src/components/createCredentialDialog/__tests__/createCredentialDialog.test.js index 510666e1..ad9dda76 100644 --- a/src/components/createCredentialDialog/__tests__/createCredentialDialog.test.js +++ b/src/components/createCredentialDialog/__tests__/createCredentialDialog.test.js @@ -2,7 +2,11 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; import { mount, shallow } from 'enzyme'; -import { ConnectedCreateCredentialDialog, CreateCredentialDialog } from '../createCredentialDialog'; +import { + ConnectedCreateCredentialDialog, + CreateCredentialDialog, + authenticationTypeOptions +} from '../createCredentialDialog'; describe('CreateCredentialDialog Component', () => { const generateEmptyStore = (obj = {}) => configureMockStore()(obj); @@ -27,4 +31,13 @@ describe('CreateCredentialDialog Component', () => { const component = shallow(); expect(component.render()).toMatchSnapshot('non-connected'); }); + + it('should export select options', () => { + expect( + authenticationTypeOptions.map(({ title, ...option }) => ({ + ...option, + title: (typeof title === 'function' && title()) || title + })) + ).toMatchSnapshot('options'); + }); }); diff --git a/src/components/createCredentialDialog/createCredentialDialog.js b/src/components/createCredentialDialog/createCredentialDialog.js index 76ae544f..468df51f 100644 --- a/src/components/createCredentialDialog/createCredentialDialog.js +++ b/src/components/createCredentialDialog/createCredentialDialog.js @@ -1,11 +1,27 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Modal, Alert, Button, Icon, Form, Grid } from 'patternfly-react'; +import { Alert, Button, ButtonVariant, Title } from '@patternfly/react-core'; +import { Form, Grid } from 'patternfly-react'; +import { Modal } from '../modal/modal'; import { connect, reduxActions, reduxTypes, store } from '../../redux'; import { helpers } from '../../common/helpers'; import { authDictionary, dictionary } from '../../constants/dictionaryConstants'; -import DropdownSelect from '../dropdownSelect/dropdownSelect'; - +import { DropdownSelect } from '../dropdownSelect/dropdownSelect'; +import { translate } from '../i18n/i18n'; + +/** + * Generate authentication type options. + * + * @type {{title: string|Function, value: *}[]} + */ +const authenticationTypeOptions = Object.keys(authDictionary).map(type => ({ + title: () => translate('form-dialog.label', { context: ['option', type] }), + value: type +})); + +/** + * Create or edit a credential. + */ class CreateCredentialDialog extends React.Component { static renderFormLabel(label) { return ( @@ -74,7 +90,8 @@ class CreateCredentialDialog extends React.Component { state = { ...this.initialState }; - UNSAFE_componentWillReceiveProps(nextProps) { //eslint-disable-line + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(nextProps) { const { edit, fulfilled, getCredentials, show, viewOptions } = this.props; if (!show && nextProps.show) { @@ -348,10 +365,10 @@ class CreateCredentialDialog extends React.Component { @@ -386,8 +403,8 @@ class CreateCredentialDialog extends React.Component { if (error) { return ( - - Error {errorMessage} + + {errorMessage} ); } @@ -396,7 +413,7 @@ class CreateCredentialDialog extends React.Component { } render() { - const { show, edit } = this.props; + const { show, edit, t } = this.props; const { credentialType, credentialName, @@ -408,14 +425,20 @@ class CreateCredentialDialog extends React.Component { } = this.state; return ( - - - , + - {edit ? `View Credential - ${credentialName}` : 'Add Credential'} - - + ]} + > {this.renderErrorMessage()}
@@ -450,10 +473,10 @@ class CreateCredentialDialog extends React.Component { @@ -474,14 +497,6 @@ class CreateCredentialDialog extends React.Component { {this.renderNetworkForm()}
- - - -
); } @@ -499,6 +514,7 @@ CreateCredentialDialog.propTypes = { fulfilled: PropTypes.bool, error: PropTypes.bool, errorMessage: PropTypes.string, + t: PropTypes.func, viewOptions: PropTypes.object }; @@ -514,6 +530,7 @@ CreateCredentialDialog.defaultProps = { fulfilled: false, error: false, errorMessage: null, + t: translate, viewOptions: {} }; @@ -530,4 +547,9 @@ const mapStateToProps = state => ({ const ConnectedCreateCredentialDialog = connect(mapStateToProps, mapDispatchToProps)(CreateCredentialDialog); -export { ConnectedCreateCredentialDialog as default, ConnectedCreateCredentialDialog, CreateCredentialDialog }; +export { + ConnectedCreateCredentialDialog as default, + ConnectedCreateCredentialDialog, + CreateCredentialDialog, + authenticationTypeOptions +}; diff --git a/src/components/createScanDialog/__tests__/__snapshots__/createScanDialog.test.js.snap b/src/components/createScanDialog/__tests__/__snapshots__/createScanDialog.test.js.snap index 20285467..1f2c635b 100644 --- a/src/components/createScanDialog/__tests__/__snapshots__/createScanDialog.test.js.snap +++ b/src/components/createScanDialog/__tests__/__snapshots__/createScanDialog.test.js.snap @@ -16,16 +16,44 @@ Object { exports[`CreateScanDialog Component should handle multiple error responses: basic error 1`] = `
-
`; @@ -64,307 +92,329 @@ exports[`CreateScanDialog Component should render a connected component with def exports[`CreateScanDialog Component should render a non-connected component: non-connected 1`] = `