feat: add blackout dates new UI and validation (#191)

* feat: implement blackout dates UI and validation

* chore: bump paragon version to 16.14.2

* refactor: blackout dates collapsible card with reusable collapsible component

Co-authored-by: Mehak Nasir <mehaknasir94@gmail.com>
This commit is contained in:
Awais Ansari
2021-09-29 18:34:19 +05:00
committed by GitHub
parent 8552a96d56
commit 6ce280e3e1
21 changed files with 921 additions and 288 deletions

181
package-lock.json generated
View File

@@ -3674,9 +3674,9 @@
}
},
"@edx/paragon": {
"version": "16.6.4",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-16.6.4.tgz",
"integrity": "sha512-2RZg9bO/SzgA24EO9XD4KfA2mfAgb8Kpky4Bw3fUPKuU+xNuGpJNxbV4UhwyS0L3E3X63kXF40bguXOHdHrU0w==",
"version": "16.14.2",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-16.14.2.tgz",
"integrity": "sha512-mIq7jeWbZN3EO+Mif38BnpdYdAYd5UG7/O2QtxPAWUedQZ1O+tPUpEapeiq+RFny/miHdyp/LnfMNfy3a5lRhA==",
"requires": {
"@fortawesome/fontawesome-svg-core": "^1.2.30",
"@fortawesome/free-solid-svg-icons": "^5.14.0",
@@ -3702,30 +3702,30 @@
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "0.2.35",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.35.tgz",
"integrity": "sha512-IHUfxSEDS9dDGqYwIW7wTN6tn/O8E0n5PcAHz9cAaBoZw6UpG20IG/YM3NNLaGPwPqgjBAFjIURzqoQs3rrtuw=="
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "1.2.35",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.35.tgz",
"integrity": "sha512-uLEXifXIL7hnh2sNZQrIJWNol7cTVIzwI+4qcBIq9QWaZqUblm0IDrtSqbNg+3SQf8SMGHkiSigD++rHmCHjBg==",
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.35"
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "5.15.3",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.3.tgz",
"integrity": "sha512-XPeeu1IlGYqz4VWGRAT5ukNMd4VHUEEJ7ysZ7pSSgaEtNvSo+FLurybGJVmiqkQdK50OkSja2bfZXOeyMGRD8Q==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"requires": {
"@fortawesome/fontawesome-common-types": "^0.2.35"
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/react-fontawesome": {
"version": "0.1.14",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.14.tgz",
"integrity": "sha512-4wqNb0gRLVaBm/h+lGe8UfPPivcbuJ6ecI4hIgW0LjI7kzpYB9FkN0L9apbVzg+lsBdcTf0AlBtODjcSX5mmKA==",
"version": "0.1.15",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.15.tgz",
"integrity": "sha512-/HFHdcoLESxxMkqZAcZ6RXDJ69pVApwdwRos/B2kiMWxDSAX2dFK8Er2/+rG+RsrzWB/dsAyjefLmemgmfE18g==",
"requires": {
"prop-types": "^15.7.2"
}
@@ -4988,9 +4988,9 @@
}
},
"@popperjs/core": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.9.2.tgz",
"integrity": "sha512-VZMYa7+fXHdwIq1TDhSXoVmSPEGM/aa+6Aiq3nVVJ9bXr24zScr+NlKFKC3iPljA7ho/GAZr+d2jOf5GIRC30Q=="
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.10.1.tgz",
"integrity": "sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw=="
},
"@reduxjs/toolkit": {
"version": "1.5.0",
@@ -6133,9 +6133,9 @@
}
},
"@types/invariant": {
"version": "2.2.34",
"resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.34.tgz",
"integrity": "sha512-lYUtmJ9BqUN688fGY1U1HZoWT1/Jrmgigx2loq4ZcJpICECm/Om3V314BxdzypO0u5PORKGMM6x0OXaljV1YFg=="
"version": "2.2.35",
"resolved": "https://registry.npmjs.org/@types/invariant/-/invariant-2.2.35.tgz",
"integrity": "sha512-DxX1V9P8zdJPYQat1gHyY0xj3efl8gnMVjiM9iCY6y27lj+PoQWkgjt8jDqmovPqULkKVpKRg8J36iQiA+EtEg=="
},
"@types/istanbul-lib-coverage": {
"version": "2.0.3",
@@ -6295,9 +6295,9 @@
"dev": true
},
"@types/react": {
"version": "17.0.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.14.tgz",
"integrity": "sha512-0WwKHUbWuQWOce61UexYuWTGuGY/8JvtUe/dtQ6lR4sZ3UiylHotJeWpf3ArP9+DSGUoLY3wbU59VyMrJps5VQ==",
"version": "17.0.24",
"resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.24.tgz",
"integrity": "sha512-eIpyco99gTH+FTI3J7Oi/OH8MZoFMJuztNRimDOJwH4iGIsKV2qkGnk4M9VzlaVWeEEWLWSQRy0FEA0Kz218cg==",
"requires": {
"@types/prop-types": "*",
"@types/scheduler": "*",
@@ -6305,16 +6305,16 @@
},
"dependencies": {
"csstype": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
}
}
},
"@types/react-transition-group": {
"version": "4.4.2",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.2.tgz",
"integrity": "sha512-KibDWL6nshuOJ0fu8ll7QnV/LVTo3PzQ9aCPnRUYPfX7eZohHwLIdNHj7pftanREzHNP4/nJa8oeM73uSiavMQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.3.tgz",
"integrity": "sha512-fUx5muOWSYP8Bw2BUQ9M9RK9+W1XBK/7FLJ8PTQpnpTEkn0ccyMffyEQvan4C3h53gHdx7KE5Qrxi/LnUGQtdg==",
"requires": {
"@types/react": "*"
}
@@ -11584,17 +11584,17 @@
"dev": true
},
"focus-lock": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.9.1.tgz",
"integrity": "sha512-/2Nj60Cps6yOLSO+CkVbeSKfwfns5XbX6HOedIK9PdzODP04N9c3xqOcPXayN0WsT9YjJvAnXmI0NdqNIDf5Kw==",
"version": "0.9.2",
"resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.9.2.tgz",
"integrity": "sha512-YtHxjX7a0IC0ZACL5wsX8QdncXofWpGPNoVMuI/nZUrPGp6LmNI6+D5j0pPj+v8Kw5EpweA+T5yImK0rnWf7oQ==",
"requires": {
"tslib": "^2.0.3"
},
"dependencies": {
"tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
}
}
},
@@ -11788,21 +11788,23 @@
},
"dependencies": {
"es-abstract": {
"version": "1.18.3",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.3.tgz",
"integrity": "sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw==",
"version": "1.18.6",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.6.tgz",
"integrity": "sha512-kAeIT4cku5eNLNuUKhlmtuk1/TRZvQoYccn6TO0cSVdf1kzB0T7+dYuVK9MWM7l+/53W2Q8M7N2c6MQvhXFcUQ==",
"requires": {
"call-bind": "^1.0.2",
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"get-intrinsic": "^1.1.1",
"get-symbol-description": "^1.0.0",
"has": "^1.0.3",
"has-symbols": "^1.0.2",
"is-callable": "^1.2.3",
"internal-slot": "^1.0.3",
"is-callable": "^1.2.4",
"is-negative-zero": "^2.0.1",
"is-regex": "^1.1.3",
"is-string": "^1.0.6",
"object-inspect": "^1.10.3",
"is-regex": "^1.1.4",
"is-string": "^1.0.7",
"object-inspect": "^1.11.0",
"object-keys": "^1.1.1",
"object.assign": "^4.1.2",
"string.prototype.trimend": "^1.0.4",
@@ -11816,17 +11818,25 @@
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
},
"is-callable": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz",
"integrity": "sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ=="
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
"integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w=="
},
"is-regex": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.3.tgz",
"integrity": "sha512-qSVXFz28HM7y+IWX6vLCsexdlvzT1PJNFSBuaQLQ5o0IEw8UDYW6/2+eCMVyIsbM8CNLX2a/QWmSpyxYEHY7CQ==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
"integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
"requires": {
"call-bind": "^1.0.2",
"has-symbols": "^1.0.2"
"has-tostringtag": "^1.0.0"
}
},
"is-string": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
"integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
"requires": {
"has-tostringtag": "^1.0.0"
}
},
"object-inspect": {
@@ -11935,6 +11945,15 @@
"pump": "^3.0.0"
}
},
"get-symbol-description": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
"integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
"requires": {
"call-bind": "^1.0.2",
"get-intrinsic": "^1.1.1"
}
},
"get-value": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz",
@@ -12281,6 +12300,21 @@
"has-symbol-support-x": "^1.4.1"
}
},
"has-tostringtag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
"integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
"requires": {
"has-symbols": "^1.0.2"
},
"dependencies": {
"has-symbols": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
}
}
},
"has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
@@ -13414,7 +13448,6 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
"integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
"dev": true,
"requires": {
"get-intrinsic": "^1.1.0",
"has": "^1.0.3",
@@ -20858,9 +20891,9 @@
}
},
"react-bootstrap": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.1.tgz",
"integrity": "sha512-ojEPQ6OtyIMdLg0Smofk+85PKN6MLKQX3bU0Vwmok/4yNa8DQ2vCGhO2IgHJvT+ERQZ4X+gAQcdn6msAHSwLBg==",
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-1.6.3.tgz",
"integrity": "sha512-zsd4l0g68pusOmJ/R5LhTfofT+9RniCwcZsMMNFGJo97d1vT1H2nGlbhLWp/j/pfeXXj9zzR8ugUtKkadcoWnA==",
"requires": {
"@babel/runtime": "^7.14.0",
"@restart/context": "^2.1.4",
@@ -20875,16 +20908,16 @@
"invariant": "^2.2.4",
"prop-types": "^15.7.2",
"prop-types-extra": "^1.1.0",
"react-overlays": "^5.0.1",
"react-overlays": "^5.1.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==",
"version": "7.15.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz",
"integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -20895,9 +20928,9 @@
"integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA=="
},
"csstype": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
},
"dom-helpers": {
"version": "5.2.1",
@@ -20919,9 +20952,9 @@
},
"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==",
"version": "7.15.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz",
"integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
@@ -21158,17 +21191,17 @@
},
"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==",
"version": "7.15.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.15.4.tgz",
"integrity": "sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
},
"csstype": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.9.tgz",
"integrity": "sha512-rpw6JPxK6Rfg1zLOYCSwle2GFOOsnjmDYDaBwEcwoOg4qlsIVCN789VkBZDJAGi4T07gI4YSutR43t9Zz4Lzuw=="
},
"dom-helpers": {
"version": "5.2.1",
@@ -22491,7 +22524,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
"integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
"dev": true,
"requires": {
"call-bind": "^1.0.0",
"get-intrinsic": "^1.0.2",
@@ -22501,8 +22533,7 @@
"object-inspect": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.10.3.tgz",
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw==",
"dev": true
"integrity": "sha512-e5mCJlSH7poANfC8z8S9s9S2IN5/4Zb3aZ33f5s8YqoazCFzNLloLU8r5VCG+G7WoqLvAAZoVMcy3tp/3X0Plw=="
}
}
},

