feat: [MICROBA-1620] Wire BulkEmailForm to API (#11)

- Wires up the BulkEmailForm to be able to actually send email tasks to
  the instructor api
- Adds testing for the BulkEmailForm, as well as BulkEmailTool itself
- Does some cleanup, and adds some additional testing tools as needed
This commit is contained in:
Thomas Tracy
2022-01-27 14:51:17 -05:00
committed by GitHub
parent 95b960964c
commit ce9cdf642b
21 changed files with 900 additions and 108 deletions

266
package-lock.json generated
View File

@@ -6861,6 +6861,7 @@
"version": "27.2.5",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-27.2.5.tgz",
"integrity": "sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==",
"dev": true,
"requires": {
"@types/istanbul-lib-coverage": "^2.0.0",
"@types/istanbul-reports": "^3.0.0",
@@ -6873,6 +6874,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
@@ -6881,6 +6883,7 @@
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -6890,6 +6893,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
@@ -6897,17 +6901,20 @@
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
@@ -7178,6 +7185,7 @@
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.2.tgz",
"integrity": "sha512-idsS/cqbYudXcVWngc1PuWNmXs416oBy2g/7Q8QAUREt5Z3MUkAL2XJD7xazLJ6esDfqRDi/ZBxk+OPPXitHRw==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -7193,6 +7201,7 @@
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
"integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
"dev": true,
"requires": {
"@babel/highlight": "^7.16.7"
}
@@ -7200,12 +7209,14 @@
"@babel/helper-validator-identifier": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
"integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw=="
"integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==",
"dev": true
},
"@babel/highlight": {
"version": "7.16.10",
"resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz",
"integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.16.7",
"chalk": "^2.0.0",
@@ -7216,6 +7227,7 @@
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"requires": {
"ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5",
@@ -7228,6 +7240,7 @@
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz",
"integrity": "sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -7235,12 +7248,14 @@
"aria-query": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg=="
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==",
"dev": true
},
"chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
@@ -7250,6 +7265,7 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
@@ -7258,6 +7274,7 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
@@ -7268,6 +7285,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
@@ -7275,12 +7293,122 @@
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
}
}
},
"@testing-library/jest-dom": {
"version": "5.16.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.1.tgz",
"integrity": "sha512-ajUJdfDIuTCadB79ukO+0l8O+QwN0LiSxDaYUTI4LndbbUsGi6rWU1SCexXzBA2NSjlVB9/vbkasQIL3tmPBjw==",
"dev": true,
"requires": {
"@babel/runtime": "^7.9.2",
"@types/testing-library__jest-dom": "^5.9.1",
"aria-query": "^5.0.0",
"chalk": "^3.0.0",
"css": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.5.6",
"lodash": "^4.17.15",
"redent": "^3.0.0"
},
"dependencies": {
"@babel/runtime": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz",
"integrity": "sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"requires": {
"color-convert": "^2.0.1"
}
},
"aria-query": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
"integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==",
"dev": true
},
"chalk": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
"integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
"dev": true,
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true
},
"indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true
},
"redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"requires": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
}
},
"strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"requires": {
"min-indent": "^1.0.0"
}
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"requires": {
"has-flag": "^4.0.0"
}
}
}
},
@@ -7288,6 +7416,7 @@
"version": "12.1.2",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.2.tgz",
"integrity": "sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==",
"dev": true,
"requires": {
"@babel/runtime": "^7.12.5",
"@testing-library/dom": "^8.0.0"
@@ -7297,6 +7426,7 @@
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.16.7.tgz",
"integrity": "sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ==",
"dev": true,
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -7327,7 +7457,8 @@
"@types/aria-query": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig=="
"integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
"dev": true
},
"@types/babel__core": {
"version": "7.1.14",
@@ -7461,12 +7592,14 @@
"@types/istanbul-lib-coverage": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz",
"integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg=="
"integrity": "sha512-hRJD2ahnnpLgsj6KWMYSrmXkM3rm2Dl1qkx6IOFD5FnuNPXJIG5L0dhgKXCYTRMGzU4n0wImQ/xfmRc4POUFlg==",
"dev": true
},
"@types/istanbul-lib-report": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-1.1.1.tgz",
"integrity": "sha512-3BUTyMzbZa2DtDI2BkERNC6jJw2Mr2Y0oGI7mRxYNBPxppbtEK1F66u3bKwU2g+wxwWI7PAoRpJnOY1grJqzHg==",
"dev": true,
"requires": {
"@types/istanbul-lib-coverage": "*"
}
@@ -7475,10 +7608,21 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
"integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
"dev": true,
"requires": {
"@types/istanbul-lib-report": "*"
}
},
"@types/jest": {
"version": "27.4.0",
"resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.0.tgz",
"integrity": "sha512-gHl8XuC1RZ8H2j5sHv/JqsaxXkDDM9iDOgu0Wp8sjs4u/snb2PVehyWXJPr+ORA0RPpgw231mnutWI1+0hgjIQ==",
"dev": true,
"requires": {
"jest-diff": "^27.0.0",
"pretty-format": "^27.0.0"
}
},
"@types/json-schema": {
"version": "7.0.9",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz",
@@ -7506,7 +7650,8 @@
"@types/node": {
"version": "15.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.2.tgz",
"integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww=="
"integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==",
"dev": true
},
"@types/normalize-package-data": {
"version": "2.4.1",
@@ -7626,6 +7771,15 @@
"integrity": "sha512-ipixuVrh2OdNmauvtT51o3d8z12p6LtFW9in7U79der/kwejjdNchQC5UMn5u/KxNoM7VHHOs/l8KS8uHxhODQ==",
"dev": true
},
"@types/testing-library__jest-dom": {
"version": "5.14.2",
"resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.2.tgz",
"integrity": "sha512-vehbtyHUShPxIa9SioxDwCvgxukDMH//icJG90sXQBUm5lJOHLT5kNeU9tnivhnA/TkOFMzGIXN2cTc4hY8/kg==",
"dev": true,
"requires": {
"@types/jest": "*"
}
},
"@types/uglify-js": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/@types/uglify-js/-/uglify-js-3.13.1.tgz",
@@ -7683,6 +7837,7 @@
"version": "16.0.4",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
"integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
"dev": true,
"requires": {
"@types/yargs-parser": "*"
}
@@ -7690,7 +7845,8 @@
"@types/yargs-parser": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-13.1.0.tgz",
"integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg=="
"integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg==",
"dev": true
},
"@webassemblyjs/ast": {
"version": "1.11.1",
@@ -8115,6 +8271,7 @@
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"requires": {
"color-convert": "^1.9.0"
}
@@ -8542,6 +8699,7 @@
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.20.0.tgz",
"integrity": "sha512-shZRhTjLP0WWdcvHKf3rH3iW9deb3UdKbdnKUoHmmsnBhVXN3sjPJM6ZvQ2r/ywgvBVQrMnjrSyQab60G1sr2w==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"is-blob": "^2.1.0",
@@ -8551,7 +8709,8 @@
"is-buffer": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz",
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==",
"dev": true
}
}
},
@@ -10032,6 +10191,7 @@
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"dev": true,
"requires": {
"color-name": "1.1.3"
}
@@ -10039,7 +10199,8 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
},
"color-string": {
"version": "1.9.0",
@@ -10362,6 +10523,29 @@
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs="
},
"css": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
"integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==",
"dev": true,
"requires": {
"inherits": "^2.0.4",
"source-map": "^0.6.1",
"source-map-resolve": "^0.6.0"
},
"dependencies": {
"source-map-resolve": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz",
"integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==",
"dev": true,
"requires": {
"atob": "^2.1.2",
"decode-uri-component": "^0.2.0"
}
}
}
},
"css-declaration-sorter": {
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.1.3.tgz",
@@ -10439,6 +10623,12 @@
"integrity": "sha512-ACUm3L0/jiZTqfzRM3Hi9Q8eZqd6IK37mMWPLz9PJxkLWllYeRf+EHUSHYEtFop2Eqytaq1FizFVh7XfBnXCDQ==",
"dev": true
},
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
"dev": true
},
"cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -11116,7 +11306,8 @@
"dom-accessibility-api": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz",
"integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g=="
"integrity": "sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g==",
"dev": true
},
"dom-converter": {
"version": "0.2.0",
@@ -11597,7 +11788,8 @@
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
"integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
"dev": true
},
"escodegen": {
"version": "2.0.0",
@@ -12441,7 +12633,8 @@
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"dev": true
},
"fast-glob": {
"version": "3.2.7",
@@ -13502,7 +13695,8 @@
"has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
"dev": true
},
"has-symbol-support-x": {
"version": "1.4.2",
@@ -14835,7 +15029,8 @@
"is-blob": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz",
"integrity": "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw=="
"integrity": "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==",
"dev": true
},
"is-boolean-object": {
"version": "1.1.0",
@@ -17427,7 +17622,8 @@
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY="
"integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
"dev": true
},
"mailto-link": {
"version": "1.0.0",
@@ -17712,6 +17908,12 @@
"dev": true,
"optional": true
},
"min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true
},
"mini-create-react-context": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
@@ -19407,6 +19609,12 @@
"dev": true,
"optional": true
},
"prettier": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.5.1.tgz",
"integrity": "sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==",
"dev": true
},
"pretty-error": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-3.0.4.tgz",
@@ -19421,6 +19629,7 @@
"version": "27.3.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.3.1.tgz",
"integrity": "sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==",
"dev": true,
"requires": {
"@jest/types": "^27.2.5",
"ansi-regex": "^5.0.1",
@@ -19431,17 +19640,20 @@
"ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true
},
"react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true
}
}
},
@@ -20779,6 +20991,12 @@
"glob": "^7.1.3"
}
},
"rosie": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.0.tgz",
"integrity": "sha512-Dbzdc+prLXZuB/suRptDnBUY29SdGvND3bLg6cll8n7PNqzuyCxSlRfrkn8PqjS9n4QVsiM7RCvxCkKAkTQRjA==",
"dev": true
},
"rsvp": {
"version": "4.8.5",
"resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz",
@@ -22203,6 +22421,7 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"requires": {
"has-flag": "^3.0.0"
}
@@ -22647,11 +22866,6 @@
"resolved": "https://registry.npmjs.org/tinymce/-/tinymce-5.10.2.tgz",
"integrity": "sha512-5QhnZ6c8F28fYucLLc00MM37fZoAZ4g7QCYzwIl38i5TwJR5xGqzOv6YMideyLM4tytCzLCRwJoQen2LI66p5A=="
},
"tinymce-language-selector": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/tinymce-language-selector/-/tinymce-language-selector-1.0.4.tgz",
"integrity": "sha512-Ko2n2oRFGIZAtWqoG8O8B5XHxZEWZ/zRabPZ5rVeMzDTZhnbVX9oOgW+Ie18PRNMTNtOpI15OUhvrT07c0rVkw=="
},
"tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@@ -46,9 +46,7 @@
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.1.16",
"@testing-library/react": "^12.1.2",
"@tinymce/tinymce-react": "^3.13.0",
"axios-mock-adapter": "^1.20.0",
"classnames": "^2.3.1",
"core-js": "3.15.2",
"prop-types": "15.7.2",
@@ -59,16 +57,20 @@
"react-router-dom": "5.3.0",
"redux": "4.1.2",
"regenerator-runtime": "0.13.9",
"tinymce": "^5.10.2",
"tinymce-language-selector": "^1.0.4"
"tinymce": "^5.10.2"
},
"devDependencies": {
"@edx/frontend-build": "8.1.6",
"axios-mock-adapter": "^1.20.0",
"codecov": "3.8.3",
"es-check": "6.1.1",
"glob": "7.2.0",
"husky": "7.0.4",
"jest": "27.3.1",
"reactifex": "1.1.1"
"prettier": "^2.5.1",
"reactifex": "1.1.1",
"rosie": "^2.1.0",
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2"
}
}

