Compare commits
47 Commits
abutterwor
...
open-relea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e101b41c08 | ||
|
|
2f01e8a646 | ||
|
|
589db9356e | ||
|
|
e2f37ff20e | ||
|
|
ab544b5d2b | ||
|
|
7bef14c329 | ||
|
|
5cb11189a7 | ||
|
|
fd951fb18a | ||
|
|
b2fa93af13 | ||
|
|
93ccdf829b | ||
|
|
65ab77bed3 | ||
|
|
efba1c1f5a | ||
|
|
3c53c4af4e | ||
|
|
2b27f0774d | ||
|
|
97f335be62 | ||
|
|
6c7af3817b | ||
|
|
a7932ed730 | ||
|
|
29b234e2f0 | ||
|
|
a718c67f36 | ||
|
|
cc5e5ecc00 | ||
|
|
7df95378d6 | ||
|
|
18426dd313 | ||
|
|
a1eee2d662 | ||
|
|
7dfb01a397 | ||
|
|
d58a81bf19 | ||
|
|
bd0ab5b6c9 | ||
|
|
5185f986df | ||
|
|
d3b22bc879 | ||
|
|
36526def67 | ||
|
|
c510fe1c1d | ||
|
|
ca8afb3294 | ||
|
|
1f4e2cd6f5 | ||
|
|
6d60584596 | ||
|
|
b20a4ed304 | ||
|
|
44f535ba1e | ||
|
|
5f0774b66d | ||
|
|
04a8638d00 | ||
|
|
1cc7dc266b | ||
|
|
a852182a00 | ||
|
|
15c3053e87 | ||
|
|
e2399e30d4 | ||
|
|
a10e6c2826 | ||
|
|
b4fbd1cf83 | ||
|
|
37610ab181 | ||
|
|
70428228a5 | ||
|
|
1dc069dbbf | ||
|
|
9b72380dea |
2
.env
2
.env
@@ -4,6 +4,7 @@ BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
INSIGHTS_BASE_URL=
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
@@ -13,4 +14,5 @@ ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
STUDIO_BASE_URL=
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
NODE_ENV='development'
|
||||
PORT=2000
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
@@ -8,10 +7,12 @@ ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
NODE_ENV='test'
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
BASE_URL='localhost:1995'
|
||||
BASE_URL='localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
PORT=2000
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
|
||||
@@ -3,3 +3,8 @@
|
||||
|
||||
oeps: {}
|
||||
owner: edx/platform-core-tnl
|
||||
openedx-release:
|
||||
# The openedx-release key is described in OEP-10:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
|
||||
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
|
||||
ref: master
|
||||
|
||||
377
package-lock.json
generated
377
package-lock.json
generated
@@ -2686,15 +2686,15 @@
|
||||
}
|
||||
},
|
||||
"@edx/paragon": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-7.2.0.tgz",
|
||||
"integrity": "sha512-rz8sTNwJVahnlG7IQeodzwxQws+SHZEzMsLdWoxv2qu6atBMdDYnrhgObT6E/mtc2UpRnN6QPJFmbmHiVvlQkQ==",
|
||||
"version": "7.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-7.2.1.tgz",
|
||||
"integrity": "sha512-5TUrMj4Wry0PAFF/uZp8xWBzNOCc6UB4W04NqjmTlJyPRI0fZgKc7+aIQeI6jIHR8GsjTUwUzEMgZ2+aMyCu4A==",
|
||||
"requires": {
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.21",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.10.1",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"airbnb-prop-types": "^2.12.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"bootstrap": "^4.4.1",
|
||||
"classnames": "^2.2.6",
|
||||
"email-prop-type": "^3.0.0",
|
||||
"font-awesome": "^4.7.0",
|
||||
@@ -4100,7 +4100,8 @@
|
||||
"array-uniq": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz",
|
||||
"integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY="
|
||||
"integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=",
|
||||
"dev": true
|
||||
},
|
||||
"array-unique": {
|
||||
"version": "0.3.2",
|
||||
@@ -4109,12 +4110,65 @@
|
||||
"dev": true
|
||||
},
|
||||
"array.prototype.find": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.0.tgz",
|
||||
"integrity": "sha512-Wn41+K1yuO5p7wRZDl7890c3xvv5UBrfVXTVIe28rSQb6LS0fZMDrQB6PAcxQFRFy6vJTLDc3A2+3CjQdzVKRg==",
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.1.1.tgz",
|
||||
"integrity": "sha512-mi+MYNJYLTx2eNYy+Yh6raoQacCsNeeMUaspFPh9Y141lFSsWxxB8V9mM2ye+eqiRs917J6/pJ4M9ZPzenWckA==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.13.0"
|
||||
"es-abstract": "^1.17.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-abstract": {
|
||||
"version": "1.17.5",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
||||
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
||||
"requires": {
|
||||
"es-to-primitive": "^1.2.1",
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.1",
|
||||
"is-callable": "^1.1.5",
|
||||
"is-regex": "^1.0.5",
|
||||
"object-inspect": "^1.7.0",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.0",
|
||||
"string.prototype.trimleft": "^2.1.1",
|
||||
"string.prototype.trimright": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-callable": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
||||
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimleft": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
||||
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimstart": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimright": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
||||
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimend": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"array.prototype.flat": {
|
||||
@@ -7115,7 +7169,8 @@
|
||||
"domelementtype": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz",
|
||||
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w=="
|
||||
"integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==",
|
||||
"dev": true
|
||||
},
|
||||
"domexception": {
|
||||
"version": "1.0.1",
|
||||
@@ -7130,6 +7185,7 @@
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz",
|
||||
"integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"domelementtype": "1"
|
||||
}
|
||||
@@ -7138,6 +7194,7 @@
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz",
|
||||
"integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"dom-serializer": "0",
|
||||
"domelementtype": "1"
|
||||
@@ -7332,9 +7389,9 @@
|
||||
}
|
||||
},
|
||||
"email-prop-type": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/email-prop-type/-/email-prop-type-3.0.0.tgz",
|
||||
"integrity": "sha512-717XiCoLR3g6KIKbjim2OuBr7BKNgCiP74z/4DGt2xPsX1h3VlY1GiKy42Qy1EqONfeg/3FhtgaUcsAy7YcqHQ==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/email-prop-type/-/email-prop-type-3.0.1.tgz",
|
||||
"integrity": "sha512-tONZGMEOOkadp5OBftuVXU8DsceWmINxYK+pqPFB4LT5ODjrPX/esel3WGqbV7d6in5/MnZE4n4QcqOr4gh7dg==",
|
||||
"requires": {
|
||||
"email-validator": "^2.0.4"
|
||||
}
|
||||
@@ -7433,7 +7490,8 @@
|
||||
"entities": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
|
||||
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w=="
|
||||
"integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
|
||||
"dev": true
|
||||
},
|
||||
"errno": {
|
||||
"version": "0.1.7",
|
||||
@@ -9691,14 +9749,66 @@
|
||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
||||
},
|
||||
"function.prototype.name": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.1.tgz",
|
||||
"integrity": "sha512-e1NzkiJuw6xqVH7YSdiW/qDHebcmMhPNe6w+4ZYYEg0VA+LaLzx37RimbPLuonHhYGFGPx1ME2nSi74JiaCr/Q==",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.2.tgz",
|
||||
"integrity": "sha512-C8A+LlHBJjB2AdcRPorc5JvJ5VUoWlXdEHLOJdCI7kjHEtGTpHQUiqMvCIKUwIsGwZX2jZJy761AXsn356bJQg==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"function-bind": "^1.1.1",
|
||||
"functions-have-names": "^1.1.1",
|
||||
"is-callable": "^1.1.4"
|
||||
"es-abstract": "^1.17.0-next.1",
|
||||
"functions-have-names": "^1.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-abstract": {
|
||||
"version": "1.17.5",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
||||
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
||||
"requires": {
|
||||
"es-to-primitive": "^1.2.1",
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.1",
|
||||
"is-callable": "^1.1.5",
|
||||
"is-regex": "^1.0.5",
|
||||
"object-inspect": "^1.7.0",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.0",
|
||||
"string.prototype.trimleft": "^2.1.1",
|
||||
"string.prototype.trimright": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-callable": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
||||
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimleft": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
||||
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimstart": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimright": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
||||
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimend": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"functional-red-black-tree": {
|
||||
@@ -9708,9 +9818,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"functions-have-names": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.0.tgz",
|
||||
"integrity": "sha512-zKXyzksTeaCSw5wIX79iCA40YAa6CJMJgNg9wdkU/ERBrIdPSimPICYiLp65lRbSBqtiHql/HZfS2DyI/AH6tQ=="
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.1.tgz",
|
||||
"integrity": "sha512-j48B/ZI7VKs3sgeI2cZp7WXWmZXu7Iq5pl5/vptV5N2mq+DGFuS/ulaDjtaoLpYzuD6u8UgrUKHfgo7fDTSiBA=="
|
||||
},
|
||||
"gauge": {
|
||||
"version": "2.7.4",
|
||||
@@ -10389,6 +10499,7 @@
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-3.10.1.tgz",
|
||||
"integrity": "sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"domelementtype": "^1.3.1",
|
||||
"domhandler": "^2.3.0",
|
||||
@@ -13604,7 +13715,8 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"dev": true
|
||||
},
|
||||
"nwsapi": {
|
||||
"version": "2.2.0",
|
||||
@@ -14554,6 +14666,7 @@
|
||||
"version": "7.0.23",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.23.tgz",
|
||||
"integrity": "sha512-hOlMf3ouRIFXD+j2VJecwssTwbvsPGJVMzupptg+85WA+i7MwyrydmQAgY3R+m0Bc0exunhbJmijy8u8+vufuQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"chalk": "^2.4.2",
|
||||
"source-map": "^0.6.1",
|
||||
@@ -14564,6 +14677,7 @@
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
|
||||
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"has-flag": "^3.0.0"
|
||||
}
|
||||
@@ -15734,6 +15848,7 @@
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.4.0.tgz",
|
||||
"integrity": "sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
@@ -16488,7 +16603,8 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz",
|
||||
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg=="
|
||||
"integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==",
|
||||
"dev": true
|
||||
},
|
||||
"safe-regex": {
|
||||
"version": "1.1.0",
|
||||
@@ -16531,20 +16647,79 @@
|
||||
}
|
||||
},
|
||||
"sanitize-html": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.20.1.tgz",
|
||||
"integrity": "sha512-txnH8TQjaQvg2Q0HY06G6CDJLVYCpbnxrdO0WN8gjCKaU5J0KbyGYhZxx5QJg3WLZ1lB7XU9kDkfrCXUozqptA==",
|
||||
"version": "1.22.1",
|
||||
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.22.1.tgz",
|
||||
"integrity": "sha512-++IMC00KfMQc45UWZJlhWOlS9eMrME38sFG9GXfR+k6oBo9JXSYQgTOZCl9j3v/smFTRNT9XNwz5DseFdMY+2Q==",
|
||||
"requires": {
|
||||
"chalk": "^2.4.1",
|
||||
"htmlparser2": "^3.10.0",
|
||||
"htmlparser2": "^4.1.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lodash.isstring": "^4.0.1",
|
||||
"lodash.mergewith": "^4.6.1",
|
||||
"postcss": "^7.0.5",
|
||||
"srcset": "^1.0.0",
|
||||
"lodash.mergewith": "^4.6.2",
|
||||
"postcss": "^7.0.27",
|
||||
"srcset": "^2.0.1",
|
||||
"xtend": "^4.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"domelementtype": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.0.1.tgz",
|
||||
"integrity": "sha512-5HOHUDsYZWV8FGWN0Njbr/Rn7f/eWSQi1v7+HsUVwXgn8nWWlL64zKDkS0n8ZmQ3mlWOMuXOnR+7Nx/5tMO5AQ=="
|
||||
},
|
||||
"domhandler": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-3.0.0.tgz",
|
||||
"integrity": "sha512-eKLdI5v9m67kbXQbJSNn1zjh0SDzvzWVWtX+qEI3eMjZw8daH9k8rlj1FZY9memPwjiskQFbe7vHVVJIAqoEhw==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"domutils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.0.0.tgz",
|
||||
"integrity": "sha512-n5SelJ1axbO636c2yUtOGia/IcJtVtlhQbFiVDBZHKV5ReJO1ViX7sFEemtuyoAnBxk5meNSYgA8V4s0271efg==",
|
||||
"requires": {
|
||||
"dom-serializer": "^0.2.1",
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"entities": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz",
|
||||
"integrity": "sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw=="
|
||||
},
|
||||
"htmlparser2": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-4.1.0.tgz",
|
||||
"integrity": "sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==",
|
||||
"requires": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^3.0.0",
|
||||
"domutils": "^2.0.0",
|
||||
"entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"postcss": {
|
||||
"version": "7.0.27",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.27.tgz",
|
||||
"integrity": "sha512-WuQETPMcW9Uf1/22HWUWP9lgsIC+KEHg2kozMflKjbeUtw9ujvFX6QmIfozaErDkmLWS9WEnEdEe6Uo9/BNTdQ==",
|
||||
"requires": {
|
||||
"chalk": "^2.4.2",
|
||||
"source-map": "^0.6.1",
|
||||
"supports-color": "^6.1.0"
|
||||
}
|
||||
},
|
||||
"supports-color": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz",
|
||||
"integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==",
|
||||
"requires": {
|
||||
"has-flag": "^3.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sass-graph": {
|
||||
@@ -17633,13 +17808,9 @@
|
||||
}
|
||||
},
|
||||
"srcset": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/srcset/-/srcset-1.0.0.tgz",
|
||||
"integrity": "sha1-pWad4StC87HV6D7QPHEEb8SPQe8=",
|
||||
"requires": {
|
||||
"array-uniq": "^1.0.2",
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/srcset/-/srcset-2.0.1.tgz",
|
||||
"integrity": "sha512-00kZI87TdRKwt+P8jj8UZxbfp7mK2ufxcIMWvhAOZNJTRROimpHeruWrGvCZneiuVDLqdyHefVp748ECTnyUBQ=="
|
||||
},
|
||||
"sshpk": {
|
||||
"version": "1.16.1",
|
||||
@@ -17927,6 +18098,68 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"string.prototype.trimend": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.0.tgz",
|
||||
"integrity": "sha512-EEJnGqa/xNfIg05SxiPSqRS7S9qwDhYts1TSLR1BQfYUfPe1stofgGKvwERK9+9yf+PpfBMlpBaCHucXGPQfUA==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-abstract": {
|
||||
"version": "1.17.5",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
||||
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
||||
"requires": {
|
||||
"es-to-primitive": "^1.2.1",
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.1",
|
||||
"is-callable": "^1.1.5",
|
||||
"is-regex": "^1.0.5",
|
||||
"object-inspect": "^1.7.0",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.0",
|
||||
"string.prototype.trimleft": "^2.1.1",
|
||||
"string.prototype.trimright": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-callable": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
||||
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimleft": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
||||
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimstart": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimright": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
||||
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimend": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"string.prototype.trimleft": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz",
|
||||
@@ -17945,10 +18178,73 @@
|
||||
"function-bind": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimstart": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.0.tgz",
|
||||
"integrity": "sha512-iCP8g01NFYiiBOnwG1Xc3WZLyoo+RuBymwIlWncShXDDJYWN6DbnM3odslBJdgCdRlq94B5s63NWAZlcn2CS4w==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"es-abstract": {
|
||||
"version": "1.17.5",
|
||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
|
||||
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
|
||||
"requires": {
|
||||
"es-to-primitive": "^1.2.1",
|
||||
"function-bind": "^1.1.1",
|
||||
"has": "^1.0.3",
|
||||
"has-symbols": "^1.0.1",
|
||||
"is-callable": "^1.1.5",
|
||||
"is-regex": "^1.0.5",
|
||||
"object-inspect": "^1.7.0",
|
||||
"object-keys": "^1.1.1",
|
||||
"object.assign": "^4.1.0",
|
||||
"string.prototype.trimleft": "^2.1.1",
|
||||
"string.prototype.trimright": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"is-callable": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
|
||||
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
|
||||
},
|
||||
"is-regex": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
|
||||
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
|
||||
"requires": {
|
||||
"has": "^1.0.3"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimleft": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
|
||||
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimstart": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"string.prototype.trimright": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
|
||||
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
|
||||
"requires": {
|
||||
"define-properties": "^1.1.3",
|
||||
"es-abstract": "^1.17.5",
|
||||
"string.prototype.trimend": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
@@ -19144,7 +19440,8 @@
|
||||
"util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
|
||||
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
|
||||
"dev": true
|
||||
},
|
||||
"util.promisify": {
|
||||
"version": "1.0.0",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@edx/frontend-component-footer": "^10.0.6",
|
||||
"@edx/frontend-component-header": "^2.0.3",
|
||||
"@edx/frontend-platform": "^1.3.1",
|
||||
"@edx/paragon": "^7.2.0",
|
||||
"@edx/paragon": "^7.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.12.0",
|
||||
|
||||
36
src/CoursewareRedirect.jsx
Normal file
36
src/CoursewareRedirect.jsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Switch, Route, useRouteMatch } from 'react-router';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import PageLoading from './PageLoading';
|
||||
|
||||
export default () => {
|
||||
const { path } = useRouteMatch();
|
||||
return (
|
||||
<div className="flex-grow-1">
|
||||
<PageLoading srMessage={(
|
||||
<FormattedMessage
|
||||
id="learn.redirect.interstitial.message"
|
||||
description="The screen-reader message when a page is about to redirect"
|
||||
defaultMessage="Redirecting..."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Switch>
|
||||
<Route
|
||||
path={`${path}/course-home/:courseId`}
|
||||
render={({ match }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${match.params.courseId}/course/`);
|
||||
}}
|
||||
/>
|
||||
<Route
|
||||
path={`${path}/dashboard`}
|
||||
render={({ location }) => {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/dashboard${location.search}`);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -33,5 +33,5 @@ export default class PageLoading extends Component {
|
||||
}
|
||||
|
||||
PageLoading.propTypes = {
|
||||
srMessage: PropTypes.string.isRequired,
|
||||
srMessage: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
21
src/access-expiration-alert/AccessExpirationAlert.jsx
Normal file
21
src/access-expiration-alert/AccessExpirationAlert.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
function AccessExpirationAlert(props) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = props;
|
||||
return rawHtml && (
|
||||
<Alert type="info">
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlert;
|
||||
28
src/access-expiration-alert/hooks.js
Normal file
28
src/access-expiration-alert/hooks.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { UserMessagesContext } from '../user-messages';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export function useAccessExpirationAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
const rawHtml = (course && course.courseExpiredMessage) || null;
|
||||
useEffect(() => {
|
||||
if (rawHtml && alertId === null) {
|
||||
setAlertId(add({
|
||||
code: 'clientAccessExpirationAlert',
|
||||
topic: 'course',
|
||||
rawHtml,
|
||||
}));
|
||||
} else if (!rawHtml && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [alertId, courseId, rawHtml]);
|
||||
}
|
||||
2
src/access-expiration-alert/index.js
Normal file
2
src/access-expiration-alert/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as AccessExpirationAlert } from './AccessExpirationAlert';
|
||||
export { useAccessExpirationAlert } from './hooks';
|
||||
@@ -1,17 +1,16 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../tabs/Tabs';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTabSlug, tabs, intl,
|
||||
activeTabSlug, className, tabs, intl,
|
||||
}) {
|
||||
return (
|
||||
<div className="course-tabs-navigation">
|
||||
<div className={classNames('course-tabs-navigation', className)}>
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
@@ -21,7 +20,7 @@ function CourseTabsNavigation({
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={`${getConfig().LMS_BASE_URL}${url}`}
|
||||
href={url}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
@@ -34,6 +33,7 @@ function CourseTabsNavigation({
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
priority: PropTypes.number.isRequired,
|
||||
@@ -45,6 +45,7 @@ CourseTabsNavigation.propTypes = {
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
className: null,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTabsNavigation);
|
||||
|
||||
@@ -68,7 +68,13 @@ export default function Header({
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
courseOrg: PropTypes.string,
|
||||
courseNumber: PropTypes.string,
|
||||
courseTitle: PropTypes.string,
|
||||
};
|
||||
|
||||
Header.defaultProps = {
|
||||
courseOrg: null,
|
||||
courseNumber: null,
|
||||
courseTitle: null,
|
||||
};
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import AlertList from '../user-messages/AlertList';
|
||||
import { Header, CourseTabsNavigation } from '../course-header';
|
||||
import { useLogistrationAlert } from '../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../enrollment-alert';
|
||||
import { AlertList } from '../user-messages';
|
||||
|
||||
import CourseDates from './CourseDates';
|
||||
import Section from './Section';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('../enrollment-alert'));
|
||||
// Note that we import from the component files themselves in the enrollment-alert package.
|
||||
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
|
||||
// default export.
|
||||
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
const { EnrollmentAlert, StaffEnrollmentAlert } = React.lazy(() => import('../enrollment-alert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
|
||||
|
||||
export default function CourseHome({
|
||||
courseId,
|
||||
}) {
|
||||
useLogistrationAlert();
|
||||
useEnrollmentAlert(courseId);
|
||||
export default function CourseHome() {
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
const {
|
||||
org,
|
||||
number,
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
@@ -30,63 +28,45 @@ export default function CourseHome({
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
tabs,
|
||||
sectionIds,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<main className="d-flex flex-column flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{title}</h2>
|
||||
<Button className="btn-primary" type="button">Resume Course</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{title}</h2>
|
||||
<Button className="btn-primary" type="button">Resume Course</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHome.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import PageLoading from '../PageLoading';
|
||||
import CourseHome from './CourseHome';
|
||||
import { fetchCourse } from '../data';
|
||||
|
||||
function CourseHomeContainer(props) {
|
||||
const {
|
||||
intl,
|
||||
match,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchCourse(match.params.courseId));
|
||||
}, [match.params.courseId]);
|
||||
|
||||
// The courseId from the store is the course we HAVE loaded. If the URL changes,
|
||||
// we don't want the application to adjust to it until it has actually loaded the new data.
|
||||
const {
|
||||
courseId,
|
||||
courseStatus,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
return (
|
||||
<>
|
||||
{courseStatus === 'loaded' ? (
|
||||
<CourseHome
|
||||
courseId={courseId}
|
||||
/>
|
||||
) : (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.outline'])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHomeContainer.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseHomeContainer);
|
||||
@@ -1 +1 @@
|
||||
export { default } from './CourseHomeContainer';
|
||||
export { default } from './CourseHome';
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.outline': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,21 +1,23 @@
|
||||
import React, { useEffect, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { history, getConfig } from '@edx/frontend-platform';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { getLocale } from '@edx/frontend-platform/i18n';
|
||||
import { useRouteMatch, Redirect } from 'react-router';
|
||||
|
||||
import { useRouteMatch } from 'react-router';
|
||||
import {
|
||||
fetchCourse,
|
||||
fetchSequence,
|
||||
getResumeBlock,
|
||||
} from '../data';
|
||||
import {
|
||||
checkBlockCompletion,
|
||||
saveSequencePosition,
|
||||
} from './data/thunks';
|
||||
import { useModel } from '../model-store';
|
||||
import { TabPage } from '../tab-page';
|
||||
|
||||
import Course from './course';
|
||||
|
||||
import { sequenceIdsSelector, firstSequenceIdSelector } from './data/selectors';
|
||||
|
||||
function useUnitNavigationHandler(courseId, sequenceId, unitId) {
|
||||
@@ -23,7 +25,7 @@ function useUnitNavigationHandler(courseId, sequenceId, unitId) {
|
||||
return useCallback((nextUnitId) => {
|
||||
dispatch(checkBlockCompletion(courseId, sequenceId, unitId));
|
||||
history.push(`/course/${courseId}/${sequenceId}/${nextUnitId}`);
|
||||
}, [courseId, sequenceId]);
|
||||
}, [courseId, sequenceId, unitId]);
|
||||
}
|
||||
|
||||
function usePreviousSequence(sequenceId) {
|
||||
@@ -55,8 +57,13 @@ function useNextSequenceHandler(courseId, sequenceId) {
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
return useCallback(() => {
|
||||
if (nextSequence !== null) {
|
||||
const nextUnitId = nextSequence.unitIds[0];
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
if (nextSequence.unitIds.length > 0) {
|
||||
const nextUnitId = nextSequence.unitIds[0];
|
||||
history.push(`/course/${courseId}/${nextSequence.id}/${nextUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${nextSequence.id}`);
|
||||
}
|
||||
}
|
||||
}, [courseStatus, sequenceStatus, sequenceId]);
|
||||
}
|
||||
@@ -67,8 +74,13 @@ function usePreviousSequenceHandler(courseId, sequenceId) {
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
return useCallback(() => {
|
||||
if (previousSequence !== null) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
if (previousSequence.unitIds.length > 0) {
|
||||
const previousUnitId = previousSequence.unitIds[previousSequence.unitIds.length - 1];
|
||||
history.push(`/course/${courseId}/${previousSequence.id}/${previousUnitId}`);
|
||||
} else {
|
||||
// Some sequences have no units. This will show a blank page with prev/next buttons.
|
||||
history.push(`/course/${courseId}/${previousSequence.id}`);
|
||||
}
|
||||
}
|
||||
}, [courseStatus, sequenceStatus, sequenceId]);
|
||||
}
|
||||
@@ -90,8 +102,14 @@ function useContentRedirect(courseStatus, sequenceStatus) {
|
||||
const firstSequenceId = useSelector(firstSequenceIdSelector);
|
||||
useEffect(() => {
|
||||
if (courseStatus === 'loaded' && !sequenceId) {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
getResumeBlock(courseId).then((data) => {
|
||||
// This is a replace because we don't want this change saved in the browser's history.
|
||||
if (data.sectionId && data.unitId) {
|
||||
history.replace(`/course/${courseId}/${data.sectionId}/${data.unitId}`);
|
||||
} else {
|
||||
history.replace(`/course/${courseId}/${firstSequenceId}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [courseStatus, sequenceId]);
|
||||
|
||||
@@ -120,21 +138,6 @@ function useSavedSequencePosition(courseId, sequenceId, unitId) {
|
||||
}, [unitId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user away from the app if they don't have access to view this course.
|
||||
*
|
||||
* @param {*} courseStatus
|
||||
* @param {*} course
|
||||
*/
|
||||
function useAccessDeniedRedirect(courseStatus, courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
useEffect(() => {
|
||||
if (courseStatus === 'loaded' && !course.userHasAccess && !course.isStaff) {
|
||||
global.location.assign(`${getConfig().LMS_BASE_URL}/courses/${course.id}/course/`);
|
||||
}
|
||||
}, [courseStatus, course]);
|
||||
}
|
||||
|
||||
export default function CoursewareContainer() {
|
||||
const { params } = useRouteMatch();
|
||||
const {
|
||||
@@ -173,13 +176,36 @@ export default function CoursewareContainer() {
|
||||
const previousSequenceHandler = usePreviousSequenceHandler(courseId, sequenceId);
|
||||
const unitNavigationHandler = useUnitNavigationHandler(courseId, sequenceId, routeUnitId);
|
||||
|
||||
useAccessDeniedRedirect(courseStatus, courseId);
|
||||
useContentRedirect(courseStatus, sequenceStatus);
|
||||
useExamRedirect(sequenceId);
|
||||
useSavedSequencePosition(courseId, sequenceId, routeUnitId);
|
||||
|
||||
const course = useModel('courses', courseId);
|
||||
|
||||
if (courseStatus === 'denied') {
|
||||
switch (course.canLoadCourseware.errorCode) {
|
||||
case 'audit_expired':
|
||||
return <Redirect to={`/redirect/dashboard?access_response_error=${course.canLoadCourseware.additionalContextUserMessage}`} />;
|
||||
case 'course_not_started':
|
||||
// eslint-disable-next-line no-case-declarations
|
||||
const startDate = (new Intl.DateTimeFormat(getLocale())).format(new Date(course.start));
|
||||
return <Redirect to={`/redirect/dashboard?notlive=${startDate}`} />;
|
||||
case 'survey_required': // TODO: Redirect to the course survey
|
||||
case 'unfulfilled_milestones':
|
||||
return <Redirect to="/redirect/dashboard" />;
|
||||
case 'authentication_required':
|
||||
case 'enrollment_required':
|
||||
default:
|
||||
return <Redirect to={`/redirect/course-home/${courseId}`} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-grow-1 d-flex flex-column">
|
||||
<TabPage
|
||||
activeTabSlug="courseware"
|
||||
courseId={courseId}
|
||||
unitId={routeUnitId}
|
||||
>
|
||||
<Course
|
||||
courseId={courseId}
|
||||
sequenceId={sequenceId}
|
||||
@@ -188,7 +214,7 @@ export default function CoursewareContainer() {
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
/>
|
||||
</main>
|
||||
</TabPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import AlertList from '../../user-messages/AlertList';
|
||||
import { useLogistrationAlert } from '../../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../../enrollment-alert';
|
||||
import PageLoading from '../../PageLoading';
|
||||
import { AlertList } from '../../user-messages';
|
||||
import { useAccessExpirationAlert } from '../../access-expiration-alert';
|
||||
import { useOfferAlert } from '../../offer-alert';
|
||||
|
||||
import InstructorToolbar from './InstructorToolbar';
|
||||
import Sequence from './sequence';
|
||||
|
||||
import CourseBreadcrumbs from './CourseBreadcrumbs';
|
||||
import { Header, CourseTabsNavigation } from '../../course-header';
|
||||
import CourseSock from './course-sock';
|
||||
import Calculator from './calculator';
|
||||
import messages from './messages';
|
||||
import ContentTools from './tools/ContentTools';
|
||||
import { useModel } from '../../model-store';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert'));
|
||||
// Note that we import from the component files themselves in the enrollment-alert package.
|
||||
// This is because Reacy.lazy() requires that we import() from a file with a Component as it's
|
||||
// default export.
|
||||
// See React.lazy docs here: https://reactjs.org/docs/code-splitting.html#reactlazy
|
||||
const AccessExpirationAlert = React.lazy(() => import('../../access-expiration-alert/AccessExpirationAlert'));
|
||||
const EnrollmentAlert = React.lazy(() => import('../../enrollment-alert/EnrollmentAlert'));
|
||||
const StaffEnrollmentAlert = React.lazy(() => import('../../enrollment-alert/StaffEnrollmentAlert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../../logistration-alert'));
|
||||
const OfferAlert = React.lazy(() => import('../../offer-alert/OfferAlert'));
|
||||
|
||||
function Course({
|
||||
courseId,
|
||||
@@ -28,79 +29,53 @@ function Course({
|
||||
nextSequenceHandler,
|
||||
previousSequenceHandler,
|
||||
unitNavigationHandler,
|
||||
intl,
|
||||
}) {
|
||||
const course = useModel('courses', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const section = useModel('sections', sequence ? sequence.sectionId : null);
|
||||
|
||||
useLogistrationAlert();
|
||||
useEnrollmentAlert(courseId);
|
||||
useOfferAlert(courseId);
|
||||
useAccessExpirationAlert(courseId);
|
||||
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
const {
|
||||
canShowUpgradeSock,
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
if (courseStatus === 'loading') {
|
||||
return (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (courseStatus === 'loaded') {
|
||||
const {
|
||||
org, number, title, isStaff, tabs, verifiedMode, showCalculator,
|
||||
} = course;
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
{isStaff && (
|
||||
<InstructorToolbar
|
||||
unitId={unitId}
|
||||
/>
|
||||
)}
|
||||
<CourseTabsNavigation tabs={tabs} activeTabSlug="courseware" />
|
||||
<div className="container-fluid">
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
/>
|
||||
<AlertList topic="sequence" />
|
||||
</div>
|
||||
<div className="flex-grow-1 d-flex flex-column">
|
||||
<Sequence
|
||||
unitId={unitId}
|
||||
sequenceId={sequenceId}
|
||||
courseId={courseId}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
/>
|
||||
{verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
|
||||
{showCalculator && <Calculator />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// courseStatus 'failed' and any other unexpected course status.
|
||||
return (
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.course.load.failure'])}
|
||||
</p>
|
||||
<>
|
||||
<AlertList
|
||||
className="my-3"
|
||||
topic="course"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientStaffEnrollmentAlert: StaffEnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
clientAccessExpirationAlert: AccessExpirationAlert,
|
||||
clientOfferAlert: OfferAlert,
|
||||
}}
|
||||
// courseId is provided because EnrollmentAlert and StaffEnrollmentAlert require it.
|
||||
customProps={{
|
||||
courseId,
|
||||
}}
|
||||
/>
|
||||
<CourseBreadcrumbs
|
||||
courseId={courseId}
|
||||
sectionId={section ? section.id : null}
|
||||
sequenceId={sequenceId}
|
||||
/>
|
||||
<AlertList topic="sequence" />
|
||||
<Sequence
|
||||
unitId={unitId}
|
||||
sequenceId={sequenceId}
|
||||
courseId={courseId}
|
||||
unitNavigationHandler={unitNavigationHandler}
|
||||
nextSequenceHandler={nextSequenceHandler}
|
||||
previousSequenceHandler={previousSequenceHandler}
|
||||
/>
|
||||
{canShowUpgradeSock && verifiedMode && <CourseSock verifiedMode={verifiedMode} />}
|
||||
<ContentTools course={course} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,7 +86,6 @@ Course.propTypes = {
|
||||
nextSequenceHandler: PropTypes.func.isRequired,
|
||||
previousSequenceHandler: PropTypes.func.isRequired,
|
||||
unitNavigationHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
Course.defaultProps = {
|
||||
@@ -120,4 +94,4 @@ Course.defaultProps = {
|
||||
unitId: null,
|
||||
};
|
||||
|
||||
export default injectIntl(Course);
|
||||
export default Course;
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function CourseBreadcrumbs({
|
||||
|
||||
const links = useMemo(() => {
|
||||
if (courseStatus === 'loaded' && sequenceStatus === 'loaded') {
|
||||
return [section, sequence].map((node) => ({
|
||||
return [section, sequence].filter(node => !!node).map((node) => ({
|
||||
id: node.id,
|
||||
label: node.title,
|
||||
url: `${getConfig().LMS_BASE_URL}/courses/${course.id}/course/#${node.id}`,
|
||||
|
||||
@@ -1,38 +1,68 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
function getInsightsUrl(courseId) {
|
||||
const urlBase = getConfig().INSIGHTS_BASE_URL;
|
||||
let urlFull;
|
||||
if (urlBase) {
|
||||
urlFull = `${urlBase}/courses`;
|
||||
// This shouldn't actually be missing, at present,
|
||||
// but we're providing a reasonable fallback,
|
||||
// in case of either error or extension.
|
||||
if (courseId) {
|
||||
urlFull += `/${courseId}`;
|
||||
}
|
||||
}
|
||||
return urlFull;
|
||||
}
|
||||
|
||||
function getStudioUrl(courseId, unitId) {
|
||||
const urlBase = getConfig().STUDIO_BASE_URL;
|
||||
let urlFull;
|
||||
if (urlBase) {
|
||||
if (unitId) {
|
||||
urlFull = `${urlBase}/container/${unitId}`;
|
||||
} else if (courseId) {
|
||||
urlFull = `{$urlBase}/course/${courseId}`;
|
||||
}
|
||||
}
|
||||
return urlFull;
|
||||
}
|
||||
|
||||
function InstructorToolbar(props) {
|
||||
// TODO: Only render this toolbar if the user is course staff
|
||||
if (!props.activeUnitLmsWebUrl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
courseId,
|
||||
unitId,
|
||||
} = props;
|
||||
const urlInsights = getInsightsUrl(courseId);
|
||||
const urlLms = props.activeUnitLmsWebUrl;
|
||||
const urlStudio = getStudioUrl(courseId, unitId);
|
||||
return (
|
||||
<div className="bg-primary text-light">
|
||||
<div className="container-fluid py-3 d-md-flex justify-content-end align-items-center">
|
||||
<div className="flex-grow-1">
|
||||
<Collapsible.Advanced className="mr-5 mb-md-0">
|
||||
You are currently previewing the new learning sequence experience.
|
||||
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<span style={{ borderBottom: 'solid 1px white' }}>More info</span> →
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body>
|
||||
This preview is to allow for early content testing, especially for custom content blocks, with the goal of ensuring it renders as expected in the next experience. You can learn more through the following <a className="text-white" style={{ textDecoration: 'underline' }} href="https://partners.edx.org/announcements/author-preview-learning-sequence-experience-update" target="blank" rel="noopener">Partner Portal post</a>. Please report any issues or provide <a className="text-white" style={{ textDecoration: 'underline' }} target="blank" rel="noopener" href="https://forms.gle/R6jMYJNTCj1vgC1D6">feedback using the linked form</a>.
|
||||
<Collapsible.Trigger className="d-inline-block ml-2" style={{ cursor: 'pointer' }}>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<span style={{ borderBottom: 'solid 1px white' }}>Close</span> ×
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<a className="btn d-block btn-outline-light" href={props.activeUnitLmsWebUrl}>View unit in the existing experience</a>
|
||||
|
||||
</div>
|
||||
{urlLms && (
|
||||
<div className="flex-shrink-0">
|
||||
<a className="btn d-block btn-outline-light" href={urlLms}>View in the existing experience</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{urlStudio && (
|
||||
<div className="flex-shrink-0">
|
||||
<a className="btn d-block btn-outline-light" href={urlStudio}>View in Studio</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{urlInsights && (
|
||||
<div className="flex-shrink-0">
|
||||
<a className="btn d-block btn-outline-light" href={urlInsights}>View in Insights</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -40,10 +70,14 @@ function InstructorToolbar(props) {
|
||||
|
||||
InstructorToolbar.propTypes = {
|
||||
activeUnitLmsWebUrl: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
unitId: PropTypes.string,
|
||||
};
|
||||
|
||||
InstructorToolbar.defaultProps = {
|
||||
activeUnitLmsWebUrl: undefined,
|
||||
courseId: undefined,
|
||||
unitId: undefined,
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, props) => {
|
||||
|
||||
@@ -32,128 +32,152 @@ export default class CourseSock extends Component {
|
||||
</button>
|
||||
</div>
|
||||
{this.state.showUpsell && (
|
||||
<div className="d-flex justify-content-around">
|
||||
<div className="mt-3">
|
||||
<h2 className="font-weight-lighter">
|
||||
<>
|
||||
<h2 className="mt-3 mb-4">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.verifiedcert"
|
||||
defaultMessage="edX Verified Certificate"
|
||||
/>
|
||||
</h2>
|
||||
<h3>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.why"
|
||||
defaultMessage="Why upgrade?"
|
||||
/>
|
||||
</h3>
|
||||
<ul>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.reason1"
|
||||
defaultMessage="Official proof of completion"
|
||||
/>
|
||||
</li>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.reason2"
|
||||
defaultMessage="Easily shareable certificate"
|
||||
/>
|
||||
</li>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.reason3"
|
||||
defaultMessage="Proven motivator to complete the course"
|
||||
/>
|
||||
</li>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.reason4"
|
||||
defaultMessage="Certificate purchases help edX continue to offer free courses"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<h3><FormattedMessage
|
||||
id="coursesock.upsell.howtitle"
|
||||
defaultMessage="How it works"
|
||||
/>
|
||||
</h3>
|
||||
<ul>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.how1"
|
||||
defaultMessage="Pay the Verified Certificate upgrade fee"
|
||||
/>
|
||||
</li>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.how2"
|
||||
defaultMessage="Verify your identity with a webcam and government-issued ID"
|
||||
/>
|
||||
</li>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.how3"
|
||||
defaultMessage="Study hard and pass the course"
|
||||
/>
|
||||
</li>
|
||||
<li><FormattedMessage
|
||||
id="coursesock.upsell.how4"
|
||||
defaultMessage="Share your certificate with friends, employers, and others"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<h3><FormattedMessage
|
||||
id="coursesock.upsell.storytitle"
|
||||
defaultMessage="edX Learner Stories"
|
||||
/>
|
||||
</h3>
|
||||
<div className="d-flex align-items-center my-4">
|
||||
<img style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
|
||||
<div className="w-50 px-4">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.story1"
|
||||
defaultMessage="My certificate has helped me showcase my knowledge on my
|
||||
resume - I feel like this certificate could really help me land
|
||||
my dream job!"
|
||||
/>
|
||||
<br />
|
||||
<strong>— <FormattedMessage
|
||||
id="coursesock.upsell.learner"
|
||||
description="Name of learner"
|
||||
defaultMessage="{ name }, edX Learner"
|
||||
values={{ name: 'Christina Fong' }}
|
||||
/>
|
||||
</strong>
|
||||
<div className="row flex-row-reverse">
|
||||
<div className="col-md-4 col-lg-6 d-flex flex-column">
|
||||
<div>
|
||||
<img alt="Example Certificate" src={VerifiedCert} className="d-block img-thumbnail mb-3 ml-md-auto" />
|
||||
</div>
|
||||
<div className="position-relative flex-grow-1 d-flex flex-column justify-content-end align-items-md-end">
|
||||
<div style={{ position: 'sticky', bottom: '4rem' }}>
|
||||
<a
|
||||
href={this.verifiedMode.upgradeUrl}
|
||||
className="btn btn-success btn-lg btn-upgrade focusable mb-3"
|
||||
data-creative="original_sock"
|
||||
data-position="sock"
|
||||
>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.upgrade"
|
||||
defaultMessage="Upgrade ({symbol}{price} {currency})"
|
||||
values={{
|
||||
symbol: this.verifiedMode.currencySymbol,
|
||||
price: this.verifiedMode.price,
|
||||
currency: this.verifiedMode.currency,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-md-8 col-lg-6">
|
||||
<h3 className="h5">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.why"
|
||||
defaultMessage="Why upgrade?"
|
||||
/>
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason1"
|
||||
defaultMessage="Official proof of completion"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason2"
|
||||
defaultMessage="Easily shareable certificate"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason3"
|
||||
defaultMessage="Proven motivator to complete the course"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.reason4"
|
||||
defaultMessage="Certificate purchases help edX continue to offer free courses"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<h3 className="h5">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.howtitle"
|
||||
defaultMessage="How it works"
|
||||
/>
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how1"
|
||||
defaultMessage="Pay the Verified Certificate upgrade fee"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how2"
|
||||
defaultMessage="Verify your identity with a webcam and government-issued ID"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how3"
|
||||
defaultMessage="Study hard and pass the course"
|
||||
/>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.how4"
|
||||
defaultMessage="Share your certificate with friends, employers, and others"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="h5">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.storytitle"
|
||||
defaultMessage="edX Learner Stories"
|
||||
/>
|
||||
</h3>
|
||||
<div className="media my-3">
|
||||
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Christina Fong" src={LearnerQuote1} />
|
||||
<div className="media-body">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.story1"
|
||||
defaultMessage="My certificate has helped me showcase my knowledge on my
|
||||
resume - I feel like this certificate could really help me land
|
||||
my dream job!"
|
||||
/>
|
||||
<p className="font-weight-bold">
|
||||
— <FormattedMessage
|
||||
id="coursesock.upsell.learner"
|
||||
description="Name of learner"
|
||||
defaultMessage="{ name }, edX Learner"
|
||||
values={{ name: 'Christina Fong' }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="media my-3">
|
||||
<img className="mr-3" style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
|
||||
<div className="media-body">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.story2"
|
||||
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
|
||||
illustrate that I am working towards this goal I have and that I have
|
||||
achieved something while I was unemployed."
|
||||
/>
|
||||
<p className="font-weight-bold">
|
||||
— <FormattedMessage
|
||||
id="coursesock.upsell.learner"
|
||||
description="Name of learner"
|
||||
defaultMessage="{ name }, edX Learner"
|
||||
values={{ name: 'Cheryl Troell' }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center my-2">
|
||||
<img style={{ maxWidth: '4rem' }} alt="Chery Troell" src={LearnerQuote2} />
|
||||
<div className="w-50 px-4">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.story2"
|
||||
defaultMessage="I wanted to include a verified certificate on my resume and my profile to
|
||||
illustrate that I am working towards this goal I have and that I have
|
||||
achieved something while I was unemployed."
|
||||
/>
|
||||
<br />
|
||||
<strong>— <FormattedMessage
|
||||
id="coursesock.upsell.learner"
|
||||
description="Name of learner"
|
||||
defaultMessage="{ name }, edX Learner"
|
||||
values={{ name: 'Cheryl Troell' }}
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex flex-column justify-content-between">
|
||||
<img alt="Example Certificate" src={VerifiedCert} />
|
||||
<a href={this.verifiedMode.upgradeUrl} className="btn btn-success btn-lg btn-upgrade focusable" data-creative="original_sock" data-position="sock">
|
||||
<FormattedMessage
|
||||
id="coursesock.upsell.upgrade"
|
||||
defaultMessage="Upgrade ({symbol}{price} {currency})"
|
||||
values={{
|
||||
symbol: this.verifiedMode.currencySymbol,
|
||||
price: this.verifiedMode.price,
|
||||
currency: this.verifiedMode.currency,
|
||||
}}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading.learning.sequence': {
|
||||
id: 'learn.loading.learning.sequence',
|
||||
defaultMessage: 'Loading learning sequence...',
|
||||
description: 'Message when learning sequence is being loaded',
|
||||
},
|
||||
'learn.course.load.failure': {
|
||||
id: 'learn.course.load.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,20 +1,19 @@
|
||||
/* eslint-disable no-use-before-define */
|
||||
import React, {
|
||||
useEffect, useContext, Suspense, useState,
|
||||
useEffect, useContext, useState,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
import Unit from './Unit';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
|
||||
import PageLoading from '../../../PageLoading';
|
||||
import messages from './messages';
|
||||
import UserMessagesContext from '../../../user-messages/UserMessagesContext';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../../user-messages';
|
||||
import { useModel } from '../../../model-store';
|
||||
|
||||
const ContentLock = React.lazy(() => import('./content-lock'));
|
||||
import messages from './messages';
|
||||
import { SequenceNavigation, UnitNavigation } from './sequence-navigation';
|
||||
import SequenceContent from './SequenceContent';
|
||||
|
||||
function Sequence({
|
||||
unitId,
|
||||
@@ -78,7 +77,7 @@ function Sequence({
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: 'info',
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
@@ -132,29 +131,13 @@ function Sequence({
|
||||
}}
|
||||
/>
|
||||
<div className="unit-container flex-grow-1">
|
||||
{gated && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ContentLock
|
||||
courseId={courseId}
|
||||
sequenceTitle={sequence.title}
|
||||
prereqSectionName={sequence.gatedContent.gatedSectionName}
|
||||
prereqId={sequence.gatedContent.prereqId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{!gated && unitId !== null && (
|
||||
<Unit
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={handleUnitLoaded}
|
||||
/>
|
||||
)}
|
||||
<SequenceContent
|
||||
courseId={courseId}
|
||||
gated={gated}
|
||||
sequenceId={sequenceId}
|
||||
unitId={unitId}
|
||||
unitLoadedHandler={handleUnitLoaded}
|
||||
/>
|
||||
{unitHasLoaded && (
|
||||
<UnitNavigation
|
||||
sequenceId={sequenceId}
|
||||
|
||||
72
src/courseware/course/sequence/SequenceContent.jsx
Normal file
72
src/courseware/course/sequence/SequenceContent.jsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { Suspense, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PageLoading from '../../../PageLoading';
|
||||
import { useModel } from '../../../model-store';
|
||||
|
||||
import messages from './messages';
|
||||
import Unit from './Unit';
|
||||
|
||||
const ContentLock = React.lazy(() => import('./content-lock'));
|
||||
|
||||
function SequenceContent({
|
||||
gated, intl, courseId, sequenceId, unitId, unitLoadedHandler,
|
||||
}) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
|
||||
// Go back to the top of the page whenever the unit or sequence changes.
|
||||
useEffect(() => {
|
||||
global.scrollTo(0, 0);
|
||||
}, [sequenceId, unitId]);
|
||||
|
||||
if (gated) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<ContentLock
|
||||
courseId={courseId}
|
||||
sequenceTitle={sequence.title}
|
||||
prereqSectionName={sequence.gatedContent.gatedSectionName}
|
||||
prereqId={sequence.gatedContent.prereqId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (unitId === null) {
|
||||
return (
|
||||
<div>
|
||||
{intl.formatMessage(messages['learn.sequence.no.content'])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Unit
|
||||
courseId={courseId}
|
||||
key={unitId}
|
||||
id={unitId}
|
||||
onLoaded={unitLoadedHandler}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceContent.propTypes = {
|
||||
gated: PropTypes.bool.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
sequenceId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
unitLoadedHandler: PropTypes.func.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
SequenceContent.defaultProps = {
|
||||
unitId: null,
|
||||
};
|
||||
|
||||
export default injectIntl(SequenceContent);
|
||||
@@ -1,25 +1,78 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import React, {
|
||||
Suspense,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useLayoutEffect,
|
||||
} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import BookmarkButton from '../bookmark/BookmarkButton';
|
||||
import { useModel } from '../../../model-store';
|
||||
import PageLoading from '../../../PageLoading';
|
||||
|
||||
export default function Unit({
|
||||
const LockPaywall = React.lazy(() => import('./lock-paywall'));
|
||||
|
||||
/**
|
||||
* We discovered an error in Firefox where - upon iframe load - React would cease to call any
|
||||
* useEffect hooks until the user interacts with the page again. This is particularly confusing
|
||||
* when navigating between sequences, as the UI partially updates leaving the user in a nebulous
|
||||
* state.
|
||||
*
|
||||
* We were able to solve this error by using a layout effect to update some component state, which
|
||||
* executes synchronously on render. Somehow this forces React to continue it's lifecycle
|
||||
* immediately, rather than waiting for user interaction. This layout effect could be anywhere in
|
||||
* the parent tree, as far as we can tell - we chose to add a conspicuously 'load bearing' (that's
|
||||
* a joke) one here so it wouldn't be accidentally removed elsewhere.
|
||||
*
|
||||
* If we remove this hook when one of these happens:
|
||||
* 1. React figures out that there's an issue here and fixes a bug.
|
||||
* 2. We cease to use an iframe for unit rendering.
|
||||
* 3. Firefox figures out that there's an issue in their iframe loading and fixes a bug.
|
||||
* 4. We stop supporting Firefox.
|
||||
* 5. An enterprising engineer decides to create a repo that reproduces the problem, submits it to
|
||||
* Firefox/React for review, and they kindly help us figure out what in the world is happening
|
||||
* so we can fix it.
|
||||
*
|
||||
* This hook depends on the unit id just to make sure it re-evaluates whenever the ID changes. If
|
||||
* we change whether or not the Unit component is re-mounted when the unit ID changes, this may
|
||||
* become important, as this hook will otherwise only evaluate the useLayoutEffect once.
|
||||
*/
|
||||
function useLoadBearingHook(id) {
|
||||
const setValue = useState(0)[1];
|
||||
useLayoutEffect(() => {
|
||||
setValue(currentValue => currentValue + 1);
|
||||
}, [id]);
|
||||
}
|
||||
|
||||
function Unit({
|
||||
courseId,
|
||||
onLoaded,
|
||||
id,
|
||||
intl,
|
||||
}) {
|
||||
const iframeRef = useRef(null);
|
||||
const iframeUrl = `${getConfig().LMS_BASE_URL}/xblock/${id}?show_title=0&show_bookmark_button=0`;
|
||||
|
||||
const [iframeHeight, setIframeHeight] = useState(0);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
const unit = useModel('units', id);
|
||||
const course = useModel('courses', courseId);
|
||||
const {
|
||||
contentTypeGatingEnabled,
|
||||
enrollmentMode,
|
||||
} = course;
|
||||
|
||||
// Do not remove this hook. See function description.
|
||||
useLoadBearingHook(id);
|
||||
|
||||
// We use this ref so that we can hold a reference to the currently active event listener.
|
||||
const messageEventListenerRef = useRef(null);
|
||||
useEffect(() => {
|
||||
global.onmessage = (event) => {
|
||||
function receiveMessage(event) {
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === 'plugin.resize') {
|
||||
setIframeHeight(payload.height);
|
||||
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
|
||||
@@ -29,8 +82,19 @@ export default function Unit({
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
// If we currently have an event listener, remove it.
|
||||
if (messageEventListenerRef.current !== null) {
|
||||
global.removeEventListener('message', messageEventListenerRef.current);
|
||||
messageEventListenerRef.current = null;
|
||||
}
|
||||
// Now add our new receiveMessage handler as the event listener.
|
||||
global.addEventListener('message', receiveMessage);
|
||||
// And then save it to our ref for next time.
|
||||
messageEventListenerRef.current = receiveMessage;
|
||||
// When the component finally unmounts, use the ref to remove the correct handler.
|
||||
return () => global.removeEventListener('message', messageEventListenerRef.current);
|
||||
}, [id, setIframeHeight, hasLoaded, iframeHeight, setHasLoaded, onLoaded]);
|
||||
|
||||
return (
|
||||
<div className="unit">
|
||||
@@ -40,11 +104,28 @@ export default function Unit({
|
||||
isBookmarked={unit.bookmarked}
|
||||
isProcessing={unit.bookmarkedUpdateState === 'loading'}
|
||||
/>
|
||||
{ contentTypeGatingEnabled && unit.graded && enrollmentMode === 'audit' && (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.content.lock'])}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<LockPaywall
|
||||
courseId={courseId}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{!hasLoaded && (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.learning.sequence'])}
|
||||
/>
|
||||
)}
|
||||
<div className="unit-iframe-wrapper">
|
||||
<iframe
|
||||
id="unit-iframe"
|
||||
title={unit.title}
|
||||
ref={iframeRef}
|
||||
src={iframeUrl}
|
||||
allowFullScreen
|
||||
height={iframeHeight}
|
||||
@@ -57,10 +138,14 @@ export default function Unit({
|
||||
}
|
||||
|
||||
Unit.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
id: PropTypes.string.isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
onLoaded: PropTypes.func,
|
||||
};
|
||||
|
||||
Unit.defaultProps = {
|
||||
onLoaded: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(Unit);
|
||||
|
||||
61
src/courseware/course/sequence/lock-paywall/LockPaywall.jsx
Normal file
61
src/courseware/course/sequence/lock-paywall/LockPaywall.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faLock } from '@fortawesome/free-solid-svg-icons';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import VerifiedCert from './assets/edx-verified-mini-cert.png';
|
||||
import { useModel } from '../../../../model-store';
|
||||
|
||||
function LockPaywall({
|
||||
intl,
|
||||
courseId,
|
||||
}) {
|
||||
const course = useModel('courses', courseId);
|
||||
const {
|
||||
verifiedMode,
|
||||
} = course;
|
||||
|
||||
if (!verifiedMode) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
currencySymbol,
|
||||
price,
|
||||
upgradeUrl,
|
||||
} = verifiedMode;
|
||||
return (
|
||||
<div className="border border-gray rounded d-flex justify-content-between mt-2 p-3">
|
||||
<div>
|
||||
<h4 className="font-weight-bold mb-2">
|
||||
<FontAwesomeIcon icon={faLock} className="text-black mr-2 ml-1" style={{ fontSize: '2rem' }} />
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.title'])}</span>
|
||||
</h4>
|
||||
<p className="mb-0">
|
||||
<span>{intl.formatMessage(messages['learn.lockPaywall.content'])}</span>
|
||||
|
||||
<a href={upgradeUrl}>
|
||||
{intl.formatMessage(messages['learn.lockPaywall.upgrade.link'], {
|
||||
currencySymbol,
|
||||
price,
|
||||
})}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<img
|
||||
alt={intl.formatMessage(messages['learn.lockPaywall.example.alt'])}
|
||||
src={VerifiedCert}
|
||||
className="border-0"
|
||||
style={{ height: '70px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
LockPaywall.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
export default injectIntl(LockPaywall);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
1
src/courseware/course/sequence/lock-paywall/index.js
Normal file
1
src/courseware/course/sequence/lock-paywall/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './LockPaywall';
|
||||
26
src/courseware/course/sequence/lock-paywall/messages.js
Normal file
26
src/courseware/course/sequence/lock-paywall/messages.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.lockPaywall.title': {
|
||||
id: 'learn.lockPaywall.title',
|
||||
defaultMessage: 'Verified Track Access',
|
||||
description: 'Heading for message shown to indicate that a piece of content is unavailable to audit track users.',
|
||||
},
|
||||
'learn.lockPaywall.content': {
|
||||
id: 'learn.lockPaywall.content',
|
||||
defaultMessage: 'Graded assessments are available to Verified Track learners.',
|
||||
description: 'Message shown to indicate that a piece of content is unavailable to audit track users.',
|
||||
},
|
||||
'learn.lockPaywall.upgrade.link': {
|
||||
id: 'learn.lockPaywall.upgrade.link',
|
||||
defaultMessage: 'Upgrade to unlock ({currencySymbol}{price})',
|
||||
description: 'A link users can click that navigates their browser to the upgrade payment page.',
|
||||
},
|
||||
'learn.lockPaywall.example.alt': {
|
||||
id: 'learn.lockPaywall.example.alt',
|
||||
defaultMessage: 'Example Certificate',
|
||||
description: 'Alternate text displayed when the example certificate image cannot be displayed.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -16,6 +16,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course fails to load',
|
||||
},
|
||||
'learn.sequence.no.content': {
|
||||
id: 'learn.sequence.no.content',
|
||||
defaultMessage: 'There is no content here.',
|
||||
description: 'Message shown when there is no content to show a user inside a learning sequence.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
33
src/courseware/course/tools/ContentTools.jsx
Normal file
33
src/courseware/course/tools/ContentTools.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import Calculator from './calculator';
|
||||
import NotesVisibility from './notes/NotesVisibility';
|
||||
import './tools.scss';
|
||||
|
||||
export default function ContentTools({
|
||||
course,
|
||||
}) {
|
||||
return (
|
||||
<div className="content-tools">
|
||||
<div className="d-flex justify-content-end align-items-end m-0">
|
||||
{course.showCalculator && (
|
||||
<Calculator />
|
||||
)}
|
||||
{course.notes.enabled && (
|
||||
<NotesVisibility course={course} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
ContentTools.propTypes = {
|
||||
course: PropTypes.shape({
|
||||
notes: PropTypes.shape({
|
||||
enabled: PropTypes.bool,
|
||||
}),
|
||||
showCalculator: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
7
src/courseware/course/tools/calculator/calculator.scss
Normal file
7
src/courseware/course/tools/calculator/calculator.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
.calculator {
|
||||
flex-grow: 1;
|
||||
.calculator-content {
|
||||
background-color: #f1f1f1;
|
||||
box-shadow: 0 -1px 0 0 #ddd;
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,8 @@ class Calculator extends Component {
|
||||
render() {
|
||||
return (
|
||||
<Collapsible.Advanced className="calculator">
|
||||
<div className="container-fluid text-right">
|
||||
<Collapsible.Trigger tag="a" className="calculator-trigger btn">
|
||||
<div className="text-right">
|
||||
<Collapsible.Trigger tag="a" className="trigger btn">
|
||||
<Collapsible.Visible whenOpen>
|
||||
<FontAwesomeIcon icon={faTimesCircle} aria-hidden="true" className="mr-2" />
|
||||
</Collapsible.Visible>
|
||||
@@ -345,7 +345,7 @@ class Calculator extends Component {
|
||||
</th>
|
||||
<td dir="auto">
|
||||
<FormattedMessage
|
||||
id="calculator.instruction.table.to.use.scientific.notation.type"
|
||||
id="calculator.instruction.table.to.use.scientific.notation.type1"
|
||||
defaultMessage="{exponentSyntax} and the exponent"
|
||||
values={{
|
||||
exponentSyntax: '10^',
|
||||
@@ -357,7 +357,7 @@ class Calculator extends Component {
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<FormattedMessage
|
||||
id="calculator.instruction.table.to.use.scientific.notation.type"
|
||||
id="calculator.instruction.table.to.use.scientific.notation.type2"
|
||||
defaultMessage="{notationSyntax} notation"
|
||||
values={{
|
||||
notationSyntax: 'e',
|
||||
@@ -366,7 +366,7 @@ class Calculator extends Component {
|
||||
</th>
|
||||
<td dir="auto">
|
||||
<FormattedMessage
|
||||
id="calculator.instruction.table.to.use.scientific.notation.type"
|
||||
id="calculator.instruction.table.to.use.scientific.notation.type3"
|
||||
defaultMessage="{notationSyntax} and the exponent"
|
||||
values={{
|
||||
notationSyntax: '1e',
|
||||
6
src/courseware/course/tools/data/api.js
Normal file
6
src/courseware/course/tools/data/api.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export default function toggleNotes() {
|
||||
const iframe = document.getElementById('unit-iframe');
|
||||
iframe.contentWindow.postMessage('tools.toggleNotes', getConfig().LMS_BASE_URL);
|
||||
}
|
||||
65
src/courseware/course/tools/notes/NotesVisibility.jsx
Normal file
65
src/courseware/course/tools/notes/NotesVisibility.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
injectIntl, intlShape,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
|
||||
import toggleNotes from '../data/api';
|
||||
import messages from './messages';
|
||||
|
||||
|
||||
class NotesVisibility extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
visible: props.course.notes.visible,
|
||||
};
|
||||
this.visibilityUrl = `${getConfig().LMS_BASE_URL}/courses/${props.course.id}/edxnotes/visibility/`;
|
||||
}
|
||||
|
||||
handleClick = () => {
|
||||
const data = { visibility: this.state.visible };
|
||||
getAuthenticatedHttpClient().put(
|
||||
this.visibilityUrl,
|
||||
data,
|
||||
).then(() => {
|
||||
this.setState((state) => ({ visible: !state.visible }));
|
||||
toggleNotes();
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const message = this.state.visible ? 'notes.button.hide' : 'notes.button.show';
|
||||
return (
|
||||
<button
|
||||
className={`trigger btn ${this.state.visible ? 'text-secondary' : 'text-success'} mx-2 `}
|
||||
role="switch"
|
||||
type="button"
|
||||
onClick={this.handleClick}
|
||||
onKeyDown={this.handleClick}
|
||||
tabIndex="-1"
|
||||
aria-checked={this.state.visible ? 'true' : 'false'}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilAlt} aria-hidden="true" className="mr-2" />
|
||||
{this.props.intl.formatMessage(messages[message])}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
NotesVisibility.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
course: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
notes: PropTypes.shape({
|
||||
enabled: PropTypes.bool,
|
||||
visible: PropTypes.bool,
|
||||
}),
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(NotesVisibility);
|
||||
16
src/courseware/course/tools/notes/messages.js
Normal file
16
src/courseware/course/tools/notes/messages.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'notes.button.show': {
|
||||
id: 'notes.button.show',
|
||||
defaultMessage: 'Show Notes',
|
||||
description: 'Message for toggling notes visibility',
|
||||
},
|
||||
'notes.button.hide': {
|
||||
id: 'notes.button.hide',
|
||||
defaultMessage: 'Hide Notes',
|
||||
description: 'Message for toggling notes visibility',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,10 +1,10 @@
|
||||
.calculator {
|
||||
.content-tools {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
.calculator-trigger {
|
||||
.trigger {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
@@ -16,12 +16,12 @@
|
||||
border-top-right-radius: .3rem;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
padding-left: .75rem;
|
||||
white-space: nowrap;
|
||||
&:before {
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
.calculator-content {
|
||||
background-color: #f1f1f1;
|
||||
box-shadow: 0 -1px 0 0 #ddd;
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,11 @@ export function sequenceIdsSelector(state) {
|
||||
if (state.courseware.courseStatus !== 'loaded') {
|
||||
return [];
|
||||
}
|
||||
const { sectionIds } = state.models.courses[state.courseware.courseId];
|
||||
let sequenceIds = [];
|
||||
sectionIds.forEach(sectionId => {
|
||||
sequenceIds = [...sequenceIds, ...state.models.sections[sectionId].sequenceIds];
|
||||
});
|
||||
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
|
||||
|
||||
const sequenceIds = sectionIds
|
||||
.flatMap(sectionId => state.models.sections[sectionId].sequenceIds);
|
||||
|
||||
return sequenceIds;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ export function firstSequenceIdSelector(state) {
|
||||
if (state.courseware.courseStatus !== 'loaded') {
|
||||
return null;
|
||||
}
|
||||
const sectionId = state.models.courses[state.courseware.courseId].sectionIds[0];
|
||||
return state.models.sections[sectionId].sequenceIds[0];
|
||||
const { sectionIds = [] } = state.models.courses[state.courseware.courseId];
|
||||
|
||||
if (sectionIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return state.models.sections[sectionIds[0]].sequenceIds[0];
|
||||
}
|
||||
|
||||
@@ -3,11 +3,30 @@ import { getConfig, camelCaseObject } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient, getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
|
||||
function overrideTabUrls(id, tabs) {
|
||||
// "LMS tab slug" to "MFE URL slug" for overridden tabs
|
||||
const tabOverrides = {};
|
||||
return tabs.map((tab) => {
|
||||
let url;
|
||||
if (tabOverrides[tab.slug]) {
|
||||
url = `/course/${id}/${tabOverrides[tab.slug]}`;
|
||||
} else {
|
||||
url = `${getConfig().LMS_BASE_URL}${tab.url}`;
|
||||
}
|
||||
return { ...tab, url };
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata) {
|
||||
return {
|
||||
canShowUpgradeSock: metadata.can_show_upgrade_sock,
|
||||
contentTypeGatingEnabled: metadata.content_type_gating_enabled,
|
||||
// TODO: TNL-7185: return course expired _date_, instead of _message_
|
||||
courseExpiredMessage: metadata.course_expired_message,
|
||||
id: metadata.id,
|
||||
title: metadata.name,
|
||||
number: metadata.number,
|
||||
offerHtml: metadata.offer_html,
|
||||
org: metadata.org,
|
||||
enrollmentStart: metadata.enrollment_start,
|
||||
enrollmentEnd: metadata.enrollment_end,
|
||||
@@ -15,11 +34,12 @@ function normalizeMetadata(metadata) {
|
||||
start: metadata.start,
|
||||
enrollmentMode: metadata.enrollment.mode,
|
||||
isEnrolled: metadata.enrollment.is_active,
|
||||
userHasAccess: metadata.user_has_access,
|
||||
isStaff: metadata.user_has_staff_access,
|
||||
canLoadCourseware: camelCaseObject(metadata.can_load_courseware),
|
||||
isStaff: metadata.is_staff,
|
||||
verifiedMode: camelCaseObject(metadata.verified_mode),
|
||||
tabs: camelCaseObject(metadata.tabs),
|
||||
tabs: overrideTabUrls(metadata.id, camelCaseObject(metadata.tabs)),
|
||||
showCalculator: metadata.show_calculator,
|
||||
notes: camelCaseObject(metadata.notes),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +83,10 @@ function normalizeBlocks(courseId, blocks) {
|
||||
break;
|
||||
case 'vertical':
|
||||
models.units[block.id] = {
|
||||
graded: block.graded,
|
||||
id: block.id,
|
||||
title: block.display_name,
|
||||
lmsWebUrl: block.lms_web_url,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
@@ -108,12 +130,13 @@ export async function getCourseBlocks(courseId) {
|
||||
url.searchParams.append('course_id', courseId);
|
||||
url.searchParams.append('username', username);
|
||||
url.searchParams.append('depth', 3);
|
||||
url.searchParams.append('requested_fields', 'children,show_gated_sections');
|
||||
url.searchParams.append('requested_fields', 'children,show_gated_sections,graded');
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return normalizeBlocks(courseId, data.blocks);
|
||||
}
|
||||
|
||||
|
||||
function normalizeSequenceMetadata(sequence) {
|
||||
return {
|
||||
sequence: {
|
||||
@@ -145,3 +168,9 @@ export async function getSequenceMetadata(sequenceId) {
|
||||
|
||||
return normalizeSequenceMetadata(data);
|
||||
}
|
||||
|
||||
export async function getResumeBlock(courseId) {
|
||||
const url = new URL(`${getConfig().LMS_BASE_URL}/api/courseware/resume/${courseId}`);
|
||||
const { data } = await getAuthenticatedHttpClient().get(url.href, {});
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ export {
|
||||
fetchSequence,
|
||||
} from './thunks';
|
||||
|
||||
export { getResumeBlock } from './api';
|
||||
export { reducer } from './slice';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createSlice } from '@reduxjs/toolkit';
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
|
||||
const slice = createSlice({
|
||||
name: 'courseware',
|
||||
@@ -26,6 +27,10 @@ const slice = createSlice({
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = FAILED;
|
||||
},
|
||||
fetchCourseDenied: (state, { payload }) => {
|
||||
state.courseId = payload.courseId;
|
||||
state.courseStatus = DENIED;
|
||||
},
|
||||
fetchSequenceRequest: (state, { payload }) => {
|
||||
state.sequenceId = payload.sequenceId;
|
||||
state.sequenceStatus = LOADING;
|
||||
@@ -45,6 +50,7 @@ export const {
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
|
||||
@@ -5,12 +5,13 @@ import {
|
||||
getSequenceMetadata,
|
||||
} from './api';
|
||||
import {
|
||||
addModelsMap, updateModel, updateModels, updateModelsMap,
|
||||
addModelsMap, updateModel, updateModels, updateModelsMap, addModel,
|
||||
} from '../model-store';
|
||||
import {
|
||||
fetchCourseRequest,
|
||||
fetchCourseSuccess,
|
||||
fetchCourseFailure,
|
||||
fetchCourseDenied,
|
||||
fetchSequenceRequest,
|
||||
fetchSequenceSuccess,
|
||||
fetchSequenceFailure,
|
||||
@@ -19,39 +20,66 @@ import {
|
||||
export function fetchCourse(courseId) {
|
||||
return async (dispatch) => {
|
||||
dispatch(fetchCourseRequest({ courseId }));
|
||||
Promise.all([
|
||||
getCourseBlocks(courseId),
|
||||
Promise.allSettled([
|
||||
getCourseMetadata(courseId),
|
||||
]).then(([
|
||||
{
|
||||
courses, sections, sequences, units,
|
||||
},
|
||||
course,
|
||||
]) => {
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'courses',
|
||||
modelsMap: courses,
|
||||
}));
|
||||
dispatch(updateModel({
|
||||
modelType: 'courses',
|
||||
model: course,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences and units because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
modelsMap: units,
|
||||
}));
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
}).catch((error) => {
|
||||
logError(error);
|
||||
getCourseBlocks(courseId),
|
||||
]).then(([courseMetadataResult, courseBlocksResult]) => {
|
||||
if (courseMetadataResult.status === 'fulfilled') {
|
||||
dispatch(addModel({
|
||||
modelType: 'courses',
|
||||
model: courseMetadataResult.value,
|
||||
}));
|
||||
}
|
||||
|
||||
if (courseBlocksResult.status === 'fulfilled') {
|
||||
const {
|
||||
courses, sections, sequences, units,
|
||||
} = courseBlocksResult.value;
|
||||
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'courses',
|
||||
modelsMap: courses,
|
||||
}));
|
||||
dispatch(addModelsMap({
|
||||
modelType: 'sections',
|
||||
modelsMap: sections,
|
||||
}));
|
||||
// We update for sequences and units because the sequence metadata may have come back first.
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'sequences',
|
||||
modelsMap: sequences,
|
||||
}));
|
||||
dispatch(updateModelsMap({
|
||||
modelType: 'units',
|
||||
modelsMap: units,
|
||||
}));
|
||||
}
|
||||
|
||||
const fetchedMetadata = courseMetadataResult.status === 'fulfilled';
|
||||
const fetchedBlocks = courseBlocksResult.status === 'fulfilled';
|
||||
|
||||
// Log errors for each request if needed. Course block failures may occur
|
||||
// even if the course metadata request is successful
|
||||
if (!fetchedBlocks) {
|
||||
logError(courseBlocksResult.reason);
|
||||
}
|
||||
if (!fetchedMetadata) {
|
||||
logError(courseMetadataResult.reason);
|
||||
}
|
||||
|
||||
if (fetchedMetadata) {
|
||||
if (courseMetadataResult.value.canLoadCourseware.hasAccess && fetchedBlocks) {
|
||||
// User has access
|
||||
dispatch(fetchCourseSuccess({ courseId }));
|
||||
return;
|
||||
}
|
||||
// User either doesn't have access or only has partial access
|
||||
// (can't access course blocks)
|
||||
dispatch(fetchCourseDenied({ courseId }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Definitely an error happening
|
||||
dispatch(fetchCourseFailure({ courseId }));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
import Alert from '../user-messages/Alert';
|
||||
import messages from './messages';
|
||||
import { useEnrollClickHandler } from './hooks';
|
||||
|
||||
function EnrollmentAlert({ intl, courseId }) {
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
intl.formatMessage(messages['learning.enrollment.success']),
|
||||
);
|
||||
|
||||
function EnrollmentAlert({ intl }) {
|
||||
return (
|
||||
<Alert type="error">
|
||||
{intl.formatMessage(messages['learning.enrollment.alert'])}
|
||||
{' '}
|
||||
<a href={`${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`}>
|
||||
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
|
||||
</a>
|
||||
</Button>
|
||||
{' '}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(EnrollmentAlert);
|
||||
|
||||
37
src/enrollment-alert/StaffEnrollmentAlert.jsx
Normal file
37
src/enrollment-alert/StaffEnrollmentAlert.jsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
import { useEnrollClickHandler } from './hooks';
|
||||
|
||||
function StaffEnrollmentAlert({ intl, courseId }) {
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
intl.formatMessage(messages['learning.enrollment.success']),
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert type="info" dismissible>
|
||||
{intl.formatMessage(messages['learning.staff.enrollment.alert'])}
|
||||
{' '}
|
||||
<Button disabled={loading} className="btn-link p-0 border-0 align-top" onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages['learning.enrollment.enroll.now'])}
|
||||
</Button>
|
||||
{' '}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
StaffEnrollmentAlert.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(StaffEnrollmentAlert);
|
||||
9
src/enrollment-alert/data/api.js
Normal file
9
src/enrollment-alert/data/api.js
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export async function postCourseEnrollment(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||
const { data } = await getAuthenticatedHttpClient().post(url, { course_details: { course_id: courseId } });
|
||||
return data;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import UserMessagesContext from '../user-messages/UserMessagesContext';
|
||||
import {
|
||||
useContext, useState, useEffect, useCallback,
|
||||
} from 'react';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../user-messages';
|
||||
import { useModel } from '../model-store';
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
@@ -10,14 +13,13 @@ export function useEnrollmentAlert(courseId) {
|
||||
const isEnrolled = course && course.isEnrolled;
|
||||
useEffect(() => {
|
||||
if (course && course.isEnrolled !== undefined) {
|
||||
if (!course.isEnrolled) {
|
||||
if (!course.isEnrolled && alertId === null) {
|
||||
const code = course.isStaff ? 'clientStaffEnrollmentAlert' : 'clientEnrollmentAlert';
|
||||
setAlertId(add({
|
||||
code: 'clientEnrollmentAlert',
|
||||
dismissible: false,
|
||||
type: 'error',
|
||||
code,
|
||||
topic: 'course',
|
||||
}));
|
||||
} else if (alertId !== null) {
|
||||
} else if (course.isEnrolled && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
@@ -29,3 +31,24 @@ export function useEnrollmentAlert(courseId) {
|
||||
};
|
||||
}, [course, isEnrolled]);
|
||||
}
|
||||
|
||||
export function useEnrollClickHandler(courseId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
global.location.reload();
|
||||
});
|
||||
}, [courseId]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default } from './EnrollmentAlert';
|
||||
export { default as EnrollmentAlert } from './EnrollmentAlert';
|
||||
export { default as StaffEnrollmentAlert } from './StaffEnrollmentAlert';
|
||||
export { useEnrollmentAlert } from './hooks';
|
||||
|
||||
@@ -6,11 +6,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
||||
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.staff.enrollment.alert': {
|
||||
id: 'learning.staff.enrollment.alert',
|
||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
'learning.enrollment.enroll.now': {
|
||||
id: 'learning.enrollment.enroll.now',
|
||||
defaultMessage: 'Enroll Now',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
||||
},
|
||||
'learning.enrollment.success': {
|
||||
id: 'learning.enrollment.success',
|
||||
defaultMessage: "You've successfully enrolled in this course!",
|
||||
description: 'A message telling the user that their course enrollment was successful.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'regenerator-runtime/runtime';
|
||||
|
||||
import {
|
||||
APP_INIT_ERROR, APP_READY, subscribe, initialize,
|
||||
mergeConfig,
|
||||
} from '@edx/frontend-platform';
|
||||
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import React from 'react';
|
||||
@@ -13,12 +14,14 @@ import { messages as headerMessages } from '@edx/frontend-component-header';
|
||||
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
|
||||
|
||||
import appMessages from './i18n';
|
||||
import UserMessagesProvider from './user-messages/UserMessagesProvider';
|
||||
import { UserMessagesProvider } from './user-messages';
|
||||
|
||||
import './index.scss';
|
||||
import './assets/favicon.ico';
|
||||
import CourseHome from './course-home';
|
||||
import CoursewareContainer from './courseware';
|
||||
import CourseHomeContainer from './course-home';
|
||||
import CoursewareRedirect from './CoursewareRedirect';
|
||||
import { TabContainer } from './tab-page';
|
||||
|
||||
import store from './store';
|
||||
|
||||
@@ -27,7 +30,12 @@ subscribe(APP_READY, () => {
|
||||
<AppProvider store={store}>
|
||||
<UserMessagesProvider>
|
||||
<Switch>
|
||||
<Route path="/course/:courseId/home" component={CourseHomeContainer} />
|
||||
<Route path="/redirect" component={CoursewareRedirect} />
|
||||
<Route path="/course/:courseId/home">
|
||||
<TabContainer tab="courseware">
|
||||
<CourseHome />
|
||||
</TabContainer>
|
||||
</Route>
|
||||
<Route
|
||||
path={[
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
@@ -49,6 +57,17 @@ subscribe(APP_INIT_ERROR, (error) => {
|
||||
});
|
||||
|
||||
initialize({
|
||||
handlers: {
|
||||
config: () => {
|
||||
mergeConfig({
|
||||
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
|
||||
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
|
||||
}, 'LearnerAppConfig');
|
||||
},
|
||||
},
|
||||
// TODO: Remove this once the course blocks api supports unauthenticated
|
||||
// access and we are prepared to support public courses in this app.
|
||||
requireAuthenticatedUser: true,
|
||||
messages: [
|
||||
appMessages,
|
||||
headerMessages,
|
||||
|
||||
@@ -32,7 +32,7 @@ $primary: #1176B2;
|
||||
flex-grow: 1;
|
||||
}
|
||||
header {
|
||||
flex: 0;
|
||||
flex: 0 0 auto;
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
@@ -108,7 +108,6 @@ $primary: #1176B2;
|
||||
@media (min-width: map-get($grid-breakpoints, 'sm')) {
|
||||
max-width: 1440px;
|
||||
width: 100%;
|
||||
padding: 0 $grid-gutter-width;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -186,7 +185,7 @@ $primary: #1176B2;
|
||||
.sequence-navigation-tabs {
|
||||
.btn {
|
||||
flex-basis: 100%;
|
||||
min-width: 4rem;
|
||||
min-width: 2rem;
|
||||
}
|
||||
}
|
||||
.sequence-navigation-dropdown {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
|
||||
import Alert from '../user-messages/Alert';
|
||||
import { Alert } from '../user-messages';
|
||||
import messages from './messages';
|
||||
|
||||
function LogistrationAlert({ intl }) {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import UserMessagesContext from '../user-messages/UserMessagesContext';
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../user-messages';
|
||||
|
||||
export function useLogistrationAlert() {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
useEffect(() => {
|
||||
if (authenticatedUser === null) {
|
||||
if (authenticatedUser === null && alertId === null) {
|
||||
setAlertId(add({
|
||||
code: 'clientLogistrationAlert',
|
||||
dismissible: false,
|
||||
type: 'error',
|
||||
type: ALERT_TYPES.ERROR,
|
||||
topic: 'course',
|
||||
}));
|
||||
} else if (alertId !== null) {
|
||||
} else if (authenticatedUser !== null && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
|
||||
21
src/offer-alert/OfferAlert.jsx
Normal file
21
src/offer-alert/OfferAlert.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Alert } from '../user-messages';
|
||||
|
||||
function OfferAlert(props) {
|
||||
const {
|
||||
rawHtml,
|
||||
} = props;
|
||||
return rawHtml && (
|
||||
<Alert type="info">
|
||||
<div dangerouslySetInnerHTML={{ __html: rawHtml }} />
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
OfferAlert.propTypes = {
|
||||
rawHtml: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default OfferAlert;
|
||||
28
src/offer-alert/hooks.js
Normal file
28
src/offer-alert/hooks.js
Normal file
@@ -0,0 +1,28 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { useContext, useState, useEffect } from 'react';
|
||||
import { UserMessagesContext } from '../user-messages';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export function useOfferAlert(courseId) {
|
||||
const course = useModel('courses', courseId);
|
||||
const { add, remove } = useContext(UserMessagesContext);
|
||||
const [alertId, setAlertId] = useState(null);
|
||||
const rawHtml = (course && course.offerHtml) || null;
|
||||
useEffect(() => {
|
||||
if (rawHtml && alertId === null) {
|
||||
setAlertId(add({
|
||||
code: 'clientOfferAlert',
|
||||
topic: 'course',
|
||||
rawHtml,
|
||||
}));
|
||||
} else if (!rawHtml && alertId !== null) {
|
||||
remove(alertId);
|
||||
setAlertId(null);
|
||||
}
|
||||
return () => {
|
||||
if (alertId !== null) {
|
||||
remove(alertId);
|
||||
}
|
||||
};
|
||||
}, [alertId, courseId, rawHtml]);
|
||||
}
|
||||
2
src/offer-alert/index.js
Normal file
2
src/offer-alert/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as OfferAlert } from './OfferAlert';
|
||||
export { useOfferAlert } from './hooks';
|
||||
60
src/tab-page/LoadedTabPage.jsx
Normal file
60
src/tab-page/LoadedTabPage.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Header, CourseTabsNavigation } from '../course-header';
|
||||
import { useModel } from '../model-store';
|
||||
import { useEnrollmentAlert } from '../enrollment-alert';
|
||||
import InstructorToolbar from '../courseware/course/InstructorToolbar';
|
||||
|
||||
function LoadedTabPage({
|
||||
activeTabSlug,
|
||||
children,
|
||||
courseId,
|
||||
unitId,
|
||||
}) {
|
||||
useEnrollmentAlert(courseId);
|
||||
|
||||
const {
|
||||
isStaff,
|
||||
number,
|
||||
org,
|
||||
tabs,
|
||||
title,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
{isStaff && (
|
||||
<InstructorToolbar
|
||||
courseId={courseId}
|
||||
unitId={unitId}
|
||||
/>
|
||||
)}
|
||||
<main className="d-flex flex-column flex-grow-1">
|
||||
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug={activeTabSlug} />
|
||||
<div className="container-fluid">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LoadedTabPage.propTypes = {
|
||||
activeTabSlug: PropTypes.string.isRequired,
|
||||
children: PropTypes.node,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
unitId: PropTypes.string,
|
||||
};
|
||||
|
||||
LoadedTabPage.defaultProps = {
|
||||
children: null,
|
||||
unitId: null,
|
||||
};
|
||||
|
||||
export default LoadedTabPage;
|
||||
42
src/tab-page/TabContainer.jsx
Normal file
42
src/tab-page/TabContainer.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { fetchCourse } from '../data';
|
||||
|
||||
import TabPage from './TabPage';
|
||||
|
||||
export default function TabContainer(props) {
|
||||
const {
|
||||
children,
|
||||
tab,
|
||||
} = props;
|
||||
|
||||
const { courseId: courseIdFromUrl } = useParams();
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchCourse(courseIdFromUrl));
|
||||
}, [courseIdFromUrl]);
|
||||
|
||||
// The courseId from the store is the course we HAVE loaded. If the URL changes,
|
||||
// we don't want the application to adjust to it until it has actually loaded the new data.
|
||||
const {
|
||||
courseId,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
return (
|
||||
<TabPage
|
||||
activeTabSlug={tab}
|
||||
courseId={courseId}
|
||||
>
|
||||
{children}
|
||||
</TabPage>
|
||||
);
|
||||
}
|
||||
|
||||
TabContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
tab: PropTypes.string.isRequired,
|
||||
};
|
||||
52
src/tab-page/TabPage.jsx
Normal file
52
src/tab-page/TabPage.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { Header } from '../course-header';
|
||||
import { useLogistrationAlert } from '../logistration-alert';
|
||||
import PageLoading from '../PageLoading';
|
||||
|
||||
import messages from './messages';
|
||||
import LoadedTabPage from './LoadedTabPage';
|
||||
|
||||
function TabPage({
|
||||
intl,
|
||||
...passthroughProps
|
||||
}) {
|
||||
useLogistrationAlert();
|
||||
|
||||
const courseStatus = useSelector(state => state.courseware.courseStatus);
|
||||
|
||||
if (courseStatus === 'loading') {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading'])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (courseStatus === 'loaded') {
|
||||
return (
|
||||
<LoadedTabPage {...passthroughProps} />
|
||||
);
|
||||
}
|
||||
|
||||
// courseStatus 'failed' and any other unexpected course status.
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<p className="text-center py-5 mx-auto" style={{ maxWidth: '30em' }}>
|
||||
{intl.formatMessage(messages['learn.loading.failure'])}
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TabPage.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(TabPage);
|
||||
2
src/tab-page/index.js
Normal file
2
src/tab-page/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as TabContainer } from './TabContainer';
|
||||
export { default as TabPage } from './TabPage';
|
||||
16
src/tab-page/messages.js
Normal file
16
src/tab-page/messages.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'learn.loading': {
|
||||
id: 'learn.loading',
|
||||
defaultMessage: 'Loading course page...',
|
||||
description: 'Message when course page is being loaded',
|
||||
},
|
||||
'learn.loading.failure': {
|
||||
id: 'learn.loading.failure',
|
||||
defaultMessage: 'There was an error loading this course.',
|
||||
description: 'Message when a course page fails to load',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -7,27 +7,29 @@ import {
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import { ALERT_TYPES } from './UserMessagesProvider';
|
||||
|
||||
function getAlertClass(type) {
|
||||
if (type === 'error') {
|
||||
if (type === ALERT_TYPES.ERROR) {
|
||||
return 'alert-warning';
|
||||
}
|
||||
if (type === 'danger') {
|
||||
if (type === ALERT_TYPES.DANGER) {
|
||||
return 'alert-danger';
|
||||
}
|
||||
if (type === 'success') {
|
||||
if (type === ALERT_TYPES.SUCCESS) {
|
||||
return 'alert-success';
|
||||
}
|
||||
return 'alert-info';
|
||||
}
|
||||
|
||||
function getAlertIcon(type) {
|
||||
if (type === 'error') {
|
||||
if (type === ALERT_TYPES.ERROR) {
|
||||
return faExclamationTriangle;
|
||||
}
|
||||
if (type === 'danger') {
|
||||
if (type === ALERT_TYPES.DANGER) {
|
||||
return faMinusCircle;
|
||||
}
|
||||
if (type === 'success') {
|
||||
if (type === ALERT_TYPES.SUCCESS) {
|
||||
return faCheckCircle;
|
||||
}
|
||||
return faInfoCircle;
|
||||
@@ -53,7 +55,7 @@ function Alert({
|
||||
|
||||
|
||||
Alert.propTypes = {
|
||||
type: PropTypes.oneOf(['error', 'danger', 'info', 'success']).isRequired,
|
||||
type: PropTypes.oneOf([ALERT_TYPES.ERROR, ALERT_TYPES.DANGER, ALERT_TYPES.INFO, ALERT_TYPES.SUCCESS]).isRequired,
|
||||
dismissible: PropTypes.bool,
|
||||
children: PropTypes.node,
|
||||
onDismiss: PropTypes.func,
|
||||
|
||||
@@ -4,7 +4,9 @@ import PropTypes from 'prop-types';
|
||||
import UserMessagesContext from './UserMessagesContext';
|
||||
import Alert from './Alert';
|
||||
|
||||
export default function AlertList({ topic, className, customAlerts }) {
|
||||
export default function AlertList({
|
||||
topic, className, customAlerts, customProps,
|
||||
}) {
|
||||
const { remove, messages } = useContext(UserMessagesContext);
|
||||
const getAlertComponent = useCallback(
|
||||
(code) => (customAlerts[code] !== undefined ? customAlerts[code] : Alert),
|
||||
@@ -26,6 +28,8 @@ export default function AlertList({ topic, className, customAlerts }) {
|
||||
type={message.type}
|
||||
dismissible={message.dismissible}
|
||||
onDismiss={() => remove(message.id)}
|
||||
rawHtml={message.rawHtml}
|
||||
{...customProps}
|
||||
>
|
||||
{message.text}
|
||||
</AlertComponent>
|
||||
@@ -46,10 +50,13 @@ AlertList.propTypes = {
|
||||
PropTypes.node,
|
||||
]),
|
||||
),
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
customProps: PropTypes.object,
|
||||
};
|
||||
|
||||
AlertList.defaultProps = {
|
||||
topic: null,
|
||||
className: null,
|
||||
customAlerts: {},
|
||||
customProps: {},
|
||||
};
|
||||
|
||||
@@ -1,39 +1,119 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import UserMessagesContext from './UserMessagesContext';
|
||||
|
||||
export const ALERT_TYPES = {
|
||||
ERROR: 'error',
|
||||
DANGER: 'danger',
|
||||
SUCCESS: 'success',
|
||||
INFO: 'info',
|
||||
};
|
||||
|
||||
// NOTE: This storage key is not namespaced. That means that it's shared for the current fully
|
||||
// qualified domain. Namespacing could be added by adding an optional prop to UserMessagesProvider
|
||||
// to set a namespace, but we'll cross that bridge when we need it.
|
||||
const FLASH_MESSAGES_LOCAL_STORAGE_KEY = 'UserMessagesProvider.flashMessages';
|
||||
|
||||
function getFlashMessages() {
|
||||
let flashMessages = [];
|
||||
try {
|
||||
if (global.localStorage) {
|
||||
const rawItem = global.localStorage.getItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
|
||||
if (rawItem) {
|
||||
// Only try to parse and set flashMessages from the raw item if it exists.
|
||||
const parsed = JSON.parse(rawItem);
|
||||
if (Array.isArray(parsed)) {
|
||||
flashMessages = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// If this fails for some reason, just return the empty array.
|
||||
}
|
||||
return flashMessages;
|
||||
}
|
||||
|
||||
function addFlashMessage(message) {
|
||||
try {
|
||||
if (global.localStorage) {
|
||||
const flashMessages = getFlashMessages();
|
||||
flashMessages.push(message);
|
||||
global.localStorage.setItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY, JSON.stringify(flashMessages));
|
||||
}
|
||||
} catch (e) {
|
||||
// If this fails, just bail.
|
||||
}
|
||||
}
|
||||
|
||||
function clearFlashMessages() {
|
||||
try {
|
||||
if (global.localStorage) {
|
||||
global.localStorage.removeItem(FLASH_MESSAGES_LOCAL_STORAGE_KEY);
|
||||
}
|
||||
} catch (e) {
|
||||
// If this fails, just bail.
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserMessagesProvider({ children }) {
|
||||
// Note: The callbacks (add, remove, clear) below interact with useState in very subtle ways.
|
||||
// When we call setMessages, we always do so with the function-based form of the handler, making
|
||||
// use of the "current" state and not relying on lexical scoping to access the state exposed
|
||||
// above with useState. This is very important and allows us to call multiple "add", "remove",
|
||||
// or "clear" functions in a single render. Without it, each call to one of the callbacks
|
||||
// references back to the -original- value of messages instead of the most recent, causing them
|
||||
// all to override each other. Last one in would win.
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [nextId, setNextId] = useState(1);
|
||||
|
||||
const refMessages = useRef(messages);
|
||||
// Because the add, remove, and clear handlers also need to access nextId, we have to do
|
||||
// something a bit different. There's no way to wait for the "currentNextId" in a setMessages
|
||||
// handler. The alternative is to update a ref, which will always point to the current value by
|
||||
// its very nature.
|
||||
const refId = useRef(nextId);
|
||||
|
||||
const add = ({
|
||||
code, dismissible, text, type, topic, ...others
|
||||
}) => {
|
||||
const id = nextId;
|
||||
refMessages.current = [...refMessages.current, {
|
||||
/**
|
||||
* Flash messages are a special kind of message that appears once on page refresh.
|
||||
*/
|
||||
function addFlash(message) {
|
||||
addFlashMessage(message);
|
||||
}
|
||||
|
||||
function add(message) {
|
||||
const {
|
||||
code, dismissible, text, type, topic, ...others
|
||||
} = message;
|
||||
const id = refId.current;
|
||||
setMessages(currentMessages => [...currentMessages, {
|
||||
code, dismissible, text, type, topic, ...others, id,
|
||||
}];
|
||||
setMessages(refMessages.current);
|
||||
setNextId(nextId + 1);
|
||||
}]);
|
||||
refId.current += 1;
|
||||
setNextId(refId.current);
|
||||
|
||||
return id;
|
||||
};
|
||||
}
|
||||
|
||||
const remove = id => {
|
||||
refMessages.current = refMessages.current.filter(message => message.id !== id);
|
||||
setMessages(refMessages.current);
|
||||
};
|
||||
function remove(id) {
|
||||
setMessages(currentMessages => currentMessages.filter(message => message.id !== id));
|
||||
}
|
||||
|
||||
const clear = (topic = null) => {
|
||||
refMessages.current = topic === null ? [] : refMessages.current.filter(message => message.topic !== topic);
|
||||
function clear(topic = null) {
|
||||
setMessages(currentMessages => (topic === null ? [] : currentMessages.filter(message => message.topic !== topic)));
|
||||
}
|
||||
|
||||
setMessages(refMessages.current);
|
||||
};
|
||||
useEffect(() => {
|
||||
const flashMessages = getFlashMessages();
|
||||
flashMessages.forEach(flashMessage => add(flashMessage));
|
||||
// We only allow flash messages to persist through one refresh, then we clear them out.
|
||||
// If we want persistent messages, then add a 'persist' key to the messages and handle that
|
||||
// as a separate local storage item.
|
||||
clearFlashMessages();
|
||||
}, []);
|
||||
|
||||
const value = {
|
||||
add,
|
||||
addFlash,
|
||||
remove,
|
||||
clear,
|
||||
messages,
|
||||
|
||||
4
src/user-messages/index.js
Normal file
4
src/user-messages/index.js
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as UserMessagesProvider, ALERT_TYPES } from './UserMessagesProvider';
|
||||
export { default as UserMessagesContext } from './UserMessagesContext';
|
||||
export { default as AlertList } from './AlertList';
|
||||
export { default as Alert } from './Alert';
|
||||
Reference in New Issue
Block a user