diff --git a/.env.development b/.env.development index 1938864a93..655c123350 100644 --- a/.env.development +++ b/.env.development @@ -29,7 +29,6 @@ FEATURE_REPORTING_CONFIGURATIONS=true FEATURE_ANALYTICS=true FEATURE_SUPPORT=true FEATURE_SAML_CONFIGURATION=true -FEATURE_CODE_VISIBILITY=true FEATURE_EXTERNAL_LMS_CONFIGURATION=true FEATURE_BULK_ENROLLMENT=true HOTJAR_APP_ID='' diff --git a/package-lock.json b/package-lock.json index 4a402c6def..727acca089 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2473,9 +2473,9 @@ } }, "@edx/paragon": { - "version": "14.12.4", - "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-14.12.4.tgz", - "integrity": "sha512-jSx+QKEGnM+Xo1KfwZycPD6O7Wa6CovC/Utg/ZjOpoBS0v8Gij8jnC3Va3vbpC4t0KA8jdgvf7gH6XDrFuitWA==", + "version": "14.16.2", + "resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-14.16.2.tgz", + "integrity": "sha512-0ngbG+H05aMKdxy5EiMkKK3X+LI1t/WlfvVQiQykdDD651ECI/3g6qR3huyMsniM/wMv4eKPGlsSikaUwZBx3Q==", "requires": { "@fortawesome/fontawesome-svg-core": "^1.2.30", "@fortawesome/free-solid-svg-icons": "^5.14.0", @@ -3620,16 +3620,16 @@ } }, "@testing-library/dom": { - "version": "7.30.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.30.1.tgz", - "integrity": "sha512-RQUvqqq2lxTCOffhSNxpX/9fCoR+nwuQPmG5uhuuEH5KBAzNf2bK3OzBoWjm5zKM78SLjnGRAKt8hRjQA4E46A==", + "version": "7.31.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", + "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", "requires": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^4.2.0", "aria-query": "^4.2.2", "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.4", + "dom-accessibility-api": "^0.5.6", "lz-string": "^1.4.4", "pretty-format": "^26.6.2" }, @@ -3652,9 +3652,9 @@ } }, "chalk": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz", - "integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", + "integrity": "sha512-diHzdDKxcU+bAsUboHLPEDQiw0qEe0qd7SYUn3HgcFlWgbDcfLGswOHYeGrHKzG9z6UYf01d9VFMfZxPM1xZSg==", "requires": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3822,9 +3822,10 @@ } }, "@testing-library/react-hooks": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.1.0.tgz", - "integrity": "sha512-ChRyyA14e0CeVkWGp24v8q/IiWUqH+B8daRx4lGZme4dsudmMNWz+Qo2Q2NzbD2O5rAVXh2hSbS/KTKeqHYhkw==", + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.0.3.tgz", + "integrity": "sha512-UrnnRc5II7LMH14xsYNm/WRch/67cBafmrSQcyFh0v+UUmSf1uzfB7zn5jQXSettGwOSxJwdQUN7PgkT0w22Lg==", + "dev": true, "requires": { "@babel/runtime": "^7.12.5", "@types/react": ">=16.9.0", @@ -3895,14 +3896,6 @@ "@babel/types": "^7.3.0" } }, - "@types/classnames": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz", - "integrity": "sha512-zeOWb0JGBoVmlQoznvqXbE0tEC/HONsnoUNH19Hc96NFsTAwTXbTqb8FMYkru1F/iqp7a18Ws3nWJvtA1sHD1A==", - "requires": { - "classnames": "*" - } - }, "@types/cookie": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", @@ -4040,9 +4033,10 @@ } }, "@types/react-dom": { - "version": "17.0.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.3.tgz", - "integrity": "sha512-4NnJbCeWE+8YBzupn/YrJxZ8VnjcJq5iR1laqQ1vkpQgBiA7bwk0Rp24fxsdNinzJY2U+HHS4dJJDPdoMjdJ7w==", + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.7.tgz", + "integrity": "sha512-Wd5xvZRlccOrCTej8jZkoFZuZRKHzanDDv1xglI33oBNFMWrqOSzrvWFw7ngSiZjrpJAzPKFtX7JvuXpkNmQHA==", + "dev": true, "requires": { "@types/react": "*" } @@ -4051,6 +4045,7 @@ "version": "17.0.1", "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, "requires": { "@types/react": "*" } @@ -8098,9 +8093,9 @@ } }, "dom-accessibility-api": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz", - "integrity": "sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==" + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz", + "integrity": "sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw==" }, "dom-converter": { "version": "0.2.0", @@ -9695,7 +9690,8 @@ "filter-console": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/filter-console/-/filter-console-0.1.1.tgz", - "integrity": "sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg==" + "integrity": "sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg==", + "dev": true }, "finalhandler": { "version": "1.1.2", @@ -9930,9 +9926,9 @@ }, "dependencies": { "tslib": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.2.0.tgz", - "integrity": "sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==" } } }, @@ -17044,28 +17040,42 @@ } }, "react-bootstrap": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.0.tgz", - "integrity": "sha512-PaeOGeRC2+JH9Uf1PukJgXcIpfGlrKKHEBZIArymjenYzSJ/RhO2UdNX+e7nalsCFFZLRRgQ0/FKkscW2LmmRg==", + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.1.tgz", + "integrity": "sha512-ojEPQ6OtyIMdLg0Smofk+85PKN6MLKQX3bU0Vwmok/4yNa8DQ2vCGhO2IgHJvT+ERQZ4X+gAQcdn6msAHSwLBg==", "requires": { - "@babel/runtime": "^7.13.8", + "@babel/runtime": "^7.14.0", "@restart/context": "^2.1.4", "@restart/hooks": "^0.3.26", - "@types/classnames": "^2.2.10", "@types/invariant": "^2.2.33", "@types/prop-types": "^15.7.3", - "@types/react": ">=16.9.35", + "@types/react": ">=16.14.8", "@types/react-transition-group": "^4.4.1", "@types/warning": "^3.0.0", - "classnames": "^2.2.6", - "dom-helpers": "^5.1.2", + "classnames": "^2.3.1", + "dom-helpers": "^5.2.1", "invariant": "^2.2.4", "prop-types": "^15.7.2", "prop-types-extra": "^1.1.0", - "react-overlays": "^5.0.0", + "react-overlays": "^5.0.1", "react-transition-group": "^4.4.1", "uncontrollable": "^7.2.1", "warning": "^4.0.3" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.14.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz", + "integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "classnames": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" + } } }, "react-clientside-effect": { @@ -17225,9 +17235,10 @@ } }, "react-error-boundary": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.1.tgz", - "integrity": "sha512-W3xCd9zXnanqrTUeViceufD3mIW8Ut29BUD+S2f0eO2XCOU8b6UrJfY46RDGe5lxCJzfe4j0yvIfh0RbTZhKJw==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.3.tgz", + "integrity": "sha512-A+F9HHy9fvt9t8SNDlonq01prnU8AmkjvGKV4kk8seB9kU3xMEO8J/PQlLVmoOIDODl5U2kufSBs4vrWIqhsAA==", + "dev": true, "requires": { "@babel/runtime": "^7.12.5" } @@ -17491,9 +17502,9 @@ } }, "react-transition-group": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.1.tgz", - "integrity": "sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw==", + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz", + "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==", "requires": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", diff --git a/package.json b/package.json index 218c4fea7f..18aacb7dca 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,12 @@ "license": "AGPL-3.0", "dependencies": { "@babel/plugin-transform-runtime": "7.12.1", - "@edx/brand": "npm:@edx/brand-openedx@^1.1.0", + "@edx/brand": "npm:@edx/brand-openedx@1.1.0", "@edx/frontend-enterprise-catalog-search": "0.1.10", "@edx/frontend-enterprise-logistration": "0.1.11", "@edx/frontend-enterprise-utils": "0.1.7", "@edx/frontend-platform": "1.11.0", - "@edx/paragon": "14.12.4", + "@edx/paragon": "14.16.2", "@fortawesome/fontawesome-svg-core": "1.2.35", "@fortawesome/free-brands-svg-icons": "5.15.3", "@fortawesome/free-regular-svg-icons": "5.15.3", @@ -34,7 +34,6 @@ "@fortawesome/react-fontawesome": "0.1.14", "@fullstory/browser": "1.4.5", "@redux-beacon/segment": "1.1.0", - "@testing-library/react-hooks": "^5.0.3", "algoliasearch": "4.8.3", "axios-mock-adapter": "1.19.0", "classnames": "2.2.6", @@ -89,6 +88,8 @@ "@testing-library/jest-dom": "5.12.0", "@testing-library/react": "10.4.9", "@testing-library/user-event": "12.8.3", + "@testing-library/dom": "7.31.2", + "@testing-library/react-hooks": "5.0.3", "codecov": "3.7.1", "coveralls": "3.1.0", "css-loader": "3.5.3", diff --git a/src/components/Admin/__snapshots__/Admin.test.jsx.snap b/src/components/Admin/__snapshots__/Admin.test.jsx.snap index fcd623cc1e..c153f242fb 100644 --- a/src/components/Admin/__snapshots__/Admin.test.jsx.snap +++ b/src/components/Admin/__snapshots__/Admin.test.jsx.snap @@ -216,7 +216,7 @@ exports[` renders correctly with dashboard analytics data renders # cou className="d-flex justify-content-between align-items-center" >
Details
@@ -309,7 +309,7 @@ exports[` renders correctly with dashboard analytics data renders # cou className="d-flex justify-content-between align-items-center" >
Details
@@ -418,7 +418,7 @@ exports[` renders correctly with dashboard analytics data renders # cou className="d-flex justify-content-between align-items-center" >
Details
@@ -543,7 +543,7 @@ exports[` renders correctly with dashboard analytics data renders # cou className="d-flex justify-content-between align-items-center" >
Details
@@ -777,7 +777,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -870,7 +870,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -979,7 +979,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -1104,7 +1104,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -1338,7 +1338,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -1431,7 +1431,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -1540,7 +1540,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -1665,7 +1665,7 @@ exports[` renders correctly with dashboard analytics data renders # of className="d-flex justify-content-between align-items-center" >
Details
@@ -1903,7 +1903,7 @@ exports[` renders correctly with dashboard analytics data renders colla className="d-flex justify-content-between align-items-center" >
Details
@@ -1996,7 +1996,7 @@ exports[` renders correctly with dashboard analytics data renders colla className="d-flex justify-content-between align-items-center" >
Details
@@ -2105,7 +2105,7 @@ exports[` renders correctly with dashboard analytics data renders colla className="d-flex justify-content-between align-items-center" >
Details
@@ -2230,7 +2230,7 @@ exports[` renders correctly with dashboard analytics data renders colla className="d-flex justify-content-between align-items-center" >
Details
@@ -2617,7 +2617,7 @@ exports[` renders correctly with dashboard analytics data renders full className="d-flex justify-content-between align-items-center" >
Details
@@ -2710,7 +2710,7 @@ exports[` renders correctly with dashboard analytics data renders full className="d-flex justify-content-between align-items-center" >
Details
@@ -2819,7 +2819,7 @@ exports[` renders correctly with dashboard analytics data renders full className="d-flex justify-content-between align-items-center" >
Details
@@ -2944,7 +2944,7 @@ exports[` renders correctly with dashboard analytics data renders full className="d-flex justify-content-between align-items-center" >
Details
@@ -3331,7 +3331,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -3424,7 +3424,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -3533,7 +3533,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -3658,7 +3658,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -3896,7 +3896,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -3989,7 +3989,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -4098,7 +4098,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -4223,7 +4223,7 @@ exports[` renders correctly with dashboard analytics data renders inact className="d-flex justify-content-between align-items-center" >
Details
@@ -4461,7 +4461,7 @@ exports[` renders correctly with dashboard analytics data renders learn className="d-flex justify-content-between align-items-center" >
Details
@@ -4554,7 +4554,7 @@ exports[` renders correctly with dashboard analytics data renders learn className="d-flex justify-content-between align-items-center" >
Details
@@ -4663,7 +4663,7 @@ exports[` renders correctly with dashboard analytics data renders learn className="d-flex justify-content-between align-items-center" >
Details
@@ -4788,7 +4788,7 @@ exports[` renders correctly with dashboard analytics data renders learn className="d-flex justify-content-between align-items-center" >
Details
@@ -5026,7 +5026,7 @@ exports[` renders correctly with dashboard analytics data renders regis className="d-flex justify-content-between align-items-center" >
Details
@@ -5119,7 +5119,7 @@ exports[` renders correctly with dashboard analytics data renders regis className="d-flex justify-content-between align-items-center" >
Details
@@ -5228,7 +5228,7 @@ exports[` renders correctly with dashboard analytics data renders regis className="d-flex justify-content-between align-items-center" >
Details
@@ -5353,7 +5353,7 @@ exports[` renders correctly with dashboard analytics data renders regis className="d-flex justify-content-between align-items-center" >
Details
@@ -5587,7 +5587,7 @@ exports[` renders correctly with dashboard analytics data renders top a className="d-flex justify-content-between align-items-center" >
Details
@@ -5680,7 +5680,7 @@ exports[` renders correctly with dashboard analytics data renders top a className="d-flex justify-content-between align-items-center" >
Details
@@ -5789,7 +5789,7 @@ exports[` renders correctly with dashboard analytics data renders top a className="d-flex justify-content-between align-items-center" >
Details
@@ -5914,7 +5914,7 @@ exports[` renders correctly with dashboard analytics data renders top a className="d-flex justify-content-between align-items-center" >
Details
diff --git a/src/components/CouponDetails/ActionButton.jsx b/src/components/CouponDetails/ActionButton.jsx new file mode 100644 index 0000000000..899eb0efae --- /dev/null +++ b/src/components/CouponDetails/ActionButton.jsx @@ -0,0 +1,124 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, +} from '@edx/paragon'; +import RemindButton from '../RemindButton'; +import RevokeButton from '../RevokeButton'; +import { ACTIONS, COUPON_FILTERS, COUPON_FILTER_TYPES } from './constants'; + +const ActionButton = ({ + couponData: { + id, + errors, + available, + title, + }, + selectedToggle, + code, + handleCodeActionSuccess, + setModalState, +}) => { + const { + assigned_to: assignedTo, + is_public: isPublic, + redemptions, + } = code; + let remainingUses = redemptions.total - redemptions.used; + + // Don't show `Assign/Remind/Revoke` buttons for an unavailable coupon + if (!available) { + return null; + } + + // Don't show a button if all total redemptions have been used + if (redemptions.used === redemptions.total) { + return null; + } + + if (isPublic) { + return null; + } + + const codeHasError = errors.find(errorItem => errorItem.code === code.code); + if (assignedTo) { + return ( + <> + {!codeHasError && ( + <> + handleCodeActionSuccess(ACTIONS.remind.value, response)} + /> + {' | '} + + )} + handleCodeActionSuccess(ACTIONS.revoke.value, response)} + /> + + ); + } + + // exclude existing assignments of code + if (selectedToggle === COUPON_FILTERS.unassigned.value) { + remainingUses -= redemptions.num_assignments; + } + + return ( + + ); +}; + +ActionButton.propTypes = { + couponData: PropTypes.shape({ + id: PropTypes.number.isRequired, + errors: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string, + })), + available: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + }).isRequired, + selectedToggle: PropTypes.oneOf(Object.values(COUPON_FILTER_TYPES)).isRequired, + code: PropTypes.shape({ + assigned_to: PropTypes.string, + is_public: PropTypes.bool.isRequired, + redemptions: PropTypes.shape({ + total: PropTypes.number.isRequired, + used: PropTypes.number.isRequired, + num_assignments: PropTypes.number, + }), + code: PropTypes.string.isRequired, + }).isRequired, + handleCodeActionSuccess: PropTypes.func.isRequired, + setModalState: PropTypes.func.isRequired, +}; + +export default ActionButton; diff --git a/src/components/CouponDetails/ActionButton.test.jsx b/src/components/CouponDetails/ActionButton.test.jsx new file mode 100644 index 0000000000..44c8ef4430 --- /dev/null +++ b/src/components/CouponDetails/ActionButton.test.jsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import ActionButton from './ActionButton'; +import { ACTIONS, COUPON_FILTERS } from './constants'; + +const fakeCouponData = { + id: 12, + errors: [], + available: true, + title: 'Best coupon', +}; + +const fakeRedemptions = { + used: 1, + total: 2, + num_assignments: 1, +}; +const props = { + couponData: fakeCouponData, + selectedToggle: COUPON_FILTERS.unassigned.value, + code: { + code: 'itsACode', + redemptions: fakeRedemptions, + is_public: false, + }, + handleCodeActionSuccess: jest.fn(), + setModalState: jest.fn(), +}; + +describe('CouponDetails ActionButton', () => { + it('returns null if the coupon is unavailable', () => { + const modifiedProps = { + ...props, + couponData: { + ...fakeCouponData, + available: false, + }, + }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + it('returns null if all redepmtions have been used', () => { + const modifiedProps = { + ...props, + code: { + ...props.code, + redemptions: { ...props.code.redemptions, total: 3, used: 3 }, + }, + }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + it('does not render remind button if there is an error', () => { + const modifiedProps = { + ...props, + couponData: { + ...fakeCouponData, + errors: [{ code: props.code.code }], + }, + code: { + ...props.code, + assigned_to: 'foo@bar.com', + }, + }; + render(); + expect(screen.queryByText(ACTIONS.remind.label)).not.toBeInTheDocument(); + expect(screen.getByText(ACTIONS.revoke.label)).toBeInTheDocument(); + }); + test.each([ + [[]], + [[{ code: 'someOtherCode ' }]], + ])('shows remind and revoke buttons if code is assigned and there are no errors %#', (errors) => { + const modifiedProps = { + ...props, + couponData: { + ...fakeCouponData, + errors, + }, + code: { + ...props.code, + assigned_to: 'foo@bar.com', + }, + }; + render(); + expect(screen.getByText(ACTIONS.remind.label)).toBeInTheDocument(); + expect(screen.getByText(ACTIONS.revoke.label)).toBeInTheDocument(); + }); + it('returns null for an unassigned and public code', () => { + const modifiedProps = { + ...props, + code: { + ...props.code, + is_public: true, + }, + }; + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + it('renders an assign button', () => { + render(); + expect(screen.queryByText(ACTIONS.remind.label)).not.toBeInTheDocument(); + expect(screen.queryByText(ACTIONS.revoke.label)).not.toBeInTheDocument(); + expect(screen.getByText(ACTIONS.assign.label)).toBeInTheDocument(); + }); +}); diff --git a/src/components/CouponDetails/CouponBulkActions.jsx b/src/components/CouponDetails/CouponBulkActions.jsx new file mode 100644 index 0000000000..0d851430b7 --- /dev/null +++ b/src/components/CouponDetails/CouponBulkActions.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, Form, +} from '@edx/paragon'; +import { BULK_ACTION } from './constants'; + +const CouponBulkActions = ({ + value, options, disabled, onChange, handleBulkAction, +}) => ( +
+ + onChange(e.target.value)} + disabled={disabled} + > + {options.map( + ({ label, value: optionValue, disabled: optionDisabled }) => ( + + ), + )} + + + +
+); + +CouponBulkActions.propTypes = { + value: PropTypes.string.isRequired, + options: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + disabled: PropTypes.bool.isRequired, + })).isRequired, + onChange: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, + handleBulkAction: PropTypes.func.isRequired, +}; + +export default CouponBulkActions; diff --git a/src/components/CouponDetails/CouponFilters.jsx b/src/components/CouponDetails/CouponFilters.jsx new file mode 100644 index 0000000000..6b728307b9 --- /dev/null +++ b/src/components/CouponDetails/CouponFilters.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Form, Col, +} from '@edx/paragon'; + +const CouponFilters = ({ + selectedToggle, tableFilterSelectOptions, isTableLoading, handleToggleSelect, +}) => ( +
+ + + handleToggleSelect(e.target.value)} + disabled={isTableLoading} + value={selectedToggle} + > + {tableFilterSelectOptions.map( + ({ label, value, disabled }) => , + )} + + + +
+); + +CouponFilters.propTypes = { + selectedToggle: PropTypes.string.isRequired, + tableFilterSelectOptions: PropTypes.arrayOf(PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + disabled: PropTypes.string, + })).isRequired, + isTableLoading: PropTypes.bool.isRequired, + handleToggleSelect: PropTypes.func.isRequired, +}; + +export default CouponFilters; diff --git a/src/components/CouponDetails/constants.js b/src/components/CouponDetails/constants.js new file mode 100644 index 0000000000..180decda7f --- /dev/null +++ b/src/components/CouponDetails/constants.js @@ -0,0 +1,155 @@ +/* eslint-disable import/prefer-default-export */ + +export const COUPON_FILTER_TYPES = { + unassigned: 'unassigned', + unredeemed: 'unredeemed', + partiallyRedeemed: 'partially-redeemed', + redeemed: 'redeemed', +}; + +export const COUPON_FILTERS = { + unassigned: { + label: 'Unassigned', + value: COUPON_FILTER_TYPES.unassigned, + }, + unredeemed: { + label: 'Unredeemed', + value: COUPON_FILTER_TYPES.unredeemed, + }, + partiallyRedeemed: { + label: 'Partially redeemed', + value: COUPON_FILTER_TYPES.partiallyRedeemed, + }, + redeemed: { + label: 'Redeemed', + value: COUPON_FILTER_TYPES.redeemed, + }, +}; + +export const ACTIONS = { + remind: { + label: 'Remind', + value: 'remind', + }, + assign: { + label: 'Assign', + value: 'assign', + }, + revoke: { + label: 'Revoke', + value: 'revoke', + }, +}; + +export const FILTER_OPTIONS = [{ + label: COUPON_FILTERS.unassigned.label, + value: COUPON_FILTERS.unassigned.value, +}, { + label: COUPON_FILTERS.unredeemed.label, + value: COUPON_FILTERS.unredeemed.value, +}, { + label: COUPON_FILTERS.partiallyRedeemed.label, + value: COUPON_FILTERS.partiallyRedeemed.value, +}, { + label: COUPON_FILTERS.redeemed.label, + value: COUPON_FILTERS.redeemed.value, +}]; + +export const BULK_ACTION_SELECT_OPTIONS = [{ + label: ACTIONS.assign.label, + value: ACTIONS.assign.value, +}, { + label: ACTIONS.remind.label, + value: ACTIONS.remind.value, +}, { + label: ACTIONS.revoke.label, + value: ACTIONS.revoke.value, +}]; + +export const BULK_ACTION = { + label: 'Bulk action', + name: 'bulk-actions', + controlId: 'bulkActions', +}; + +export const DETAILS_TEXT = { + expanded: 'Detailed breakdown', + unexpanded: 'Details', + expandedScreenReader: 'Close details', + unexpandedScreenReader: 'Show details', +}; + +export const COLUMNS = { + redemptions: { + label: 'Redemptions', + key: 'redemptions', + }, + code: { + label: 'Code', + key: 'code', + }, + assignmentsRemaining: { + label: 'Assignments remaining', + key: 'assignments_remaining', + }, + actions: { + label: 'Actions', + key: 'actions', + }, + lastReminderDate: { + label: 'Last reminder date', + key: 'last_reminder_date', + }, + assignmentDate: { + label: 'Assignment date', + key: 'assignment_date', + }, + assignedTo: { + label: 'Assigned to', + key: 'assigned_to', + }, + redeemedBy: { + label: 'Redeemed by', + key: 'assigned_to', + }, +}; + +const COMMON_COLUMNS = [ + COLUMNS.redemptions, + COLUMNS.code, +]; + +const REDEMTION_COLUMNS = [ + ...COMMON_COLUMNS, + COLUMNS.assignmentDate, + COLUMNS.lastReminderDate, + COLUMNS.actions, +]; + +export const DEFAULT_TABLE_COLUMNS = { + [COUPON_FILTERS.unassigned.value]: [ + ...COMMON_COLUMNS, + COLUMNS.assignmentsRemaining, + COLUMNS.actions, + ], + [COUPON_FILTERS.unredeemed.value]: [ + COLUMNS.assignedTo, + ...REDEMTION_COLUMNS, + ], + [COUPON_FILTERS.partiallyRedeemed.value]: [ + COLUMNS.assignedTo, + ...REDEMTION_COLUMNS, + ], + [COUPON_FILTERS.redeemed.value]: [ + COLUMNS.redeemedBy, + ...COMMON_COLUMNS, + COLUMNS.assignmentDate, + COLUMNS.lastReminderDate, + ], +}; + +export const SUCCESS_MESSAGES = { + assign: 'Successfully assigned code(s)', + remind: 'Reminder request processed.', + revoke: 'Successfully revoked code(s)', +}; diff --git a/src/components/CouponDetails/helpers.js b/src/components/CouponDetails/helpers.js new file mode 100644 index 0000000000..6a79948324 --- /dev/null +++ b/src/components/CouponDetails/helpers.js @@ -0,0 +1,64 @@ +/* eslint-disable import/prefer-default-export */ +import { SINGLE_USE, ONCE_PER_CUSTOMER } from '../../data/constants/coupons'; +import { + ACTIONS, COUPON_FILTERS, FILTER_OPTIONS, +} from './constants'; + +export const getFilterOptions = (usageLimitation) => { + const shouldHidePartialRedeem = [SINGLE_USE, ONCE_PER_CUSTOMER].includes(usageLimitation); + let options = FILTER_OPTIONS; + + if (shouldHidePartialRedeem) { + options = FILTER_OPTIONS.filter(option => option.value !== COUPON_FILTERS.partiallyRedeemed.value); + } + + return options; +}; + +export const getFirstNonDisabledOption = (options) => { + const firstNonDisabledOption = options.find(option => !option.disabled); + + if (firstNonDisabledOption) { + return firstNonDisabledOption.value; + } + + return options[0].value; +}; + +export const getBASelectOptions = ({ + isAssignView, + isRedeemedView, + hasTableData, + couponAvailable, + numUnassignedCodes, + numSelectedCodes, +}) => ([{ + label: ACTIONS.assign.label, + value: ACTIONS.assign.value, + disabled: !isAssignView || isRedeemedView || !hasTableData || !couponAvailable || numUnassignedCodes === 0, // eslint-disable-line max-len +}, { + label: ACTIONS.remind.label, + value: ACTIONS.remind.value, + disabled: isAssignView || isRedeemedView || !hasTableData || !couponAvailable, +}, { + label: ACTIONS.revoke.label, + value: ACTIONS.revoke.value, + disabled: isAssignView || isRedeemedView || !hasTableData || !couponAvailable || numSelectedCodes === 0, // eslint-disable-line max-len +}]); + +export const shouldShowSelectAllStatusAlert = ({ + tableData, hasAllCodesSelected, selectedToggle, selectedCodes, +}) => { + if (!tableData || selectedToggle !== COUPON_FILTERS.unassigned.value) { + return false; + } + + if (hasAllCodesSelected) { + return true; + } + + return ( + selectedCodes.length === tableData.results.length + && selectedCodes.length !== tableData.count + ); +}; diff --git a/src/components/CouponDetails/helpers.test.jsx b/src/components/CouponDetails/helpers.test.jsx new file mode 100644 index 0000000000..099c618083 --- /dev/null +++ b/src/components/CouponDetails/helpers.test.jsx @@ -0,0 +1,162 @@ +/* eslint-disable max-len */ +/* eslint-disable object-curly-newline */ +import { + SINGLE_USE, ONCE_PER_CUSTOMER, MULTI_USE, MULTI_USE_PER_CUSTOMER, +} from '../../data/constants/coupons'; +import { ACTIONS, COUPON_FILTERS, FILTER_OPTIONS } from './constants'; +import { getBASelectOptions, getFilterOptions, getFirstNonDisabledOption, shouldShowSelectAllStatusAlert } from './helpers'; + +describe('getFilterOptions', () => { + test.each([ + [MULTI_USE], + [MULTI_USE_PER_CUSTOMER], + ])('includes partially redeemed for partially redeemable coupons %p', (usageLimitation) => { + expect(getFilterOptions(usageLimitation)).toEqual(FILTER_OPTIONS); + }); + test.each([ + [SINGLE_USE], + [ONCE_PER_CUSTOMER], + ])('does not include partially redeemed for single-use coupons %p', (usageLimitation) => { + const expected = [{ + label: 'Unassigned', + value: 'unassigned', + }, { + label: 'Unredeemed', + value: 'unredeemed', + }, { + label: 'Redeemed', + value: 'redeemed', + }]; + expect(getFilterOptions(usageLimitation)).toEqual(expected); + }); +}); + +describe('getFirstNonDisabledOption', () => { + test.each([ + [[{ value: 'foo', disabled: true }, { value: 'bar' }], 'bar'], + [[{ value: 'foo' }, { value: 'bar', disabled: false }, { value: 'baz', disabled: true }], 'foo'], + [[{ value: 'foo', disabled: true }, { value: 'bar', disabled: true }, { value: 'baz', disabled: false }], 'baz'], + ])('it returns the first non-disabled option %#', (options, expected) => { + expect(getFirstNonDisabledOption(options)).toEqual(expected); + }); + it('returns the first option if all are disabled', () => { + const options = [{ value: 'foo', disabled: true }, { value: 'bar', disabled: true }, { value: 'baz', disabled: true }]; + expect(getFirstNonDisabledOption(options)).toEqual('foo'); + }); +}); + +describe('shouldShowSelectAllStatusAlert', () => { + test.each([ + [{ tableData: null, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: [] }], + [{ tableData: undefined, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: [] }], + ])('returns false when there is no table data', (options) => { + expect(shouldShowSelectAllStatusAlert(options)).toEqual(false); + }); + test.each([ + [{ tableData: true, selectedToggle: COUPON_FILTERS.partiallyRedeemed.value, hasAllCodesSelected: false, selectedCodes: [] }], + [{ tableData: true, selectedToggle: COUPON_FILTERS.redeemed.value, hasAllCodesSelected: false, selectedCodes: [] }], + [{ tableData: true, selectedToggle: COUPON_FILTERS.unredeemed.value, hasAllCodesSelected: false, selectedCodes: [] }], + ])('returns false when the selected toggle is not unassigned', (options) => { + expect(shouldShowSelectAllStatusAlert(options)).toEqual(false); + }); + it('returns true if all codes are selected', () => { + const options = { tableData: true, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: true, selectedCodes: [] }; + expect(shouldShowSelectAllStatusAlert(options)).toEqual(true); + }); + test.each([ + [{ tableData: { results: Array(3) }, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: Array(2) }], + [{ tableData: { results: Array(100) }, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: Array(25) }], + ])('returns false if selected codes do not have the same length as the table data results', (options) => { + expect(shouldShowSelectAllStatusAlert(options)).toEqual(false); + }); + test.each([ + [{ tableData: { results: Array(2), count: 4 }, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: Array(2) }], + [{ tableData: { results: Array(100), count: 200 }, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: Array(100) }], + ])('returns true if the selected codes does not equal the tableData count', (options) => { + expect(shouldShowSelectAllStatusAlert(options)).toEqual(true); + }); + it('returns false if the code count matches the tableData count', () => { + const options = { tableData: { results: Array(100), count: 100 }, selectedToggle: COUPON_FILTERS.unassigned.value, hasAllCodesSelected: false, selectedCodes: Array(100) }; + expect(shouldShowSelectAllStatusAlert(options)).toEqual(false); + }); +}); + +describe('getBASelectOptions', () => { + describe('assign option', () => { + const defaultAssignOptions = { + isAssignView: true, + isRedeemedView: false, + hasTableData: true, + couponAvailable: true, + numUnassignedCodes: 3, + numSelectedCodes: 2, + }; + it('has the correct label and value', () => { + const assignOption = getBASelectOptions(defaultAssignOptions)[0]; + expect(assignOption.label).toEqual(ACTIONS.assign.label); + expect(assignOption.value).toEqual(ACTIONS.assign.value); + }); + test.each([ + [{ ...defaultAssignOptions }, false], + + [{ ...defaultAssignOptions, isRedeemedView: true }, true], + [{ ...defaultAssignOptions, isAssignView: false }, true], + [{ ...defaultAssignOptions, hasTableData: false }, true], + [{ ...defaultAssignOptions, numUnassignedCodes: 0 }, true], + ])('has the correct disabled value %#', (options, expected) => { + const assignOption = getBASelectOptions(options)[0]; + expect(assignOption.disabled).toEqual(expected); + }); + }); + describe('remind option', () => { + const defaultRemindOptions = { + isAssignView: false, + isRedeemedView: false, + hasTableData: true, + couponAvailable: true, + numUnssignedCodes: 3, + numSelectedCodes: 2, + }; + it('has the correct label and value', () => { + const remindOption = getBASelectOptions(defaultRemindOptions)[1]; + expect(remindOption.label).toEqual(ACTIONS.remind.label); + expect(remindOption.value).toEqual(ACTIONS.remind.value); + }); + test.each([ + [{ ...defaultRemindOptions }, false], + [{ ...defaultRemindOptions, isRedeemedView: true }, true], + [{ ...defaultRemindOptions, isAssignView: true }, true], + [{ ...defaultRemindOptions, hasTableData: false }, true], + [{ ...defaultRemindOptions, couponAvailable: false }, true], + ])('has the correct disabled value %#', (options, expected) => { + const remindOption = getBASelectOptions(options)[1]; + expect(remindOption.disabled).toEqual(expected); + }); + }); + describe('revoke option', () => { + const defaultRemindOptions = { + isAssignView: false, + isRedeemedView: false, + hasTableData: true, + couponAvailable: true, + numUnssignedCodes: 3, + numSelectedCodes: 2, + }; + it('has the correct label and value', () => { + const revokeOption = getBASelectOptions(defaultRemindOptions)[2]; + expect(revokeOption.label).toEqual(ACTIONS.revoke.label); + expect(revokeOption.value).toEqual(ACTIONS.revoke.value); + }); + test.each([ + [{ ...defaultRemindOptions }, false], + [{ ...defaultRemindOptions, isRedeemedView: true }, true], + [{ ...defaultRemindOptions, isAssignView: true }, true], + [{ ...defaultRemindOptions, hasTableData: false }, true], + [{ ...defaultRemindOptions, couponAvailable: false }, true], + [{ ...defaultRemindOptions, numSelectedCodes: 0 }, true], + ])('has the correct disabled value %#', (options, expected) => { + const revokeOption = getBASelectOptions(options)[2]; + expect(revokeOption.disabled).toEqual(expected); + }); + }); +}); diff --git a/src/components/CouponDetails/index.jsx b/src/components/CouponDetails/index.jsx index 83738d72b6..249e9257b8 100644 --- a/src/components/CouponDetails/index.jsx +++ b/src/components/CouponDetails/index.jsx @@ -2,7 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { - Button, CheckBox, Icon, InputSelect, + Button, CheckBox, Icon, } from '@edx/paragon'; import TableContainer from '../../containers/TableContainer'; @@ -11,14 +11,20 @@ import CodeAssignmentModal from '../../containers/CodeAssignmentModal'; import CodeReminderModal from '../../containers/CodeReminderModal'; import CodeRevokeModal from '../../containers/CodeRevokeModal'; import StatusAlert from '../StatusAlert'; -import RemindButton from '../RemindButton'; -import RevokeButton from '../RevokeButton'; -import { features } from '../../config'; -import { SINGLE_USE, ONCE_PER_CUSTOMER } from '../../data/constants/coupons'; import EcommerceApiService from '../../data/services/EcommerceApiService'; import { updateUrl } from '../../utils'; import { MODAL_TYPES } from '../EmailTemplateForm/constants'; +import { + getBASelectOptions, getFilterOptions, getFirstNonDisabledOption, + shouldShowSelectAllStatusAlert, +} from './helpers'; +import { + ACTIONS, COUPON_FILTERS, DEFAULT_TABLE_COLUMNS, SUCCESS_MESSAGES, +} from './constants'; +import ActionButton from './ActionButton'; +import CouponFilters from './CouponFilters'; +import CouponBulkActions from './CouponBulkActions'; class CouponDetails extends React.Component { constructor(props) { @@ -30,51 +36,11 @@ class CouponDetails extends React.Component { this.hasAllTableRowsSelected = false; this.selectedTableRows = {}; - const tableColumns = [ - { - label: ( - {this.getSelectAllCheckBoxLabel()} - } - onChange={(checked) => { - this.hasAllTableRowsSelected = checked; - this.handleSelectAllCodes(checked); - }} - ref={this.selectAllCheckBoxRef} - /> - ), - key: 'select', - }, - { - label: 'Redemptions', - key: 'redemptions', - }, - { - label: 'Code', - key: 'code', - }, - { - label: 'Assignments Remaining', - key: 'assignments_remaining', - }, - { - label: 'Actions', - key: 'actions', - }, - ]; - - if (features.CODE_VISIBILITY) { - tableColumns.splice(3, 0, { - label: 'Visibility', - key: 'is_public', - }); - } + const tableColumns = this.getNewColumns(COUPON_FILTERS.unassigned.value); this.state = { - selectedToggle: 'unassigned', + selectedToggle: COUPON_FILTERS.unassigned.value, + bulkActionToggle: ACTIONS.assign.value, tableColumns, modals: { assignment: null, @@ -82,7 +48,6 @@ class CouponDetails extends React.Component { isCodeAssignmentSuccessful: undefined, isCodeReminderSuccessful: undefined, isCodeRevokeSuccessful: undefined, - isCodeVisibilitySuccessful: undefined, doesCodeActionHaveErrors: undefined, selectedCodes: [], hasAllCodesSelected: false, @@ -99,11 +64,12 @@ class CouponDetails extends React.Component { this.formatCouponData = this.formatCouponData.bind(this); this.handleToggleSelect = this.handleToggleSelect.bind(this); - this.handleVisibilitySelect = this.handleVisibilitySelect.bind(this); - this.handleBulkActionSelect = this.handleBulkActionSelect.bind(this); + this.handleBulkAction = this.handleBulkAction.bind(this); this.resetModals = this.resetModals.bind(this); this.handleCodeActionSuccess = this.handleCodeActionSuccess.bind(this); this.resetCodeActionStatus = this.resetCodeActionStatus.bind(this); + this.setModalState = this.setModalState.bind(this); + this.handleBulkActionChange = this.handleBulkActionChange.bind(this); } componentDidUpdate(prevProps) { @@ -120,184 +86,78 @@ class CouponDetails extends React.Component { } } - getTableFilterSelectOptions() { - const { couponData: { usage_limitation: usageLimitation } } = this.props; - const shouldHidePartialRedeem = [SINGLE_USE, ONCE_PER_CUSTOMER].includes(usageLimitation); - - let options = [{ - label: 'Unassigned', - value: 'unassigned', - }, { - label: 'Unredeemed', - value: 'unredeemed', - }, { - label: 'Partially Redeemed', - value: 'partially-redeemed', - }, { - label: 'Redeemed', - value: 'redeemed', - }]; - - if (shouldHidePartialRedeem) { - options = options.filter(option => option.value !== 'partially-redeemed'); - } + getNewColumns(selectedToggle) { + const selectColumn = { + label: ( + {this.getSelectAllCheckBoxLabel()} + } + onChange={(checked) => { + this.hasAllTableRowsSelected = checked; + this.handleSelectAllCodes(checked); + }} + ref={this.selectAllCheckBoxRef} + /> + ), + key: 'select', + }; - return options; + switch (selectedToggle) { + case COUPON_FILTERS.unassigned.value: + return [ + selectColumn, + ...DEFAULT_TABLE_COLUMNS[COUPON_FILTERS.unassigned.value], + ]; + case COUPON_FILTERS.unredeemed.value: + return [ + selectColumn, + ...DEFAULT_TABLE_COLUMNS[COUPON_FILTERS.unredeemed.value], + ]; + case COUPON_FILTERS.partiallyRedeemed.value: + return [ + selectColumn, + ...DEFAULT_TABLE_COLUMNS[COUPON_FILTERS.partiallyRedeemed.value], + ]; + case COUPON_FILTERS.redeemed.value: + return [ + selectColumn, + ...DEFAULT_TABLE_COLUMNS[COUPON_FILTERS.redeemed.value], + ]; + default: + return this.tableColumns; + } } - getTableFilterVisibilitySelectionOptions() { - return [{ - label: 'Both', - value: undefined, - }, { - label: 'Public', - value: 'public', - }, { - label: 'Private', - value: 'private', - }]; + getTableFilterSelectOptions() { + const { couponData: { usage_limitation: usageLimitation } } = this.props; + return getFilterOptions(usageLimitation); } getBulkActionSelectOptions() { const { selectedToggle, selectedCodes } = this.state; + const { couponData: { num_unassigned: unassignedCodes, available: couponAvailable }, couponDetailsTable: { data: tableData }, } = this.props; - const isAssignView = selectedToggle === 'unassigned'; - const isRedeemedView = selectedToggle === 'redeemed'; + const isAssignView = selectedToggle === COUPON_FILTERS.unassigned.value; + const isRedeemedView = selectedToggle === COUPON_FILTERS.redeemed.value; const hasTableData = tableData && tableData.count; - const hasPublicCodes = selectedCodes.filter(code => code.is_public).length > 0; - - const bulkActionSelectOptions = [{ - label: 'Assign', - value: 'assign', - disabled: hasPublicCodes || !isAssignView || isRedeemedView || !hasTableData || !couponAvailable || unassignedCodes === 0, // eslint-disable-line max-len - }, { - label: 'Remind', - value: 'remind', - disabled: isAssignView || isRedeemedView || !hasTableData || !couponAvailable, - }, { - label: 'Revoke', - value: 'revoke', - disabled: isAssignView || isRedeemedView || !hasTableData || !couponAvailable || selectedCodes.length === 0, // eslint-disable-line max-len - }]; - - if (features.CODE_VISIBILITY) { - bulkActionSelectOptions.push({ - label: 'Make Public', - value: 'make_public', - disabled: !hasTableData || selectedCodes.length === 0, - }); - bulkActionSelectOptions.push({ - label: 'Make Private', - value: 'make_private', - disabled: !hasTableData || selectedCodes.length === 0, - }); - } - return bulkActionSelectOptions; - } - - getBulkActionSelectValue() { - const bulkActionSelectOptions = this.getBulkActionSelectOptions(); - const firstNonDisabledOption = bulkActionSelectOptions.find(option => !option.disabled); - - if (firstNonDisabledOption) { - return firstNonDisabledOption.value; - } - - return bulkActionSelectOptions[0].value; - } - - getActionButton(code) { - const { - couponData: { - id, - errors, - available: couponAvailable, - title: couponTitle, - }, - } = this.props; - const { selectedToggle } = this.state; - const { - assigned_to: assignedTo, - is_public: isPublic, - redemptions, - } = code; - - let remainingUses = redemptions.total - redemptions.used; - - // Don't show `Assign/Remind/Revoke` buttons for an unavailable coupon - if (!couponAvailable) { - return null; - } - - // Don't show a button if all total redemptions have been used - if (redemptions.used === redemptions.total) { - return null; - } - - const codeHasError = errors.find(errorItem => errorItem.code === code.code); - if (assignedTo) { - return ( - <> - {!codeHasError && ( - <> - this.handleCodeActionSuccess('remind', response)} - /> - {' | '} - - )} - this.handleCodeActionSuccess('revoke', response)} - /> - - ); - } - - if (isPublic) { - return null; - } - - // exclude existing assignments of code - if (selectedToggle === 'unassigned') { - remainingUses -= redemptions.num_assignments; - } + const bulkActionSelectOptions = getBASelectOptions({ + isAssignView, + isRedeemedView, + hasTableData, + couponAvailable, + numUnassignedCodes: unassignedCodes.length, + numSelectedCodes: selectedCodes.length, + }); - return ( - - ); + return bulkActionSelectOptions; } setModalState({ key, options }) { @@ -323,6 +183,14 @@ class CouponDetails extends React.Component { return `select code ${code}`; }; + updateBulkActionSelectValue() { + const bulkActionSelectOptions = this.getBulkActionSelectOptions(); + const bulkActionToggle = getFirstNonDisabledOption(bulkActionSelectOptions); + this.setState({ + bulkActionToggle, + }); + } + reset() { this.resetModals(); this.resetCodeActionStatus(); @@ -337,25 +205,9 @@ class CouponDetails extends React.Component { shouldShowSelectAllStatusAlert() { const { couponDetailsTable: { data: tableData } } = this.props; const { selectedToggle, selectedCodes, hasAllCodesSelected } = this.state; - - if (!tableData || selectedToggle !== 'unassigned') { - return false; - } - - if (hasAllCodesSelected) { - return true; - } - - return ( - selectedCodes.length === tableData.results.length - && selectedCodes.length !== tableData.count - ); - } - - shouldShowVisibilityStatusAlert() { - const { selectedCodes } = this.state; - return features.CODE_VISIBILITY && (selectedCodes.some(code => code.is_public) - && selectedCodes.some(code => !code.is_public)); + return shouldShowSelectAllStatusAlert({ + tableData, selectedToggle, selectedCodes, hasAllCodesSelected, + }); } hasStatusAlert() { @@ -373,7 +225,6 @@ class CouponDetails extends React.Component { isCodeAssignmentSuccessful, isCodeReminderSuccessful, isCodeRevokeSuccessful, - isCodeVisibilitySuccessful, doesCodeActionHaveErrors, } = this.state; @@ -383,107 +234,28 @@ class CouponDetails extends React.Component { isCodeAssignmentSuccessful, isCodeReminderSuccessful, isCodeRevokeSuccessful, - isCodeVisibilitySuccessful, doesCodeActionHaveErrors, this.shouldShowSelectAllStatusAlert(), - this.shouldShowVisibilityStatusAlert(), ].some(item => item); return !this.isTableLoading() && hasStatusAlert; } handleToggleSelect(newValue) { - const { tableColumns, selectedToggle } = this.state; + const { selectedToggle } = this.state; const value = newValue || selectedToggle; - const assignedToColumnLabel = value === 'unredeemed' ? 'Assigned To' : 'Redeemed By'; - - const getColumnIndexForKey = key => tableColumns.findIndex(column => column.key === key); - - // `assigned_to, assignment_date, last_reminder_date` columns - if (value !== 'unassigned' && getColumnIndexForKey('assigned_to') === -1) { - // Add columns if they donot already exist - tableColumns.splice(1, 0, { - label: assignedToColumnLabel, - key: 'assigned_to', - }); - tableColumns.splice(tableColumns.length - 1, 0, { - label: 'Last Reminder Date', - key: 'last_reminder_date', - }); - tableColumns.splice(tableColumns.length - 2, 0, { - label: 'Assignment Date', - key: 'assignment_date', - }); - } else if (value !== 'unassigned' && getColumnIndexForKey('assigned_to') > -1) { - // Update `assigned_to` column with the appropriate label - tableColumns[1].label = assignedToColumnLabel; - } else if (value === 'unassigned' && getColumnIndexForKey('assigned_to') > -1) { - // Remove columns if they already exist - tableColumns.splice(getColumnIndexForKey('assigned_to'), 1); - tableColumns.splice(getColumnIndexForKey('last_reminder_date'), 1); - tableColumns.splice(getColumnIndexForKey('assignment_date'), 1); - } - - // `assignments_remaining` column - if (value === 'unassigned' && getColumnIndexForKey('assignments_remaining') === -1) { - // Add `assignments_remaining` column if it doesn't already exist. - tableColumns.splice(3, 0, { - label: 'Assignments Remaining', - key: 'assignments_remaining', - }); - } else if (value !== 'unassigned' && getColumnIndexForKey('assignments_remaining') > -1) { - // Remove `assignments_remaining` column if it already exists. - tableColumns.splice(getColumnIndexForKey('assignments_remaining'), 1); - } - - // `is_public` column - if (features.CODE_VISIBILITY && getColumnIndexForKey('is_public') === -1) { - // Add `is_public` column if it doesn't already exist - tableColumns.splice(tableColumns.length, 0, { - label: 'Visibility', - key: 'is_public', - }); - } - - // `actions` column - if (value !== 'redeemed' && getColumnIndexForKey('actions') === -1) { - // Add `actions` column if it doesn't already exist - tableColumns.splice(tableColumns.length, 0, { - label: 'Actions', - key: 'actions', - }); - } else if (value === 'redeemed' && getColumnIndexForKey('actions') > -1) { - // Remove `actions` column if it already exists - tableColumns.splice(getColumnIndexForKey('actions'), 1); - } this.resetCodeActionStatus(); updateUrl({ page: undefined }); this.setState({ - tableColumns, + tableColumns: this.getNewColumns(value), selectedToggle: value, selectedCodes: [], hasAllCodesSelected: false, }, () => { this.updateSelectAllCheckBox(); - }); - } - - handleVisibilitySelect(newValue) { - const { tableColumns, visibilityToggle } = this.state; - // Paragon InputSelect will use the `label` if the value isn't defined - // this will intentionally keep the value set as undefined so that qs.stringify - // won't send along a value we didn't set to the API. - const value = newValue === 'Both' ? undefined : newValue || visibilityToggle; - this.resetCodeActionStatus(); - this.setState({ - tableColumns, - visibilityToggle: value, - selectedCodes: [], - hasAllCodesSelected: false, - }, () => { - this.updateSelectAllCheckBox(); + this.updateBulkActionSelectValue(); }); } @@ -508,7 +280,7 @@ class CouponDetails extends React.Component { // get around this, we get the DOM node of the checkbox and replace the `aria-checked` // attribute appropriately. // - // TODO: We may want to update Paragon `CheckBox` component to handle mixed state. + // TODO: Paragon now has an IndeterminateCheckbox that can be used here. const selectAllCheckBoxRef = selectColumn.label.ref && selectColumn.label.ref.current; const selectAllCheckBoxDOM = ( selectAllCheckBoxRef && document.getElementById(selectAllCheckBoxRef.props.id) @@ -535,25 +307,20 @@ class CouponDetails extends React.Component { let doesCodeActionHaveErrors; switch (action) { - case 'assign': { + case ACTIONS.assign.value: { stateKey = 'isCodeAssignmentSuccessful'; break; } - case 'revoke': { + case ACTIONS.revoke.value: { stateKey = 'isCodeRevokeSuccessful'; doesCodeActionHaveErrors = response && response.some && response.some(item => item.detail === 'failure'); break; } - case 'remind': { + case ACTIONS.remind.value: { stateKey = 'isCodeReminderSuccessful'; doesCodeActionHaveErrors = response && response.some && response.some(item => item.detail === 'failure'); break; } - case 'visibility': { - stateKey = 'isCodeVisibilitySuccessful'; - doesCodeActionHaveErrors = response && response.some && response.some(item => item.detail === 'failure'); - break; - } default: { stateKey = null; doesCodeActionHaveErrors = null; @@ -561,7 +328,7 @@ class CouponDetails extends React.Component { } } - if (action === 'assign' || action === 'revoke') { + if (action === ACTIONS.assign.value || action === ACTIONS.revoke.value) { this.updateCouponOverviewData(); } @@ -579,6 +346,12 @@ class CouponDetails extends React.Component { } } + handleBulkActionChange(newValue) { + this.setState({ + bulkActionToggle: newValue, + }); + } + handleCodeSelection({ checked, code }) { let { selectedCodes, hasAllCodesSelected } = this.state; @@ -619,7 +392,8 @@ class CouponDetails extends React.Component { } formatCouponData(data) { - const { selectedCodes } = this.state; + const { couponData } = this.props; + const { selectedCodes, selectedToggle } = this.state; return data.map(code => ({ ...code, @@ -634,8 +408,13 @@ class CouponDetails extends React.Component { assignments_remaining: `${code.redemptions.total - code.redemptions.used - code.redemptions.num_assignments}`, assignment_date: `${code.assignment_date}`, last_reminder_date: `${code.last_reminder_date}`, - is_public: code.is_public ? 'Public' : 'Private', - actions: this.getActionButton(code), + actions: , select: ( option.disabled); } - handleBulkActionSelect() { + handleBulkAction() { const { couponData: { id, @@ -680,12 +459,10 @@ class CouponDetails extends React.Component { hasAllCodesSelected, selectedCodes, selectedToggle, + bulkActionToggle, } = this.state; - const ref = this.bulkActionSelectRef && this.bulkActionSelectRef.current; - const selectedBulkAction = ref && ref.value; - - if (selectedBulkAction === 'assign') { + if (bulkActionToggle === ACTIONS.assign.value) { this.setModalState({ key: 'assignment', options: { @@ -700,7 +477,7 @@ class CouponDetails extends React.Component { }, }, }); - } else if (selectedBulkAction === 'revoke') { + } else if (bulkActionToggle === ACTIONS.revoke.value) { this.setModalState({ key: 'revoke', options: { @@ -712,7 +489,7 @@ class CouponDetails extends React.Component { }, }, }); - } else if (selectedBulkAction === 'remind') { + } else if (bulkActionToggle === ACTIONS.remind.value) { this.setModalState({ key: 'remind', options: { @@ -725,16 +502,6 @@ class CouponDetails extends React.Component { }, }, }); - } else if (selectedBulkAction === 'make_public' || selectedBulkAction === 'make_private') { - const isPublic = selectedBulkAction === 'make_public'; - const codeIds = selectedCodes.map(selectedCode => selectedCode.code); - const options = { - couponId: id, - codeIds, - isPublic, - onSuccess: response => this.handleCodeActionSuccess('visibility', response), - }; - this.props.updateCodeVisibility(options); } } @@ -753,7 +520,6 @@ class CouponDetails extends React.Component { isCodeAssignmentSuccessful: undefined, isCodeReminderSuccessful: undefined, isCodeRevokeSuccessful: undefined, - isCodeVisibilitySuccessful: undefined, doesCodeActionHaveErrors: undefined, }); } @@ -813,11 +579,10 @@ class CouponDetails extends React.Component { isCodeAssignmentSuccessful, isCodeReminderSuccessful, isCodeRevokeSuccessful, - isCodeVisibilitySuccessful, doesCodeActionHaveErrors, refreshIndex, hasAllCodesSelected, - visibilityToggle, + bulkActionToggle, } = this.state; const { @@ -859,49 +624,20 @@ class CouponDetails extends React.Component { /> -
-
- - {features.CODE_VISIBILITY && ( -
- -
- )} -
-
- - -
+
+ +
{this.hasStatusAlert() && (
@@ -937,7 +673,7 @@ class CouponDetails extends React.Component { ), })} {isCodeAssignmentSuccessful && this.renderSuccessMessage({ - title: 'Successfully assigned code(s)', + title: SUCCESS_MESSAGES.assign, message: ( <> To view the newly assigned code(s), filter by @@ -958,21 +694,15 @@ class CouponDetails extends React.Component { ), })} {isCodeReminderSuccessful && this.renderSuccessMessage({ - message: 'Reminder request processed.', + message: SUCCESS_MESSAGES.remind, })} {isCodeRevokeSuccessful && this.renderSuccessMessage({ - message: 'Successfully revoked code(s)', - })} - {isCodeVisibilitySuccessful && this.renderSuccessMessage({ - message: 'Successfully changed visibility for code(s)', + message: SUCCESS_MESSAGES.revoke, })} {doesCodeActionHaveErrors && this.renderErrorMessage({ title: 'An unexpected error has occurred. Please try again or contact your Customer Success Manager.', message: '', })} - {this.shouldShowVisibilityStatusAlert() && this.renderInfoMessage({ - message: "You've selected one or more public codes. If you wish to assign codes in bulk, please select only private codes.", - })} {this.shouldShowSelectAllStatusAlert() && this.renderInfoMessage({ message: ( <> @@ -997,14 +727,17 @@ class CouponDetails extends React.Component { EcommerceApiService.fetchCouponDetails(id, { - ...options, - code_filter: selectedToggle, - visibility_filter: visibilityToggle, - })} + fetchMethod={(enterpriseId, options) => { + const apiOptions = { + ...options, + code_filter: selectedToggle, + }; + + return EcommerceApiService.fetchCouponDetails(id, apiOptions); + }} columns={tableColumns} formatData={this.formatCouponData} /> @@ -1047,7 +780,6 @@ CouponDetails.defaultProps = { CouponDetails.propTypes = { // props from container fetchCouponOrder: PropTypes.func.isRequired, - updateCodeVisibility: PropTypes.func.isRequired, couponDetailsTable: PropTypes.shape({ data: PropTypes.shape({}), loading: PropTypes.bool, diff --git a/src/components/CouponDetails/index.test.jsx b/src/components/CouponDetails/index.test.jsx new file mode 100644 index 0000000000..b7f670fdba --- /dev/null +++ b/src/components/CouponDetails/index.test.jsx @@ -0,0 +1,172 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import { MemoryRouter } from 'react-router-dom'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import userEvent from '@testing-library/user-event'; +import { renderWithRouter } from '../test/testUtils'; +import CouponDetails from './index'; +import { COUPON_FILTERS, DEFAULT_TABLE_COLUMNS } from './constants'; +import { EMAIL_TEMPLATE_SOURCE_NEW_EMAIL } from '../../data/constants/emailTemplate'; +import { MULTI_USE } from '../../data/constants/coupons'; + +const mockStore = configureMockStore([thunk]); + +const sampleEmailTemplate = { + 'email-address': '', + 'email-template-greeting': 'Sample email greeting.. ', + 'email-template-body': 'Sample email body template.. ', + 'email-template-closing': 'Sample email closing template.. ', +}; + +const emailDefaults = { + 'template-id': 0, + 'email-address': '', + 'template-name-select': '', + 'email-template-subject': 'Sample email subject.. ', + 'email-template-greeting': 'Sample email greeting.. ', + 'email-template-body': 'Sample email body template.. ', + 'email-template-closing': 'Sample email closing template.. ', +}; + +const sampleCodeData = { + code: 'test-code-1', + assigned_to: 'test@bestrun.com', + redemptions: { + total: 100, + used: 10, + num_assignments: 5, + }, + assignment_date: 'June 02, 2020 13:09', + last_reminder_date: 'June 22, 2020 12:01', + revocation_date: '', + error: null, +}; + +const sampleTableData = { + loading: false, + error: null, + data: { + count: 5, + num_pages: 2, + current_page: 1, + results: [ + sampleCodeData, + { + ...sampleCodeData, + code: 'test-code-2', + redemptions: { + total: 100, + used: 100, + num_assignments: 0, + }, + }, + { + ...sampleCodeData, + code: 'test-code-3', + assigned_to: null, + }, + ], + }, +}; +const reduxState = { + portalConfiguration: { + enterpriseId: 'LaelCo', + enterpriseSlug: 'bearsRus', + enableLearnerPortal: true, + }, + csv: { + 'coupon-details': {}, + }, + table: { + 'coupon-details': sampleTableData, + }, + form: { + 'code-assignment-modal-form': { + values: { + 'email-address': '', + }, + }, + }, + coupons: { + couponOverviewLoading: false, + couponOverviewError: null, + }, + emailTemplate: { + loading: false, + error: null, + emailTemplateSource: EMAIL_TEMPLATE_SOURCE_NEW_EMAIL, + default: { + assign: sampleEmailTemplate, + remind: sampleEmailTemplate, + revoke: sampleEmailTemplate, + }, + assign: emailDefaults, + remind: emailDefaults, + revoke: emailDefaults, + }, +}; + +const couponData = { + id: 2, + title: 'LaelCoupon', + errors: [], + num_assigned: 2, + usage_limitation: MULTI_USE, + available: true, + num_unassigned: 90, +}; + +const defaultProps = { + fetchCouponOrder: () => {}, + couponDetailsTable: { + data: sampleTableData.data, + loading: false, + }, + couponData, + isExpanded: true, +}; + +const CouponDetailsWrapper = props => ( + + + + + +); + +// NOTE: Further integration testing can be found in src/containers/CouponDetails.test.jsx + +describe('CouponDetails component', () => { + it('does not display contents when not expanded', () => { + render(); + expect(screen.queryByText('Coupon Details')).not.toBeInTheDocument(); + }); + it('renders an expanded page', () => { + renderWithRouter(); + expect(screen.getByText('Coupon Details')).toBeInTheDocument(); + expect(screen.getByText('Download full report (CSV)')).toBeInTheDocument(); + }); + it('renders the unassigned table by default', () => { + renderWithRouter(); + expect(screen.getByText(COUPON_FILTERS.unassigned.label)).toBeInTheDocument(); + DEFAULT_TABLE_COLUMNS.unassigned.forEach(({ label }) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + it('renders with error state', () => { + renderWithRouter(); + userEvent.selectOptions(screen.getByLabelText('Filter by code status'), COUPON_FILTERS.unredeemed.label); + expect(screen.getByText('An error has occurred:', { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/src/components/ErrorPage/__snapshots__/ErrorPage.test.jsx.snap b/src/components/ErrorPage/__snapshots__/ErrorPage.test.jsx.snap index 977f3023d9..29b099e84a 100644 --- a/src/components/ErrorPage/__snapshots__/ErrorPage.test.jsx.snap +++ b/src/components/ErrorPage/__snapshots__/ErrorPage.test.jsx.snap @@ -69,13 +69,9 @@ exports[` renders correctly for 403 errors 1`] = ` For assistance, please contact the edX Customer Success team at customersuccess@edx.org diff --git a/src/components/ForbiddenPage/__snapshots__/ForbiddenPage.test.jsx.snap b/src/components/ForbiddenPage/__snapshots__/ForbiddenPage.test.jsx.snap index 8a04862ccf..7a271c637a 100644 --- a/src/components/ForbiddenPage/__snapshots__/ForbiddenPage.test.jsx.snap +++ b/src/components/ForbiddenPage/__snapshots__/ForbiddenPage.test.jsx.snap @@ -22,13 +22,9 @@ exports[` renders correctly 1`] = ` For assistance, please contact the edX Customer Success team at customersuccess@edx.org diff --git a/src/components/NumberCard/__snapshots__/NumberCard.test.jsx.snap b/src/components/NumberCard/__snapshots__/NumberCard.test.jsx.snap index af5a60599d..59b61ff6c0 100644 --- a/src/components/NumberCard/__snapshots__/NumberCard.test.jsx.snap +++ b/src/components/NumberCard/__snapshots__/NumberCard.test.jsx.snap @@ -48,7 +48,7 @@ exports[` renders correctly with detail actions 1`] = ` className="d-flex justify-content-between align-items-center" >
Details
diff --git a/src/components/NumberCard/index.jsx b/src/components/NumberCard/index.jsx index 6824f5e926..26a955ad81 100644 --- a/src/components/NumberCard/index.jsx +++ b/src/components/NumberCard/index.jsx @@ -5,6 +5,7 @@ import { Button, Icon } from '@edx/paragon'; import { Link, withRouter } from 'react-router-dom'; import { removeTrailingSlash, isTriggerKey } from '../../utils'; +import { DETAILS_TEXT } from '../CouponDetails/constants'; export const triggerKeys = { OPEN_DETAILS: ['ArrowDown', 'Enter'], @@ -212,8 +213,8 @@ class NumberCard extends React.Component { aria-controls={`footer-body-${id}`} >
-
- {detailsExpanded ? 'Detailed breakdown' : 'Details'} +
+ {detailsExpanded ? DETAILS_TEXT.expanded : DETAILS_TEXT.unexpanded}
diff --git a/src/components/RemindButton/index.jsx b/src/components/RemindButton/index.jsx index 4b1f15ca66..9ca1534a27 100644 --- a/src/components/RemindButton/index.jsx +++ b/src/components/RemindButton/index.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import CodeReminderModal from '../../containers/CodeReminderModal'; import ActionButtonWithModal from '../ActionButtonWithModal'; +import { ACTIONS } from '../CouponDetails/constants'; const RemindButton = ({ couponId, @@ -12,7 +13,7 @@ const RemindButton = ({ onClose, }) => ( ( diff --git a/src/components/RevokeButton/index.jsx b/src/components/RevokeButton/index.jsx index 1cd0e909b6..96b8e8d891 100644 --- a/src/components/RevokeButton/index.jsx +++ b/src/components/RevokeButton/index.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import CodeRevokeModal from '../../containers/CodeRevokeModal'; import ActionButtonWithModal from '../ActionButtonWithModal'; +import { ACTIONS } from '../CouponDetails/constants'; const RevokeButton = ({ couponId, @@ -12,7 +13,7 @@ const RevokeButton = ({ onClose, }) => ( ( diff --git a/src/components/SupportPage/__snapshots__/SupportPage.test.jsx.snap b/src/components/SupportPage/__snapshots__/SupportPage.test.jsx.snap index e5dd230b9d..c02dbf3f53 100644 --- a/src/components/SupportPage/__snapshots__/SupportPage.test.jsx.snap +++ b/src/components/SupportPage/__snapshots__/SupportPage.test.jsx.snap @@ -30,13 +30,9 @@ Array [ For assistance, please contact the edX Customer Success team at
customersuccess@edx.org diff --git a/src/config/index.js b/src/config/index.js index 1660308a97..7737ac898b 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -45,7 +45,6 @@ const features = { ANALYTICS: process.env.FEATURE_ANALYTICS, SAML_CONFIGURATION: process.env.FEATURE_SAML_CONFIGURATION, SUPPORT: process.env.FEATURE_SUPPORT, - CODE_VISIBILITY: process.env.FEATURE_CODE_VISIBILITY, EXTERNAL_LMS_CONFIGURATION: process.env.FEATURE_EXTERNAL_LMS_CONFIGURATION, ANALYTICS_API_V1: process.env.FEATURE_ANALYTICS_API_V1 || hasFeatureFlagEnabled(FEATURE_ANALYTICS_API_V1), }; diff --git a/src/containers/CouponDetails/CouponDetails.test.jsx b/src/containers/CouponDetails/CouponDetails.test.jsx index c8dfed5f32..79584b48a9 100644 --- a/src/containers/CouponDetails/CouponDetails.test.jsx +++ b/src/containers/CouponDetails/CouponDetails.test.jsx @@ -1,21 +1,28 @@ import React from 'react'; import { Provider } from 'react-redux'; import PropTypes from 'prop-types'; -import renderer from 'react-test-renderer'; +import userEvent from '@testing-library/user-event'; +import { within } from '@testing-library/dom'; import { MemoryRouter } from 'react-router-dom'; import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { mount } from 'enzyme'; +import { render, screen } from '@testing-library/react'; + +import '@testing-library/jest-dom/extend-expect'; import { StatusAlert } from '@edx/paragon'; import { SINGLE_USE, MULTI_USE, ONCE_PER_CUSTOMER } from '../../data/constants/coupons'; import EcommerceaApiService from '../../data/services/EcommerceApiService'; -import CouponDetailsComponent from '../../components/CouponDetails'; import CouponDetails from './index'; import { EMAIL_TEMPLATE_SOURCE_NEW_EMAIL } from '../../data/constants/emailTemplate'; import CodeReminderModal from '../CodeReminderModal'; import CodeAssignmentModal from '../../components/CodeAssignmentModal'; +import { renderWithRouter } from '../../components/test/testUtils'; +import { + ACTIONS, COUPON_FILTERS, DEFAULT_TABLE_COLUMNS, SUCCESS_MESSAGES, +} from '../../components/CouponDetails/constants'; const enterpriseId = 'test-enterprise'; const mockStore = configureMockStore([thunk]); @@ -115,6 +122,7 @@ const sampleCodeData = { last_reminder_date: 'June 22, 2020 12:01', revocation_date: '', error: null, + is_public: false, }; const sampleTableData = { @@ -155,76 +163,68 @@ describe('CouponDetails container', () => { }; describe('renders', () => { - it.skip('with collapsed coupon details', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + it('does not display contents when not expanded', () => { + render(); + expect(screen.queryByText('Coupon Details')).not.toBeInTheDocument(); }); - - it.skip('with expanded coupon details', () => { - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + it('renders an expanded page', () => { + renderWithRouter(); + expect(screen.getByText('Coupon Details')).toBeInTheDocument(); + expect(screen.getByText('Download full report (CSV)')).toBeInTheDocument(); }); - - it.skip('with error', () => { - const tree = renderer.create(( - - )); - tree.root.findByType(CouponDetailsComponent).instance.setState({ selectedToggle: 'unredeemed' }); - expect(tree.toJSON()).toMatchSnapshot(); + it('renders the unassigned table by default', () => { + store = mockStore({ + ...initialState, + table: { + 'coupon-details': sampleTableData, + }, + }); + renderWithRouter(); + expect(screen.getByText(COUPON_FILTERS.unassigned.label)).toBeInTheDocument(); + DEFAULT_TABLE_COLUMNS.unassigned.forEach(({ label }) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); }); - it.skip('with table data', () => { + test.each([ + [COUPON_FILTERS.unassigned.value], + [COUPON_FILTERS.unredeemed.value], + [COUPON_FILTERS.partiallyRedeemed.value], + [COUPON_FILTERS.redeemed.value], + ])('renders the correct table columns for each coupon filter type %s', (filterType) => { store = mockStore({ ...initialState, table: { 'coupon-details': sampleTableData, }, }); - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + renderWithRouter(); + userEvent.selectOptions(screen.getByLabelText('Filter by code status'), filterType); + + DEFAULT_TABLE_COLUMNS[filterType].forEach(({ label }) => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); }); - it.skip('with assignment and reminder dates table data', () => { + it('shows Assign button for an available coupon', () => { store = mockStore({ ...initialState, table: { 'coupon-details': sampleTableData, }, }); - const tree = renderer - .create(( - - )); - tree.root.findByType(CouponDetailsComponent).instance.setState({ selectedToggle: 'unredeemed' }); - expect(tree.toJSON()).toMatchSnapshot(); - tree.root.findByType(CouponDetailsComponent).instance.setState({ selectedToggle: 'partially-redeemed' }); - expect(tree.toJSON()).toMatchSnapshot(); - tree.root.findByType(CouponDetailsComponent).instance.setState({ selectedToggle: 'redeemed' }); - expect(tree.toJSON()).toMatchSnapshot(); + + renderWithRouter(); + + const table = document.getElementsByTagName('table')[0]; + // getByText will throw an error if the text is not present + within(table).getByText(ACTIONS.assign.label); }); it.skip('does not show Assign button for an unavailable coupon', () => { @@ -235,19 +235,17 @@ describe('CouponDetails container', () => { }, }); - const tree = renderer - .create(( - - )) - .toJSON(); - expect(tree).toMatchSnapshot(); + renderWithRouter(); + + const table = document.getElementsByTagName('table')[0]; + expect(within(table).queryByText(ACTIONS.assign.label)).toBeNull(); }); }); @@ -345,8 +343,8 @@ describe('CouponDetails container', () => { expect(spy).toBeCalledWith({ coupon_id: initialCouponData.id }); }); - it('sets disabled to true when unassignedCodes === 0', () => { - wrapper = mount(( + it('disables bulk action select when unassignedCodes === 0', () => { + renderWithRouter(( { isExpanded /> )); - expect(wrapper.find('select').last().prop('name')).toEqual('bulk-action'); - expect(wrapper.find('select').last().prop('disabled')).toEqual(true); + expect(screen.getByLabelText('Bulk action')).toBeDisabled(); }); - it('sets disabled to false when unassignedCodes !== 0', () => { + it('enables bulk action select when unassignedCodes !== 0', () => { store = mockStore({ ...initialState, table: { @@ -367,10 +364,9 @@ describe('CouponDetails container', () => { }, }); - wrapper = mount(); + renderWithRouter(); - expect(wrapper.find('select').last().prop('name')).toEqual('bulk-action'); - expect(wrapper.find('select').last().prop('disabled')).toEqual(false); + expect(screen.getByLabelText('Bulk action')).toBeEnabled(); }); it('removes remind button in case overview has errors', () => { @@ -392,16 +388,15 @@ describe('CouponDetails container', () => { describe('modals', () => { let spy; - const openModalByActionButton = ({ key, label }) => { + const openModalByActionButton = ({ key }) => { + const actionButton = wrapper.find('table').find('button').find(`.${key}-btn`); + actionButton.simulate('click'); + }; + + const testModalActionButton = ({ key, label }) => { const actionButton = wrapper.find('table').find('button').find(`.${key}-btn`); expect(actionButton.text()).toEqual(label); actionButton.simulate('click'); - // TODO: The remind/revoke buttons now manage their modal state in their - // own components, so we only need to worry about the `assign` action now. - // We might also want to move the Assign button to its own component as well. - if (key === 'assign') { - expect(wrapper.find('CouponDetails').instance().state.modals[key]).toBeTruthy(); - } }; beforeEach(() => { @@ -426,28 +421,32 @@ describe('CouponDetails container', () => { }); it('sets remind modal state on Remind button click', () => { - openModalByActionButton({ + testModalActionButton({ key: 'remind', label: 'Remind', }); }); it('sets revoke modal state on Revoke button click', () => { - openModalByActionButton({ + testModalActionButton({ key: 'revoke', label: 'Revoke', }); }); it('sets assignment modal state on Assign button click', () => { - openModalByActionButton({ + testModalActionButton({ key: 'assignment', label: 'Assign', }); + // TODO: The remind/revoke buttons now manage their modal state in their + // own components, so we only need to worry about the `assign` action now. + // We might also want to move the Assign button to its own component as well. + expect(wrapper.find('CouponDetails').instance().state.modals.assignment).toBeTruthy(); }); it('shows correct remaining uses on assignment modal', () => { - openModalByActionButton({ + testModalActionButton({ key: 'assignment', label: 'Assign', }); @@ -500,16 +499,17 @@ describe('CouponDetails container', () => { // fake successful code assignment wrapper.find(CodeAssignmentModal).prop('onSuccess')(); - expect(wrapper.find('CouponDetails').instance().state.isCodeAssignmentSuccessful).toBeTruthy(); - wrapper.update(); // success status alert - expect(wrapper.find(StatusAlert).prop('alertType')).toEqual('success'); - wrapper.find(StatusAlert).find('.alert-dialog .btn').simulate('click'); + const statusAlert = wrapper.find(StatusAlert); + expect(statusAlert.prop('alertType')).toEqual('success'); + expect(statusAlert.text()).toContain(SUCCESS_MESSAGES.assign); + statusAlert.find('.alert-dialog .btn').simulate('click'); - expect(wrapper.find('CouponDetails').instance().state.isCodeAssignmentSuccessful).toBeFalsy(); - expect(wrapper.find('CouponDetails').instance().state.selectedToggle).toEqual('unredeemed'); + // after alert is dismissed + expect(wrapper.find(StatusAlert)).toHaveLength(0); + expect(wrapper.find('CouponDetails').text()).toContain(COUPON_FILTERS.unredeemed.label); // fetches overview data for coupon expect(spy).toBeCalledTimes(1); @@ -524,12 +524,12 @@ describe('CouponDetails container', () => { // fake successful code assignment wrapper.find('CodeRevokeModal').prop('onSuccess')(); - expect(wrapper.find('CouponDetails').instance().state.isCodeRevokeSuccessful).toBeTruthy(); - wrapper.update(); // success status alert - expect(wrapper.find(StatusAlert).prop('alertType')).toEqual('success'); + const statusAlert = wrapper.find(StatusAlert); + expect(statusAlert.prop('alertType')).toEqual('success'); + expect(statusAlert.text()).toContain(SUCCESS_MESSAGES.revoke); // fetches overview data for coupon expect(spy).toBeCalledTimes(1); @@ -544,12 +544,12 @@ describe('CouponDetails container', () => { // fake successful code assignment wrapper.find(CodeReminderModal).prop('onSuccess')(); - expect(wrapper.find('CouponDetails').instance().state.isCodeReminderSuccessful).toBeTruthy(); - wrapper.update(); // success status alert - expect(wrapper.find(StatusAlert).prop('alertType')).toEqual('success'); + const statusAlert = wrapper.find(StatusAlert); + expect(statusAlert.prop('alertType')).toEqual('success'); + expect(statusAlert.text()).toContain(SUCCESS_MESSAGES.remind); // does not fetch overview data for coupon expect(spy).toBeCalledTimes(0); diff --git a/src/containers/CouponDetails/__snapshots__/CouponDetails.test.jsx.snap b/src/containers/CouponDetails/__snapshots__/CouponDetails.test.jsx.snap deleted file mode 100644 index 48b247452c..0000000000 --- a/src/containers/CouponDetails/__snapshots__/CouponDetails.test.jsx.snap +++ /dev/null @@ -1,3624 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`CouponDetails container renders does not show Assign button for an unavailable coupon 1`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- -
-
-
- Redemptions - - Code - - Assignments Remaining - - Actions -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-1 - - - 85 - -
-
- - -
- -
-
-
- 100 of 100 - - - test-code-2 - - - 0 - -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-3 - - - 85 - -
-
-
-
-
-
- -
-
-
-
-
-`; - -exports[`CouponDetails container renders with assignment and reminder dates table data 1`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- -
-
-
- Redemptions - - Code - - Assignments Remaining - - Actions -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-1 - - - 85 - - - | - -
-
- - -
- -
-
-
- 100 of 100 - - - test-code-2 - - - 0 - -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-3 - - - 85 - - -
-
-
-
-
-
- -
-
-
-
-
-`; - -exports[`CouponDetails container renders with assignment and reminder dates table data 2`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- -
-
-
- Redemptions - - Code - - Assignments Remaining - - Actions -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-1 - - - 85 - - - | - -
-
- - -
- -
-
-
- 100 of 100 - - - test-code-2 - - - 0 - -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-3 - - - 85 - - -
-
-
-
-
-
- -
-
-
-
-
-`; - -exports[`CouponDetails container renders with assignment and reminder dates table data 3`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- -
-
-
- Redemptions - - Code - - Assignments Remaining - - Actions -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-1 - - - 85 - - - | - -
-
- - -
- -
-
-
- 100 of 100 - - - test-code-2 - - - 0 - -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-3 - - - 85 - - -
-
-
-
-
-
- -
-
-
-
-
-`; - -exports[`CouponDetails container renders with collapsed coupon details 1`] = ` -
-
-
-`; - -exports[`CouponDetails container renders with error 1`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
- -
-
-
-
-`; - -exports[`CouponDetails container renders with expanded coupon details 1`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
-`; - -exports[`CouponDetails container renders with table data 1`] = ` -
-
-
-
-

- Coupon Details -

-
-
- -
-
-
-
-
- - -
- -
-
-
-
-
- - -
- -
-
- -
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- -
-
-
- Redemptions - - Code - - Assignments Remaining - - Actions -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-1 - - - 85 - - - | - -
-
- - -
- -
-
-
- 100 of 100 - - - test-code-2 - - - 0 - -
-
- - -
- -
-
-
- 10 of 100 - - - test-code-3 - - - 85 - - -
-
-
-
-
-
- -
-
-
-
-
-`; diff --git a/src/containers/CouponDetails/index.jsx b/src/containers/CouponDetails/index.jsx index cd7b0b19a2..8da5c9f24e 100644 --- a/src/containers/CouponDetails/index.jsx +++ b/src/containers/CouponDetails/index.jsx @@ -4,7 +4,6 @@ import { withRouter } from 'react-router-dom'; import CouponDetails from '../../components/CouponDetails'; import { fetchCouponOrder } from '../../data/actions/coupons'; -import updateCodeVisibility from '../../data/actions/codeVisibility'; const couponDetailsTableId = 'coupon-details'; @@ -18,9 +17,6 @@ const mapDispatchToProps = dispatch => ({ fetchCouponOrder: (couponId) => { dispatch(fetchCouponOrder(couponId)); }, - updateCodeVisibility: (couponId, codeIds, isPublic) => { - dispatch(updateCodeVisibility(couponId, codeIds, isPublic)); - }, }); export default withRouter(connect( diff --git a/src/containers/SaveTemplateButton/__snapshots__/SaveTemplateButton.test.jsx.snap b/src/containers/SaveTemplateButton/__snapshots__/SaveTemplateButton.test.jsx.snap index 5deabe4e99..3a17338a28 100644 --- a/src/containers/SaveTemplateButton/__snapshots__/SaveTemplateButton.test.jsx.snap +++ b/src/containers/SaveTemplateButton/__snapshots__/SaveTemplateButton.test.jsx.snap @@ -12,7 +12,9 @@ exports[` renders correctly in disabled state 1`] = ` - + Save Template @@ -31,7 +33,9 @@ exports[` renders correctly in enabled state 1`] = ` - + Save Template @@ -50,7 +54,9 @@ exports[` renders correctly while saving a template 1`] = - + Save Template diff --git a/src/data/actions/codeVisibility.js b/src/data/actions/codeVisibility.js deleted file mode 100644 index ee6a0d3301..0000000000 --- a/src/data/actions/codeVisibility.js +++ /dev/null @@ -1,48 +0,0 @@ -import { - CODE_VISIBILITY_REQUEST, - CODE_VISIBILITY_SUCCESS, - CODE_VISIBILITY_FAILURE, -} from '../constants/codeVisibility'; - -import EcommerceApiService from '../services/EcommerceApiService'; - -const sendCodeVisibilityRequest = () => ({ - type: CODE_VISIBILITY_REQUEST, -}); - -const sendCodeVisibilitySuccess = data => ({ - type: CODE_VISIBILITY_SUCCESS, - payload: { - data, - }, -}); - -const sendCodeVisibilityFailure = error => ({ - type: CODE_VISIBILITY_FAILURE, - payload: { - error, - }, -}); - -const updateCodeVisibility = ({ - couponId, - codeIds, - isPublic, - onSuccess = () => {}, - onError = () => {}, -}) => ( - (dispatch) => { - dispatch(sendCodeVisibilityRequest()); - return EcommerceApiService.sendCodeVisibility(couponId, codeIds, isPublic) - .then((response) => { - dispatch(sendCodeVisibilitySuccess(response.data)); - onSuccess(response.data); - }) - .catch((error) => { - dispatch(sendCodeVisibilityFailure(error)); - onError(error); - }); - } -); - -export default updateCodeVisibility; diff --git a/src/data/actions/coupons.js b/src/data/actions/coupons.js index dbece80f2e..df284ca4ee 100644 --- a/src/data/actions/coupons.js +++ b/src/data/actions/coupons.js @@ -63,7 +63,7 @@ const fetchCouponOrders = options => ( logError(error); // This endpoint returns a 404 if no data exists, // so we convert it to an empty response here. - if (error.response.status === 404) { + if (error?.response?.status === 404) { dispatch(fetchCouponOrdersSuccess({ results: [] })); return; } diff --git a/src/data/constants/codeVisibility.js b/src/data/constants/codeVisibility.js deleted file mode 100644 index eaa30db248..0000000000 --- a/src/data/constants/codeVisibility.js +++ /dev/null @@ -1,9 +0,0 @@ -const CODE_VISIBILITY_REQUEST = 'CODE_VISIBILITY_REQUEST'; -const CODE_VISIBILITY_SUCCESS = 'CODE_VISIBILITY_SUCCESS'; -const CODE_VISIBILITY_FAILURE = 'CODE_VISIBILITY_FAILURE'; - -export { - CODE_VISIBILITY_REQUEST, - CODE_VISIBILITY_SUCCESS, - CODE_VISIBILITY_FAILURE, -}; diff --git a/src/data/reducers/codeVisibility.js b/src/data/reducers/codeVisibility.js deleted file mode 100644 index 4e79b5f4c3..0000000000 --- a/src/data/reducers/codeVisibility.js +++ /dev/null @@ -1,36 +0,0 @@ -import { - CODE_VISIBILITY_REQUEST, - CODE_VISIBILITY_SUCCESS, - CODE_VISIBILITY_FAILURE, -} from '../constants/codeVisibility'; - -const initialState = { - loading: false, - error: null, - data: null, -}; - -const codeRevoke = (state = initialState, action) => { - switch (action.type) { - case CODE_VISIBILITY_REQUEST: - return { - loading: true, - error: null, - }; - case CODE_VISIBILITY_SUCCESS: - return { - loading: false, - error: null, - data: action.payload.data, - }; - case CODE_VISIBILITY_FAILURE: - return { - loading: false, - error: action.payload.error, - }; - default: - return state; - } -}; - -export default codeRevoke; diff --git a/src/data/services/EcommerceApiService.js b/src/data/services/EcommerceApiService.js index 864a23e00a..6cd308de01 100644 --- a/src/data/services/EcommerceApiService.js +++ b/src/data/services/EcommerceApiService.js @@ -50,11 +50,6 @@ class EcommerceApiService { return EcommerceApiService.apiClient().post(url, options); } - static sendCodeVisibility(couponId, codeIds, isPublic) { - const url = `${EcommerceApiService.ecommerceBaseUrl}/api/v2/enterprise/coupons/${couponId}/visibility/`; - return EcommerceApiService.apiClient().post(url, { code_ids: codeIds, is_public: isPublic }); - } - static fetchCodeSearchResults(options) { const { enterpriseId } = store.getState().portalConfiguration; let url = `${EcommerceApiService.ecommerceBaseUrl}/api/v2/enterprise/coupons/${enterpriseId}/search/`;