View File

@@ -1,44 +0,0 @@
import React, { useRef } from 'react';
import { Button, Form } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import TextEditor from './TextEditor';
export default function BulkEmailBody() {
const editorRef = useRef(null);
const onInit = (event, editor) => { editorRef.current = editor; };
return (
<div className="w-100 m-auto p-lg-4 p-2.5">
<Form>
<Form.Group controlId="emailSubject">
<Form.Label>
<FormattedMessage
id="bulk.email.subject.label"
defaultMessage="Subject:"
description="Email subject line input label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<Form.Control className="w-lg-50" />
</Form.Group>
<Form.Group controlId="emailBody">
<Form.Label>
<FormattedMessage
id="bulk.email.body.label"
defaultMessage="Body:"
description="Email Body label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<TextEditor onInit={onInit} />
</Form.Group>
<Button variant="primary" type="submit">
<FormattedMessage
id="bulk.email.submit.button"
defaultMessage="Submit"
description="Submit/Send email button"
/>
</Button>
</Form>
</div>
);
}

View File

@@ -1,5 +0,0 @@
import React from 'react';
export default function BulkEmailRecepient() {
return <div />;
}

View File

@@ -4,12 +4,11 @@ import classnames from 'classnames';
import { useParams } from 'react-router-dom';
import { Spinner } from '@edx/paragon';
import { ErrorPage } from '@edx/frontend-platform/react';
import BulkEmailRecepient from './BulkEmailRecepient';
import BulkEmailBody from './BulkEmailBody';
import BulkEmailTaskManager from './bulk-email-task-manager/BulkEmailTaskManager';
import Navigationtabs from '../navigation-tabs/NavigationTabs';
import { getCourseHomeCourseMetadata } from './api';
import { getCourseHomeCourseMetadata } from './data/api';
import useMobileResponsive from '../../utils/useMobileResponsive';
import BulkEmailForm from './bulk-email-form';
export default function BulkEmailTool() {
const { courseId } = useParams();
@@ -39,23 +38,20 @@ export default function BulkEmailTool() {
}, []);
if (courseMetadata) {
return (
courseMetadata.isStaff ? (
<div>
<Navigationtabs courseId={courseId} tabData={courseMetadata.tabs} />
<div className={classnames({ 'border border-primary-200': !isMobile })}>
<div className="row">
<BulkEmailRecepient courseId={courseId} />
</div>
<div className="row">
<BulkEmailBody courseId={courseId} />
</div>
<div className="row">
<BulkEmailTaskManager courseId={courseId} />
</div>
return courseMetadata.isStaff ? (
<div>
<Navigationtabs courseId={courseId} tabData={courseMetadata.tabs} />
<div className={classnames({ 'border border-primary-200': !isMobile })}>
<div className="row">
<BulkEmailForm courseId={courseId} />
</div>
<div className="row">
<BulkEmailTaskManager courseId={courseId} />
</div>
</div>
) : <ErrorPage />
</div>
) : (
<ErrorPage />
);
}
return (
@@ -64,7 +60,7 @@ export default function BulkEmailTool() {
animation="border"
variant="primary"
role="status"
screenReaderText="loading"
screenreadertext="loading"
className="spinner-border spinner-border-lg text-primary p-5 m-5"
/>
</div>

View File

@@ -0,0 +1,225 @@
import React, { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import {
Form, Icon, StatefulButton, useCheckboxSetValues, useToggle,
} from '@edx/paragon';
import { SpinnerSimple, CheckCircle, Cancel } from '@edx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import TextEditor from '../text-editor/TextEditor';
import { postBulkEmail } from './api';
import BulkEmailRecipient from './BulkEmailRecipient';
import TaskAlertModal from './TaskAlertModal';
import useTimeout from '../../../utils/useTimeout';
export const FORM_SUBMIT_STATES = {
DEFAULT: 'default',
PENDING: 'pending',
COMPLETE: 'complete',
COMPLETED_DEFAULT: 'completed_default',
ERROR: 'error',
};
export default function BulkEmailForm(props) {
const { courseId } = props;
const [subject, setSubject] = useState('');
const [emailFormStatus, setEmailFormStatus] = useState(FORM_SUBMIT_STATES.DEFAULT);
const [emailFormValidation, setEmailFormValidation] = useState({
// set these as true on initialization, to prevent invalid messages from prematurely showing
subject: true,
body: true,
recipients: true,
});
const [selectedRecipients, { add, remove }] = useCheckboxSetValues([]);
const [isTaskAlertOpen, openTaskAlert, closeTaskAlert] = useToggle(false);
const editorRef = useRef(null);
const resetEmailForm = useTimeout(() => {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETED_DEFAULT);
}, 3000);
const onRecipientChange = (event) => {
if (event.target.checked) {
add(event.target.value);
} else {
remove(event.target.value);
}
};
const onInit = (event, editor) => {
editorRef.current = editor;
};
const onSubjectChange = (event) => setSubject(event.target.value);
const validateEmailForm = () => {
const subjectValid = subject.length !== 0;
const bodyValid = editorRef.current.getContent().length !== 0;
const recipientsValid = selectedRecipients.length !== 0;
setEmailFormValidation({
subject: subjectValid,
recipients: recipientsValid,
body: bodyValid,
});
return subjectValid && bodyValid && recipientsValid;
};
const createEmailTask = async () => {
const emailData = new FormData();
if (validateEmailForm()) {
setEmailFormStatus(() => FORM_SUBMIT_STATES.PENDING);
emailData.append('action', 'send');
emailData.append('send_to', JSON.stringify(selectedRecipients));
emailData.append('subject', subject);
emailData.append('message', editorRef.current.getContent());
let data;
try {
data = await postBulkEmail(emailData, courseId);
} catch (e) {
setEmailFormStatus(FORM_SUBMIT_STATES.ERROR);
return;
}
if (data.status === 200) {
setEmailFormStatus(FORM_SUBMIT_STATES.COMPLETE);
resetEmailForm();
}
}
};
return (
<div className="w-100 m-auto p-lg-4 py-2.5 px-5">
<TaskAlertModal
isOpen={isTaskAlertOpen}
alertMessage={(
<>
<p>
<FormattedMessage
id="bulk.email.task.alert.recipients"
defaultMessage="You are sending an email message with the subject {subject} to the following recipients:"
description="A warning shown to the user after submitting the email, to confirm the email recipients."
values={{
subject,
}}
/>
</p>
<ul>
{selectedRecipients.map((group) => (
<li key={group}>{group}</li>
))}
</ul>
<p>
<FormattedMessage
id="bulk.email.task.alert.warning"
defaultMessage="CAUTION! When you select Send Email, your email message is added to the queue for sending, and cannot be cancelled."
description="Warns the user in an alert that the email may not be immediately sent out to users."
/>
</p>
</>
)}
close={(event) => {
closeTaskAlert();
if (event.target.name === 'continue') {
createEmailTask();
}
}}
/>
<Form>
<BulkEmailRecipient
selectedGroups={selectedRecipients}
handleCheckboxes={onRecipientChange}
isValid={emailFormValidation.recipients}
/>
<Form.Group controlId="emailSubject">
<Form.Label>
<FormattedMessage
id="bulk.email.subject.label"
defaultMessage="Subject:"
description="Email subject line input label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<Form.Control name="subject" className="w-lg-50" onChange={onSubjectChange} />
{!emailFormValidation.subject && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.subject.error"
defaultMessage="A subject is required"
description="An Error message located under the subject line. Visible only on failure."
/>
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group controlId="emailBody">
<Form.Label>
<FormattedMessage
id="bulk.email.body.label"
defaultMessage="Body:"
description="Email Body label. Meant to have colon or equivilant punctuation."
/>
</Form.Label>
<TextEditor onInit={onInit} />
{!emailFormValidation.body && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.body.error"
defaultMessage="The message cannot be blank"
description="An error message located under the body editor. Visible only on failure."
/>
</Form.Control.Feedback>
)}
</Form.Group>
<Form.Group className="d-flex flex-row">
<StatefulButton
variant="primary"
type="submit"
onClick={(event) => {
event.preventDefault();
openTaskAlert();
}}
state={emailFormStatus}
icons={{
default: <Icon className="icon-download" />,
pending: <Icon src={SpinnerSimple} className="icon-spin" />,
complete: <Icon src={CheckCircle} />,
error: <Icon src={Cancel} />,
}}
labels={{
default: 'Submit',
pending: 'Submitting',
complete: 'Task Created',
error: 'Error',
}}
disabledStates={['pending', 'complete']}
>
<FormattedMessage
id="bulk.email.submit.button"
defaultMessage="Submit"
description="Submit/Send email button"
/>
</StatefulButton>
{emailFormStatus === FORM_SUBMIT_STATES.ERROR && (
<Form.Control.Feedback className="px-3" hasIcon={false} type="invalid">
<FormattedMessage
id="bulk.email.form.error"
defaultMessage="An error occured while attempting to send the email."
description="An Error message located under the submit button for the email form. Visible only on a failure."
/>
</Form.Control.Feedback>
)}
{(emailFormStatus === FORM_SUBMIT_STATES.COMPLETED_DEFAULT
|| emailFormStatus === FORM_SUBMIT_STATES.COMPLETE) && (
<Form.Control.Feedback className="px-3" hasIcon={false} type="valid">
<FormattedMessage
id="bulk.email.form.complete"
defaultMessage="A task to send the emails has been successfully created!"
description="A success message displays under the submit button when successfully completing the form."
/>
</Form.Control.Feedback>
)}
</Form.Group>
</Form>
</div>
);
}
BulkEmailForm.propTypes = {
courseId: PropTypes.string.isRequired,
};

View File

@@ -0,0 +1,95 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const DEFAULT_GROUPS = {
SELF: 'myself',
STAFF: 'staff',
ALL_LEARNERS: 'learners',
VERIFIED: 'track:verified',
AUDIT: 'track:audit',
};
export default function BulkEmailRecipient(props) {
const { handleCheckboxes, selectedGroups } = props;
return (
<Form.Group>
<Form.Label>
<FormattedMessage
id="bulk.email.form.recipients.sendLabel"
defaultMessage="Send To:"
description="A label before the list of potential recipients"
/>
</Form.Label>
<Form.CheckboxSet name="recipientGroups" onChange={handleCheckboxes} value={selectedGroups}>
<Form.Checkbox key="myself" value="myself">
<FormattedMessage
id="bulk.email.form.recipients.myself"
defaultMessage="Myself"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox key="staff" value="staff">
<FormattedMessage
id="bulk.email.form.recipients.staff"
defaultMessage="Staff/Administrators"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="track:audit"
value="track:audit"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
>
<FormattedMessage
id="bulk.email.form.recipients.audit"
defaultMessage="Learners in the audit track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="track:verified"
value="track:verified"
disabled={selectedGroups.find((group) => group === DEFAULT_GROUPS.ALL_LEARNERS)}
>
<FormattedMessage
id="bulk.email.form.recipients.verified"
defaultMessage="Learners in the verified certificate track"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
<Form.Checkbox
key="learners"
value="learners"
disabled={selectedGroups.find((group) => group === (DEFAULT_GROUPS.AUDIT || DEFAULT_GROUPS.VERIFIED))}
>
<FormattedMessage
id="bulk.email.form.recipients.learners"
defaultMessage="All Learners"
description="A selectable choice from a list of potential email recipients"
/>
</Form.Checkbox>
</Form.CheckboxSet>
{!props.isValid && (
<Form.Control.Feedback className="px-3" hasIcon type="invalid">
<FormattedMessage
id="bulk.email.form.recipients.error"
defaultMessage="At least one recipient is required"
description="An Error message located under the recipients list. Visible only on failure"
/>
</Form.Control.Feedback>
)}
</Form.Group>
);
}
BulkEmailRecipient.defaultProps = {
isValid: true,
};
BulkEmailRecipient.propTypes = {
selectedGroups: PropTypes.arrayOf(PropTypes.string).isRequired,
handleCheckboxes: PropTypes.func.isRequired,
isValid: PropTypes.bool,
};

View File

@@ -0,0 +1,55 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
function TaskAlertModal(props) {
const {
isOpen, close, alertMessage, intl,
} = props;
const messages = {
taskAlertTitle: {
id: 'bulk.email.task.alert.title',
defaultMessage: 'Caution',
description: 'Title in the header of the alert',
},
};
return (
<AlertModal
title={intl.formatMessage(messages.taskAlertTitle)}
isBlocking
isOpen={isOpen}
onClose={close}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={close} name="cancel">
<FormattedMessage
id="bulk.email.task.alert.cancel"
defaultMessage="Cancel"
description="Cancel button for the task alert"
/>
</Button>
<Button variant="primary" onClick={close} name="continue">
<FormattedMessage
id="bulk.email.form.recipients.Contine"
defaultMessage="Continue"
description="Continue button for the task alert"
/>
</Button>
</ActionRow>
)}
>
{alertMessage}
</AlertModal>
);
}
TaskAlertModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
alertMessage: PropTypes.node.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(TaskAlertModal);

View File

@@ -0,0 +1,8 @@
/* eslint-disable import/prefer-default-export */
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
export async function postBulkEmail(email, courseId) {
const url = `${getConfig().LMS_BASE_URL}/courses/${courseId}/instructor/api/send_email`;
return getAuthenticatedHttpClient().post(url, email);
}

View File

@@ -0,0 +1 @@
export { default } from './BulkEmailForm';

View File

@@ -0,0 +1,65 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import {
render, screen, cleanup, act, fireEvent,
} from '../../../../setupTest';
import BulkEmailForm from '..';
import { postBulkEmail } from '../api';
jest.mock('../../text-editor/TextEditor');
jest.mock('../api', () => ({
__esModule: true,
postBulkEmail: jest.fn(() => ({ status: 200 })),
}));
describe('bulk-email-form', () => {
beforeEach(() => jest.resetModules());
afterEach(cleanup);
test('it renders', () => {
render(<BulkEmailForm courseId="test-course-id" />);
expect(screen.getByText('Submit')).toBeTruthy();
});
test('it shows a warning when clicking submit', async () => {
render(<BulkEmailForm courseId="test-course-id" />);
fireEvent.click(screen.getByText('Submit'));
const warning = await screen.findByText('CAUTION!', { exact: false });
expect(warning).toBeTruthy();
});
test('Prevent form POST if invalid', async () => {
render(<BulkEmailForm courseId="test-course-id" />);
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('At least one recipient is required', { exact: false })).toBeInTheDocument();
expect(await screen.findByText('A subject is required')).toBeInTheDocument();
});
test('Shows complete message on completed POST', async () => {
render(<BulkEmailForm courseId="test-course-id" />);
fireEvent.click(screen.getByRole('checkbox', { name: 'Myself' }));
expect(screen.getByRole('checkbox', { name: 'Myself' })).toBeChecked();
fireEvent.change(screen.getByRole('textbox', { name: 'Subject:' }), { target: { value: 'test subject' } });
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('Submitting')).toBeInTheDocument();
expect(await screen.findByText('A task to send the emails has been successfully created!')).toBeInTheDocument();
});
test('Shows Error on failed POST', async () => {
postBulkEmail.mockImplementation(() => {
throw Error('api-response-error');
});
await act(async () => {
render(<BulkEmailForm courseId="test-course-id" />);
const subjectLine = screen.getByRole('textbox', { name: 'Subject:' });
const recipient = screen.getByRole('checkbox', { name: 'Myself' });
fireEvent.click(recipient);
fireEvent.change(subjectLine, { target: { value: 'test subject' } });
fireEvent.click(screen.getByText('Submit'));
expect(await screen.findByRole('button', { name: /continue/i })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
expect(await screen.findByText('Error')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,77 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
import './tab.factory';
export default Factory.define('courseMetadata')
.sequence('id', i => `course-v1:edX+DemoX+Demo_Course_${i}`)
.option('host', 'http://localhost:18000')
.attrs({
is_staff: true,
original_user_is_staff: false,
number: 'DemoX',
org: 'edX',
verified_mode: {
upgrade_url: 'test',
price: 10,
currency_symbol: '$',
},
})
.attr('tabs', ['id', 'host'], (id, host) => {
const tabs = [
Factory.build(
'tab',
{
title: 'Course',
slug: 'courseware',
type: 'courseware',
},
{ courseId: id, host, path: 'course/' },
),
Factory.build(
'tab',
{
title: 'Discussion',
slug: 'discussion',
type: 'discussion',
},
{ courseId: id, host, path: 'discussion/forum/' },
),
Factory.build(
'tab',
{
title: 'Wiki',
slug: 'wiki',
type: 'wiki',
},
{ courseId: id, host, path: 'course_wiki' },
),
Factory.build(
'tab',
{
title: 'Progress',
slug: 'progress',
type: 'progress',
},
{ courseId: id, host, path: 'progress' },
),
Factory.build(
'tab',
{
title: 'Instructor',
slug: 'instructor',
type: 'instructor',
},
{ courseId: id, host, path: 'instructor' },
),
Factory.build(
'tab',
{
title: 'Dates',
slug: 'dates',
type: 'dates',
},
{ courseId: id, host, path: 'dates' },
),
];
return tabs;
});

View File

@@ -0,0 +1,12 @@
import { Factory } from 'rosie'; // eslint-disable-line import/no-extraneous-dependencies
Factory.define('tab')
.option('courseId', 'course-v1:edX+DemoX+Demo_Course')
.option('path', 'course/')
.option('host', 'http://localhost:18000')
.attrs({
title: 'Course',
slug: 'courseware',
})
.attr('tab_id', ['slug'], slug => slug)
.attr('url', ['courseId', 'path', 'host'], (courseId, path, host) => `${host}/courses/${courseId}/${path}`);

View File

@@ -0,0 +1,42 @@
/**
* @jest-environment jsdom
*/
import React from 'react';
import { Factory } from 'rosie';
import { render, screen } from '../../../setupTest';
import BulkEmailTool from '../BulkEmailTool';
import { getCourseHomeCourseMetadata } from '../data/api';
import '../data/__factories__/courseMetadata.factory';
jest.mock('../text-editor/TextEditor');
jest.mock('../bulk-email-task-manager/api', () => ({
getInstructorTasks: jest.fn(() => ({ tasks: {} })),
getEmailTaskHistory: jest.fn(() => ({ tasks: {} })),
}));
jest.mock('../data/api', () => ({
__esModule: true,
getCourseHomeCourseMetadata: jest.fn(() => {}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(() => ({
courseId: 'test-course-id',
})),
}));
describe('BulkEmailTool', () => {
test('BulkEmailTool renders properly when given course metadata', async () => {
const courseMetadata = Factory.build('courseMetadata');
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(<BulkEmailTool />);
expect(await screen.findByText('Course')).toBeTruthy();
});
test('BulkEmailTool renders error page on no staff user', async () => {
const courseMetadata = Factory.build('courseMetadata', { is_staff: false });
getCourseHomeCourseMetadata.mockImplementation(() => courseMetadata);
render(<BulkEmailTool />);
expect(
await screen.findByText('An unexpected error occurred. Please click the button below to refresh the page.'),
).toBeTruthy();
});
});

View File

@@ -13,7 +13,7 @@ import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/link';
import 'tinymce/plugins/lists';
import 'tinymce/plugins/table';
import 'tinymce-language-selector';
import '@edx/tinymce-language-selector';
import contentUiCss from 'tinymce/skins/ui/oxide/content.css';
import contentCss from 'tinymce/skins/content/default/content.css';

View File

@@ -0,0 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
function MockTinyMCE({ onInit }) {
const mockedEditor = {
getContent: () => 'test body',
};
onInit({}, mockedEditor);
return <div />;
}
MockTinyMCE.propTypes = {
onInit: PropTypes.func.isRequired,
};
export default function TextEditor({ onInit }) {
return <MockTinyMCE onInit={onInit} />;
}
TextEditor.propTypes = {
onInit: PropTypes.func.isRequired,
};

View File

@@ -0,0 +1 @@
export { default } from './TextEditor';

View File

@@ -11,7 +11,7 @@ export default function NavigationTabs(props) {
<Nav>
{tabData && tabData.map(tab => (
<Nav.Item key={tab.tab_id}>
<Nav.Link eventKey={tab.tab_id} href={tab.url} className="mx-3 py-2">{tab.title}</Nav.Link>
<Nav.Link eventKey={tab.url} href={tab.url} className="mx-3 py-2">{tab.title}</Nav.Link>
</Nav.Item>
))}
</Nav>
@@ -20,7 +20,7 @@ export default function NavigationTabs(props) {
}
NavigationTabs.propTypes = {
tabData: PropTypes.arrayOf(PropTypes.exact({
tabData: PropTypes.arrayOf(PropTypes.shape({
tab_id: PropTypes.string,
title: PropTypes.string,
url: PropTypes.string,

View File

@@ -1,5 +1,6 @@
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
import React from 'react';
import PropTypes from 'prop-types';
import { render } from '@testing-library/react';
@@ -50,9 +51,7 @@ configureI18n({
function Wrapper({ children }) {
return (
// eslint-disable-next-line react/jsx-filename-extension
<AppProvider>
{children}
</AppProvider>
<AppProvider>{children}</AppProvider>
);
}
@@ -67,6 +66,4 @@ Wrapper.propTypes = {
export * from '@testing-library/react';
// Override `render` method.
export {
renderWithProviders as render,
};
export { renderWithProviders as render };

28
src/utils/useTimeout.js Normal file
View File

@@ -0,0 +1,28 @@
import { useRef, useEffect } from 'react';
/**
* React hook to delay a function being called by a given delay amount.
* Works by creating a ref to a setTimeout() function call to the DOM and
* waiting for the timer to end before firing the callback. if multiple
* timouts are made at once, the hook will remove all previous timeouts
* and only allow one at a time.
* @param {function} callback The function to call once the delay ends
* @param {millisecond} delay time to delay function call
*/
export default function useTimeout(callback, delay) {
const timeoutRef = useRef(null);
useEffect(() => {
const timeout = timeoutRef.current;
if (timeout) {
clearTimeout(timeout);
}
}, []);
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(callback, delay);
};
}