View File

@@ -38,7 +38,7 @@
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.0.11",
"@edx/frontend-platform": "1.11.0",
"@edx/paragon": "16.6.4",
"@edx/paragon": "16.14.2",
"@fortawesome/fontawesome-svg-core": "1.2.28",
"@fortawesome/free-brands-svg-icons": "5.11.2",
"@fortawesome/free-regular-svg-icons": "5.11.2",

View File

@@ -9,6 +9,7 @@ const CollapsableEditor = ({
open,
defaultOpen,
onToggle,
onClose,
onDelete,
children,
expandAlt,
@@ -19,6 +20,7 @@ const CollapsableEditor = ({
<Collapsible.Advanced
className="collapsible-card rounded mb-3 px-3 py-2"
onToggle={onToggle}
onClose={onClose}
defaultOpen={defaultOpen}
open={open}
{...props}
@@ -31,9 +33,11 @@ const CollapsableEditor = ({
{title}
</div>
<Collapsible.Visible whenClosed>
<Icon
screenReaderText={expandAlt}
<IconButton
alt={expandAlt}
src={ExpandMore}
iconAs={Icon}
onClick={() => {}}
variant="dark"
/>
</Collapsible.Visible>
@@ -53,9 +57,11 @@ const CollapsableEditor = ({
</div>
)}
<div className="pl-4">
<Icon
screenReaderText={collapseAlt}
<IconButton
alt={collapseAlt}
src={ExpandLess}
iconAs={Icon}
onClick={() => {}}
variant="dark"
/>
</div>
@@ -77,12 +83,14 @@ CollapsableEditor.propTypes = {
expandAlt: PropTypes.string.isRequired,
deleteAlt: PropTypes.string.isRequired,
collapseAlt: PropTypes.string.isRequired,
onClose: PropTypes.func,
};
CollapsableEditor.defaultProps = {
onDelete: null,
defaultOpen: undefined,
open: undefined,
onClose: () => {},
};
export default CollapsableEditor;

View File

@@ -0,0 +1,38 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Card } from '@edx/paragon';
const DeletePopup = ({
label,
bodyText,
onDelete,
deleteLabel,
onCancel,
cancelLabel,
}) => (
<Card className="rounded mb-3 p-1">
<Card.Body>
<div className="text-primary-500 mb-2 h4">{label}</div>
<Card.Text className="text-justify text-muted">{bodyText}</Card.Text>
<div className="d-flex justify-content-end">
<Button variant="tertiary" onClick={onCancel}>
{cancelLabel}
</Button>
<Button variant="outline-brand" className="ml-2" onClick={onDelete}>
{deleteLabel}
</Button>
</div>
</Card.Body>
</Card>
);
DeletePopup.propTypes = {
label: PropTypes.string.isRequired,
bodyText: PropTypes.string.isRequired,
onDelete: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
deleteLabel: PropTypes.string.isRequired,
cancelLabel: PropTypes.string.isRequired,
};
export default DeletePopup;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Form, TransitionReplace } from '@edx/paragon';
function FieldFeedback({
renderCondition,
feedback,
type,
hasIcon,
feedbackClasses,
transitionClasses,
}) {
return (
<TransitionReplace className={transitionClasses}>
{renderCondition ? (
<React.Fragment key="open">
<Form.Control.Feedback type={type} hasIcon={hasIcon} key={`${feedback}-feedback`}>
<div className={`small ${feedbackClasses}`}>{feedback}</div>
</Form.Control.Feedback>
</React.Fragment>
) : (
<React.Fragment key="close" />
)}
</TransitionReplace>
);
}
FieldFeedback.propTypes = {
feedback: PropTypes.string.isRequired,
type: PropTypes.string,
renderCondition: PropTypes.bool.isRequired,
hasIcon: PropTypes.bool,
feedbackClasses: PropTypes.string,
transitionClasses: PropTypes.string,
};
FieldFeedback.defaultProps = {
type: 'default',
hasIcon: false,
feedbackClasses: '',
transitionClasses: '',
};
export default FieldFeedback;

View File

@@ -8,45 +8,35 @@ import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import DivisionByGroupFields from '../shared/DivisionByGroupFields';
import AnonymousPostingFields from '../shared/AnonymousPostingFields';
import DiscussionTopics from '../shared/discussion-topics/DiscussionTopics';
import BlackoutDatesField, { blackoutDatesRegex } from '../shared/BlackoutDatesField';
import BlackoutDatesField from '../shared/BlackoutDatesField';
import LegacyConfigFormProvider from './LegacyConfigFormProvider';
import messages from '../shared/messages';
import AppConfigFormDivider from '../shared/AppConfigFormDivider';
import { checkFieldErrors } from '../../utils';
import { setupYupExtensions } from '../../../../../utils';
// eslint-disable-next-line func-names
Yup.addMethod(Yup.object, 'uniqueProperty', function (propertyName, message) {
// eslint-disable-next-line func-names
return this.test('unique', message, function (discussionTopic) {
if (!discussionTopic || !discussionTopic[propertyName]) {
return true;
}
const isDuplicate = this.parent.filter(topic => topic !== discussionTopic)
.some(topic => topic[propertyName]?.toLowerCase() === discussionTopic[propertyName].toLowerCase());
if (isDuplicate) {
throw this.createError({
path: `${this.path}.${propertyName}`,
error: message,
});
}
return true;
});
});
setupYupExtensions();
function LegacyConfigForm({
appConfig, onSubmit, formRef, intl, title,
}) {
const [validDiscussionTopics, setValidDiscussionTopics] = useState(appConfig.discussionTopics);
const legacyFormValidationSchema = Yup.object().shape({
blackoutDates: Yup.string().matches(
blackoutDatesRegex,
intl.formatMessage(messages.blackoutDatesFormattingError),
blackoutDates: Yup.array(
Yup.object().shape({
startDate: Yup.date().required(intl.formatMessage(messages.blackoutStartDateRequired)),
endDate: Yup.date().required(intl.formatMessage(messages.blackoutEndDateRequired)).when('startDate', {
is: (startDate) => startDate,
then: Yup.date().min(Yup.ref('startDate'), intl.formatMessage(messages.blackoutEndDateInPast)),
}),
startTime: Yup.string(),
endTime: Yup.string().compare(intl.formatMessage(messages.blackoutEndTimeInPast)),
}),
),
discussionTopics: Yup.array(
Yup.object({
name: Yup.string().required(intl.formatMessage(messages.discussionTopicRequired)),
}).uniqueProperty('name', intl.formatMessage(messages.discussionTopicNameAlreadyExist)),
}).uniqueObjectProperty('name', intl.formatMessage(messages.discussionTopicNameAlreadyExist)),
),
});
@@ -67,19 +57,22 @@ function LegacyConfigForm({
touched,
},
) => {
const { discussionTopics } = values;
const discussionTopicErrors = discussionTopics.map((value, index) => Boolean(
touched.discussionTopics
&& touched.discussionTopics[index]?.name
&& errors.discussionTopics
&& errors?.discussionTopics[index]?.name,
const { discussionTopics, blackoutDates } = values;
const discussionTopicErrors = discussionTopics.map((value, index) => (
checkFieldErrors(touched, errors, `discussionTopics.${index}`, 'name')
));
const blackoutDatesErrors = blackoutDates.map((value, index) => (
checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'startDate')
|| checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'endDate')
|| checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'startTime')
|| checkFieldErrors(touched, errors, `blackoutDates.${index}`, 'endTime')));
const contextValue = {
validDiscussionTopics,
setValidDiscussionTopics,
discussionTopicErrors,
isFormInvalid: discussionTopicErrors.some((error) => error === true)
|| Boolean(touched.blackoutDates && errors.blackoutDates),
blackoutDatesErrors,
isFormInvalid: discussionTopicErrors.some((error) => error) || blackoutDatesErrors.some((error) => error),
};
return (
@@ -114,7 +107,14 @@ LegacyConfigForm.propTypes = {
divideCourseTopicsByCohorts: PropTypes.bool.isRequired,
allowAnonymousPosts: PropTypes.bool.isRequired,
allowAnonymousPostsPeers: PropTypes.bool.isRequired,
blackoutDates: PropTypes.string.isRequired,
blackoutDates: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
startDate: PropTypes.string,
endDate: PropTypes.string,
startTime: PropTypes.string,
endTime: PropTypes.string,
status: PropTypes.string,
})),
discussionTopics: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
id: PropTypes.string,
@@ -134,7 +134,7 @@ LegacyConfigForm.defaultProps = {
divideCourseTopicsByCohorts: false,
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
blackoutDates: '[]',
blackoutDates: [],
},
};

View File

@@ -3,7 +3,6 @@ import React, { createRef } from 'react';
import {
act,
fireEvent,
getByText,
queryByLabelText,
queryByRole,
queryByTestId,
@@ -43,7 +42,7 @@ const defaultAppConfig = {
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
allowDivisionByUnit: false,
blackoutDates: '[]',
blackoutDates: [],
};
describe('LegacyConfigForm', () => {
let axiosMock;
@@ -145,8 +144,7 @@ describe('LegacyConfigForm', () => {
).not.toBeInTheDocument();
// BlackoutDatesField
expect(container.querySelector('#blackoutDates')).toBeInTheDocument();
expect(container.querySelector('#blackoutDates')).toHaveValue('[]');
expect(queryByText(container, messages.blackoutDatesLabel.defaultMessage)).toBeInTheDocument();
});
test('folded sub-fields are in the DOM when parents are enabled', async () => {
@@ -206,7 +204,7 @@ describe('LegacyConfigForm', () => {
const updateTopicName = async (topicId, topicName) => {
const topicCard = queryByTestId(container, topicId);
userEvent.click(queryByText(topicCard, 'Expand'));
userEvent.click(queryByLabelText(topicCard, 'Expand'));
const topicInput = topicCard.querySelector('input');
topicInput.focus();
await act(async () => { fireEvent.change(topicInput, { target: { value: topicName } }); });
@@ -220,10 +218,7 @@ describe('LegacyConfigForm', () => {
if (expectExists) { expect(error).toBeInTheDocument(); } else { expect(error).not.toBeInTheDocument(); }
};
const assertDuplicateTopicNameValidation = async (topicCard, waitForBlur = true, expectExists = true) => {
if (waitForBlur) {
await waitForElementToBeRemoved(queryByText(topicCard, messages.addTopicHelpText.defaultMessage));
}
const assertDuplicateTopicNameValidation = async (topicCard, expectExists = true) => {
const error = queryByText(topicCard, messages.discussionTopicNameAlreadyExist.defaultMessage);
if (expectExists) { expect(error).toBeInTheDocument(); } else { expect(error).not.toBeInTheDocument(); }
};
@@ -248,7 +243,7 @@ describe('LegacyConfigForm', () => {
createComponent(defaultAppConfig);
const topicCard = await updateTopicName('13f106c6-6735-4e84-b097-0456cff55960', '');
const collapseButton = getByText(topicCard, 'Collapse');
const collapseButton = queryByLabelText(topicCard, 'Collapse');
await act(async () => userEvent.click(collapseButton));
expect(collapseButton).toBeInTheDocument();

View File

@@ -1,118 +1,89 @@
import React, { useState } from 'react';
import React, { useCallback } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form, TransitionReplace } from '@edx/paragon';
import { useFormikContext } from 'formik';
import messages from './messages';
import { Button } from '@edx/paragon';
import { Add } from '@edx/paragon/icons';
/**
* Lets break this regex down.
*
* The goal is to accept arrays of dates like the following:
*
* [["2015-09-15", "2015-09-21"], ["2015-10-01T11:45", "2015-10-08"]]
*
* Any date can be YYYY-MM-DDTHH:MM or just YYYY-MM-DD like the above.
* The hours and minutes are optional, as illustrated.
*
* The regex errs on the side of being too loose, so you'll see things that are not perfect. It's
* better to be too liberal than to accidentally reject something that should be fine.
*
* So let multi-line this regex and explain the parts:
*
* Beginning of the string:
* ^
* The outer square brackets:
* \[
* Start of a group for a pair of dates with their square brackets:
* (\[
* A group for the first date (YYYY-MM-DDTHH:MM) with its opening double quote:
* ("
* Any four digits for the YYYY year, and a dash:
* [0-9]{4}-
* MM Months 00 - 12 with either a 0 followed by a digit, 1-9, OR a 1 followed by a digit 0-2
* Then a dash:
* (0[1-9]|1[0-2])-
* Finally, for days, accepts any digit 0-3 followed by any digit 0-9. Not a very exact regex.
* [0-3][0-9]
* A sub-group for the hours and minutes. T is just part of it:
* (T
* The hours HH are a 0 or 1 followed by 0-9, OR a 2 followed by 0-3. Captures digits 00-23:
* ([0-1][0-9]|2[0-3])
* Then a colon!
* :
* The minutes MM are any digit 0-5 followed by any digit 0-9, capturing 00-59:
* ([0-5][0-9])
* The THH:MM is optional, so end the group by allowing it to repeat 0 or 1 times
* ){0,1}
* Now end the first date group with its closing double quote, the group parenthesis, and a comma.
* The comma is a necessary separator between the first and second dates:
* "),
* Now start the second date of the pair:
* ("
* The second date is identical to the first, so here it is in all its glory:
* [0-9]{4}-(0[1-9]|1[0-2])-[0-3][0-9] // YYYY-MM-DD, identical to above
* (T([0-1][0-9]|2[0-3]):([0-5][0-9])){0,1} // THH:MM, identical to above
* Close out the second date with its closing double quotes:
* ")
* Close out the pair of dates with its closing square bracket:
* \]
* An optional comma after the pair of dates, in case there's another pair. If there isn't another
* date, there shouldn't be another comma, but this regex errors on the side of looseness.
* (,){0,1}
* This entire group, ["YYYY-MM-DDTHH:MM", "YYYY-MM-DDTHH:MM"], can be repeated zero or more times.
* )*
* Close out the last square bracket around all the groups:
* \]
* End of string:
* $
*/
export const blackoutDatesRegex = /^\[(\[("[0-9]{4}-(0[1-9]|1[0-2])-[0-3][0-9](T([0-1][0-9]|2[0-3]):([0-5][0-9])){0,1}"),("[0-9]{4}-(0[1-9]|1[0-2])-[0-3][0-9](T([0-1][0-9]|2[0-3]):([0-5][0-9])){0,1}")\](,){0,1})*\]$/;
import { FieldArray, useFormikContext } from 'formik';
import { v4 as uuid } from 'uuid';
import messages from './messages';
import BlackoutDatesItem from './blackout-dates/BlackoutDatesItem';
import { checkStatus } from '../../utils';
import { denormalizeBlackoutDate } from '../../../data/api';
import { blackoutDatesStatus as STATUS } from '../../../data/constants';
const BlackoutDatesField = ({ intl }) => {
const [inFocus, setInFocus] = useState(false);
const {
handleChange, handleBlur, errors,
touched, values: appConfig,
values: appConfig,
setFieldValue,
errors,
validateForm,
} = useFormikContext();
const { blackoutDates } = appConfig;
const hasError = Boolean(touched.blackoutDates && errors.blackoutDates);
const handleOnClose = useCallback((index) => {
const updatedBlackoutDates = [...blackoutDates];
updatedBlackoutDates[index] = {
...updatedBlackoutDates[index],
status: checkStatus(denormalizeBlackoutDate(updatedBlackoutDates[index])),
};
setFieldValue('blackoutDates', updatedBlackoutDates);
}, [blackoutDates]);
const handleFocusOut = (event) => {
handleBlur(event);
setInFocus(false);
const newBlackoutDateItem = {
id: uuid(),
startDate: '',
startTime: '',
endDate: '',
endTime: '',
status: STATUS.NEW,
};
const onAddNewItem = async (push) => {
await push(newBlackoutDateItem);
validateForm();
};
return (
<>
<h5 className="my-4 text-gray-500">{intl.formatMessage(messages.blackoutDates)}</h5>
<Form.Group
controlId="blackoutDates"
isInvalid={hasError && !inFocus}
className="m-2"
>
<Form.Control
value={appConfig.blackoutDates}
onChange={handleChange}
onBlur={(event) => handleFocusOut(event)}
className="mb-1"
floatingLabel={intl.formatMessage(messages.blackoutDatesLabel)}
onFocus={() => setInFocus(true)}
/>
<TransitionReplace key="blackoutDates">
{hasError && !inFocus ? (
<React.Fragment key="open">
<Form.Control.Feedback type="invalid" hasIcon={false}>
<div className="small">{errors.blackoutDates}</div>
</Form.Control.Feedback>
</React.Fragment>
) : (
<React.Fragment key="closed" />
<h5 className="text-gray-500 mt-4 mb-2">
{intl.formatMessage(messages.blackoutDatesLabel)}
</h5>
<label className="text-primary-500 mb-1 h4">
{intl.formatMessage(messages.blackoutDates)}
</label>
<div className="small mb-4 text-muted">
{intl.formatMessage(messages.blackoutDatesHelp)}
</div>
<div>
<FieldArray
name="blackoutDates"
render={({ push, remove }) => (
<div>
{blackoutDates.map((blackoutDate, index) => (
<BlackoutDatesItem
fieldNameCommonBase={`blackoutDates.${index}`}
blackoutDate={blackoutDate}
key={`date-${blackoutDate.id}`}
id={blackoutDate.id}
onDelete={() => remove(index)}
onClose={() => handleOnClose(index)}
hasError={Boolean(errors?.blackoutDates?.[index])}
/>
))}
<div className="mb-4">
<Button
onClick={() => onAddNewItem(push)}
variant="link"
iconBefore={Add}
className="text-primary-500 p-0"
>
{intl.formatMessage(messages.addBlackoutDatesButton)}
</Button>
</div>
</div>
)}
</TransitionReplace>
<Form.Text muted className="mt-3">
{intl.formatMessage(messages.blackoutDatesHelp)}
</Form.Text>
</Form.Group>
/>
</div>
</>
);
};

View File

@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { Form } from '@edx/paragon';
import { useFormikContext, getIn } from 'formik';
import PropTypes from 'prop-types';
import FieldFeedback from '../../../../../../generic/FieldFeedback';
const BlackoutDatesInput = ({
value,
type,
label,
fieldName,
helpText,
fieldClasses,
feedbackClasses,
formGroupClasses,
fieldNameCommonBase,
}) => {
const {
handleChange, handleBlur, errors, touched,
} = useFormikContext();
const [inFocus, setInFocus] = useState(false);
const fieldError = getIn(errors, `${fieldNameCommonBase}.${fieldName}`);
const fieldTouched = getIn(touched, `${fieldNameCommonBase}.${fieldName}`);
const isInvalidInput = Boolean(!inFocus && fieldError && fieldTouched);
const handleFocusOut = (event) => {
handleBlur(event);
setInFocus(false);
};
return (
<Form.Group
controlId={`${fieldNameCommonBase}.${fieldName}`}
className={`col ${formGroupClasses}`}
isInvalid={isInvalidInput}
>
<Form.Control
name={`${fieldNameCommonBase}.${fieldName}`}
value={value}
type={type}
onChange={handleChange}
floatingLabel={label}
className={fieldClasses}
onBlur={(event) => handleFocusOut(event)}
onFocus={() => setInFocus(true)}
/>
<FieldFeedback
renderCondition={inFocus}
feedback={helpText}
transitionClasses="mt-1"
feedbackClasses={feedbackClasses}
/>
<FieldFeedback
renderCondition={isInvalidInput}
feedback={fieldError || ''}
type="invalid"
transitionClasses="mt-1"
feedbackClasses={feedbackClasses}
/>
</Form.Group>
);
};
BlackoutDatesInput.propTypes = {
value: PropTypes.string.isRequired,
fieldName: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
helpText: PropTypes.string,
feedbackClasses: PropTypes.string,
fieldClasses: PropTypes.string,
formGroupClasses: PropTypes.string,
fieldNameCommonBase: PropTypes.string.isRequired,
};
BlackoutDatesInput.defaultProps = {
fieldClasses: '',
helpText: '',
feedbackClasses: '',
formGroupClasses: '',
};
export default BlackoutDatesInput;

View File

@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import _ from 'lodash';
import messages from '../messages';
import BlackoutDatesInput from './BlackoutDatesInput';
import { formatBlackoutDates } from '../../../utils';
import {
blackoutDatesStatus as constants,
deleteHelperText,
badgeVariant,
} from '../../../../data/constants';
import CollapsableEditor from '../../../../../../generic/CollapsableEditor';
import DeletePopup from '../../../../../../generic/DeletePopup';
import CollapseCardHeading from './CollapseCardHeading';
const BlackoutDatesItem = ({
intl,
blackoutDate,
onDelete,
hasError,
onClose,
fieldNameCommonBase,
}) => {
const [showDeletePopup, setShowDeletePopup] = useState(false);
const [collapseIsOpen, setCollapseOpen] = useState(hasError);
const { setFieldTouched } = useFormikContext();
const handleToggle = (isOpen) => {
if (!isOpen && hasError) {
return setCollapseOpen(true);
}
return setCollapseOpen(isOpen);
};
useEffect(() => {
setCollapseOpen(hasError);
}, [hasError]);
const getHeading = (isOpen) => (
<CollapseCardHeading
isOpen={isOpen}
expandHeadingText={intl.formatMessage(messages.configureBlackoutDates)}
collapseHeadingText={formatBlackoutDates(blackoutDate)}
badgeVariant={badgeVariant[blackoutDate.status]}
badgeStatus={intl.formatMessage(messages.blackoutDatesStatus, {
status: _.startCase(_.toLower(blackoutDate.status)),
})}
/>
);
if (showDeletePopup) {
return (
<DeletePopup
label={blackoutDate.status === constants.ACTIVE
? intl.formatMessage(messages.activeBlackoutDatesDeletionLabel)
: intl.formatMessage(messages.blackoutDatesDeletionLabel)}
bodyText={intl.formatMessage(deleteHelperText[blackoutDate.status])}
onDelete={onDelete}
deleteLabel={intl.formatMessage(messages.deleteButton)}
onCancel={() => setShowDeletePopup(false)}
cancelLabel={intl.formatMessage(messages.cancelButton)}
/>
);
}
const handleOnClose = () => {
['startDate', 'startTime', 'endDate', 'endTime'].forEach(field => (
setFieldTouched(`${fieldNameCommonBase}.${field}`, true)
));
if (!hasError) {
onClose();
}
};
return (
<CollapsableEditor
open={collapseIsOpen}
onToggle={handleToggle}
title={getHeading(collapseIsOpen)}
onDelete={() => setShowDeletePopup(true)}
expandAlt={intl.formatMessage(messages.expandAltText)}
collapseAlt={intl.formatMessage(messages.collapseAltText)}
deleteAlt={intl.formatMessage(messages.deleteAltText)}
data-testid={blackoutDate.id}
onClose={() => handleOnClose()}
>
<Form.Row className="mx-2 pt-3">
<BlackoutDatesInput
value={blackoutDate.startDate}
type="date"
label={intl.formatMessage(messages.startDateLabel)}
helpText={intl.formatMessage(messages.blackoutStartDateHelp)}
fieldName="startDate"
formGroupClasses="pl-md-0"
fieldClasses="pr-md-2"
fieldNameCommonBase={fieldNameCommonBase}
/>
<BlackoutDatesInput
value={blackoutDate.startTime}
type="time"
label={intl.formatMessage(messages.startTimeLabel)}
helpText={intl.formatMessage(messages.blackoutStartTimeHelp)}
fieldName="startTime"
formGroupClasses="pr-md-0"
fieldClasses="ml-md-2"
feedbackClasses="ml-md-2"
fieldNameCommonBase={fieldNameCommonBase}
/>
</Form.Row>
<hr className="mx-2 my-2 border-light-400" />
<Form.Row className="mx-2 pt-4">
<BlackoutDatesInput
value={blackoutDate.endDate}
type="date"
label={intl.formatMessage(messages.endDateLabel)}
helpText={intl.formatMessage(messages.blackoutEndDateHelp)}
fieldName="endDate"
formGroupClasses="pl-md-0"
fieldClasses="pr-md-2"
fieldNameCommonBase={fieldNameCommonBase}
/>
<BlackoutDatesInput
value={blackoutDate.endTime}
type="time"
label={intl.formatMessage(messages.endTimeLabel)}
helpText={intl.formatMessage(messages.blackoutEndTimeHelp)}
fieldName="endTime"
formGroupClasses="pr-md-0"
fieldClasses="ml-md-2"
feedbackClasses="ml-md-2"
fieldNameCommonBase={fieldNameCommonBase}
/>
</Form.Row>
</CollapsableEditor>
);
};
BlackoutDatesItem.propTypes = {
intl: intlShape.isRequired,
onDelete: PropTypes.func.isRequired,
hasError: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
fieldNameCommonBase: PropTypes.string.isRequired,
blackoutDate: PropTypes.shape({
id: PropTypes.string,
startDate: PropTypes.string,
endDate: PropTypes.string,
startTime: PropTypes.string,
endTime: PropTypes.string,
status: PropTypes.string,
}).isRequired,
};
export default injectIntl(BlackoutDatesItem);

View File

@@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Badge } from '@edx/paragon';
const CollapseCardHeading = ({
isOpen,
expandHeadingText,
collapseHeadingText,
badgeVariant,
badgeStatus,
}) => {
if (isOpen) {
return <span className="h4 py-2 mr-auto">{expandHeadingText}</span>;
}
return (
<div className="py-2">
{badgeStatus && <Badge variant={badgeVariant}>{badgeStatus}</Badge>}
<div>{collapseHeadingText}</div>
</div>
);
};
CollapseCardHeading.propTypes = {
isOpen: PropTypes.bool.isRequired,
collapseHeadingText: PropTypes.string.isRequired,
expandHeadingText: PropTypes.string.isRequired,
badgeVariant: PropTypes.string,
badgeStatus: PropTypes.string,
};
CollapseCardHeading.defaultProps = {
badgeVariant: 'primary',
badgeStatus: '',
};
export default CollapseCardHeading;

View File

@@ -8,7 +8,7 @@ import _ from 'lodash';
import messages from '../messages';
import TopicItem from './TopicItem';
import { LegacyConfigFormContext } from '../../legacy/LegacyConfigFormProvider';
import filterItemFromObject from '../../../utils';
import { filterItemFromObject } from '../../../utils';
const DiscussionTopics = ({ intl }) => {
const {
@@ -53,7 +53,7 @@ const DiscussionTopics = ({ intl }) => {
<h5 className="text-gray-500 mt-4 mb-2">
{intl.formatMessage(messages.discussionTopics)}
</h5>
<label className="text-primary-500 mb-2 h4">
<label className="text-primary-500 mb-1 h4">
{intl.formatMessage(messages.discussionTopicsLabel)}
</label>
<div className="small mb-4 text-muted">

View File

@@ -1,7 +1,7 @@
import React from 'react';
import {
act, fireEvent, queryAllByTestId, queryByTestId, queryByText, render, waitFor,
act, fireEvent, queryAllByTestId, queryByTestId, queryByText, render, waitFor, queryByLabelText,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
@@ -33,7 +33,7 @@ const appConfig = {
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
allowDivisionByUnit: false,
blackoutDates: '[]',
blackoutDates: [],
};
const contextValue = {
@@ -134,11 +134,11 @@ describe('DiscussionTopics', () => {
createComponent(appConfig);
const topicCard = queryByTestId(container, '13f106c6-6735-4e84-b097-0456cff55960');
await act(async () => userEvent.click(queryByText(topicCard, 'Expand')));
await act(async () => userEvent.click(queryByLabelText(topicCard, 'Expand')));
await act(async () => {
fireEvent.change(topicCard.querySelector('input'), { target: { value: 'new name' } });
});
await act(async () => userEvent.click(queryByText(topicCard, 'Collapse')));
await act(async () => userEvent.click(queryByLabelText(topicCard, 'Collapse')));
expect(queryByText(topicCard, 'new name')).toBeInTheDocument();
});

View File

@@ -5,11 +5,12 @@ import { useFormikContext } from 'formik';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Card, Form, TransitionReplace,
Button, Card, Form,
} from '@edx/paragon';
import CollapsableEditor from '../../../../../../generic/CollapsableEditor';
import messages from '../messages';
import FieldFeedback from '../../../../../../generic/FieldFeedback';
const TopicItem = ({
intl,
@@ -63,12 +64,6 @@ const TopicItem = ({
setShowDeletePopup(true);
};
const renderFormFeedback = (message, messageType = 'default') => (
<Form.Control.Feedback type={messageType} hasIcon={false}>
<div className="small">{message}</div>
</Form.Control.Feedback>
);
const handleFocusOut = (event) => {
handleBlur(event);
setInFocus(false);
@@ -125,24 +120,17 @@ const TopicItem = ({
controlClassName="bg-white"
onFocus={() => setInFocus(true)}
/>
<TransitionReplace key={id} className="mt-1">
{inFocus ? (
<React.Fragment key="open">
{renderFormFeedback(intl.formatMessage(messages.addTopicHelpText))}
</React.Fragment>
) : (
<React.Fragment key="closed" />
)}
</TransitionReplace>
<TransitionReplace key={`${name}-${id}`}>
{hasError && !inFocus ? (
<React.Fragment key="open">
{renderFormFeedback(errors?.discussionTopics[index].name, 'invalid')}
</React.Fragment>
) : (
<React.Fragment key="closed" />
)}
</TransitionReplace>
<FieldFeedback
renderCondition={inFocus}
feedback={intl.formatMessage(messages.addTopicHelpText)}
transitionClasses="mt-1"
/>
<FieldFeedback
renderCondition={hasError && !inFocus}
feedback={errors?.discussionTopics?.[index]?.name || ''}
type="invalid"
transitionClasses="mt-1"
/>
</Form.Group>
</CollapsableEditor>
);

View File

@@ -6,6 +6,7 @@ import {
queryByRole,
queryByTestId,
queryByText,
queryByLabelText,
render,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -106,8 +107,8 @@ describe('TopicItem', () => {
createComponent(generalTopic);
const generalTopicNode = queryByTestId(container, 'course');
expect(queryByText(generalTopicNode, 'Expand')).toBeInTheDocument();
expect(queryByText(generalTopicNode, 'Collapse')).not.toBeInTheDocument();
expect(queryByLabelText(generalTopicNode, 'Expand')).toBeInTheDocument();
expect(queryByLabelText(generalTopicNode, 'Collapse')).not.toBeInTheDocument();
expect(queryByText(generalTopicNode, 'General')).toBeInTheDocument();
});
@@ -116,10 +117,10 @@ describe('TopicItem', () => {
createComponent(generalTopic);
const generalTopicNode = queryByTestId(container, 'course');
userEvent.click(queryByText(generalTopicNode, 'Expand'));
userEvent.click(queryByLabelText(generalTopicNode, 'Expand'));
expect(queryByText(generalTopicNode, 'Expand')).not.toBeInTheDocument();
expect(queryByText(generalTopicNode, 'Collapse')).toBeInTheDocument();
expect(queryByLabelText(generalTopicNode, 'Expand')).not.toBeInTheDocument();
expect(queryByLabelText(generalTopicNode, 'Collapse')).toBeInTheDocument();
expect(queryByRole(generalTopicNode, 'button', { name: 'Delete Topic' })).not.toBeInTheDocument();
expect(generalTopicNode.querySelector('input')).toBeInTheDocument();
});
@@ -129,10 +130,10 @@ describe('TopicItem', () => {
createComponent(additionalTopic);
const topicCard = queryByTestId(container, '13f106c6-6735-4e84-b097-0456cff55960');
userEvent.click(queryByText(topicCard, 'Expand'));
userEvent.click(queryByLabelText(topicCard, 'Expand'));
expect(queryByText(topicCard, 'Expand')).not.toBeInTheDocument();
expect(queryByText(topicCard, 'Collapse')).toBeInTheDocument();
expect(queryByLabelText(topicCard, 'Expand')).not.toBeInTheDocument();
expect(queryByLabelText(topicCard, 'Collapse')).toBeInTheDocument();
expect(queryByRole(topicCard, 'button', { name: 'Delete Topic' })).toBeInTheDocument();
expect(topicCard.querySelector('input')).toBeInTheDocument();
});
@@ -142,7 +143,7 @@ describe('TopicItem', () => {
createComponent(additionalTopic);
const topicCard = queryByTestId(container, '13f106c6-6735-4e84-b097-0456cff55960');
userEvent.click(queryByText(topicCard, 'Expand'));
userEvent.click(queryByLabelText(topicCard, 'Expand'));
userEvent.click(queryByRole(topicCard, 'button', { name: 'Delete Topic' }));
expect(queryAllByText(container, messages.discussionTopicDeletionLabel.defaultMessage)).toHaveLength(1);
@@ -158,7 +159,7 @@ describe('TopicItem', () => {
createComponent(additionalTopic);
const topicCard = queryByTestId(container, '13f106c6-6735-4e84-b097-0456cff55960');
userEvent.click(queryByText(topicCard, 'Expand'));
userEvent.click(queryByLabelText(topicCard, 'Expand'));
topicCard.querySelector('input').focus();
expect(queryByText(topicCard, messages.addTopicHelpText.defaultMessage)).toBeInTheDocument();

View File

@@ -179,17 +179,96 @@ const messages = defineMessages({
},
blackoutDatesHelp: {
id: 'authoring.discussions.builtIn.blackoutDates.help',
defaultMessage:
`Enter pairs of dates between which students cannot post to discussion forums. Inside the provided
brackets, enter an additional set of square brackets surrounding each pair of dates you add.
Format each pair of dates as ["YYYY-MM-DD", "YYYY-MM-DD"]. To specify times as well as dates,
format each pair as ["YYYY-MM-DDTHH:MM", "YYYY-MM-DDTHH:MM"]. Be sure to include the "T" between
the date and time. For example, an entry defining two blackout periods looks like this, including
the outer pair of square brackets: [["2015-09-15", "2015-09-21"], ["2015-10-01", "2015-10-08"]]`,
defaultMessage: 'If added, learners will not be able to post in discussions between these dates.',
},
blackoutDatesFormattingError: {
id: 'authoring.discussions.builtIn.blackoutDates.formattingError',
defaultMessage: "There's a formatting error in your blackout dates.",
addBlackoutDatesButton: {
id: 'authoring.discussions.addBlackoutDatesButton',
defaultMessage: 'Add blackout date range',
description: 'Button label when Add a new blackout date.',
},
configureBlackoutDates: {
id: 'authoring.discussions.builtIn.configureBlackoutDates.label',
defaultMessage: 'Configure blackout date range',
description: 'Label for blockout dates allowing user to configure blackout dates',
},
blackoutStartDateHelp: {
id: 'authoring.discussions.blackoutStartDate.help',
defaultMessage: 'Enter a start date, e.g. 12/10/2023',
},
blackoutEndDateHelp: {
id: 'authoring.discussions.blackoutEndDate.help',
defaultMessage: 'Enter an end date, e.g. 12/17/2023',
},
blackoutStartTimeHelp: {
id: 'authoring.discussions.blackoutStartTime.help',
defaultMessage: 'Enter a start time, e.g. 09:00 AM',
},
blackoutEndTimeHelp: {
id: 'authoring.discussions.blackoutEndTime.help',
defaultMessage: 'Enter an end time, e.g. 05:00 PM',
},
activeBlackoutDatesDeletionHelp: {
id: 'authoring.discussions.activeBlackoutDatesDeletion.help',
defaultMessage: 'These blackout dates are currently active. If deleted, learners will be able to post in discussions during these dates. Are you sure you want to proceed?',
description: 'Help text for delete a active blackout dates from blackout dates section.',
},
blackoutDatesDeletionHelp: {
id: 'authoring.discussions.blackoutDatesDeletion.help',
defaultMessage: 'If deleted, learners will be able to post in discussions during these dates.',
description: 'Help text for delete a upcoming blackout dates from blackout dates section.',
},
completeBlackoutDatesDeletionHelp: {
id: 'authoring.discussions.completeBlackoutDatesDeletion.help',
defaultMessage: 'Are you sure you want to delete these blackout dates?',
description: 'Help text for delete a complete blackout dates from blackout dates section.',
},
activeBlackoutDatesDeletionLabel: {
id: 'authoring.discussions.activeBlackoutDatesDeletion.label',
defaultMessage: 'Delete active blackout dates?',
description: 'Label for active blackout dates delete popup allowing a user to delete a blackout date range.',
},
blackoutDatesDeletionLabel: {
id: 'authoring.discussions.blackoutDatesDeletion.label',
defaultMessage: 'Delete blackout dates?',
description: 'Label for blackout dates delete popup allowing a user to delete a blackout date range.',
},
deleteBlackoutDatesAltText: {
id: 'authoring.blackoutDates.delete',
defaultMessage: 'Delete Blackout Dates',
},
blackoutDatesStatus: {
id: 'authoring.blackoutDates.status',
defaultMessage: '{status}',
},
blackoutStartDateRequired: {
id: 'authoring.blackoutDates.startDate.required',
defaultMessage: 'Start date is a required field',
description: 'Tells the user that the blackout dates must have start date and it is required.',
},
blackoutEndDateRequired: {
id: 'authoring.blackoutDates.endDate.required',
defaultMessage: 'End date is a required field',
description: 'Tells the user that the blackout dates must have end date and it is required.',
},
blackoutStartDateInPast: {
id: 'authoring.blackoutDates.startDate.inPast',
defaultMessage: 'Start date cannot be after end date',
description: 'Tells the user that the blackout start date cannot be in past and cannot be after end date',
},
blackoutEndDateInPast: {
id: 'authoring.blackoutDates.endDate.inPast',
defaultMessage: 'End date cannot be before start date',
description: 'Tells the user that the blackout end date cannot be in past and cannot be before start date',
},
blackoutStartTimeInPast: {
id: 'authoring.blackoutDates.startTime.inPast',
defaultMessage: 'Start time cannot be after end time',
description: 'Tells the user that the blackout start time cannot be in past and cannot be after end time',
},
blackoutEndTimeInPast: {
id: 'authoring.blackoutDates.endTime.inPast',
defaultMessage: 'End time cannot be before start time',
description: 'Tells the user that the blackout end time cannot be in past and cannot be before start time',
},
deleteAltText: {
id: 'authoring.topics.delete',
@@ -203,6 +282,26 @@ const messages = defineMessages({
id: 'authoring.topics.collapse',
defaultMessage: 'Collapse',
},
startDateLabel: {
id: 'authoring.blackoutDates.start.date',
defaultMessage: 'Start date',
description: 'Label for start date field',
},
startTimeLabel: {
id: 'authoring.blackoutDates.start.time',
defaultMessage: 'Start time (optional)',
description: 'label for start time field',
},
endDateLabel: {
id: 'authoring.blackoutDates.end.date',
defaultMessage: 'End date',
description: 'label for end date field',
},
endTimeLabel: {
id: 'authoring.blackoutDates.end.time',
defaultMessage: 'End time (optional)',
description: 'label for end time field',
},
});
export default messages;

View File

@@ -1,5 +1,82 @@
const filterItemFromObject = (array, key, value) => (
import moment from 'moment';
import _ from 'lodash';
import { getIn } from 'formik';
import { blackoutDatesStatus as constants } from '../data/constants';
export const filterItemFromObject = (array, key, value) => (
array.filter(item => item[key] !== value)
);
export default filterItemFromObject;
export const checkFieldErrors = (touched, errors, fieldPath, propertyName) => Boolean(
getIn(errors, `${fieldPath}.${propertyName}`) && getIn(touched, `${fieldPath}.${propertyName}`),
);
export const errorExists = (errors, fieldPath, propertyName) => getIn(errors, `${fieldPath}.${propertyName}`);
export const checkStatus = ([startDate, endDate]) => {
const today = moment(); let status;
if (moment(endDate).isBefore(today, 'days')) {
status = constants.COMPLETE;
} else if (moment(startDate).isAfter(today, 'days')) {
status = constants.UPCOMING;
} else {
status = constants.ACTIVE;
}
return status;
};
export const formatDate = (date, time) => (time ? `${date}T${time}` : date);
export const isSameDay = (startDate, endDate) => moment(startDate).isSame(endDate, 'day');
export const isSameMonth = (startDate, endDate) => moment(startDate).isSame(endDate, 'month');
export const isSameYear = (startDate, endDate) => moment(startDate).isSame(endDate, 'year');
export const sortBlackoutDatesByStatus = (data, status, order) => (
_.orderBy(data.filter(date => date.status === status),
[(obj) => moment(formatDate(obj.startDate, obj.startTime))], [order])
);
export const formatBlackoutDates = ({
startDate, startTime, endDate, endTime,
}) => {
let formattedDate;
const hasSameDay = isSameDay(startDate, endDate);
const hasSameMonth = isSameMonth(startDate, endDate);
const hasSameYear = isSameYear(startDate, endDate);
const isTimeAvailable = Boolean(startTime && endTime);
const mStartDate = moment(startDate);
const mEndDate = moment(endDate);
const mStartDateTime = moment(`${startDate}T${startTime}`);
const mEndDateTime = moment(`${endDate}T${endTime}`);
if (hasSameDay && !isTimeAvailable) {
formattedDate = mStartDate.format('MMMM D, YYYY');
} else if (hasSameDay && isTimeAvailable) {
formattedDate = `
${mStartDateTime.format('MMMM D, YYYY, h:mma')} -
${mEndDateTime.format('h:mma')}
`;
} else if (hasSameMonth && !isTimeAvailable) {
formattedDate = `
${mStartDate.format('MMMM D')} -
${mEndDate.format('D, YYYY')}
`;
} else if (hasSameMonth && isTimeAvailable) {
formattedDate = `
${mStartDateTime.format('MMMM D, YYYY, h:mma')} -
${mEndDateTime.format('MMMM D, YYYY, h:mma')}
`;
} else if (!hasSameMonth && hasSameYear) {
formattedDate = `
${mStartDate.format('MMMM D')} -
${mEndDate.format('MMMM D, YYYY')}
`;
} else if (!hasSameMonth && !hasSameYear) {
formattedDate = `
${mStartDate.format('MMMM D, YYYY')} -
${mEndDate.format('MMMM D, YYYY')}
`;
}
return formattedDate;
};

View File

@@ -1,6 +1,11 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import _ from 'lodash';
import moment from 'moment';
import { v4 as uuid } from 'uuid';
import { checkStatus, sortBlackoutDatesByStatus, formatDate } from '../app-config-form/utils';
import { blackoutDatesStatus as constants } from './constants';
function normalizeLtiConfig(data) {
if (!data || Object.keys(data).length < 1) {
@@ -33,6 +38,25 @@ function extractDiscussionTopicIds(data) {
).map(([key, value]) => value.id);
}
export function normalizeBlackoutDates(data) {
if (!data.length) { return []; }
const normalizeData = data.map(([startDate, endDate]) => ({
id: uuid(),
startDate: moment(startDate).format('YYYY-MM-DD'),
startTime: startDate.split('T')[1] || '',
endDate: moment(endDate).format('YYYY-MM-DD'),
endTime: endDate.split('T')[1] || '',
status: checkStatus([startDate, endDate]),
}));
return [
...sortBlackoutDatesByStatus(normalizeData, constants.ACTIVE, 'desc'),
...sortBlackoutDatesByStatus(normalizeData, constants.UPCOMING, 'asc'),
...sortBlackoutDatesByStatus(normalizeData, constants.COMPLETE, 'desc'),
];
}
function normalizePluginConfig(data) {
if (!data || Object.keys(data).length < 1) {
return {};
@@ -45,7 +69,7 @@ function normalizePluginConfig(data) {
return {
allowAnonymousPosts: data.allow_anonymous,
allowAnonymousPostsPeers: data.allow_anonymous_to_peers,
blackoutDates: JSON.stringify(data.discussion_blackouts),
blackoutDates: normalizeBlackoutDates(data.discussion_blackouts),
allowDivisionByUnit: false,
divideByCohorts: discussionDividedTopicsCount > 0,
divideCourseTopicsByCohorts: enableDivideCourseTopicsByCohorts,
@@ -88,6 +112,13 @@ function normalizeApps(data) {
};
}
export function denormalizeBlackoutDate(date) {
return [
formatDate(date.startDate, date.startTime),
formatDate(date.endDate, date.endTime),
];
}
function denormalizeData(courseId, appId, data) {
const pluginConfiguration = {};
@@ -97,8 +128,12 @@ function denormalizeData(courseId, appId, data) {
if (data.allowAnonymousPostsPeers) {
pluginConfiguration.allow_anonymous_to_peers = data.allowAnonymousPostsPeers;
}
if (data.blackoutDates) {
pluginConfiguration.discussion_blackouts = JSON.parse(data.blackoutDates);
if (data.blackoutDates?.length) {
pluginConfiguration.discussion_blackouts = data.blackoutDates.map((blackoutDates) => (
denormalizeBlackoutDate(blackoutDates)
));
} else if (data.blackoutDates?.length === 0) {
pluginConfiguration.discussion_blackouts = [];
}
if (data.discussionTopics?.length) {
pluginConfiguration.discussion_topics = data.discussionTopics.reduce((topics, currentTopic) => {

View File

@@ -0,0 +1,29 @@
import moment from 'moment';
import messages from '../app-config-form/apps/shared/messages';
export const blackoutDatesStatus = {
UPCOMING: 'UPCOMING',
COMPLETE: 'COMPLETE',
ACTIVE: 'ACTIVE',
NEW: 'NEW',
};
export const badgeVariant = {
UPCOMING: 'primary',
COMPLETE: 'light',
ACTIVE: 'success',
NEW: 'LIGHT',
};
export const deleteHelperText = {
UPCOMING: messages.blackoutDatesDeletionHelp,
COMPLETE: messages.completeBlackoutDatesDeletionHelp,
ACTIVE: messages.activeBlackoutDatesDeletionHelp,
NEW: messages.completeBlackoutDatesDeletionHelp,
};
export const today = moment();
export const active = [today.format('YYYY-MM-DDTHH:mm'), today.add(5, 'hours').format('YYYY-MM-DDTHH:mm')];
export const upcoming = [today.add(2, 'days').format('YYYY-MM-DD'), today.add(5, 'days').format('YYYY-MM-DD')];
export const complete = [today.subtract(7, 'days').format('YYYY-MM-DD'), today.subtract(5, 'days').format('YYYY-MM-DD')];

View File

@@ -233,7 +233,7 @@ describe('Data layer integration tests', () => {
id: 'legacy',
allowAnonymousPosts: false,
allowAnonymousPostsPeers: false,
blackoutDates: '[]',
blackoutDates: [],
// TODO: Note! As of this writing, all the data below this line is NOT returned in the API
// but we add it in during normalization.
divideByCohorts: true,
@@ -387,7 +387,7 @@ describe('Data layer integration tests', () => {
plugin_configuration: {
allow_anonymous: true,
allow_anonymous_to_peers: true,
discussion_blackouts: [['2015-09-15', '2015-09-21'], ['2015-10-01', '2015-10-08']],
discussion_blackouts: [],
discussion_topics: {
Edx: { id: '13f106c6-6735-4e84-b097-0456cff55960' },
General: { id: 'course' },
@@ -404,7 +404,7 @@ describe('Data layer integration tests', () => {
plugin_configuration: {
allow_anonymous: true,
allow_anonymous_to_peers: true,
discussion_blackouts: [['2015-09-15', '2015-09-21'], ['2015-10-01', '2015-10-08']],
discussion_blackouts: [],
discussion_topics: {
Edx: { id: '13f106c6-6735-4e84-b097-0456cff55960' },
General: { id: 'course' },
@@ -425,7 +425,7 @@ describe('Data layer integration tests', () => {
{
allowAnonymousPosts: true,
allowAnonymousPostsPeers: true,
blackoutDates: '[["2015-09-15","2015-09-21"],["2015-10-01","2015-10-08"]]',
blackoutDates: [],
// TODO: Note! As of this writing, all the data below this line is NOT returned in the API
// but we technically send it to the thunk, so here it is.
divideByCohorts: true,
@@ -459,7 +459,7 @@ describe('Data layer integration tests', () => {
// These three fields should be updated.
allowAnonymousPosts: true,
allowAnonymousPostsPeers: true,
blackoutDates: '[["2015-09-15","2015-09-21"],["2015-10-01","2015-10-08"]]',
blackoutDates: [],
// TODO: Note! The values we tried to save were ignored, this test reflects what currently
// happens, but NOT what we want to have happen!
divideByCohorts: true,

View File

@@ -3,6 +3,7 @@ import { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useMediaQuery } from 'react-responsive';
import * as Yup from 'yup';
import moment from 'moment';
import { RequestStatus } from './data/constants';
import { getCourseAppSettingValue, getLoadingStatus } from './pages-and-resources/data/selectors';
@@ -71,4 +72,40 @@ export function setupYupExtensions() {
return true;
});
});
Yup.addMethod(Yup.object, 'uniqueObjectProperty', function uniqueObjectProperty(propertyName, message) {
return this.test('unique', message, function testUniqueness(discussionTopic) {
if (!discussionTopic || !discussionTopic[propertyName]) {
return true;
}
const isDuplicate = this.parent.filter(topic => topic !== discussionTopic)
.some(topic => topic[propertyName]?.toLowerCase() === discussionTopic[propertyName].toLowerCase());
if (isDuplicate) {
throw this.createError({
path: `${this.path}.${propertyName}`,
error: message,
});
}
return true;
});
});
Yup.addMethod(Yup.string, 'compare', function compare(message) {
return this.test('isGreater', message, function isGreater() {
if (!this.parent || !this.parent.startTime || !this.parent.endTime) {
return true;
}
const isInvalidStartDateTime = moment(`${moment(this.parent.startDate).format('YYYY-MM-DD')}T${this.parent.startTime}`)
.isSameOrAfter(moment(`${moment(this.parent.endDate).format('YYYY-MM-DD')}T${this.parent.endTime}`));
if (isInvalidStartDateTime) {
throw this.createError({
path: `${this.path}`,
error: message,
});
}
return true;
});
});
}