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 (
+ setModalState({
+ key: 'assignment',
+ options: {
+ couponId: id,
+ title,
+ data: {
+ code,
+ remainingUses,
+ },
+ },
+ })}
+ >
+ {ACTIONS.assign.label}
+
+ );
+};
+
+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 }) => (
+
+ {label}
+
+ ),
+ )}
+
+
+
+ Go
+
+
+);
+
+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 }) => {label} ,
+ )}
+
+
+
+
+);
+
+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 (
- this.setModalState({
- key: 'assignment',
- options: {
- couponId: id,
- title: couponTitle,
- data: {
- code,
- remainingUses,
- },
- },
- })}
- >
- Assign
-
- );
+ 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 {
/>
-