Compare commits

..

72 Commits

Author SHA1 Message Date
Ahtisham Shahid
0b3fc507bf Merge branch 'master' into ahtisham/INF-867 2023-01-17 20:47:25 +05:00
AhtishamShahid
a1a9e3b21e fix: update isEmpty logic 2023-01-17 20:40:17 +05:00
Awais Ansari
e1d8af4498 fix: editedBy and closedBy banner will be visible to global staff (#410)
* fix: editedBy and closedBy banner will be visible to global staff

* refactor: rename isAdmin to userIsGlobalStaff
2023-01-17 17:41:32 +05:00
AhtishamShahid
6f96b0d6ef refactor: made tour component generic 2023-01-17 16:14:50 +05:00
AhtishamShahid
087adf6562 refactor: made tour component generic 2023-01-17 15:27:15 +05:00
AhtishamShahid
bb8f4b1c50 refactor: made tour component generic 2023-01-17 15:10:10 +05:00
Jenkins
2a50edc334 chore(i18n): update translations 2023-01-15 15:26:43 -05:00
AhtishamShahid
5c75651481 refactor: added translations , removed redundent code, fixed tests 2023-01-13 11:40:37 +05:00
Mehak Nasir
a218c29831 style: fixes add/edit post section redesigned 2023-01-11 16:59:04 +05:00
AhtishamShahid
5f563ab702 fix: resolved linter errors 2023-01-10 13:53:41 +05:00
AhtishamShahid
e6d560733b feat: added tour for not responded filter 2023-01-10 11:23:35 +05:00
Awais Ansari
6e03d5bfe8 fix: update post delete function to async (#404) 2023-01-09 16:55:19 +05:00
Awais Ansari
fd4dbef2e0 fix: deleting a post from sidebar renders entire MFE in the sidebar (#403) 2023-01-09 14:35:54 +05:00
Jenkins
f614c42cc5 chore(i18n): update translations 2023-01-08 15:26:40 -05:00
Mehak Nasir
928108e96c style: username space issue post summary 2023-01-05 20:06:52 +05:00
Mehak Nasir
aade749d54 fix: tooltip text updates according to new requirements 2023-01-05 20:06:52 +05:00
Mehak Nasir
1494741fae fix: text update for blackout dates banner 2023-01-05 14:32:14 +05:00
Awais Ansari
01547730a8 fix: inContext topics screen center issue on every loading (#399) 2023-01-04 14:38:28 +05:00
Awais Ansari
58e724d724 feat: display archived topics is post editor and topics list (#397)
* fix: display subsection name when there is no units in subsection

* feat: display archived topics is post editor and topics list

* fix: posts filter are affecting topic posts result
2023-01-04 14:01:56 +05:00
Mehak Nasir
9c576ff3dc style: post summary card design updates 2023-01-03 20:20:00 +05:00
Awais Ansari
3f890401e8 feat: implements new v3 in-context topics structure (#371) 2022-12-30 17:15:47 +05:00
ayesha waris
3622d46538 fix: moved spinner to center and reply menu icon to right side (#396) 2022-12-28 17:21:04 +05:00
ayesha waris
4eaac1eb03 fix: loading icon and filter menu shows when api call is in progress in my posts tab (#389) 2022-12-27 18:24:58 +05:00
Jenkins
2a5e643562 chore(i18n): update translations 2022-12-25 15:26:57 -05:00
Mehak Nasir
b47fc9b3e9 fix: post edit reason will not be sent in case of global staff own post 2022-12-23 17:22:17 +05:00
Mehak Nasir
bb6e47ce70 fix: disabled relative link 2022-12-23 17:15:54 +05:00
Mehak Nasir
f378b21e32 chore: code style fix 2022-12-23 16:24:31 +05:00
Mehak Nasir
c6a81e6d15 style: z-index fixes for UI elements in user dropdown and editor toolbar 2022-12-23 15:43:04 +05:00
ayesha waris
283e16a477 fix: add response button does not show when no responses avaiable (#391) 2022-12-21 16:03:32 +05:00
Adolfo R. Brandes
0c71e8b5b7 feat: Support runtime configuration (second attempt)
(This reintroduces the change in 9f84230c that was later reverted by
67b0b33a.)

frontend-platform supports runtime configuration since 2.5.0 (see the PR
that introduced it[1], but it requires MFE cooperation.  This implements
just that: by avoiding making configuration values constant, it should
now be possible to change them after initialization.

Almost all changes here relate to the `LMS_BASE_URL` setting, which in
most places was treated as a constant.

[1] https://github.com/openedx/frontend-platform/pull/335
2022-12-20 17:54:21 +05:00
ayesha waris
49d6fbed3c fix: removed no endorsed responses from question type post (#383)
* fix: removed no endorsed responses from question type post

* refactor: add a reponse button only shows once at the bottom

* refactor: removes unnecessaary arguments from handle comments
2022-12-20 17:42:44 +05:00
Ahtisham Shahid
b1c1f1c024 fix: removed sidebar in case there are no posts (#367)
* fix: removed sidebar in case there are no posts
2022-12-19 11:35:14 +05:00
Jenkins
af029b43a2 chore(i18n): update translations 2022-12-18 15:26:38 -05:00
ayesha waris
b7aff94513 Merge pull request #372 from openedx/INF-685
fix: discussions navigation tab is not sticking to top
2022-12-16 15:21:28 +05:00
ayeshoali
c26c7d34e6 fix: discussions navigation tab is not sticking to top 2022-12-16 14:39:45 +05:00
SaadYousaf
2eee6c3ca2 fix: fix cohort field on content filter event. 2022-12-15 18:37:30 +05:00
SaadYousaf
e7a41b2391 fix: cleanup events and add filter content event to learners tab filters 2022-12-15 15:39:29 +05:00
AsadAzam
ad42959e56 Merge pull request #382 from openedx/revert-377-support-runtime-config
Revert "feat: Support runtime configuration"
2022-12-13 12:45:35 +05:00
AsadAzam
67b0b33a81 Revert "feat: Support runtime configuration"
This reverts commit 9f84230c17.
2022-12-12 15:36:40 +05:00
Jenkins
da108a2054 chore(i18n): update translations 2022-12-11 15:26:38 -05:00
Mehak Nasir
1005752bf1 fix: image paste is blocked in tinymce 2022-12-09 18:07:57 +05:00
Mehak Nasir
31a66f6832 fix: filter result rendering fixed in topics 2022-12-09 17:58:08 +05:00
SaadYousaf
37053e9bd3 feat: add tracking event for content filtering in discussion MFE 2022-12-09 16:21:23 +05:00
Adolfo R. Brandes
9f84230c17 feat: Support runtime configuration
frontend-platform supports runtime configuration since 2.5.0 (see the PR
that introduced it[1], but it requires MFE cooperation.  This implements
just that: by avoiding making configuration values constant, it should
now be possible to change them after initialization.

Almost all changes here relate to the `LMS_BASE_URL` setting, which in
most places was treated as a constant.

[1] https://github.com/openedx/frontend-platform/pull/335
2022-12-09 10:39:59 +00:00
SaadYousaf
d60f1afa6b feat: add event tracking for user sorting on learners tab in discussions MFE. 2022-12-08 14:50:01 +05:00
Jenkins
df6a0d4293 chore(i18n): update translations 2022-12-04 15:26:37 -05:00
ayesha waris
e8bd91b418 Merge pull request #361 from openedx/INF-628
fix: add a post/response/comment during active blackout dates fixed for all roles
2022-12-02 18:23:13 +05:00
ayesha waris
bdaa13a7ad Merge branch 'master' into INF-628 2022-12-02 17:51:38 +05:00
ayeshoali
5ab324c9ca test: changed structure of test cases 2022-12-02 17:48:17 +05:00
Mehak Nasir
00ab8283e2 fix: UI style fixed for medium screens 2022-12-02 16:14:13 +05:00
ayesha waris
1719315681 Merge branch 'master' into INF-628 2022-12-01 21:06:10 +05:00
ayeshoali
8e19eed468 test: testcases made for hook useusercanaddthreadinblackoutDate 2022-12-01 20:46:37 +05:00
ayesha waris
11460a3d26 Merge pull request #368 from openedx/INF-628-fix
fix: lint fixes
2022-12-01 17:51:20 +05:00
ayeshoali
a3d0273de6 fix: lint fixes 2022-12-01 17:41:27 +05:00
ayesha waris
f1d2de6694 Merge branch 'master' into INF-628 2022-12-01 16:56:55 +05:00
ayeshoali
1169de04f6 refactor: hook's return condition changed 2022-12-01 16:48:44 +05:00
ayesha waris
aa7a5a8cc1 Merge pull request #366 from openedx/INF-661
style: 3 level topic hierarchy accomodated when incontext discussions…
2022-12-01 16:32:59 +05:00
ayeshoali
9d9377bb8c refactor: used classname in conditional class 2022-12-01 16:31:48 +05:00
ayeshoali
f45f47f2e0 style: 3 level topic hierarchy accomodated when incontext discussions is enabled 2022-12-01 16:31:28 +05:00
ayesha waris
bea247f6e5 Merge pull request #357 from openedx/INF-666
style: confirmation modal added when reporting content
2022-12-01 16:25:29 +05:00
ayesha waris
9e878fc916 Merge branch 'master' into INF-666 2022-12-01 16:08:23 +05:00
ayeshoali
21176131a7 fix: removed divider from report in action drop down 2022-12-01 16:05:40 +05:00
Mehak Nasir
b23846b1e4 fix: handled thread not found result on frontend 2022-12-01 15:44:18 +05:00
ayeshoali
7912d70388 refactor: handled confirm action to functions 2022-11-30 19:04:32 +05:00
ayeshoali
42f1efd0a0 refactor: confirmation modal modified to handle variants 2022-11-30 19:04:32 +05:00
ayeshoali
8e449acde7 refactor: changed deleteconfirm and reportconfirm rename to comfirmAction and made it required 2022-11-30 19:04:32 +05:00
ayeshoali
5f477cb93f fix: report icon changed to correct report icon 2022-11-30 19:04:32 +05:00
ayeshoali
cf8f08172f fix: fixed test cases 2022-11-30 19:04:32 +05:00
ayeshoali
b72dbae4f0 style: confirmation modal added when reporting content 2022-11-30 19:04:32 +05:00
ayeshoali
f805f73447 refactor: modified hook to use can add thread in blackout dates 2022-11-30 13:42:47 +05:00
ayeshoali
27c4d7e3d6 refactor: made hook to get privilage users 2022-11-30 13:42:47 +05:00
ayeshoali
b976e812dc fix: add a post during active blackout dates fixed for all roles 2022-11-30 13:42:47 +05:00
105 changed files with 3501 additions and 1734 deletions

755
package-lock.json generated
View File

@@ -10,9 +10,9 @@
"license": "AGPL-3.0",
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-platform": "4.2.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/paragon": "20.15.0",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",
@@ -3395,42 +3395,21 @@
}
},
"node_modules/@edx/frontend-component-footer": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.0.0.tgz",
"integrity": "sha512-m8Rx6ZPWzIN5XLrz6Ft3aTuFo0rty0jECd79CBYWdm0D9KD1WxoYEG+fElluyOQp/t42T5jLImHTSWjFURx5kw==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-11.2.0.tgz",
"integrity": "sha512-prN6SeoWenbNq6jCqlpmYx57OhNbVGw1wjQFVH+aA5JMjjLUf6PmjPG87fuD9udQyFteb2lvbCyongPEZOozrg==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",
"@edx/frontend-platform": "^2.3.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz",
"integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"engines": {
"node": ">=6"
"react": "^16.9.0",
"react-dom": "^16.9.0"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/react-fontawesome": {
@@ -3446,110 +3425,25 @@
}
},
"node_modules/@edx/frontend-component-header": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.0.0.tgz",
"integrity": "sha512-r/L3p2ZSI1DitjxVKAor18GmgJllafYslrdpzGI0vcX/gTemH13jf2Xr9iQqrT921DP2nzZ5GOwGJNptTSjiaA==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-3.2.0.tgz",
"integrity": "sha512-ep9yOwe9CG03lSf/QBsBOZO3S/Ksrttgi4MkjpUe/6Sm/KdAnMCOhx2Gbj9jBcFrHqCkpeef26zGjjhpHGDwkQ==",
"dependencies": {
"@edx/paragon": "20.30.1",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
},
"peerDependencies": {
"@edx/frontend-platform": "^4.0.0",
"@edx/frontend-platform": "^2.0.0",
"@edx/paragon": ">= 7.0.0 < 21.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon": {
"version": "20.30.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.30.1.tgz",
"integrity": "sha512-v3Ek8deZWqVKi3IWP08Mj4egrvbmbqQEyRA6+qazHZdgHJA4qOP1SST42UKd9XxPeRbLWUgaJWd0iBAOAna/gw==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"peerDependencies": {
"react": "^16.8.6 || ^17.0.0",
"react-dom": "^16.8.6 || ^17.0.0",
"react-intl": "^5.25.1"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@edx/paragon/node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"dependencies": {
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@fortawesome/fontawesome-svg-core": "~1 || ~6",
"react": ">=16.x"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz",
"integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.3.0.tgz",
"integrity": "sha512-cZnwiVHZ51SVzWHOaNCIA+u9wevZjCuAGSvSYpNlm6A4H4Vhwh8481Bf/5rwheIC3fFKlgXxLKaw8Xeroz8Ntg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz",
"integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
},
"engines": {
"node": ">=6"
"react": "^16.9.0",
"react-dom": "^16.9.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/@fortawesome/react-fontawesome": {
@@ -3564,63 +3458,18 @@
"react": ">=16.3"
}
},
"node_modules/@edx/frontend-component-header/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@edx/frontend-component-header/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@edx/frontend-component-header/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@edx/frontend-component-header/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@edx/frontend-platform": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.2.0.tgz",
"integrity": "sha512-iDoFeccENQKBjqUgdjl5KSwBrjNEj8YW6Ual+6twcHHJUBg3yRoBEphwHIoRREcMgQjhdKVAdWj8eleh4JsEKA==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.6.1.tgz",
"integrity": "sha512-5ZcHBvwmJRYPPKEqv+H4DbsfW9atr5V2+uY/zX58F/Tqh0T5X8MmgppVjplWFbmon5QV4aNw5C1ViQnirNqUiA==",
"dependencies": {
"@cospired/i18n-iso-languages": "2.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
"@formatjs/intl-relativetimeformat": "10.0.1",
"axios": "0.27.2",
"axios-cache-interceptor": "0.10.7",
"axios": "0.26.1",
"axios-cache-adapter": "2.7.3",
"form-urlencoded": "4.1.4",
"glob": "7.2.3",
"glob": "7.2.0",
"history": "4.10.1",
"i18n-iso-countries": "4.3.1",
"jwt-decode": "3.1.2",
@@ -3635,58 +3484,24 @@
"universal-cookie": "4.0.4"
},
"bin": {
"intl-imports.js": "i18n/scripts/intl-imports.js",
"transifex-utils.js": "i18n/scripts/transifex-utils.js"
},
"peerDependencies": {
"@edx/paragon": ">= 10.0.0 < 21.0.0",
"prop-types": "^15.7.2",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"react": "^16.9.0",
"react-dom": "^16.9.0",
"react-redux": "^7.1.1",
"react-router-dom": "^5.0.1",
"redux": "^4.0.4"
}
},
"node_modules/@edx/frontend-platform/node_modules/axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/@edx/frontend-platform/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@edx/frontend-platform/node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"follow-redirects": "^1.14.8"
}
},
"node_modules/@edx/new-relic-source-map-webpack-plugin": {
@@ -3733,6 +3548,27 @@
"react-intl": "^5.25.1"
}
},
"node_modules/@edx/paragon/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz",
"integrity": "sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/paragon/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz",
"integrity": "sha512-Cf2mAAeMWFMzpLC7Y9H1I4o3wEU+XovVJhTiNG8ZNgSQj53yl7OCJaS80K4YjrABWZzbAHVaoHE1dVJ27AAYXw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/paragon/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -4056,89 +3892,62 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="
},
"node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz",
"integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg==",
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz",
"integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==",
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.3.0"
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz",
"integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-brands-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz",
"integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz",
"integrity": "sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.4.0"
"@fortawesome/fontawesome-common-types": "^0.2.36"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/react-fontawesome": {
"version": "0.1.18",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.18.tgz",
@@ -7677,7 +7486,8 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -7739,22 +7549,20 @@
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dev": true,
"dependencies": {
"follow-redirects": "^1.14.0"
}
},
"node_modules/axios-cache-interceptor": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-0.10.7.tgz",
"integrity": "sha512-UjpxChG5DpF6Kf1IPGMLOzRDNL8ZNS6TOn1jTaVvCE7cWFU904jJwi0T1s+IbijpnLEjK2iq5uLIuR8Sj+RsFQ==",
"node_modules/axios-cache-adapter": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/axios-cache-adapter/-/axios-cache-adapter-2.7.3.tgz",
"integrity": "sha512-A+ZKJ9lhpjthOEp4Z3QR/a9xC4du1ALaAsejgRGrH9ef6kSDxdFrhRpulqsh9khsEnwXxGfgpUuDp1YXMNMEiQ==",
"dependencies": {
"cache-parser": "^1.2.4",
"fast-defer": "^1.1.7",
"object-code": "^1.2.4"
"cache-control-esm": "1.0.0",
"md5": "^2.2.1"
},
"funding": {
"url": "https://github.com/ArthurFiorette/axios-cache-interceptor?sponsor=1"
"peerDependencies": {
"axios": "~0.21.1"
}
},
"node_modules/axios-mock-adapter": {
@@ -8910,10 +8718,10 @@
"node": ">=0.10.0"
}
},
"node_modules/cache-parser": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.4.tgz",
"integrity": "sha512-O0KwuHuJnbHUrghHi2kGp0SxnWSIBXTYt7M8WVhW0kbPRUNUKoE/Of6e1rRD6AAxmfxFunKnt90yEK09D+sc5g=="
"node_modules/cache-control-esm": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cache-control-esm/-/cache-control-esm-1.0.0.tgz",
"integrity": "sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g=="
},
"node_modules/cacheable-request": {
"version": "2.1.4",
@@ -9146,6 +8954,14 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"node_modules/charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA==",
"engines": {
"node": "*"
}
},
"node_modules/check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@@ -9588,6 +9404,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -9886,6 +9703,14 @@
"semver": "bin/semver"
}
},
"node_modules/crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==",
"engines": {
"node": "*"
}
},
"node_modules/css": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
@@ -10681,6 +10506,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -12523,11 +12349,6 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-defer": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.7.tgz",
"integrity": "sha512-tJ01ulDWT2WhqxMKS20nXX6wyX2iInBYpbN3GO7yjKwXMY4qvkdBRxak9IFwBLlFDESox+SwSvqMCZDfe1tqeg=="
},
"node_modules/fast-glob": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
@@ -13596,7 +13417,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -19883,6 +19703,21 @@
"css-mediaquery": "^0.1.2"
}
},
"node_modules/md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"dependencies": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
}
},
"node_modules/md5/node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -20460,11 +20295,6 @@
"node": ">=0.10.0"
}
},
"node_modules/object-code": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.2.4.tgz",
"integrity": "sha512-uGq4ETUuWe+GA586NXEriiaozNuff+YNFXlpD8cVrM1GoiuTZpCABP+bZCWDrvQDoCiSTyiWAFHD/HF/iwhb2w=="
},
"node_modules/object-copy": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
@@ -22455,15 +22285,6 @@
"react": "^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-dev-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz",
@@ -30201,30 +30022,17 @@
}
},
"@edx/frontend-component-footer": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.0.0.tgz",
"integrity": "sha512-m8Rx6ZPWzIN5XLrz6Ft3aTuFo0rty0jECd79CBYWdm0D9KD1WxoYEG+fElluyOQp/t42T5jLImHTSWjFURx5kw==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-11.2.0.tgz",
"integrity": "sha512-prN6SeoWenbNq6jCqlpmYx57OhNbVGw1wjQFVH+aA5JMjjLUf6PmjPG87fuD9udQyFteb2lvbCyongPEZOozrg==",
"requires": {
"@fortawesome/fontawesome-svg-core": "6.4.0",
"@fortawesome/free-brands-svg-icons": "6.4.0",
"@fortawesome/free-regular-svg-icons": "6.4.0",
"@fortawesome/free-solid-svg-icons": "6.4.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "0.2.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.4.0.tgz",
"integrity": "sha512-Bertv8xOiVELz5raB2FlXDPKt+m94MQ3JgDfsVbrqNpLU9+UE2E18GKjLKw+d3XbeYPqg1pzyQKGsrzbw+pPaw==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.0"
}
},
"@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
@@ -30236,87 +30044,20 @@
}
},
"@edx/frontend-component-header": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.0.0.tgz",
"integrity": "sha512-r/L3p2ZSI1DitjxVKAor18GmgJllafYslrdpzGI0vcX/gTemH13jf2Xr9iQqrT921DP2nzZ5GOwGJNptTSjiaA==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-3.2.0.tgz",
"integrity": "sha512-ep9yOwe9CG03lSf/QBsBOZO3S/Ksrttgi4MkjpUe/6Sm/KdAnMCOhx2Gbj9jBcFrHqCkpeef26zGjjhpHGDwkQ==",
"requires": {
"@edx/paragon": "20.30.1",
"@fortawesome/fontawesome-svg-core": "6.3.0",
"@fortawesome/free-brands-svg-icons": "6.3.0",
"@fortawesome/free-regular-svg-icons": "6.3.0",
"@fortawesome/free-solid-svg-icons": "6.3.0",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@fortawesome/free-brands-svg-icons": "5.15.4",
"@fortawesome/free-regular-svg-icons": "5.15.4",
"@fortawesome/free-solid-svg-icons": "5.15.4",
"@fortawesome/react-fontawesome": "^0.2.0",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
},
"dependencies": {
"@edx/paragon": {
"version": "20.30.1",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.30.1.tgz",
"integrity": "sha512-v3Ek8deZWqVKi3IWP08Mj4egrvbmbqQEyRA6+qazHZdgHJA4qOP1SST42UKd9XxPeRbLWUgaJWd0iBAOAna/gw==",
"requires": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
"@popperjs/core": "^2.11.4",
"bootstrap": "^4.6.2",
"classnames": "^2.3.1",
"email-prop-type": "^3.0.0",
"file-selector": "^0.6.0",
"font-awesome": "^4.7.0",
"glob": "^8.0.3",
"lodash.uniqby": "^4.7.0",
"mailto-link": "^2.0.0",
"prop-types": "^15.8.1",
"react-bootstrap": "^1.6.5",
"react-colorful": "^5.6.1",
"react-dropzone": "^14.2.1",
"react-focus-on": "^3.5.4",
"react-loading-skeleton": "^3.1.0",
"react-popper": "^2.2.5",
"react-proptype-conditional-require": "^1.0.4",
"react-responsive": "^8.2.0",
"react-table": "^7.7.0",
"react-transition-group": "^4.4.2",
"tabbable": "^5.3.3",
"uncontrollable": "^7.2.1",
"uuid": "^9.0.0"
},
"dependencies": {
"@fortawesome/react-fontawesome": {
"version": "0.1.19",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.1.19.tgz",
"integrity": "sha512-Hyb+lB8T18cvLNX0S3llz7PcSOAJMLwiVKBuuzwM/nI5uoBw+gQjnf9il0fR1C3DKOI5Kc79pkJ4/xB0Uw9aFQ==",
"requires": {
"prop-types": "^15.8.1"
}
}
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.3.0.tgz",
"integrity": "sha512-xI0c+a8xnKItAXCN8rZgCNCJQiVAd2Y7p9e2ND6zN3J3ekneu96qrePieJ7yA7073C1JxxoM3vH1RU7rYsaj8w==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/free-regular-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.3.0.tgz",
"integrity": "sha512-cZnwiVHZ51SVzWHOaNCIA+u9wevZjCuAGSvSYpNlm6A4H4Vhwh8481Bf/5rwheIC3fFKlgXxLKaw8Xeroz8Ntg==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.3.0.tgz",
"integrity": "sha512-x5tMwzF2lTH8pyv8yeZRodItP2IVlzzmBuD1M7BjawWgg9XAvktqJJ91Qjgoaf8qJpHQ8FEU9VxRfOkLhh86QA==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
}
},
"@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
@@ -30324,54 +30065,21 @@
"requires": {
"prop-types": "^15.8.1"
}
},
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"requires": {
"balanced-match": "^1.0.0"
}
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
}
},
"minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"requires": {
"brace-expansion": "^2.0.1"
}
},
"uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg=="
}
}
},
"@edx/frontend-platform": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.2.0.tgz",
"integrity": "sha512-iDoFeccENQKBjqUgdjl5KSwBrjNEj8YW6Ual+6twcHHJUBg3yRoBEphwHIoRREcMgQjhdKVAdWj8eleh4JsEKA==",
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-2.6.1.tgz",
"integrity": "sha512-5ZcHBvwmJRYPPKEqv+H4DbsfW9atr5V2+uY/zX58F/Tqh0T5X8MmgppVjplWFbmon5QV4aNw5C1ViQnirNqUiA==",
"requires": {
"@cospired/i18n-iso-languages": "2.2.0",
"@formatjs/intl-pluralrules": "4.3.3",
"@formatjs/intl-relativetimeformat": "10.0.1",
"axios": "0.27.2",
"axios-cache-interceptor": "0.10.7",
"axios": "0.26.1",
"axios-cache-adapter": "2.7.3",
"form-urlencoded": "4.1.4",
"glob": "7.2.3",
"glob": "7.2.0",
"history": "4.10.1",
"i18n-iso-countries": "4.3.1",
"jwt-decode": "3.1.2",
@@ -30387,35 +30095,11 @@
},
"dependencies": {
"axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz",
"integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==",
"requires": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
},
"glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"follow-redirects": "^1.14.8"
}
}
}
@@ -30459,6 +30143,19 @@
"uncontrollable": "^7.2.1"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz",
"integrity": "sha512-rBevIsj2nclStJ7AxTdfsa3ovHb1H+qApwrxcTVo+NNdeJiB9V75hsKfrkG5AwNcRUNxrPPiScGYCNmLMoh8pg=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.2.0.tgz",
"integrity": "sha512-Cf2mAAeMWFMzpLC7Y9H1I4o3wEU+XovVJhTiNG8ZNgSQj53yl7OCJaS80K4YjrABWZzbAHVaoHE1dVJ27AAYXw==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.2.0"
}
},
"brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@@ -30782,61 +30479,40 @@
}
},
"@fortawesome/fontawesome-common-types": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.3.0.tgz",
"integrity": "sha512-4BC1NMoacEBzSXRwKjZ/X/gmnbp/HU5Qqat7E8xqorUtBFZS+bwfGH5/wqOC2K6GV0rgEobp3OjGRMa5fK9pFg=="
"version": "0.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz",
"integrity": "sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg=="
},
"@fortawesome/fontawesome-svg-core": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.3.0.tgz",
"integrity": "sha512-uz9YifyKlixV6AcKlOX8WNdtF7l6nakGyLYxYaCa823bEBqyj/U2ssqtctO38itNEwXb8/lMzjdoJ+aaJuOdrw==",
"version": "1.2.36",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz",
"integrity": "sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.3.0"
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/free-brands-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.4.0.tgz",
"integrity": "sha512-qvxTCo0FQ5k2N+VCXb/PZQ+QMhqRVM4OORiO6MXdG6bKolIojGU/srQ1ptvKk0JTbRgaJOfL2qMqGvBEZG7Z6g==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-5.15.4.tgz",
"integrity": "sha512-f1witbwycL9cTENJegcmcZRYyawAFbm8+c6IirLmwbbpqz46wyjbQYLuxOc7weXFXfB7QR8/Vd2u5R3q6JYD9g==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
}
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/free-regular-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.4.0.tgz",
"integrity": "sha512-ZfycI7D0KWPZtf7wtMFnQxs8qjBXArRzczABuMQqecA/nXohquJ5J/RCR77PmY5qGWkxAZDxpnUFVXKwtY/jPw==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz",
"integrity": "sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
}
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/free-solid-svg-icons": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.4.0.tgz",
"integrity": "sha512-kutPeRGWm8V5dltFP1zGjQOEAzaLZj4StdQhWVZnfGFCvAPVvHh8qk5bRrU4KXnRRRNni5tKQI9PBAdI6MP8nQ==",
"version": "5.15.4",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz",
"integrity": "sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w==",
"requires": {
"@fortawesome/fontawesome-common-types": "6.4.0"
},
"dependencies": {
"@fortawesome/fontawesome-common-types": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.4.0.tgz",
"integrity": "sha512-HNii132xfomg5QVZw0HwXXpN22s7VBHQBv9CeOu9tfJnhsWQNd2lmTNi8CSrnw5B+5YOmzu1UoPAyxaXsJ6RgQ=="
}
"@fortawesome/fontawesome-common-types": "^0.2.36"
}
},
"@fortawesome/react-fontawesome": {
@@ -33583,7 +33259,8 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
},
"at-least-node": {
"version": "1.0.0",
@@ -33620,19 +33297,17 @@
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
"dev": true,
"requires": {
"follow-redirects": "^1.14.0"
}
},
"axios-cache-interceptor": {
"version": "0.10.7",
"resolved": "https://registry.npmjs.org/axios-cache-interceptor/-/axios-cache-interceptor-0.10.7.tgz",
"integrity": "sha512-UjpxChG5DpF6Kf1IPGMLOzRDNL8ZNS6TOn1jTaVvCE7cWFU904jJwi0T1s+IbijpnLEjK2iq5uLIuR8Sj+RsFQ==",
"axios-cache-adapter": {
"version": "2.7.3",
"resolved": "https://registry.npmjs.org/axios-cache-adapter/-/axios-cache-adapter-2.7.3.tgz",
"integrity": "sha512-A+ZKJ9lhpjthOEp4Z3QR/a9xC4du1ALaAsejgRGrH9ef6kSDxdFrhRpulqsh9khsEnwXxGfgpUuDp1YXMNMEiQ==",
"requires": {
"cache-parser": "^1.2.4",
"fast-defer": "^1.1.7",
"object-code": "^1.2.4"
"cache-control-esm": "1.0.0",
"md5": "^2.2.1"
}
},
"axios-mock-adapter": {
@@ -34552,10 +34227,10 @@
"unset-value": "^1.0.0"
}
},
"cache-parser": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/cache-parser/-/cache-parser-1.2.4.tgz",
"integrity": "sha512-O0KwuHuJnbHUrghHi2kGp0SxnWSIBXTYt7M8WVhW0kbPRUNUKoE/Of6e1rRD6AAxmfxFunKnt90yEK09D+sc5g=="
"cache-control-esm": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/cache-control-esm/-/cache-control-esm-1.0.0.tgz",
"integrity": "sha512-Fa3UV4+eIk4EOih8FTV6EEsVKO0W5XWtNs6FC3InTfVz+EjurjPfDXY5wZDo/lxjDxg5RjNcurLyxEJBcEUx9g=="
},
"cacheable-request": {
"version": "2.1.4",
@@ -34740,6 +34415,11 @@
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==",
"dev": true
},
"charenc": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/charenc/-/charenc-0.0.2.tgz",
"integrity": "sha512-yrLQ/yVUFXkzg7EDQsPieE/53+0RlaWTs+wBrvW36cyilJ2SaDWfl4Yj7MtLTXleV9uEKefbAGUPv2/iWSooRA=="
},
"check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
@@ -35098,6 +34778,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@@ -35331,6 +35012,11 @@
}
}
},
"crypt": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/crypt/-/crypt-0.0.2.tgz",
"integrity": "sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow=="
},
"css": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
@@ -35934,7 +35620,8 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true
},
"depd": {
"version": "2.0.0",
@@ -37400,11 +37087,6 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"fast-defer": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.7.tgz",
"integrity": "sha512-tJ01ulDWT2WhqxMKS20nXX6wyX2iInBYpbN3GO7yjKwXMY4qvkdBRxak9IFwBLlFDESox+SwSvqMCZDfe1tqeg=="
},
"fast-glob": {
"version": "3.2.11",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
@@ -38188,7 +37870,6 @@
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
"integrity": "sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==",
"dev": true,
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -42978,6 +42659,23 @@
"css-mediaquery": "^0.1.2"
}
},
"md5": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/md5/-/md5-2.3.0.tgz",
"integrity": "sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==",
"requires": {
"charenc": "0.0.2",
"crypt": "0.0.2",
"is-buffer": "~1.1.6"
},
"dependencies": {
"is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
}
}
},
"mdn-data": {
"version": "2.0.14",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
@@ -43425,11 +43123,6 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="
},
"object-code": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/object-code/-/object-code-1.2.4.tgz",
"integrity": "sha512-uGq4ETUuWe+GA586NXEriiaozNuff+YNFXlpD8cVrM1GoiuTZpCABP+bZCWDrvQDoCiSTyiWAFHD/HF/iwhb2w=="
},
"object-copy": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz",
@@ -44842,12 +44535,6 @@
"@babel/runtime": "^7.12.13"
}
},
"react-colorful": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz",
"integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==",
"requires": {}
},
"react-dev-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.0.tgz",

View File

@@ -34,9 +34,9 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "12.0.0",
"@edx/frontend-component-header": "4.0.0",
"@edx/frontend-platform": "4.2.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/paragon": "20.15.0",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",

View File

@@ -120,28 +120,27 @@ function FilterBar({
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
key={value.name}
name={value.name}
className="d-flex flex-column list-group list-group-flush"
value={selectedFilters[value.name]}
onChange={onFilterChange}
>
{
value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})
}
{value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
key={element.id}
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})}
</Form.RadioSet>
))}
</div>

View File

@@ -0,0 +1,3 @@
/* eslint-disable import/prefer-default-export */
export const selectCourseTabs = state => state.courseTabs;

View File

@@ -37,9 +37,10 @@ function PostPreviewPane({
&& (
<Button
variant="link"
size="md"
size="sm"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
className={`text-primary-500 p-0 ${editExisting && 'mb-4.5'}`}
style={{ lineHeight: '26px' }}
>
{intl.formatMessage(messages.showPreviewButton)}
</Button>

View File

@@ -32,6 +32,7 @@ import 'tinymce/plugins/lists';
import 'tinymce/plugins/emoticons';
import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/charmap';
import 'tinymce/plugins/paste';
/* eslint import/no-webpack-loader-syntax: off */
// eslint-disable-next-line import/no-unresolved
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
@@ -100,12 +101,13 @@ export default function TinyMCEEditor(props) {
skin: false,
menubar: false,
branding: false,
paste_data_images: false,
contextmenu: false,
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
autosave_restore_when_empty: false,
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap',
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap paste',
toolbar: 'undo redo'
+ ' | formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
@@ -117,6 +119,7 @@ export default function TinyMCEEditor(props) {
content_css: false,
content_style: contentStyle,
body_class: 'm-2 text-editor',
relative_urls: false,
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler,

View File

@@ -190,6 +190,8 @@ export const Routes = {
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`,
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
},
};

View File

@@ -38,12 +38,6 @@ export const selectTopicsUnderCategory = createSelector(
),
);
export const selectSequences = createSelector(
selectChapters,
selectBlocks,
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
);
export const selectArchivedTopics = createSelector(
state => state.topics.topics,
state => state.topics.archivedIds || [],

View File

@@ -77,47 +77,65 @@ function DiscussionCommentsView({
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
const endorsedComments = useMemo(() => [...filterPosts(comments, 'endorsed')], [comments]);
const unEndorsedComments = useMemo(() => [...filterPosts(comments, 'unendorsed')], [comments]);
const handleDefinition = (message, commentsLength) => (
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
{intl.formatMessage(message, { num: commentsLength })}
</div>
);
const handleComments = (postComments, showAddResponse = false, showLoadMoreResponses = false) => (
<div className="mx-4" role="list">
{postComments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
))}
{hasMorePages && !isLoading && !showLoadMoreResponses && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4 mb-4 font-weight-500 font-size-14"
style={{
lineHeight: '20px',
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading && !showLoadMoreResponses && (
<div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" />
</div>
)}
{!!postComments.length && !isClosed && showAddResponse
&& <ResponseEditor postId={postId} addWrappingDiv />}
</div>
);
return (
<>
{((hasMorePages && isLoading) || !isLoading)
&& (
<div className="mx-4 text-primary-700" role="heading" aria-level="2" style={{ lineHeight: '28px' }}>
{endorsed === EndorsementStatus.ENDORSED
? intl.formatMessage(messages.endorsedResponseCount, { num: sortedComments.length })
: intl.formatMessage(messages.responseCount, { num: sortedComments.length })}
</div>
{((hasMorePages && isLoading) || !isLoading) && (
<>
{endorsedComments.length > 0 && (
<>
{handleDefinition(messages.endorsedResponseCount, endorsedComments.length)}
{endorsed === EndorsementStatus.DISCUSSION
? handleComments(endorsedComments, false, true)
: handleComments(endorsedComments)}
</>
)}
{endorsed !== EndorsementStatus.ENDORSED && (
<>
{handleDefinition(messages.responseCount, unEndorsedComments.length)}
{unEndorsedComments.length === 0 && <br />}
{handleComments(unEndorsedComments, true)}
</>
)}
</>
)}
<div className="mx-4" role="list">
{sortedComments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
))}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4 mb-4 font-weight-500 font-size-14"
style={{
lineHeight: '20px',
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
&& (
<div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" />
</div>
)}
{!!sortedComments.length && !isClosed
&& <ResponseEditor postId={postId} addWrappingDiv />}
</div>
</>
);
@@ -141,7 +159,7 @@ function CommentsView({ intl }) {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const {
courseId, learnerUsername, category, topicId, page, inContext,
courseId, learnerUsername, category, topicId, page, enableInContextSidebar,
} = useContext(DiscussionContext);
useEffect(() => {
@@ -162,7 +180,7 @@ function CommentsView({ intl }) {
return (
<>
{!isOnDesktop && (
inContext ? (
enableInContextSidebar ? (
<>
<div className="px-4 py-1.5 bg-white">
<Button
@@ -194,12 +212,12 @@ function CommentsView({ intl }) {
)
)}
<div className={classNames('discussion-comments d-flex flex-column card', {
'm-4 p-4.5': !inContext,
'p-4 rounded-0 border-0 mb-4': inContext,
'm-4 p-4.5': !enableInContextSidebar,
'p-4 rounded-0 border-0 mb-4': enableInContextSidebar,
})}
>
<Post post={thread} />
{!thread.closed && <ResponseEditor postId={postId} /> }
{!thread.closed && <ResponseEditor postId={postId} />}
</div>
{thread.type === ThreadType.DISCUSSION && (
<DiscussionCommentsView

View File

@@ -384,6 +384,7 @@ describe('CommentsView', () => {
});
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
});
it('should allow pinning the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
@@ -397,6 +398,7 @@ describe('CommentsView', () => {
});
assertLastUpdateData({ pinned: false });
});
it('should allow reporting the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
@@ -408,6 +410,11 @@ describe('CommentsView', () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /report/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
assertLastUpdateData({ abuse_flagged: true });
});
@@ -426,12 +433,8 @@ describe('CommentsView', () => {
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
});
it.each([
['endorsing comments', 'Endorse', { endorsed: true }],
['reporting comments', 'Report', { abuse_flagged: true }],
])('handles %s', async (label, buttonLabel, patchData) => {
it('handles endorsing comments', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await screen.findByText('comment number 7', { exact: false });
@@ -441,11 +444,36 @@ describe('CommentsView', () => {
await act(async () => {
fireEvent.click(actionButtons[1]);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
});
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
});
it('handles reporting comments', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await screen.findByText('comment number 7', { exact: false });
// There should be three buttons, one for the post, the second for the
// comment and the third for a response to that comment
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionButtons[1]);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
});
});
@@ -627,12 +655,8 @@ describe('CommentsView', () => {
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ voted: true });
});
it.each([
['endorsing comments', 'Endorse', { endorsed: true }],
['reporting comments', 'Report', { abuse_flagged: true }],
])('handles %s', async (label, buttonLabel, patchData) => {
it('handles endorsing comments', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await screen.findByText('comment number 7', { exact: false });
@@ -642,11 +666,36 @@ describe('CommentsView', () => {
await act(async () => {
fireEvent.click(actionButtons[1]);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: buttonLabel }));
fireEvent.click(screen.getByRole('button', { name: /Endorse/i }));
});
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ endorsed: true });
});
it('handles reporting comments', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await screen.findByText('comment number 7', { exact: false });
// There should be three buttons, one for the post, the second for the
// comment and the third for a response to that comment
const actionButtons = screen.queryAllByRole('button', { name: /actions menu/i });
await act(async () => {
fireEvent.click(actionButtons[1]);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /Report/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /Confirm/i }));
});
expect(screen.queryByRole('dialog', { name: /Report \w+/i, exact: false })).not.toBeInTheDocument();
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject({ abuse_flagged: true });
});
});

View File

@@ -9,11 +9,10 @@ import { Button, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants';
import { AlertBanner, DeleteConfirmation, EndorsedAlertBanner } from '../../common';
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
import { DiscussionContext } from '../../common/context';
import { selectBlackoutDate } from '../../data/selectors';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { fetchThread } from '../../posts/data/thunks';
import { inBlackoutDateRange } from '../../utils';
import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
@@ -36,10 +35,11 @@ function Comment({
const inlineReplies = useSelector(selectCommentResponses(comment.id));
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isReplying, setReplying] = useState(false);
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const blackoutDateRange = useSelector(selectBlackoutDate);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const {
courseId,
} = useContext(DiscussionContext);
@@ -50,6 +50,24 @@ function Comment({
}
}, [comment.id]);
const handleAbusedFlag = () => {
if (comment.abuseFlagged) {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
} else {
showReportConfirmation();
}
};
const handleDeleteConfirmation = () => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
};
const handleReportConfirmation = () => {
dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged }));
hideReportConfirmation();
};
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: async () => {
@@ -57,7 +75,7 @@ function Comment({
await dispatch(fetchThread(comment.threadId, courseId));
},
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
[ContentActions.REPORT]: () => handleAbusedFlag(),
};
const handleLoadMoreComments = () => (
@@ -67,16 +85,25 @@ function Comment({
return (
<div className={classNames({ 'py-2 my-3': showFullThread })}>
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
<DeleteConfirmation
<Confirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!comment.abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportResponseTitle)}
description={intl.formatMessage(messages.reportResponseDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
<EndorsedAlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column p-4.5">
<AlertBanner content={comment} />
@@ -130,18 +157,18 @@ function Comment({
/>
) : (
<>
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
{!isClosedPost && userCanAddThreadInBlackoutDate
&& (
<Button
className="d-flex flex-grow mt-3 py-2 font-size-14"
variant="outline-primary"
style={{
lineHeight: '20px',
}}
onClick={() => setReplying(true)}
>
{intl.formatMessage(messages.addComment)}
</Button>
<Button
className="d-flex flex-grow mt-3 py-2 font-size-14"
variant="outline-primary"
style={{
lineHeight: '20px',
}}
onClick={() => setReplying(true)}
>
{intl.formatMessage(messages.addComment)}
</Button>
)}
</>

View File

@@ -66,19 +66,15 @@ function CommentHeader({
<div className="d-flex align-items-center">
{actionIcons && (
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
<Icon
data-testid="check-icon"
onClick={
() => {
handleIcons(actionIcons.action);
}
}
src={actionIcons.icon}
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
/>
</span>
<span className="btn-icon btn-icon-sm mr-1 align-items-center pointer-cursor-hover">
<Icon
data-testid="check-icon"
onClick={() => handleIcons(actionIcons.action)}
src={actionIcons.icon}
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
/>
</span>
)}
<ActionsDropdown

View File

@@ -10,7 +10,7 @@ import { Avatar, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
import {
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
ActionsDropdown, AlertBanner, AuthorLabel, Confirmation,
} from '../../common';
import timeLocale from '../../common/time-locale';
import { useAlertBannerVisible } from '../../data/hooks';
@@ -29,6 +29,26 @@ function Reply({
const dispatch = useDispatch();
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const handleAbusedFlag = () => {
if (reply.abuseFlagged) {
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
} else {
showReportConfirmation();
}
};
const handleDeleteConfirmation = () => {
dispatch(removeComment(reply.id));
hideDeleteConfirmation();
};
const handleReportConfirmation = () => {
dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged }));
hideReportConfirmation();
};
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment(
@@ -37,7 +57,7 @@ function Reply({
ContentActions.ENDORSE,
)),
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
[ContentActions.REPORT]: () => handleAbusedFlag(),
};
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
@@ -45,17 +65,25 @@ function Reply({
return (
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
<DeleteConfirmation
<Confirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)}
description={intl.formatMessage(messages.deleteCommentDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(reply.id));
hideDeleteConfirmation();
}}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!reply.abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportCommentTitle)}
description={intl.formatMessage(messages.reportCommentDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
{hasAnyAlert && (
<div className="d-flex">
<div className="d-flex invisible">
@@ -85,13 +113,15 @@ function Reply({
>
<div className="d-flex flex-row justify-content-between align-items-center mb-0.5">
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} linkToProfile />
<ActionsDropdown
commentOrPost={{
...reply,
postType,
}}
actionHandlers={actionHandlers}
/>
<div className="ml-auto d-flex">
<ActionsDropdown
commentOrPost={{
...reply,
postType,
}}
actionHandlers={actionHandlers}
/>
</div>
</div>
{isEditing
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />

View File

@@ -2,14 +2,12 @@ import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { DiscussionContext } from '../../common/context';
import { selectBlackoutDate } from '../../data/selectors';
import { inBlackoutDateRange } from '../../utils';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import messages from '../messages';
import CommentEditor from './CommentEditor';
@@ -18,15 +16,14 @@ function ResponseEditor({
intl,
addWrappingDiv,
}) {
const { inContext } = useContext(DiscussionContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const [addingResponse, setAddingResponse] = useState(false);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
useEffect(() => {
setAddingResponse(false);
}, [postId]);
const blackoutDateRange = useSelector(selectBlackoutDate);
return addingResponse
? (
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
@@ -37,11 +34,11 @@ function ResponseEditor({
/>
</div>
)
: !inBlackoutDateRange(blackoutDateRange) && (
: userCanAddThreadInBlackoutDate && (
<div className={classNames({ 'mb-4': addWrappingDiv }, 'actions d-flex')}>
<Button
variant="primary"
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': inContext })}
className={classNames('px-2.5 py-2 font-size-14', { 'w-100': enableInContextSidebar })}
onClick={() => setAddingResponse(true)}
style={{
lineHeight: '20px',

View File

@@ -148,6 +148,31 @@ const messages = defineMessages({
defaultMessage: 'Are you sure you want to permanently delete this comment?',
description: 'Text displayed in confirmation dialog when deleting a comment',
},
deleteConfirmationDelete: {
id: 'discussions.delete.confirmation.button.delete',
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
reportResponseTitle: {
id: 'discussions.editor.response.response.title',
defaultMessage: 'Report inappropriate content?',
description: 'Title of confirmation dialog shown when reporting a response',
},
reportResponseDescription: {
id: 'discussions.editor.response.description',
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
description: 'Text displayed in confirmation dialog when deleting a response',
},
reportCommentTitle: {
id: 'discussions.editor.report.comment.title',
defaultMessage: 'Report inappropriate content?',
description: 'Title of confirmation dialog shown when reporting a comment',
},
reportCommentDescription: {
id: 'discussions.editor.report.comment.description',
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
description: 'Text displayed in confirmation dialog when deleting a response',
},
editReasonCode: {
id: 'discussions.editor.comments.editReasonCode',
defaultMessage: 'Reason for editing',

View File

@@ -27,7 +27,7 @@ function ActionsDropdown({
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const { inContext } = useContext(DiscussionContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const handleActions = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
@@ -52,37 +52,39 @@ function ActionsDropdown({
size="sm"
ref={setTarget}
/>
<ModalPopup
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={inContext ? 'left' : 'auto-start'}
>
<div
className="bg-white p-1 shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
<div className="actions-dropdown">
<ModalPopup
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={enableInContextSidebar ? 'left' : 'auto-start'}
>
{actions.map(action => (
<React.Fragment key={action.id}>
{action.action === ContentActions.DELETE
<div
className="bg-white p-1 shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE)
&& <Dropdown.Divider />}
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start py-1.5 mr-4"
>
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start py-1.5 mr-4"
>
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
</div>
</>
);
}

View File

@@ -6,11 +6,13 @@ import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { Report } from '@edx/paragon/icons';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
import {
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
} from '../data/selectors';
import { postShape } from '../posts/post/proptypes';
import AuthorLabel from './AuthorLabel';
@@ -20,15 +22,18 @@ function AlertBanner({
}) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsGlobalStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content?.abuseFlagged;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|| userIsGlobalStaff || userIsContentAuthor
);
return (
<>
{canSeeReportedBanner && (
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
<Alert icon={Report} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
{intl.formatMessage(messages.abuseFlaggedMessage)}
</Alert>
)}

View File

@@ -38,7 +38,7 @@ function AuthorLabel({
const isRetiredUser = author ? author.startsWith('retired__user') : false;
const className = classNames('d-flex align-items-center', labelColor);
const className = classNames('d-flex align-items-center mb-0.5', labelColor);
const showUserNameAsLink = useShowLearnersTab()
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);

View File

@@ -6,13 +6,16 @@ import { ActionRow, Button, ModalDialog } from '@edx/paragon';
import messages from '../messages';
function DeleteConfirmation({
function Confirmation({
intl,
isOpen,
title,
description,
onClose,
onDelete,
comfirmAction,
closeButtonVaraint,
confirmButtonVariant,
confirmButtonText,
}) {
return (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
@@ -26,11 +29,11 @@ function DeleteConfirmation({
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.deleteConfirmationCancel)}
<ModalDialog.CloseButton variant={closeButtonVaraint}>
{intl.formatMessage(messages.confirmationCancel)}
</ModalDialog.CloseButton>
<Button variant="primary" onClick={onDelete}>
{intl.formatMessage(messages.deleteConfirmationDelete)}
<Button variant={confirmButtonVariant} onClick={comfirmAction}>
{ confirmButtonText || intl.formatMessage(messages.confirmationConfirm)}
</Button>
</ActionRow>
</ModalDialog.Footer>
@@ -38,13 +41,22 @@ function DeleteConfirmation({
);
}
DeleteConfirmation.propTypes = {
Confirmation.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
comfirmAction: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
closeButtonVaraint: PropTypes.string,
confirmButtonVariant: PropTypes.string,
confirmButtonText: PropTypes.string,
};
export default injectIntl(DeleteConfirmation);
Confirmation.defaultProps = {
closeButtonVaraint: 'default',
confirmButtonVariant: 'primary',
confirmButtonText: '',
};
export default injectIntl(Confirmation);

View File

@@ -6,7 +6,7 @@ export const DiscussionContext = React.createContext({
courseId: null,
postId: null,
topicId: null,
inContext: false,
enableInContextSidebar: false,
category: null,
learnerUsername: null,
});

View File

@@ -1,5 +1,5 @@
export { default as ActionsDropdown } from './ActionsDropdown';
export { default as AlertBanner } from './AlertBanner';
export { default as AuthorLabel } from './AuthorLabel';
export { default as DeleteConfirmation } from './DeleteConfirmation';
export { default as Confirmation } from './Confirmation';
export { default as EndorsedAlertBanner } from './EndorsedAlertBanner';

View File

@@ -1,6 +1,9 @@
/* eslint-disable import/prefer-default-export */
import {
useContext, useEffect, useRef, useState,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -10,20 +13,30 @@ import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { AppContext } from '@edx/frontend-platform/react';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { Routes } from '../../data/constants';
import { RequestStatus, Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { DiscussionContext } from '../common/context';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { discussionsPath } from '../utils';
import tourCheckpoints from '../tours/constants';
import { selectTours } from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath, inBlackoutDateRange } from '../utils';
import {
selectAreThreadsFiltered, selectLearnersTabEnabled,
selectAreThreadsFiltered,
selectBlackoutDate,
selectEnableInContext,
selectIsCourseAdmin,
selectIsCourseStaff,
selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from './selectors';
import { fetchCourseConfig } from './thunks';
@@ -34,27 +47,28 @@ export function useTotalTopicThreadCount() {
return 0;
}
return Object.keys(topics).reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
return Object.keys(topics)
.reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
}
export const useSidebarVisible = () => {
const enableInContext = useSelector(selectEnableInContext);
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
if (isFiltered) {
if (isIncontextTopicsView) {
return true;
}
if (isViewingTopics || isViewingLearners) {
return true;
}
return totalThreads > 0;
return !hideSidebar;
};
export function useCourseDiscussionData(courseId) {
@@ -64,7 +78,6 @@ export function useCourseDiscussionData(courseId) {
useEffect(() => {
async function fetchBaseData() {
await dispatch(fetchCourseConfig(courseId));
await dispatch(fetchCourseTopics(courseId));
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
}
@@ -72,7 +85,7 @@ export function useCourseDiscussionData(courseId) {
}, [courseId]);
}
export function useRedirectToThread(courseId, inContext) {
export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
@@ -85,7 +98,7 @@ export function useRedirectToThread(courseId, inContext) {
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
@@ -97,7 +110,7 @@ export function useRedirectToThread(courseId, inContext) {
export function useIsOnDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.large.minWidth;
return windowSize.width >= breakpoints.medium.minWidth;
}
export function useIsOnXLDesktop() {
@@ -174,3 +187,40 @@ export const useCurrentDiscussionTopic = () => {
}
return null;
};
export const useUserCanAddThreadInBlackoutDate = () => {
const blackoutDateRange = useSelector(selectBlackoutDate);
const isUserAdmin = useSelector(selectUserIsStaff);
const userHasModerationPrivilages = useSelector(selectUserHasModerationPrivileges);
const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff);
const isInBlackoutDateRange = inBlackoutDateRange(blackoutDateRange);
return (!(isInBlackoutDateRange)
|| (isUserAdmin || userHasModerationPrivilages || isUserGroupTA || isCourseAdmin || isCourseStaff));
};
function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
}
export const useTourConfiguration = (intl) => {
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const tours = useSelector(selectTours);
return tours.map((tour) => (
{
tourId: tour.tourName,
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.showTour && !enableInContextSidebar),
onDismiss: () => dispatch(updateTourShowStatus(tour.id)),
onEnd: () => dispatch(updateTourShowStatus(tour.id)),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
));
};

View File

@@ -1,15 +1,33 @@
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { useCurrentDiscussionTopic } from './hooks';
import { getCourseConfigApiUrl } from './api';
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
import { fetchCourseConfig } from './thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
const courseConfigApiUrl = getCourseConfigApiUrl();
let store;
initializeMockApp();
let axiosMock;
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
blackouts,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin,
isCourseStaff: false,
isUserAdmin: false,
});
describe('Hooks', () => {
describe('useCurrentDiscussionTopic', () => {
function ComponentWithHook() {
@@ -39,6 +57,7 @@ describe('Hooks', () => {
}
beforeEach(() => {
initializeMockApp();
store = initializeStore({
blocks: {
blocks: {
@@ -82,4 +101,75 @@ describe('Hooks', () => {
expect(queryByText('null')).toBeInTheDocument();
});
});
describe('useUserCanAddThreadInBlackoutDate', () => {
function ComponentWithHook() {
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
return (
<div>
{String(userCanAddThreadInBlackoutDate)}
</div>
);
}
function renderComponent() {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ComponentWithHook />
</AppProvider>
</IntlProvider>,
);
}
describe('User can add Thread in blackoutdates ', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
Factory.resetAll();
store = initializeStore();
});
test('when blackoutdates are not active and Role is Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([], false));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
test('when blackoutdates are not active and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([], true));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
test('when blackoutdates are active and Role is Learner return false', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([{
start: '2022-11-25T00:00:00Z',
end: '2050-11-25T23:59:00Z',
}], false));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { queryByText } = renderComponent();
expect(queryByText('false')).toBeInTheDocument();
});
test('when blackoutdates are active and Role is not Learner return true', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`)
.reply(200, generateApiResponse([
{ start: '2022-11-25T00:00:00Z', end: '2050-11-25T23:59:00Z' }], true));
const { queryByText } = renderComponent();
expect(queryByText('true')).toBeInTheDocument();
});
});
});
});

View File

@@ -24,6 +24,12 @@ export const selectBlackoutDate = state => state.config.blackouts;
export const selectGroupAtSubsection = state => state.config.groupAtSubsection;
export const selectIsCourseAdmin = state => state.config.isCourseAdmin;
export const selectIsCourseStaff = state => state.config.isCourseStaff;
export const selectEnableInContext = state => state.config.enableInContext;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,
@@ -47,7 +53,7 @@ export function selectAreThreadsFiltered(state) {
export function selectTopicThreadCount(topicId) {
return state => {
const topic = state.topics.topics[topicId];
const topic = topicId && state.topics?.topics[topicId];
if (!topic) {
return 0;
}

View File

@@ -14,6 +14,8 @@ const configSlice = createSlice({
groupAtSubsection: false,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
learnersTabEnabled: false,
settings: {
@@ -25,6 +27,7 @@ const configSlice = createSlice({
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
enableInContext: false,
},
reducers: {
fetchConfigRequest: (state) => {

View File

@@ -3,7 +3,7 @@ import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import {
LearnersOrdering,
DiscussionProvider, LearnersOrdering,
PostsStatusFilter,
} from '../../data/constants';
import { setSortedBy } from '../learners/data';
@@ -36,7 +36,10 @@ export function fetchCourseConfig(courseId) {
learnerSort = LearnersOrdering.BY_FLAG;
}
dispatch(fetchConfigSuccess(camelCaseObject(config)));
dispatch(fetchConfigSuccess(camelCaseObject({
...config,
enable_in_context: config.provider === DiscussionProvider.OPEN_EDX,
})));
dispatch(setSortedBy(learnerSort));
dispatch(setStatusFilter(postsFilterStatus));
} catch (error) {

View File

@@ -14,51 +14,72 @@ import { DiscussionContext } from '../common/context';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus } from '../data/selectors';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import { TopicPostsView, TopicsView as InContextTopicsView } from '../in-context-topics';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView } from '../topics';
import { TopicsView as LegacyTopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const enableInContext = useSelector(selectEnableInContext);
const configStatus = useSelector(selectconfigLoadingStatus);
const redirectToLearnersTab = useShowLearnersTab();
const sidebarRef = useRef(null);
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
const { inContext } = useContext(DiscussionContext);
useEffect(() => {
if (sidebarRef && postActionBarHeight && !inContext) {
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
if (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
sidebarRef.current.style.top = `${postActionBarHeight}px`;
}
}, [sidebarRef, postActionBarHeight, inContext]);
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
return (
<div
ref={sidebarRef}
className={classNames('flex-column position-sticky', {
className={classNames('flex-column position-sticky', {
'd-none': !displaySidebar,
'd-flex overflow-auto': displaySidebar,
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'min-content-height': !inContext,
'min-content-height': !enableInContextSidebar,
})}
data-testid="sidebar"
>
<Switch>
{enableInContext && !enableInContextSidebar && (
<Route
path={Routes.TOPICS.ALL}
component={InContextTopicsView}
exact
/>
)}
{enableInContext && !enableInContextSidebar && (
<Route
path={[
Routes.TOPICS.TOPIC,
Routes.TOPICS.CATEGORY,
Routes.TOPICS.TOPIC_POST,
Routes.TOPICS.TOPIC_POST_EDIT,
]}
component={TopicPostsView}
exact
/>
)}
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
@@ -66,13 +87,13 @@ export default function DiscussionSidebar({ displaySidebar, postActionBarRef })
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</div>

View File

@@ -12,60 +12,53 @@ import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { selectPostEditorVisible } from '../posts/data/selectors';
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationsBanner';
import InformationBanner from './InformationBanner';
export default function DiscussionsHome() {
const location = useLocation();
const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(
(state) => state.threads.postEditorVisible,
);
const {
params: { page },
} = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const postEditorVisible = useSelector(selectPostEditorVisible);
const provider = useSelector(selectDiscussionProvider);
const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params: { path } } = useRouteMatch(`${Routes.DISCUSSIONS.PATH}/:path*`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId,
postId,
topicId,
category,
learnerUsername,
} = params;
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId, postId, topicId, category, learnerUsername,
} = params;
const { courseNumber, courseTitle, org } = useSelector((state) => state.courseTabs);
if (displayContentArea) {
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
displaySidebar = isOnDesktop;
}
const provider = useSelector(selectDiscussionProvider);
useCourseDiscussionData(courseId);
useRedirectToThread(courseId, inContext);
useRedirectToThread(courseId, enableInContextSidebar);
/* Display the content area if we are currently viewing/editing a post or creating one.
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
However, for smaller screens or embeds, onlyshow the sidebar if the content area isn't displayed. */
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; }
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
@@ -78,41 +71,47 @@ export default function DiscussionsHome() {
courseId,
postId,
topicId,
inContext,
enableInContextSidebar,
category,
learnerUsername,
}}
>
{!inContext && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
{!enableInContextSidebar && <Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />}
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!inContext && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<div
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
className={classNames('header-action-bar', {
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
})}
ref={postActionBarRef}
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-2.5 py-1.5': inContext,
'pl-4 pr-2.5 py-1.5': enableInContextSidebar,
})}
>
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar inContext={inContext} />
{!enableInContextSidebar && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div>
{!inContext && (
{provider === DiscussionProvider.LEGACY && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
component={LegacyBreadcrumbMenu}
/>
)}
<div className="d-flex flex-row">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<Switch>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.TOPICS.PATH}
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
/>
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
@@ -125,8 +124,9 @@ export default function DiscussionsHome() {
</Switch>
)}
</div>
<DiscussionsProductTour />
</main>
{!inContext && <Footer />}
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>
);
}

View File

@@ -1,18 +1,25 @@
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import navigationBarMessages from '../navigation/navigation-bar/messages';
import DiscussionsHome from './DiscussionsHome';
const courseConfigApiUrl = getCourseConfigApiUrl();
let axiosMock;
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -40,7 +47,7 @@ describe('DiscussionsHome', () => {
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
});
@@ -63,7 +70,9 @@ describe('DiscussionsHome', () => {
});
test('in-context view should show close button', async () => {
renderComponent(`/${courseId}/topics?inContext`);
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
renderComponent(`/${courseId}/topics?inContextSidebar`);
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
.not
@@ -73,10 +82,12 @@ describe('DiscussionsHome', () => {
});
test('the close button should post a message', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { parent } = window;
delete window.parent;
window.parent = { ...window, postMessage: jest.fn() };
renderComponent(`/${courseId}/topics?inContext`);
renderComponent(`/${courseId}/topics?inContextSidebar`);
const closeButton = screen.queryByRole('button', { name: 'Close' });
@@ -88,7 +99,7 @@ describe('DiscussionsHome', () => {
window.parent = parent;
});
test('header, course navigation bar and footer are visible', async () => {
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();

View File

@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import InformationBanner from './InformationsBanner';
import InformationBanner from './InformationBanner';
import '../posts/data/__factories__';

View File

@@ -52,7 +52,11 @@ function EmptyPosts({ intl, subTitleMessage }) {
}
EmptyPosts.propTypes = {
subTitleMessage: propTypes.string.isRequired,
subTitleMessage: propTypes.shape({
id: propTypes.string,
defaultMessage: propTypes.string,
description: propTypes.string,
}).isRequired,
intl: intlShape.isRequired,
};

View File

@@ -0,0 +1,85 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Spinner } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectTopicThreads } from '../posts/data/selectors';
import PostsList from '../posts/PostsList';
import { discussionsPath, handleKeyDown } from '../utils';
import {
selectArchivedTopic, selectLoadingStatus, selectNonCoursewareTopics,
selectSubsection, selectSubsectionUnits, selectUnits,
} from './data/selectors';
import { BackButton, NoResults } from './components';
import messages from './messages';
import { Topic } from './topic';
function TopicPostsView({ intl }) {
const location = useLocation();
const { courseId, topicId, category } = useContext(DiscussionContext);
const topicsLoadingStatus = useSelector(selectLoadingStatus);
const posts = useSelector(selectTopicThreads([topicId]));
const selectedSubsectionUnits = useSelector(selectSubsectionUnits(category));
const selectedSubsection = useSelector(selectSubsection(category));
const selectedUnit = useSelector(selectUnits)?.find(unit => unit.id === topicId);
const selectedNonCoursewareTopic = useSelector(selectNonCoursewareTopics)?.find(topic => topic.id === topicId);
const selectedArchivedTopic = useSelector(selectArchivedTopic(topicId));
const backButtonPath = () => {
const path = selectedUnit ? Routes.TOPICS.CATEGORY : Routes.TOPICS.ALL;
const params = selectedUnit ? { courseId, category: selectedUnit?.parentId } : { courseId };
return discussionsPath(path, params)(location);
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{topicId ? (
<BackButton
path={backButtonPath()}
title={selectedUnit?.name || selectedNonCoursewareTopic?.name || selectedArchivedTopic?.name
|| intl.formatMessage(messages.unnamedTopic)}
/>
) : (
<BackButton
path={discussionsPath(Routes.TOPICS.ALL, { courseId })(location)}
title={selectedSubsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
/>
)}
<div className="border-bottom border-light-400" />
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
{topicId ? (
<PostsList
posts={posts}
topics={[topicId]}
/>
) : (
selectedSubsectionUnits?.map((unit) => (
<Topic
key={unit.id}
topic={unit}
/>
))
)}
{(category && selectedSubsectionUnits.length === 0 && topicsLoadingStatus === RequestStatus.SUCCESSFUL) && (
<NoResults />
)}
{(category && topicsLoadingStatus === RequestStatus.IN_PROGRESS) && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
)}
</div>
</div>
);
}
TopicPostsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicPostsView);

View File

@@ -0,0 +1,121 @@
import React, { useContext, useEffect } from 'react';
import classNames from 'classnames';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import { Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectAreThreadsFiltered, selectDiscussionProvider } from '../data/selectors';
import { clearFilter, clearSort } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import {
selectArchivedTopics, selectCoursewareTopics, selectFilteredTopics, selectLoadingStatus,
selectNonCoursewareTopics, selectTopicFilter, selectTopics,
} from './data/selectors';
import { setFilter } from './data/slices';
import { fetchCourseTopicsV3 } from './data/thunks';
import { ArchivedBaseGroup, SectionBaseGroup, Topic } from './topic';
function TopicsList() {
const loadingStatus = useSelector(selectLoadingStatus);
const coursewareTopics = useSelector(selectCoursewareTopics);
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const archivedTopics = useSelector(selectArchivedTopics);
return (
<>
{nonCoursewareTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(nonCoursewareTopics.length - 1) !== index}
/>
))}
{coursewareTopics?.map((topic, index) => (
<SectionBaseGroup
key={topic.id}
section={topic?.children}
sectionId={topic.id}
sectionTitle={topic.displayName}
showDivider={(coursewareTopics.length - 1) !== index}
/>
))}
{!isEmpty(archivedTopics) && (
<ArchivedBaseGroup
archivedTopics={archivedTopics}
showDivider={(!isEmpty(nonCoursewareTopics) || !isEmpty(coursewareTopics))}
/>
)}
{loadingStatus === RequestStatus.IN_PROGRESS && (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
)}
</>
);
}
function TopicsView() {
const dispatch = useDispatch();
const { courseId } = useContext(DiscussionContext);
const provider = useSelector(selectDiscussionProvider);
const topicFilter = useSelector(selectTopicFilter);
const filteredTopics = useSelector(selectFilteredTopics);
const loadingStatus = useSelector(selectLoadingStatus);
const isPostsFiltered = useSelector(selectAreThreadsFiltered);
const topics = useSelector(selectTopics);
useEffect(() => {
if (provider) {
dispatch(fetchCourseTopicsV3(courseId));
}
}, [provider]);
useEffect(() => {
if (isPostsFiltered) {
dispatch(clearFilter());
dispatch(clearSort());
}
}, [isPostsFiltered]);
return (
<div className="d-flex flex-column h-100" data-testid="inContext-topics-view">
{topicFilter && (
<>
<SearchInfo
text={topicFilter}
count={filteredTopics.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setFilter(''))}
/>
{filteredTopics.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</>
)}
<div
className={classNames('list-group list-group-flush flex-fill', {
'justify-content-center': loadingStatus === RequestStatus.IN_PROGRESS && isEmpty(topics),
})}
role="list"
onKeyDown={e => handleKeyDown(e)}
>
{topicFilter ? (
filteredTopics?.map((topic) => (
<Topic
key={topic.id}
topic={topic}
/>
))
) : (
<TopicsList />
)}
</div>
</div>
);
}
export default TopicsView;

View File

@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import messages from '../messages';
function BackButton({ intl, path, title }) {
const history = useHistory();
return (
<>
<div className="d-flex py-2.5 px-3 font-weight-bold border-light-400 border-bottom">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(path)}
alt={intl.formatMessage(messages.backAlt)}
/>
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">
{title}
</div>
</div>
<div className="border-bottom border-light-400" />
</>
);
}
BackButton.propTypes = {
intl: intlShape.isRequired,
path: PropTypes.shape({}).isRequired,
title: PropTypes.string.isRequired,
};
export default injectIntl(BackButton);

View File

@@ -0,0 +1,83 @@
import React, { useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useIsOnDesktop } from '../../data/hooks';
import { selectPostThreadCount } from '../../data/selectors';
import EmptyPage from '../../empty-posts/EmptyPage';
import messages from '../../messages';
import { messages as postMessages, showPostEditor } from '../../posts';
import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../data/selectors';
function EmptyTopics({ intl }) {
const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
function addPost() {
return dispatch(showPostEditor());
}
const isOnDesktop = useIsOnDesktop();
let title = messages.emptyTitle;
let fullWidth = false;
let subTitle;
let action;
let actionText;
if (!isOnDesktop) {
return null;
}
if (match.params.topicId) {
if (topicThreadsCount > 0) {
title = messages.noPostSelected;
} else {
action = addPost;
actionText = postMessages.addAPost;
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (match.params.category) {
if (enableInContextSidebar && topicThreadsCount > 0) {
title = messages.noPostSelected;
} else if (courseWareThreadsCount > 0) {
title = messages.noTopicSelected;
} else {
action = addPost;
actionText = postMessages.addAPost;
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (hasGlobalThreads) {
title = messages.noTopicSelected;
} else {
fullWidth = true;
}
return (
<EmptyPage
title={intl.formatMessage(title)}
subTitle={subTitle && intl.formatMessage(subTitle)}
action={action}
actionText={actionText && intl.formatMessage(actionText)}
fullWidth={fullWidth}
/>
);
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyTopics);

View File

@@ -0,0 +1,29 @@
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectTopics } from '../data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const topics = useSelector(selectTopics);
let title = messages.nothingHere;
const helpMessage = '';
if (topics.length === 0) {
title = messages.noTopicExists;
}
return (
<div className="h-100 mt-5 align-self-center mx-auto w-50 d-flex flex-column justify-content-center text-center">
<h4 className="font-weight-normal text-primary-500">{intl.formatMessage(title)}</h4>
{ helpMessage && <small className="font-weight-normal text-gray-700">{intl.formatMessage(helpMessage)}</small>}
</div>
);
}
NoResults.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(NoResults);

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export { default as BackButton } from './BackButton';
export { default as EmptyTopic } from './EmptyTopics';
export { default as NoResults } from './NoResults';

View File

@@ -0,0 +1,11 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { getApiBaseUrl } from '../../../data/constants';
export async function getCourseTopicsV3(courseId) {
const url = `${getApiBaseUrl()}/api/discussion/v3/course_topics/${courseId}`;
const { data } = await getAuthenticatedHttpClient().get(url);
return camelCaseObject(data);
}

View File

@@ -0,0 +1 @@
export * from './slices';

View File

@@ -0,0 +1,67 @@
/* eslint-disable import/prefer-default-export */
import { createSelector } from '@reduxjs/toolkit';
export const selectTopicFilter = state => state.inContextTopics.filter.trim().toLowerCase();
export const selectTopics = state => state.inContextTopics.topics;
export const selectCoursewareTopics = state => state.inContextTopics.coursewareTopics;
export const selectNonCoursewareTopics = state => state.inContextTopics.nonCoursewareTopics;
export const selectNonCoursewareIds = state => state.inContextTopics.nonCoursewareIds;
export const selectUnits = state => state.inContextTopics.units;
export const selectSubsectionUnits = subsectionId => state => state.inContextTopics.units?.filter(
unit => unit.parentId === subsectionId,
);
export const selectSubsection = category => createSelector(
selectCoursewareTopics,
(coursewareTopics) => (
coursewareTopics?.map((topic) => topic?.children)?.flat()?.find((topic) => topic.id === category)
),
);
export const selectArchivedTopics = state => state.inContextTopics.archivedTopics;
export const selectArchivedTopic = topic => createSelector(
selectArchivedTopics,
(archivedTopics) => (
archivedTopics?.find((archivedTopic) => archivedTopic.id === topic)
),
);
export const selectLoadingStatus = state => state.inContextTopics.status;
export const selectFilteredTopics = createSelector(
selectUnits,
selectNonCoursewareTopics,
selectTopicFilter,
(units, nonCoursewareTopics, filter) => (
(units && nonCoursewareTopics && filter) && [...units, ...nonCoursewareTopics]?.filter(
topic => topic.name.toLowerCase().includes(filter),
)
),
);
export const selectTotalTopicsThreadsCount = createSelector(
selectUnits,
selectNonCoursewareTopics,
(units, nonCoursewareTopics) => (
(units && nonCoursewareTopics) && [...units, ...nonCoursewareTopics]?.reduce((total, topic) => (
total + topic.threadCounts.discussion + topic.threadCounts.question
), 0)
),
);
export const selectCourseWareThreadsCount = category => createSelector(
selectSubsectionUnits(category),
(units) => (
units?.reduce((total, unit) => (
total + unit.threadCounts.discussion + unit.threadCounts.question
), 0)
),
);

View File

@@ -0,0 +1,51 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
const topicsSlice = createSlice({
name: 'inContextTopics',
initialState: {
status: RequestStatus.IN_PROGRESS,
topics: [],
coursewareTopics: [],
nonCoursewareTopics: [],
nonCoursewareIds: [],
units: [],
archivedTopics: [],
filter: '',
},
reducers: {
fetchCourseTopicsRequest: (state) => {
state.status = RequestStatus.IN_PROGRESS;
},
fetchCourseTopicsSuccess: (state, { payload }) => {
state.status = RequestStatus.SUCCESSFUL;
state.topics = payload.topics;
state.coursewareTopics = payload.coursewareTopics;
state.nonCoursewareTopics = payload.nonCoursewareTopics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.units = payload.units;
state.archivedTopics = payload.archivedTopics;
},
fetchCourseTopicsFailed: (state) => {
state.status = RequestStatus.FAILED;
},
fetchCourseTopicsDenied: (state) => {
state.status = RequestStatus.DENIED;
},
setFilter: (state, { payload }) => {
state.filter = payload;
},
},
});
export const {
fetchCourseTopicsRequest,
fetchCourseTopicsSuccess,
fetchCourseTopicsFailed,
setFilter,
setSortBy,
} = topicsSlice.actions;
export const inContextTopicsReducer = topicsSlice.reducer;

View File

@@ -0,0 +1,64 @@
/* eslint-disable import/prefer-default-export */
import { reduce } from 'lodash';
import { logError } from '@edx/frontend-platform/logging';
import { getCourseTopicsV3 } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normalizeTopicsV3(topics) {
const coursewareUnits = reduce(topics, (arrayOfUnits, chapter) => {
if (chapter?.children) {
return [
...arrayOfUnits,
...reduce(chapter.children, (units, sequential) => {
if (sequential?.children) {
return [
...units,
...sequential.children.map((unit) => ({
...unit,
parentId: sequential.id,
parentTitle: sequential.displayName,
})),
];
}
return units;
}, []),
];
}
return arrayOfUnits;
}, []);
const archivedTopics = reduce(topics, (arrayOfArchivedTopics, topic) => {
if (topic.id === 'archived') {
return topic.children;
}
return arrayOfArchivedTopics;
}, []);
const coursewareTopics = topics.filter((topic) => topic.courseware);
const nonCoursewareTopics = topics.filter((topic) => !topic.courseware && topic.enabledInContext);
const nonCoursewareIds = nonCoursewareTopics?.map((topic) => topic.id);
return {
topics,
units: coursewareUnits,
coursewareTopics,
nonCoursewareTopics,
nonCoursewareIds,
archivedTopics,
};
}
export function fetchCourseTopicsV3(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopicsV3(courseId);
dispatch(fetchCourseTopicsSuccess(normalizeTopicsV3(data)));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

@@ -0,0 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as TopicPostsView } from './TopicPostsView';
export { default as TopicsView } from './TopicsView';

View File

@@ -0,0 +1,79 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
backAlt: {
id: 'discussions.topics.backAlt',
defaultMessage: 'Back to topics list',
description: 'Display back button text used to navigate back to topics list',
},
discussions: {
id: 'discussions.topics.discussions',
defaultMessage: `{count, plural,
=0 {Discussion}
one {# Discussion}
other {# Discussions}
}`,
description: 'Display tooltip text used to indicate how many posts type are discussion',
},
questions: {
id: 'discussions.topics.questions',
defaultMessage: `{count, plural,
=0 {Question}
one {# Question}
other {# Questions}
}`,
description: 'Display tooltip text used to indicate how many posts type are questions',
},
reported: {
id: 'discussions.topics.reported',
defaultMessage: '{reported} reported',
description: 'Display tooltip text used to indicate how many posts are reported',
},
previouslyReported: {
id: 'discussions.topics.previouslyReported',
defaultMessage: '{previouslyReported} previously reported',
description: 'Display tooltip text used to indicate how many posts are previously reported',
},
searchTopics: {
id: 'discussions.topics.find.label',
defaultMessage: 'Search topics',
description: 'Placeholder text in search bar',
},
unnamedSection: {
id: 'discussions.topics.unnamed.section.label',
defaultMessage: 'Unnamed Section',
description: 'Text to display in place of section name if section name is empty',
},
unnamedSubsection: {
id: 'discussions.topics.unnamed.subsection.label',
defaultMessage: 'Unnamed Subsection',
description: 'Text to display in place of subsection name if subsection name is empty',
},
unnamedTopic: {
id: 'discussions.subtopics.unnamed.topic.label',
defaultMessage: 'Unnamed Topic',
description: 'Text to display in place of topic name if topic name is empty',
},
noTopicExists: {
id: 'discussions.topics.title',
defaultMessage: 'No topic exists',
description: 'Text to display in place of topic list if topic does not exist',
},
createTopic: {
id: 'discussions.topics.createTopic',
defaultMessage: 'Please contact you admin to create a topic',
description: 'Helping Text to display in place of topic list if topic does not exist',
},
nothingHere: {
id: 'discussions.topics.nothing',
defaultMessage: 'Nothing here yet',
description: 'Helping Text to display if nothing here yet',
},
archivedTopics: {
id: 'discussions.topics.archived.label',
defaultMessage: 'Archived',
description: 'Heading for displaying topics that are archived.',
},
});
export default messages;

View File

@@ -0,0 +1,64 @@
import React, { useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
import { DiscussionContext } from '../../common/context';
import postsMessages from '../../posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../data/slices';
function TopicSearchBar({ intl }) {
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
let searchValue = '';
const onClear = () => {
dispatch(setTopicFilter(''));
};
const onChange = (query) => {
searchValue = query;
};
const onSubmit = (query) => {
if (query === '') {
return;
}
dispatch(setTopicFilter(query));
};
useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={topicSearch}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: 'topics' })}
/>
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
/>
</span>
</SearchField.Advanced>
</>
);
}
TopicSearchBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicSearchBar);

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { SearchField } from '@edx/paragon';
import { setFilter } from '../data';
import messages from '../messages';
function TopicSearchResultBar({ intl }) {
const dispatch = useDispatch();
return (
<div className="d-flex flex-row p-1 align-items-center">
<SearchField
className="flex-fill m-1 border-0"
placeholder={intl.formatMessage(messages.searchTopics)}
onSubmit={(query) => dispatch(setFilter(query))}
onChange={(query) => dispatch(setFilter(query))}
/>
</div>
);
}
TopicSearchResultBar.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(TopicSearchResultBar);

View File

@@ -0,0 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as TopicSearchBar } from './TopicSearchBar';
export { default as TopicSearchResultBar } from './TopicSearchResultBar';

View File

@@ -0,0 +1,48 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from '../messages';
import Topic, { topicShape } from './Topic';
function ArchivedBaseGroup({
archivedTopics,
showDivider,
intl,
}) {
return (
<>
{showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
<div
className="discussion-topic-group d-flex flex-column text-primary-500"
data-testid="archived-group"
>
<div className="pt-3 px-4 font-weight-bold">{intl.formatMessage(messages.archivedTopics)}</div>
{archivedTopics?.map((topic, index) => (
<Topic
key={topic.id}
topic={topic}
showDivider={(archivedTopics.length - 1) !== index}
/>
))}
</div>
</>
);
}
ArchivedBaseGroup.propTypes = {
archivedTopics: PropTypes.arrayOf(topicShape).isRequired,
showDivider: PropTypes.bool,
intl: intlShape.isRequired,
};
ArchivedBaseGroup.defaultProps = {
showDivider: false,
};
export default injectIntl(ArchivedBaseGroup);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Routes } from '../../../data/constants';
import { discussionsPath } from '../../utils';
import messages from '../messages';
import { topicShape } from './Topic';
function SectionBaseGroup({
section,
sectionTitle,
sectionId,
showDivider,
intl,
}) {
const { courseId } = useParams();
const isSelected = (id) => window.location.pathname.includes(id);
const sectionUrl = (id) => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: id,
});
return (
<div
className="discussion-topic-group d-flex flex-column text-primary-500"
data-section-id={sectionId}
data-testid="section-group"
>
<div className="pt-3 px-4 font-weight-bold">
{sectionTitle || intl.formatMessage(messages.unnamedSection)}
</div>
{section.map((subsection, index) => (
<Link
className={classNames('subsection p-0 text-decoration-none text-primary-500', {
'border-bottom border-light-400': (section.length - 1 !== index),
})}
key={subsection.id}
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row py-3.5 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{subsection?.displayName || intl.formatMessage(messages.unnamedSubsection)}
</div>
</div>
</div>
</div>
</Link>
))}
{showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
</div>
);
}
SectionBaseGroup.propTypes = {
section: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
blockId: PropTypes.string,
lmsWebUrl: PropTypes.string,
legacyWebUrl: PropTypes.string,
studentViewUrl: PropTypes.string,
type: PropTypes.string,
displayName: PropTypes.string,
children: PropTypes.arrayOf(topicShape),
})).isRequired,
sectionTitle: PropTypes.string.isRequired,
sectionId: PropTypes.string.isRequired,
showDivider: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(SectionBaseGroup);

View File

@@ -0,0 +1,155 @@
/* eslint-disable no-unused-vars, react/forbid-prop-types */
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
import { Routes } from '../../../data/constants';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import { discussionsPath } from '../../utils';
import messages from '../messages';
function Topic({
topic,
showDivider,
index,
intl,
}) {
const { courseId } = useParams();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { inactiveFlags, activeFlags } = topic;
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
const isSelected = (id) => window.location.pathname.includes(id);
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, {
courseId,
topicId: topic.id,
});
return (
<>
<Link
className={classNames('discussion-topic p-0 text-decoration-none text-primary-500', {
'border-light-400 border-bottom': showDivider,
})}
data-topic-id={topic.id}
to={topicUrl}
onClick={() => isSelected(topic.id)}
aria-current={isSelected(topic.id) ? 'page' : undefined}
role="option"
tabIndex={(isSelected(topic.id) || index === 0) ? 0 : -1}
>
<div className="d-flex flex-row pt-2.5 pb-2 px-4">
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="topic-name text-truncate">
{topic?.name || topic?.displayName || intl.formatMessage(messages.unnamedTopicSubCategories)}
</div>
</div>
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.discussions, {
count: topic.threadCounts?.discussion || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={PostOutline} className="icon-size mr-2" />
{topic.threadCounts?.discussion || 0}
</div>
</OverlayTrigger>
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.questions, {
count: topic.threadCounts?.question || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={HelpOutline} className="icon-size mr-2" />
{topic.threadCounts?.question || 0}
</div>
</OverlayTrigger>
{Boolean(canSeeReportedStats) && (
<OverlayTrigger
overlay={(
<Tooltip>
<div className="d-flex flex-column align-items-start">
{Boolean(activeFlags) && (
<span>
{intl.formatMessage(messages.reported, { reported: activeFlags })}
</span>
)}
{Boolean(inactiveFlags) && (
<span>
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
</span>
)}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center">
<Icon src={Report} className="icon-size mr-2 text-danger" />
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
</div>
</OverlayTrigger>
)}
</div>
</div>
</div>
</Link>
{!showDivider && (
<>
<div className="divider border-top border-light-500" />
<div className="divider pt-1 bg-light-300" />
</>
)}
</>
);
}
export const topicShape = PropTypes.shape({
id: PropTypes.string,
usage_key: PropTypes.string,
name: PropTypes.string,
thread_counts: PropTypes.shape({
discussions: PropTypes.number,
questions: PropTypes.number,
}),
enabled_in_context: PropTypes.bool,
flags: PropTypes.number,
});
Topic.propTypes = {
intl: intlShape.isRequired,
topic: topicShape,
showDivider: PropTypes.bool,
index: PropTypes.number,
};
Topic.defaultProps = {
showDivider: true,
index: -1,
topic: {
usage_key: '',
},
};
export default injectIntl(Topic);

View File

@@ -0,0 +1,4 @@
/* eslint-disable import/prefer-default-export */
export { default as ArchivedBaseGroup } from './ArchivedBaseGroup';
export { default as SectionBaseGroup } from './SectionBaseGroup';
export { default as Topic } from './Topic';

View File

@@ -4,6 +4,8 @@ import { isEmpty } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import FilterBar from '../../../components/FilterBar';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
@@ -39,12 +41,20 @@ function LearnerPostFilterBar() {
const handleFilterChange = (event) => {
const { name, value } = event.currentTarget;
const filterContentEventProperties = {
statusFilter: postFilter.status,
threadTypeFilter: postFilter.postType,
sortFilter: postFilter.orderBy,
cohortFilter: postFilter.cohort,
triggeredBy: name,
};
if (name === 'postType') {
if (postFilter.postType !== value) {
dispatch(setPostFilter({
...postFilter,
postType: value,
}));
filterContentEventProperties.threadTypeFilter = value;
}
} else if (name === 'status') {
if (postFilter.status !== value) {
@@ -52,6 +62,7 @@ function LearnerPostFilterBar() {
...postFilter,
status: value,
}));
filterContentEventProperties.statusFilter = value;
}
} else if (name === 'orderBy') {
if (postFilter.orderBy !== value) {
@@ -59,6 +70,7 @@ function LearnerPostFilterBar() {
...postFilter,
orderBy: value,
}));
filterContentEventProperties.sortFilter = value;
}
} else if (name === 'cohort') {
if (postFilter.cohort !== value) {
@@ -66,8 +78,10 @@ function LearnerPostFilterBar() {
...postFilter,
cohort: value,
}));
filterContentEventProperties.cohortFilter = value;
}
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
};
useEffect(() => {

View File

@@ -16,9 +16,9 @@ function LearnerCard({
learner,
courseId,
}) {
const { inContext, learnerUsername } = useContext(DiscussionContext);
const { enableInContextSidebar, learnerUsername } = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.LEARNERS.POSTS, {
0: inContext ? 'in-context' : undefined,
0: enableInContextSidebar ? 'in-context' : undefined,
learnerUsername: learner.username,
courseId,
});

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Collapsible, Form, Icon } from '@edx/paragon';
import { Check, Tune } from '@edx/paragon/icons';
@@ -58,6 +59,12 @@ function LearnerFilterBar({
if (name === 'sort') {
dispatch(setSortedBy(value));
sendTrackEvent(
'edx.forum.sort.user',
{
sort: value,
},
);
}
};

View File

@@ -31,6 +31,11 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Action to delete a post or comment',
},
confirmationConfirm: {
id: 'discussions.confirmation.button.confirm',
defaultMessage: 'Confirm',
description: 'Confirm button shown on confirmation dialog',
},
closeAction: {
id: 'discussions.actions.close',
defaultMessage: 'Close',
@@ -71,16 +76,11 @@ const messages = defineMessages({
defaultMessage: 'Unmark as answered',
description: 'Action to unmark a comment as answering a post',
},
deleteConfirmationCancel: {
id: 'discussions.delete.confirmation.button.cancel',
confirmationCancel: {
id: 'discussions.modal.confirmation.button.cancel',
defaultMessage: 'Cancel',
description: 'Cancel button shown on delete confirmation dialog',
},
deleteConfirmationDelete: {
id: 'discussions.delete.confirmation.button.delete',
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
emptyAllTopics: {
id: 'discussions.empty.allTopics',
defaultMessage:
@@ -185,8 +185,8 @@ const messages = defineMessages({
},
blackoutDiscussionInformation: {
id: 'discussion.blackoutBanner.information',
defaultMessage: 'Blackout dates are currently active. Posting in discussions is unavailable at this time.',
description: 'Informative text when discussions blackout is active',
defaultMessage: 'Posting in discussions is temporarily disabled by the course team',
description: 'Informative text when discussion posting is disabled',
},
imageWarningMessage: {
id: 'discussions.editor.image.warning.message',

View File

@@ -1,90 +0,0 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import { Routes } from '../../../data/constants';
import { selectBlocks, selectChapters } from '../../../data/selectors';
import { DiscussionContext } from '../../common/context';
import { selectTopic } from '../../topics/data/selectors';
import { discussionsPath } from '../../utils';
import BreadcrumbDropdown from './BreadcrumbDropdown';
function BreadcrumbMenu() {
const {
courseId,
topicId,
category,
} = useContext(DiscussionContext);
const blocks = useSelector(selectBlocks);
const chapters = useSelector(selectChapters);
const blockKey = useSelector(selectTopic(topicId))?.usageKey || category;
let currentChapter = null;
let currentVertical = null;
let currentSequential = null;
if (!blocks[blockKey]) {
// Data is still loading
return null;
}
if (blocks[blockKey].type === 'chapter') {
currentChapter = blockKey;
} else if (blocks[blockKey].type === 'sequential') {
currentSequential = blockKey;
currentChapter = blocks[currentSequential].parent;
} else if (blocks[blockKey].type === 'vertical') {
currentVertical = blockKey;
currentSequential = blocks[currentVertical].parent;
currentChapter = blocks[currentSequential].parent;
}
const getItemDisplayName = itemId => blocks[itemId]?.displayName;
const getItemPath = itemId => discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: itemId,
});
return (
<div className="breadcrumb-menu d-flex flex-row bg-light-200 box-shadow-down-1 px-2.5 py-1">
<BreadcrumbDropdown
currentItem={currentChapter}
showAllPath={discussionsPath(Routes.TOPICS.ALL, { courseId })}
items={chapters}
itemPathFunc={getItemPath}
itemActiveFunc={item => item === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
{currentChapter
&& (
<>
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentSequential}
showAllPath={getItemPath(currentChapter)}
items={blocks[currentChapter].children}
itemPathFunc={getItemPath}
itemActiveFunc={seqId => seqId === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
</>
)}
{currentSequential
&& (
<>
<div className="d-flex py-2">/</div>
<BreadcrumbDropdown
currentItem={currentVertical}
showAllPath={getItemPath(currentSequential)}
items={blocks[currentSequential].children}
itemPathFunc={getItemPath}
itemActiveFunc={vertId => vertId === currentChapter}
itemLabelFunc={getItemDisplayName}
/>
</>
)}
</div>
);
}
BreadcrumbMenu.propTypes = {};
export default BreadcrumbMenu;

View File

@@ -1,151 +0,0 @@
import React from 'react';
import {
act, fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getBlocksAPIResponse } from '../../../data/__factories__';
import { getBlocksAPIURL } from '../../../data/api';
import { getApiBaseUrl, Routes } from '../../../data/constants';
import { fetchCourseBlocks } from '../../../data/thunks';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { DiscussionContext } from '../../common/context';
import { fetchCourseTopics } from '../../topics/data/thunks';
import { BreadcrumbMenu } from '../index';
import '../../topics/data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
let store;
let axiosMock;
function renderComponent(path, topicId = null, category = null) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
courseId,
topicId,
category,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={BreadcrumbMenu}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('BreadcrumbMenu', () => {
let blocksAPIResponse;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: {
provider: 'openedx',
},
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
blocksAPIResponse = getBlocksAPIResponse();
axiosMock.onGet(getBlocksAPIURL())
.reply(200, blocksAPIResponse);
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
const data = [
...Factory.buildList('topic.v2', 3, { usage_key: null }, { topicPrefix: 'ncw' }),
Factory.build('topic.v2', { id: 'vertical_0270f6de40fc' }),
Factory.build('topic.v2', { id: '867dddb6f55d410caaa9c1eb9c6743ec' }),
Factory.build('topic.v2', { id: '4f6c1b4e316a419ab5b6bf30e6c708e9' }),
];
axiosMock
.onGet(topicsApiUrl)
.reply(200, data);
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
});
it('shows the category dropdown with a category selected', async () => {
const chapterKey = 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@d8a6192ade314473a78242dfeedfbf5b';
const sectionKey = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
renderComponent(`/${courseId}/category/${chapterKey}`, null, chapterKey);
await waitFor(() => screen.findByText(blocksAPIResponse.blocks[chapterKey].display_name));
const chapterDropdown = screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name);
// Since a category is selected a subcategory dropdown should also be visible with "show all" selected by default
const sectionDropdown = screen.queryByRole('button', { name: 'Show all' });
// A show all button should show up that lists topics in the current category
expect(sectionDropdown)
.toBeInTheDocument();
// Other categories should not be visible.
expect(screen.queryByText(blocksAPIResponse.blocks[sectionKey].display_name))
.not
.toBeInTheDocument();
// Click on the category dropdown.
act(() => {
fireEvent.click(chapterDropdown);
});
// Now other categories should be visible in the dropdown.
expect(screen.queryByText(blocksAPIResponse.blocks[chapterKey].display_name))
.toBeInTheDocument();
// There are 4 categories but this has a length of 5 since there is also a link to show all.
expect(screen.queryAllByRole('link', { exact: false }))
.toHaveLength(5);
// Now click on the topics dropdown
act(() => {
fireEvent.click(sectionDropdown);
});
// Topics in the category should be visible.
expect(screen.queryByRole('link', { name: 'Demo Course Overview' }))
.toBeInTheDocument();
});
it('shows the category correct dropdown labels with a topic selected', async () => {
const topicId = 'vertical_0270f6de40fc';
renderComponent(`/${courseId}/topics/${topicId}`, topicId);
// Since a topic is selected, we have both a category and topic, so "show all shouldn't be visible"
expect(screen.queryByText('Show all'))
.not
.toBeInTheDocument();
// The name of the category and topic should be visible.
expect(await screen.findByRole('button', { name: 'Introduction' }))
.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Demo Course Overview' }))
.toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Introduction: Video and Sequences' }))
.toBeInTheDocument();
});
});

View File

@@ -1,4 +1,3 @@
/* eslint-disable import/prefer-default-export */
export { default as BreadcrumbMenu } from './breadcrumb-menu/BreadcrumbMenu';
export { default as LegacyBreadcrumbMenu } from './breadcrumb-menu/LegacyBreadcrumbMenu';
export { default as NavigationBar } from './navigation-bar/NavigationBar';

View File

@@ -4,21 +4,24 @@ import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { selectAreThreadsFiltered } from '../data/selectors';
import { selectTopicFilter } from '../in-context-topics/data/selectors';
import messages from '../messages';
function NoResults({ intl }) {
const postsFiltered = useSelector(selectAreThreadsFiltered);
const inContextTopicsFilter = useSelector(selectTopicFilter);
const topicsFilter = useSelector(({ topics }) => topics.filter);
const filters = useSelector((state) => state.threads.filters);
const learnersFilter = useSelector(({ learners }) => learners.usernameSearch);
const isFiltered = postsFiltered || (topicsFilter !== '') || (learnersFilter !== null);
const isFiltered = postsFiltered || (topicsFilter !== '')
|| (learnersFilter !== null) || (inContextTopicsFilter !== '');
let helpMessage = messages.removeFilters;
if (!isFiltered) {
return null;
} if (filters.search || learnersFilter) {
helpMessage = messages.removeKeywords;
} if (topicsFilter) {
} if (topicsFilter || inContextTopicsFilter) {
helpMessage = messages.removeKeywordsOnly;
}
const titleCssClasses = classNames(

View File

@@ -22,7 +22,9 @@ import { fetchThreads } from './data/thunks';
import NoResults from './NoResults';
import { PostLink } from './post';
function PostsList({ posts, topics, intl }) {
function PostsList({
posts, topics, intl, isTopicTab,
}) {
const dispatch = useDispatch();
const {
courseId,
@@ -38,7 +40,7 @@ function PostsList({ posts, topics, intl }) {
const userIsStaff = useSelector(selectUserIsStaff);
const configStatus = useSelector(selectconfigLoadingStatus);
const loadThreads = (topicIds, pageNum = undefined) => {
const loadThreads = (topicIds, pageNum = undefined, isFilterChanged = false) => {
const params = {
orderBy,
filters,
@@ -46,9 +48,10 @@ function PostsList({ posts, topics, intl }) {
author: showOwnPosts ? authenticatedUser.username : null,
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
topicIds,
isFilterChanged,
};
if (showOwnPosts && filters.search === '') {
if (showOwnPosts) {
dispatch(fetchUserPosts(courseId, params));
} else {
dispatch(fetchThreads(courseId, params));
@@ -59,7 +62,11 @@ function PostsList({ posts, topics, intl }) {
if (topics !== undefined && configStatus === RequestStatus.SUCCESSFUL) {
loadThreads(topics);
}
}, [courseId, orderBy, filters, page, JSON.stringify(topics), configStatus]);
}, [courseId, filters, orderBy, page, JSON.stringify(topics), configStatus]);
useEffect(() => {
if (isTopicTab) { loadThreads(topics, 1, true); }
}, [filters]);
const checkIsSelected = (id) => window.location.pathname.includes(id);
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
@@ -83,7 +90,7 @@ function PostsList({ posts, topics, intl }) {
{postInstances(unpinnedPosts)}
{posts?.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
@@ -103,12 +110,14 @@ PostsList.propTypes = {
id: PropTypes.string.isRequired,
})),
topics: PropTypes.arrayOf(PropTypes.string),
isTopicTab: PropTypes.bool,
intl: intlShape.isRequired,
};
PostsList.defaultProps = {
posts: [],
topics: undefined,
isTopicTab: false,
};
export default injectIntl(PostsList);

View File

@@ -1,11 +1,18 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import SearchInfo from '../../components/SearchInfo';
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
import { DiscussionContext } from '../common/context';
import { selectEnableInContext } from '../data/selectors';
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { handleKeyDown } from '../utils';
import {
selectAllThreads,
selectTopicThreads,
@@ -21,7 +28,7 @@ function AllPostsList() {
function TopicPostsList({ topicId }) {
const posts = useSelector(selectTopicThreads([topicId]));
return <PostsList posts={posts} topics={[topicId]} />;
return <PostsList posts={posts} topics={[topicId]} isTopicTab />;
}
TopicPostsList.propTypes = {
@@ -29,10 +36,10 @@ TopicPostsList.propTypes = {
};
function CategoryPostsList({ category }) {
const { inContext } = useContext(DiscussionContext);
const { enableInContextSidebar } = useContext(DiscussionContext);
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
const topicIds = useSelector(selectTopicsUnderCategory)(inContext ? groupedCategory : category);
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
const posts = useSelector(selectTopicThreads(topicIds));
return <PostsList posts={posts} topics={topicIds} />;
}
@@ -45,12 +52,24 @@ function PostsView() {
const {
topicId,
category,
courseId,
enableInContextSidebar,
} = useContext(DiscussionContext);
const dispatch = useDispatch();
const enableInContext = useSelector(selectEnableInContext);
const searchString = useSelector(({ threads }) => threads.filters.search);
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
const loadingStatus = useSelector(({ threads }) => threads.status);
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
useEffect(() => {
if (isEmpty(topics)) {
dispatch((enableInContext || enableInContextSidebar)
? fetchCourseTopicsV3(courseId)
: fetchCourseTopics(courseId));
}
}, [topics]);
let postsListComponent;
@@ -62,20 +81,6 @@ function PostsView() {
postsListComponent = <AllPostsList />;
}
const handleKeyDown = (event) => {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
};
return (
<div className="discussion-posts d-flex flex-column h-100">
{searchString && (

View File

@@ -13,27 +13,31 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { Routes, ThreadType } from '../../data/constants';
import { getApiBaseUrl, Routes, ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import { getCoursesApiUrl } from '../learners/data/api';
import { fetchCourseTopics } from '../topics/data/thunks';
import { getThreadsApiUrl } from './data/api';
import { PostsView } from './index';
import './data/__factories__';
import '../cohorts/data/__factories__';
import '../topics/data/__factories__';
const courseId = 'course-v1:edX+TestX+Test_Course';
const coursesApiUrl = getCoursesApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
let store;
let axiosMock;
const username = 'abc123';
async function renderComponent({
postId, topicId, category, myPosts, inContext = false,
postId, topicId, category, myPosts, enableInContextSidebar = false,
} = { myPosts: false }) {
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
let page;
@@ -60,7 +64,7 @@ async function renderComponent({
topicId,
category,
page,
inContext,
enableInContextSidebar,
}}
>
<Switch>
@@ -106,6 +110,12 @@ describe('PostsView', () => {
pageSize: 6,
})];
});
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
});
function setupStore(data = {}) {
@@ -114,7 +124,6 @@ describe('PostsView', () => {
config: { hasModerationPrivileges: true },
...data,
};
// console.log(storeData);
store = initializeStore(storeData);
store.dispatch(fetchConfigSuccess({}));
}
@@ -173,7 +182,7 @@ describe('PostsView', () => {
config: { groupAtSubsection: grouping, hasModerationPrivileges: true, provider: 'openedx' },
});
await act(async () => {
await renderComponent({ category: 'test-usage-key', inContext: true, p: true });
await renderComponent({ category: 'test-usage-key', enableInContextSidebar: true, p: true });
});
const topicThreadCount = Math.ceil(threadCount / 3);
expect(screen.queryAllByText(/this is thread-\d+ in topic some-topic-2/i))
@@ -197,6 +206,8 @@ describe('PostsView', () => {
beforeEach(async () => {
setupStore();
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
await act(async () => {
await renderComponent();
});

View File

@@ -6,6 +6,8 @@ const selectThreads = state => state.threads.threadsById;
const mapIdsToThreads = (ids, threads) => ids.map(id => threads?.[id]);
export const selectPostEditorVisible = state => state.threads.postEditorVisible;
export const selectTopicThreads = topicIds => createSelector(
[
state => (topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || []),

View File

@@ -1,5 +1,6 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import omitBy from 'lodash/omitBy';
import {
PostsStatusFilter, RequestStatus, ThreadOrdering, ThreadType,
@@ -57,7 +58,6 @@ const threadsSlice = createSlice({
if (state.author !== payload.author) {
state.pages = [];
state.author = payload.author;
state.totalThreads = null;
}
state.status = RequestStatus.IN_PROGRESS;
},
@@ -79,7 +79,13 @@ const threadsSlice = createSlice({
}
state.status = RequestStatus.SUCCESSFUL;
state.threadsById = { ...state.threadsById, ...payload.threadsById };
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
// filter
if (payload.isFilterChanged) {
state.threadsInTopic = { ...payload.threadsInTopic };
} else {
state.threadsInTopic = mergeThreadsInTopics(state.threadsInTopic, payload.threadsInTopic);
}
state.avatars = { ...state.avatars, ...payload.avatars };
state.nextPage = (payload.page < payload.pagination.numPages) ? payload.page + 1 : null;
state.totalPages = payload.pagination.numPages;
@@ -155,9 +161,11 @@ const threadsSlice = createSlice({
},
updateThreadFailed: (state) => {
state.postStatus = RequestStatus.FAILED;
state.totalThreads = 0;
},
updateThreadDenied: (state) => {
state.postStatus = RequestStatus.DENIED;
state.totalThreads = 0;
},
deleteThreadRequest: (state) => {
state.postStatus = RequestStatus.IN_PROGRESS;
@@ -168,7 +176,7 @@ const threadsSlice = createSlice({
state.postStatus = RequestStatus.SUCCESSFUL;
state.threadsInTopic[topicId] = state.threadsInTopic[topicId].filter(item => item !== threadId);
state.pages = state.pages.map(page => page?.filter(item => item !== threadId));
delete state.threadsById[threadId];
state.threadsById = omitBy(state.threadsById, (thread) => thread.id === threadId);
},
deleteThreadFailed: (state) => {
state.postStatus = RequestStatus.FAILED;
@@ -210,6 +218,19 @@ const threadsSlice = createSlice({
clearRedirect: (state) => {
state.redirectToThread = null;
},
clearFilter: (state) => {
state.filters = {
status: PostsStatusFilter.ALL,
postType: ThreadType.ALL,
cohort: '',
search: '',
};
state.pages = [];
},
clearSort: (state) => {
state.sortedBy = ThreadOrdering.BY_LAST_ACTIVITY;
state.pages = [];
},
},
});
@@ -246,6 +267,8 @@ export const {
hidePostEditor,
clearRedirect,
clearPostsPages,
clearFilter,
clearSort,
} = threadsSlice.actions;
export const threadsReducer = threadsSlice.reducer;

View File

@@ -102,6 +102,7 @@ export function fetchThreads(courseId, {
author = null,
filters = {},
page = 1,
isFilterChanged,
countFlagged,
} = {}) {
const options = {
@@ -141,7 +142,7 @@ export function fetchThreads(courseId, {
const data = await getThreads(courseId, options);
const normalisedData = normaliseThreads(camelCaseObject(data), topicIds);
dispatch(fetchThreadsSuccess({
...normalisedData, page, author, textSearchRewrite: data.text_search_rewrite,
...normalisedData, page, author, textSearchRewrite: data.text_search_rewrite, isFilterChanged,
}));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {

View File

@@ -1,5 +1,4 @@
import React from 'react';
import PropTypes from 'prop-types';
import React, { useContext } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -12,8 +11,11 @@ import { Close } from '@edx/paragon/icons';
import Search from '../../../components/Search';
import { RequestStatus } from '../../../data/constants';
import { selectBlackoutDate, selectconfigLoadingStatus } from '../../data/selectors';
import { inBlackoutDateRange, postMessageToParent } from '../../utils';
import { DiscussionContext } from '../../common/context';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { selectconfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
import { postMessageToParent } from '../../utils';
import { showPostEditor } from '../data';
import messages from './messages';
@@ -21,38 +23,44 @@ import './actionBar.scss';
function PostActionsBar({
intl,
inContext,
}) {
const dispatch = useDispatch();
const loadingStatus = useSelector(selectconfigLoadingStatus);
const blackoutDateRange = useSelector(selectBlackoutDate);
const enableInContext = useSelector(selectEnableInContext);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const { enableInContextSidebar, page } = useContext(DiscussionContext);
const handleCloseInContext = () => {
postMessageToParent('learning.events.sidebar.close');
};
return (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !inContext })}>
{!inContext && <Search />}
{inContext && (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
{!enableInContextSidebar && (
(enableInContext && ['topics', 'category'].includes(page))
? <IncontextSearch />
: <Search />
)}
{enableInContextSidebar && (
<h4 className="d-flex flex-grow-1 font-weight-bold my-0 py-0 align-self-center">
{intl.formatMessage(messages.title)}
</h4>
)}
{(!inBlackoutDateRange(blackoutDateRange) && loadingStatus === RequestStatus.SUCCESSFUL) && (
{loadingStatus === RequestStatus.SUCCESSFUL && userCanAddThreadInBlackoutDate
&& (
<>
{!inContext && <div className="border-right border-light-400 mx-3" />}
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
<Button
variant={inContext ? 'plain' : 'brand'}
className={classNames('my-0', { 'p-0': inContext })}
variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames('my-0', { 'p-0': enableInContextSidebar })}
onClick={() => dispatch(showPostEditor())}
size={inContext ? 'md' : 'sm'}
size={enableInContextSidebar ? 'md' : 'sm'}
>
{intl.formatMessage(messages.addAPost)}
</Button>
</>
)}
{inContext && (
{enableInContextSidebar && (
<>
<div className="border-right border-light-300 mr-2 ml-3.5 my-2" />
<IconButton
@@ -69,7 +77,6 @@ function PostActionsBar({
PostActionsBar.propTypes = {
intl: intlShape.isRequired,
inContext: PropTypes.bool.isRequired,
};
export default injectIntl(PostActionsBar);

View File

@@ -28,12 +28,19 @@ import { useCurrentDiscussionTopic } from '../../data/hooks';
import {
selectAnonymousPostingConfig,
selectDivisionSettings,
selectEnableInContext,
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../data/selectors';
import { EmptyPage } from '../../empty-posts';
import {
selectArchivedTopics,
selectCoursewareTopics as inContextCourseware,
selectNonCoursewareIds as inContextCoursewareIds,
selectNonCoursewareTopics as inContextNonCourseware,
} from '../../in-context-topics/data/selectors';
import { selectCoursewareTopics, selectNonCoursewareIds, selectNonCoursewareTopics } from '../../topics/data/selectors';
import {
discussionsPath, formikCompatibleHandler, isFormikFieldInvalid, useCommentsPagePath,
@@ -52,7 +59,7 @@ function DiscussionPostType({
}) {
// Need to use regular label since Form.Label doesn't support overriding htmlFor
return (
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-2 mr-3">
<label htmlFor={`post-type-${value}`} className="d-flex p-0 my-0 mr-3">
<Form.Radio value={value} id={`post-type-${value}`} className="sr-only">{type}</Form.Radio>
<Card
className={classNames('border-2 shadow-none', {
@@ -94,10 +101,12 @@ function PostEditor({
courseId,
postId,
} = useParams();
const { category, enableInContextSidebar } = useContext(DiscussionContext);
const topicId = useCurrentDiscussionTopic();
const nonCoursewareTopics = useSelector(selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(selectNonCoursewareIds);
const coursewareTopics = useSelector(selectCoursewareTopics);
const enableInContext = useSelector(selectEnableInContext);
const nonCoursewareTopics = useSelector(enableInContext ? inContextNonCourseware : selectNonCoursewareTopics);
const nonCoursewareIds = useSelector(enableInContext ? inContextCoursewareIds : selectNonCoursewareIds);
const coursewareTopics = useSelector(enableInContext ? inContextCourseware : selectCoursewareTopics);
const cohorts = useSelector(selectCourseCohorts);
const post = useSelector(selectThread(postId));
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
@@ -106,7 +115,7 @@ function PostEditor({
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff);
const { category, inContext } = useContext(DiscussionContext);
const archivedTopics = useSelector(selectArchivedTopics);
const canDisplayEditReason = (reasonCodesEnabled && editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
@@ -138,7 +147,7 @@ function PostEditor({
follow: isEmpty(post?.following) ? true : post?.following,
anonymous: allowAnonymous ? false : undefined,
anonymousToPeers: allowAnonymousToPeers ? false : undefined,
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
editReasonCode: post?.lastEdit?.reasonCode || (userIsStaff && canDisplayEditReason ? 'violates-guidelines' : undefined),
cohort: post?.cohort || 'default',
};
@@ -240,6 +249,10 @@ function PostEditor({
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const handleInContextSelectLabel = (section, subsection) => (
`${section.displayName} / ${subsection.displayName}` || intl.formatMessage(messages.unnamedTopics)
);
return (
<Formik
enableReinitialize
@@ -257,11 +270,11 @@ function PostEditor({
resetForm,
}) => (
<Form className="m-4 card p-4 post-form" onSubmit={handleSubmit}>
<h3 className="mb-3">
<h4 className="mb-4" style={{ lineHeight: '16px' }}>
{editExisting
? intl.formatMessage(messages.editPostHeading)
: intl.formatMessage(messages.addPostHeading)}
</h3>
</h4>
<Form.RadioSet
name="postType"
className="d-flex flex-row flex-wrap"
@@ -275,14 +288,12 @@ function PostEditor({
selected={values.postType === 'discussion'}
type={intl.formatMessage(messages.discussionType)}
icon={<Post />}
description={intl.formatMessage(messages.discussionDescription)}
/>
<DiscussionPostType
value="question"
selected={values.postType === 'question'}
type={intl.formatMessage(messages.questionType)}
icon={<Help />}
description={intl.formatMessage(messages.questionDescription)}
/>
</Form.RadioSet>
<div className="d-flex flex-row my-4.5 justify-content-between">
@@ -296,7 +307,7 @@ function PostEditor({
onBlur={handleBlur}
aria-describedby="topicAreaInput"
floatingLabel={intl.formatMessage(messages.topicArea)}
disabled={inContext}
disabled={enableInContextSidebar}
>
{nonCoursewareTopics.map(topic => (
<option
@@ -305,15 +316,46 @@ function PostEditor({
>{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
{coursewareTopics.map(categoryObj => (
<optgroup label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)} key={categoryObj.id}>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
{enableInContext ? (
<>
{coursewareTopics?.map(section => (
section?.children?.map(subsection => (
<optgroup
label={handleInContextSelectLabel(section, subsection)}
key={subsection.id}
>
{subsection?.children?.map(unit => (
<option key={unit.id} value={unit.id}>
{unit.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
))}
</optgroup>
))}
{(userIsStaff || userIsGroupTa || userHasModerationPrivileges) && (
<optgroup label={intl.formatMessage(messages.archivedTopics)}>
{archivedTopics.map(topic => (
<option key={topic.id} value={topic.id}>
{topic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
)}
</>
) : (
coursewareTopics.map(categoryObj => (
<optgroup
label={categoryObj.name || intl.formatMessage(messages.unnamedTopics)}
key={categoryObj.id}
>
{categoryObj.topics.map(subtopic => (
<option key={subtopic.id} value={subtopic.id}>
{subtopic.name || intl.formatMessage(messages.unnamedSubTopics)}
</option>
))}
</optgroup>
))
)}
</Form.Control>
</Form.Group>
{canSelectCohort(values.topic) && (
@@ -336,8 +378,8 @@ function PostEditor({
</Form.Group>
)}
</div>
<div className="border-bottom border-light-400" />
<div className="d-flex flex-row my-4.5 justify-content-between">
<div className="d-flex flex-row mb-4.5 justify-content-between">
<Form.Group
className="w-100 m-0"
isInvalid={isFormikFieldInvalid('title', {
@@ -359,7 +401,7 @@ function PostEditor({
</Form.Group>
{canDisplayEditReason && (
<Form.Group
className="w-100 ml-3 mb-0"
className="w-100 ml-4 mb-0"
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
@@ -384,7 +426,7 @@ function PostEditor({
</Form.Group>
)}
</div>
<div className="mb-2">
<div className="mb-3">
<TinyMCEEditor
onInit={
/* istanbul ignore next: TinyMCE is mocked so this cannot be easily tested */
@@ -402,7 +444,7 @@ function PostEditor({
<PostPreviewPane htmlNode={values.comment} isPost editExisting={editExisting} />
<div className="d-flex flex-row mt-n4.5 w-75 text-primary">
<div className="d-flex flex-row mt-n4 w-75 text-primary">
{!editExisting && (
<>
<Form.Group>
@@ -413,7 +455,9 @@ function PostEditor({
onBlur={handleBlur}
className="mr-4.5"
>
{intl.formatMessage(messages.followPost)}
<span className="font-size-14">
{intl.formatMessage(messages.followPost)}
</span>
</Form.Checkbox>
</Form.Group>
{allowAnonymousToPeers && (
@@ -424,7 +468,9 @@ function PostEditor({
onChange={handleChange}
onBlur={handleBlur}
>
{intl.formatMessage(messages.anonymousToPeersPost)}
<span className="font-size-14">
{intl.formatMessage(messages.anonymousToPeersPost)}
</span>
</Form.Checkbox>
</Form.Group>
)}
@@ -432,7 +478,7 @@ function PostEditor({
)}
</div>
<div className="d-flex justify-content-end mt-2.5">
<div className="d-flex justify-content-end">
<Button
variant="outline-primary"
onClick={() => hideEditor(resetForm)}

View File

@@ -136,6 +136,11 @@ const messages = defineMessages({
defaultMessage: 'Thread not found',
description: 'message to show on screen if the request thread is not found in course',
},
archivedTopics: {
id: 'discussions.topics.archived.label',
defaultMessage: 'Archived',
description: 'Heading for displaying topics that are archived.',
},
});
export default messages;

View File

@@ -8,6 +8,7 @@ import { capitalize, isEmpty, toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Collapsible, Form, Icon, Spinner,
@@ -84,6 +85,13 @@ function PostFilterBar({
name,
value,
} = event.currentTarget;
const filterContentEventProperties = {
statusFilter: currentStatus,
threadTypeFilter: currentType,
sortFilter: currentSorting,
cohortFilter: selectedCohort,
triggeredBy: name,
};
if (name === 'type') {
dispatch(setPostsTypeFilter(value));
if (
@@ -92,6 +100,7 @@ function PostFilterBar({
// You can't filter discussions by unanswered
dispatch(setStatusFilter(PostsStatusFilter.ALL));
}
filterContentEventProperties.threadTypeFilter = value;
}
if (name === 'status') {
dispatch(setStatusFilter(value));
@@ -103,13 +112,17 @@ function PostFilterBar({
// You can't filter questions by not responded so switch type to discussion
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
}
filterContentEventProperties.statusFilter = value;
}
if (name === 'sort') {
dispatch(setSortedBy(value));
filterContentEventProperties.sortFilter = value;
}
if (name === 'cohort') {
dispatch(setCohortFilter(value));
filterContentEventProperties.cohortFilter = value;
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
};
useEffect(() => {
@@ -135,12 +148,15 @@ function PostFilterBar({
cohort: capitalize(selectedCohort?.name),
})}
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
<span id="icon-tune">
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</span>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>

View File

@@ -1,17 +1,18 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants';
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
import { AlertBanner, DeleteConfirmation } from '../../common';
import { AlertBanner, Confirmation } from '../../common';
import { DiscussionContext } from '../../common/context';
import { selectModerationSettings } from '../../data/selectors';
import { selectTopic } from '../../topics/data/selectors';
import { removeThread, updateExistingThread } from '../data/thunks';
@@ -29,15 +30,37 @@ function Post({
const location = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const { courseId } = useSelector((state) => state.courseTabs);
const topic = useSelector(selectTopic(post.topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(post.topicId));
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
const postURL = new URL(`${getConfig().PUBLIC_PATH}${courseId}/posts/${post.id}`, window.location.origin);
const handleAbusedFlag = () => {
if (post.abuseFlagged) {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
} else {
showReportConfirmation();
}
};
const handleDeleteConfirmation = async () => {
await dispatch(removeThread(post.id));
history.push({
pathname: '.',
search: enableInContextSidebar && '?inContextSidebar',
});
hideDeleteConfirmation();
};
const handleReportConfirmation = () => {
dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged }));
hideReportConfirmation();
};
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => history.push({
@@ -54,9 +77,9 @@ function Post({
dispatch(updateExistingThread(post.id, { closed: true }));
}
},
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(postURL.href); },
[ContentActions.COPY_LINK]: () => { navigator.clipboard.writeText(`${window.location.origin}/${courseId}/posts/${post.id}`); },
[ContentActions.PIN]: () => dispatch(updateExistingThread(post.id, { pinned: !post.pinned })),
[ContentActions.REPORT]: () => dispatch(updateExistingThread(post.id, { flagged: !post.abuseFlagged })),
[ContentActions.REPORT]: () => handleAbusedFlag(),
};
const getTopicCategoryName = topicData => (
@@ -65,30 +88,50 @@ function Post({
return (
<div className="d-flex flex-column w-100 mw-100" data-testid={`post-${post.id}`}>
<DeleteConfirmation
<Confirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deletePostTitle)}
description={intl.formatMessage(messages.deletePostDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeThread(post.id));
history.push('.');
hideDeleteConfirmation();
}}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
{!post.abuseFlagged && (
<Confirmation
isOpen={isReporting}
title={intl.formatMessage(messages.reportPostTitle)}
description={intl.formatMessage(messages.reportPostDescription)}
onClose={hideReportConfirmation}
comfirmAction={handleReportConfirmation}
confirmButtonVariant="danger"
/>
)}
<AlertBanner content={post} />
<PostHeader post={post} actionHandlers={actionHandlers} />
<div className="d-flex mt-4 mb-2 text-break font-style-normal text-primary-500">
<HTMLLoader htmlNode={post.renderedBody} id="post" />
</div>
{topicContext && topic && (
<div className="border px-3 rounded mb-4 border-light-400 align-self-start py-2.5">
<div className={classNames('border px-3 rounded mb-4 border-light-400 align-self-start py-2.5',
{ 'w-100': enableInContextSidebar })}
>
<span className="text-gray-500">{intl.formatMessage(messages.relatedTo)}{' '}</span>
<Hyperlink
destination={topicContext.unitLink}
target="_top"
>
{`${getTopicCategoryName(topic)} / ${topic.name}`}
{enableInContextSidebar
? (
<>
<span className="w-auto">{topicContext.chapterName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.verticalName}</span>
<span className="mx-1">/</span>
<span className="w-auto">{topicContext.unitName}</span>
</>
)
: `${getTopicCategoryName(topic)} / ${topic.name}`}
</Hyperlink>
</div>
)}

View File

@@ -22,11 +22,11 @@ export function PostAvatar({
const outlineColor = AvatarOutlineAndLabelColors[authorLabel];
const avatarSize = useMemo(() => {
let size = '1.75rem';
let size = '2rem';
if (post.type === ThreadType.DISCUSSION && !fromPostLink) {
size = '2.375rem';
} else if (post.type === ThreadType.QUESTION) {
size = '1.375rem';
size = '1.5rem';
}
return size;
}, [post.type]);

View File

@@ -15,8 +15,8 @@ import AuthorLabel from '../../common/AuthorLabel';
import { DiscussionContext } from '../../common/context';
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
import messages from './messages';
import PostFooter from './PostFooter';
import { PostAvatar } from './PostHeader';
import PostSummaryFooter from './PostSummaryFooter';
import { postShape } from './proptypes';
function PostLink({
@@ -29,12 +29,12 @@ function PostLink({
const {
page,
postId,
inContext,
enableInContextSidebar,
category,
learnerUsername,
} = useContext(DiscussionContext);
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: inContext ? 'in-context' : undefined,
0: enableInContextSidebar ? 'in-context' : undefined,
courseId: post.courseId,
topicId: post.topicId,
postId: post.id,
@@ -56,24 +56,20 @@ function PostLink({
}
to={linkUrl}
onClick={() => isSelected(post.id)}
style={{ lineHeight: '22px' }}
aria-current={isSelected(post.id) ? 'page' : undefined}
role="option"
tabIndex={(isSelected(post.id) || idx === 0) ? 0 : -1}
>
<div
className={
classNames('d-flex flex-row pt-2.5 pb-2 px-4 border-primary-500 position-relative',
{ 'bg-light-300': read })
classNames('d-flex flex-row pt-2 pb-2 px-4 border-primary-500 position-relative',
{ 'bg-light-300': read },
{ 'post-summary-card-selected': post.id === postId })
}
style={post.id === postId ? {
borderRightWidth: '4px',
borderRightStyle: 'solid',
} : null}
>
<PostAvatar post={post} authorLabel={post.authorLabel} fromPostLink read={read} />
<div className="d-flex flex-column flex-fill" style={{ minWidth: 0 }}>
<div className="d-flex flex-column justify-content-start mw-100 flex-fill">
<div className="d-flex flex-column justify-content-start mw-100 flex-fill" style={{ marginBottom: '-3px' }}>
<div className="d-flex align-items-center pb-0 mb-0 flex-fill font-weight-500">
<Truncate lines={1} className="mr-1.5" whiteSpace>
<span
@@ -100,20 +96,20 @@ function PostLink({
)}
{canSeeReportedBadge && (
<Badge
variant="danger"
data-testid="reported-post"
className={`font-weight-500 badge-padding ${showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
>
{intl.formatMessage(messages.contentReported)}
<span className="sr-only">{' '}reported</span>
</Badge>
<Badge
variant="danger"
data-testid="reported-post"
className={`font-weight-500 badge-padding ${showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
>
{intl.formatMessage(messages.contentReported)}
<span className="sr-only">{' '}reported</span>
</Badge>
)}
{post.pinned && (
<Icon
src={PushPin}
className={`icon-size ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
className={`post-summary-icons-dimensions text-gray-700 ${canSeeReportedBadge || showAnsweredBadge ? 'ml-2' : 'ml-auto'}`}
/>
)}
</div>
@@ -123,7 +119,7 @@ function PostLink({
authorLabel={post.authorLabel}
labelColor={authorLabelColor && `text-${authorLabelColor}`}
/>
<PostFooter post={post} preview intl={intl} showNewCountLabel={read} />
<PostSummaryFooter post={post} preview showNewCountLabel={read} />
</div>
</div>
{!showDivider && post.pinned && <div className="pt-1 bg-light-500 border-top border-light-700" />}

View File

@@ -0,0 +1,147 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Badge, Icon, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
Locked,
} from '@edx/paragon/icons';
import {
People,
QuestionAnswer,
QuestionAnswerOutline,
StarFilled,
StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../../components/icons';
import timeLocale from '../../common/time-locale';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import messages from './messages';
import { postShape } from './proptypes';
function PostSummaryFooter({
post,
intl,
preview,
showNewCountLabel,
}) {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
timeago.register('time-locale', timeLocale);
return (
<div className="d-flex align-items-center text-gray-700">
<div className="d-flex align-items-center mr-4.5">
<OverlayTrigger
overlay={(
<Tooltip id={`liked-${post.id}-tooltip`}>
{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}
</Tooltip>
)}
>
<Icon src={post.voted ? ThumbUpFilled : ThumbUpOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '}{intl.formatMessage(post.voted ? messages.likedPost : messages.postLikes)}</span>
</Icon>
</OverlayTrigger>
<div className="font-family-inter font-style-normal">
{(post.voteCount && post.voteCount > 0) ? post.voteCount : null}
</div>
</div>
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(post.following ? messages.followed : messages.notFollowed)}
</Tooltip>
)}
>
<Icon src={post.following ? StarFilled : StarOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '}{ intl.formatMessage(post.following ? messages.srOnlyFollowDescription : messages.srOnlyUnFollowDescription)}</span>
</Icon>
</OverlayTrigger>
{preview && post.commentCount > 1 && (
<div className="d-flex align-items-center ml-4.5">
<OverlayTrigger
overlay={(
<Tooltip id={`follow-${post.id}-tooltip`}>
{intl.formatMessage(messages.activity)}
</Tooltip>
)}
>
<Icon src={post.unreadCommentCount ? QuestionAnswer : QuestionAnswerOutline} className="post-summary-icons-dimensions mr-0.5">
<span className="sr-only">{' '} {intl.formatMessage(messages.activity)}</span>
</Icon>
</OverlayTrigger>
{post.commentCount}
</div>
)}
{showNewCountLabel && preview && post?.unreadCommentCount > 0 && post.commentCount > 1 && (
<Badge variant="light" className="ml-2">
{intl.formatMessage(messages.newLabel, { count: post.unreadCommentCount })}
</Badge>
)}
<div className="d-flex flex-fill justify-content-end align-items-center">
{post.groupId && userHasModerationPrivileges && (
<>
<OverlayTrigger
overlay={(
<Tooltip id={`visibility-${post.id}-tooltip`}>{post.groupName}</Tooltip>
)}
>
<span data-testid="cohort-icon" className="post-summary-icons-dimensions">
<People />
</span>
</OverlayTrigger>
<span
className="text-gray-700 mx-1.5 font-weight-500"
style={{ fontSize: '16px' }}
>
·
</span>
</>
)}
<span title={post.createdAt} className="text-gray-700 post-summary-timestamp">
{timeago.format(post.createdAt, 'time-locale')}
</span>
{!preview && post.closed
&& (
<OverlayTrigger
overlay={(
<Tooltip id={`closed-${post.id}-tooltip`}>
{intl.formatMessage(messages.postClosed)}
</Tooltip>
)}
>
<Icon
src={Locked}
style={{
width: '1rem',
height: '1rem',
}}
className="ml-3 post-summary-icons-dimensions"
/>
</OverlayTrigger>
)}
</div>
</div>
);
}
PostSummaryFooter.propTypes = {
intl: intlShape.isRequired,
post: postShape.isRequired,
preview: PropTypes.bool,
showNewCountLabel: PropTypes.bool,
};
PostSummaryFooter.defaultProps = {
preview: false,
showNewCountLabel: false,
};
export default injectIntl(PostSummaryFooter);

View File

@@ -28,6 +28,16 @@ const messages = defineMessages({
defaultMessage: 'Follow',
description: 'Tooltip/alttext for button to follow a discussion post',
},
followed: {
id: 'discussions.post.followed',
defaultMessage: 'Followed',
description: 'Tooltip/alttext for follow icon showing user followed a post',
},
notFollowed: {
id: 'discussions.post.notFollowed',
defaultMessage: 'Not Followed',
description: 'Tooltip/alttext for follow icon showing user not following a post',
},
answered: {
id: 'discussions.post.answered',
defaultMessage: 'Answered',
@@ -48,11 +58,26 @@ const messages = defineMessages({
defaultMessage: 'Unlike',
description: 'Tooltip/alttext for button to remove the like applied to a discussion post',
},
likedPost: {
id: 'discussions.post.liked',
defaultMessage: 'liked',
description: 'Tooltip/alttext for like icon to tell user this post is liked by user',
},
postLikes: {
id: 'discussions.post.likes',
defaultMessage: 'likes',
description: 'Tooltip/alttext for like icon to tell user about like on the post',
},
viewActivity: {
id: 'discussions.post.viewActivity',
defaultMessage: 'View activity',
description: 'Tooltip/alttext for button to view the activity of a discussion post',
},
activity: {
id: 'discussions.post.activity',
defaultMessage: 'Activity',
description: 'Tooltip/alttext for icon for showing icon represents activity on a post',
},
postClosed: {
id: 'discussions.post.closed',
defaultMessage: 'Post closed for responses and comments',
@@ -71,6 +96,21 @@ const messages = defineMessages({
id: 'discussions.editor.delete.post.description',
defaultMessage: 'Are you sure you want to permanently delete this post?',
},
deleteConfirmationDelete: {
id: 'discussions.post.delete.confirmation.button.delete',
defaultMessage: 'Delete',
description: 'Delete button shown on delete confirmation dialog',
},
reportPostTitle: {
id: 'discussions.editor.report.post.title',
defaultMessage: 'Report inappropriate content?',
description: 'Title of confirmation dialog shown when reporting a post',
},
reportPostDescription: {
id: 'discussions.editor.report.post.description',
defaultMessage: 'The discussion moderation team will review this content and take appropriate action.',
description: 'Text displayed in confirmation dialog when deleting a post',
},
closePostModalTitle: {
id: 'discussions.post.closePostModal.title',
defaultMessage: 'Close post',
@@ -116,6 +156,16 @@ const messages = defineMessages({
defaultMessage: 'No preview available',
description: 'No preview available',
},
srOnlyFollowDescription: {
id: 'discussions.post.follow.description',
defaultMessage: 'you are following this post',
description: 'tell screen readers if user is following a post',
},
srOnlyUnFollowDescription: {
id: 'discussions.post.unfollow.description',
defaultMessage: 'you are not following this post',
description: 'tell screen readers if user is not following a post',
},
});
export default messages;

View File

@@ -4,17 +4,15 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import SearchInfo from '../../components/SearchInfo';
import { DiscussionProvider, RequestStatus } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { RequestStatus } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectDiscussionProvider } from '../data/selectors';
import NoResults from '../posts/NoResults';
import { handleKeyDown } from '../utils';
import { selectCategories, selectNonCoursewareTopics, selectTopicFilter } from './data/selectors';
import { setFilter, setTopicsCount } from './data/slices';
import { fetchCourseTopics } from './data/thunks';
import ArchivedTopicGroup from './topic-group/ArchivedTopicGroup';
import LegacyTopicGroup from './topic-group/LegacyTopicGroup';
import SequenceTopicGroup from './topic-group/SequenceTopicGroup';
import Topic from './topic-group/topic/Topic';
import countFilteredTopics from './utils';
@@ -38,24 +36,6 @@ function CourseWideTopics() {
));
}
function CoursewareTopics() {
const sequences = useSelector(selectSequences);
return (
<>
{ sequences?.map(
sequence => (
<SequenceTopicGroup
sequence={sequence}
key={sequence.id}
/>
),
)}
<ArchivedTopicGroup />
</>
);
}
function LegacyCoursewareTopics() {
const { category } = useParams();
const categories = useSelector(selectCategories)
@@ -80,20 +60,6 @@ function TopicsView() {
const { courseId } = useContext(DiscussionContext);
const dispatch = useDispatch();
const handleKeyDown = (event) => {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
};
useEffect(() => {
// Don't load till the provider information is available
if (provider) {
@@ -118,8 +84,7 @@ function TopicsView() {
)}
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
<CourseWideTopics />
{provider === DiscussionProvider.OPEN_EDX && <CoursewareTopics />}
{provider === DiscussionProvider.LEGACY && <LegacyCoursewareTopics />}
<LegacyCoursewareTopics />
</div>
{
filteredTopicsCount === 0

View File

@@ -1,5 +1,5 @@
import {
fireEvent, render, screen, within,
fireEvent, render, screen,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
@@ -10,11 +10,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getBlocksAPIResponse } from '../../data/__factories__';
import { getBlocksAPIURL } from '../../data/api';
import { DiscussionProvider, getApiBaseUrl } from '../../data/constants';
import { selectSequences } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { getApiBaseUrl } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
@@ -27,7 +23,6 @@ import './data/__factories__';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
const topicsv2ApiUrl = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
let store;
let axiosMock;
let lastLocation;
@@ -57,131 +52,86 @@ function renderComponent() {
);
}
describe('TopicsView', () => {
describe.each(['legacy', 'openedx'])('%s provider', (provider) => {
let inContextTopics;
let globalTopics;
let categories;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore({
config: { provider },
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
describe('Legacy Topics View', () => {
let inContextTopics;
let globalTopics;
let categories;
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
async function setupMockResponse() {
if (provider === 'legacy') {
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = state.topics.categoryIds;
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
} else {
const blocksAPIResponse = getBlocksAPIResponse(true);
const ids = Object.values(blocksAPIResponse.blocks).filter(block => block.type === 'vertical')
.map(block => block.block_id);
const deletedIds = [
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-1',
'block-v1:edX+DemoX+Demo_Course+type@vertical+block@deleted-vertical-2',
];
const data = [
...Factory.buildList('topic.v2', 2, { usage_key: null }, { topicPrefix: 'ncw' }),
...ids.map(id => Factory.build('topic.v2', { id })),
...deletedIds.map(id => Factory.build('topic.v2', { id, enabled_in_context: false }, { topicPrefix: 'archived ' })),
];
axiosMock
.onGet(topicsv2ApiUrl)
.reply(200, data);
axiosMock.onGet(getBlocksAPIURL())
.reply(200, getBlocksAPIResponse(true));
axiosMock.onAny().networkError();
await executeThunk(fetchCourseBlocks(courseId, 'abc123'), store.dispatch, store.getState);
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = selectSequences(state);
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
}
}
it('displays non-courseware topics', async () => {
await setupMockResponse();
renderComponent();
globalTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
store = initializeStore({
config: { provider: 'legacy' },
blocks: {
topics: {},
},
});
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
lastLocation = undefined;
});
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
categories.forEach(category => {
// For the new provider categories are blocks so use the display name
// otherwise use the category itself which is a string
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
async function setupMockResponse() {
axiosMock
.onGet(topicsApiUrl)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
const state = store.getState();
categories = state.topics.categoryIds;
globalTopics = selectNonCoursewareTopics(state);
inContextTopics = selectCoursewareTopics(state);
}
const topicGroups = screen.queryAllByTestId('topic-group');
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(
provider === DiscussionProvider.LEGACY
? categories.length
: categories.length + 1,
);
});
it('displays non-courseware topics', async () => {
await setupMockResponse();
renderComponent();
if (provider === DiscussionProvider.OPEN_EDX) {
it('displays archived topics', async () => {
await setupMockResponse();
renderComponent();
const archivedTopicGroup = screen.queryAllByTestId('topic-group').pop();
expect(archivedTopicGroup).toHaveTextContent(/archived/i);
const archivedTopicLinks = within(archivedTopicGroup).queryAllByRole('option');
expect(archivedTopicLinks).toHaveLength(2);
});
}
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();
inContextTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('clicking on courseware topic (category) takes to category page', async () => {
await setupMockResponse();
renderComponent();
const categoryName = categories[0].displayName || categories[0];
const categoryPath = provider === 'legacy' ? categoryName : categories[0].id;
const topic = await screen.findByText(categoryName);
fireEvent.click(topic);
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
globalTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('displays non-courseware outside of a topic group', async () => {
await setupMockResponse();
renderComponent();
categories.forEach(category => {
// For the new provider categories are blocks so use the display name
// otherwise use the category itself which is a string
expect(screen.queryByText(category.displayName || category)).toBeInTheDocument();
});
const topicGroups = screen.queryAllByTestId('topic-group');
// For the new provider there should be a section for archived topics
expect(topicGroups).toHaveLength(categories.length);
});
it('displays courseware topics', async () => {
await setupMockResponse();
renderComponent();
inContextTopics.forEach(topic => {
expect(screen.queryByText(topic.name)).toBeInTheDocument();
});
});
it('clicking on courseware topic (category) takes to category page', async () => {
await setupMockResponse();
renderComponent();
const categoryName = categories[0].displayName || categories[0];
const categoryPath = categoryName;
const topic = await screen.findByText(categoryName);
fireEvent.click(topic);
expect(lastLocation.pathname.endsWith(`/category/${categoryPath}`)).toBeTruthy();
});
});

View File

@@ -13,14 +13,3 @@ export async function getCourseTopics(courseId, topicIds) {
.get(url);
return data;
}
export async function getCourseTopicsV2(courseId, topicIds) {
const url = `${getApiBaseUrl()}/api/discussion/v2/course_topics/${courseId}`;
const params = {};
if (topicIds) {
params.topic_id = topicIds.join(',');
}
const { data } = await getAuthenticatedHttpClient()
.get(url);
return data;
}

View File

@@ -2,10 +2,6 @@
import { createSelector } from '@reduxjs/toolkit';
import { DiscussionProvider } from '../../../data/constants';
import { selectSequences } from '../../../data/selectors';
import { selectDiscussionProvider } from '../../data/selectors';
export const selectTopicFilter = state => state.topics.filter.trim()
.toLowerCase();
@@ -19,29 +15,22 @@ export const selectTopicsInCategory = (categoryId) => state => (
export const selectTopics = state => state.topics.topics;
export const selectCoursewareTopics = createSelector(
selectDiscussionProvider,
selectCategories,
selectTopicCategoryMap,
selectTopics,
selectSequences,
(provider, categoryIds, topicsInCategory, topics, sequences) => (
provider === DiscussionProvider.LEGACY
? categoryIds.map(category => ({
id: category,
name: category,
topics: topicsInCategory[category].map(id => topics[id]),
}))
: sequences.map(sequence => ({
id: sequence.id,
name: sequence.displayName,
topics: sequence.topics.map(topicId => ({ id: topicId, name: topics[topicId]?.name || 'unnamed' })),
}))
(categoryIds, topicsInCategory, topics) => (
categoryIds.map(category => ({
id: category,
name: category,
topics: topicsInCategory[category].map(id => topics[id]),
}))
),
);
export const selectNonCoursewareIds = state => state.topics.nonCoursewareIds;
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds.map(id => state.topics.topics[id]);
export const selectNonCoursewareTopics = state => state.topics.nonCoursewareIds?.map(id => state.topics.topics[id])
|| [];
export const selectTopic = topicId => state => state.topics.topics[topicId];

View File

@@ -11,8 +11,6 @@ const topicsSlice = createSlice({
categoryIds: [],
// List of all non-courseware topics
nonCoursewareIds: [],
// Topics that have been archived
archivedIds: [],
// Mapping of all topics in each category
topicsInCategory: {},
// Map of topics ids to topic data
@@ -32,7 +30,6 @@ const topicsSlice = createSlice({
state.topics = payload.topics;
state.nonCoursewareIds = payload.nonCoursewareIds;
state.categoryIds = payload.categoryIds;
state.archivedIds = payload.archivedIds;
state.topicsInCategory = payload.topicsInCategory;
},
fetchCourseTopicsFailed: (state) => {

View File

@@ -2,8 +2,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { DiscussionProvider } from '../../../data/constants';
import { getCourseTopics, getCourseTopicsV2 } from './api';
import { getCourseTopics } from './api';
import { fetchCourseTopicsFailed, fetchCourseTopicsRequest, fetchCourseTopicsSuccess } from './slices';
function normaliseTopics(data) {
@@ -26,34 +25,12 @@ function normaliseTopics(data) {
};
}
function normaliseTopicsV2(data) {
const nonCoursewareIds = [];
const topics = {};
const archivedIds = [];
data.forEach(topic => {
if (!topic.enabledInContext) {
archivedIds.push(topic.id);
} else if (topic.usageKey === null) {
nonCoursewareIds.push(topic.id);
}
topics[topic.id] = topic;
});
return {
topics, nonCoursewareIds, archivedIds,
};
}
export function fetchCourseTopics(courseId) {
return async (dispatch, getState) => {
return async (dispatch) => {
try {
const { config } = getState();
dispatch(fetchCourseTopicsRequest({ courseId }));
let data = {};
if (config.provider === DiscussionProvider.LEGACY) {
data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
} else if (config.provider === DiscussionProvider.OPEN_EDX) {
data = normaliseTopicsV2(camelCaseObject(await getCourseTopicsV2(courseId)));
}
const data = normaliseTopics(camelCaseObject(await getCourseTopics(courseId)));
dispatch(fetchCourseTopicsSuccess(data));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
@@ -61,16 +38,3 @@ export function fetchCourseTopics(courseId) {
}
};
}
export function fetchCourseTopicsV2(courseId) {
return async (dispatch) => {
try {
dispatch(fetchCourseTopicsRequest({ courseId }));
const data = await getCourseTopicsV2(courseId);
dispatch(fetchCourseTopicsSuccess(normaliseTopicsV2(camelCaseObject(data))));
} catch (error) {
dispatch(fetchCourseTopicsFailed());
logError(error);
}
};
}

View File

@@ -10,7 +10,7 @@ export default function countFilteredTopics(topicsSelector, provider) {
? item.name.toLowerCase().includes(query)
: true
));
count += nonCoursewareTopicsList.length;
count += nonCoursewareTopicsList?.length;
// Counting legacy topics
if (provider === DiscussionProvider.LEGACY) {
const categories = topicsSelector?.categoryIds;

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react';
import isEmpty from 'lodash/isEmpty';
import { useDispatch } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ProductTour } from '@edx/paragon';
import { useTourConfiguration } from '../data/hooks';
import { fetchDiscussionTours } from './data/thunks';
function DiscussionsProductTour({ intl }) {
const dispatch = useDispatch();
const config = useTourConfiguration(intl);
useEffect(() => {
dispatch(fetchDiscussionTours());
}, []);
return (
<>
{!isEmpty(config) && (
<ProductTour
tours={config}
/>
)}
</>
);
}
DiscussionsProductTour.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(DiscussionsProductTour);

View File

@@ -0,0 +1,14 @@
import messages from './messages';
export default function tourCheckpoints(intl) {
return {
NOT_RESPONDED_FILTER: [
{
body: intl.formatMessage(messages.notRespondedFilterTourBody),
placement: 'right',
target: '#icon-tune',
title: intl.formatMessage(messages.notRespondedFilterTourTitle),
},
],
};
}

View File

@@ -0,0 +1,30 @@
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
// create constant for the API URL
export const getDiscussionTourUrl = () => `${getConfig().LMS_BASE_URL}/api/user_tours/discussion_tours/`;
/**
* getDiscussionTours
* This function makes an HTTP GET request to the API to retrieve a list of tours for the authenticated user.
* @returns {Promise} - A promise that resolves to the API response data.
*/
export async function getDiscssionTours() {
const { data } = await getAuthenticatedHttpClient()
.get(getDiscussionTourUrl());
return data;
}
/**
* updateDiscussionTour
* This function makes an HTTP PUT request to the API to update a specific tour for the authenticated user.
* @param {number} tourId - The ID of the tour to be updated.
* @returns {Promise} - A promise that resolves to the API response data.
*/
export async function updateDiscussionTour(tourId) {
const { data } = await getAuthenticatedHttpClient()
.put(`${getDiscussionTourUrl()}${tourId}`, {
show_tour: false,
});
return data;
}

View File

@@ -0,0 +1 @@
export * from './slices';

View File

@@ -0,0 +1,218 @@
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { RequestStatus } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { getDiscussionTourUrl } from './api';
import { selectTours } from './selectors';
import {
discussionsTourRequest,
discussionsToursRequestError,
fetchUserDiscussionsToursSuccess,
toursReducer,
updateUserDiscussionsTourSuccess,
} from './slices';
import { fetchDiscussionTours, updateTourShowStatus } from './thunks';
import discussionTourFactory from './tours.factory';
let mockAxios;
// eslint-disable-next-line no-unused-vars
let store;
const url = getDiscussionTourUrl();
describe('DiscussionToursThunk', () => {
let actualActions;
const dispatch = (action) => {
actualActions.push(action);
};
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
mockAxios = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
actualActions = [];
});
afterEach(() => {
mockAxios.reset();
});
it('dispatches get request, success actions', async () => {
const mockData = discussionTourFactory.buildList(2);
mockAxios.onGet(url)
.reply(200, mockData);
const expectedActions = [
{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
{
type: 'userDiscussionsTours/fetchUserDiscussionsToursSuccess',
payload: mockData,
},
];
await fetchDiscussionTours()(dispatch);
expect(actualActions)
.toEqual(expectedActions);
});
it('dispatches request, and error actions', async () => {
mockAxios.onGet('/api/discussion-tours/')
.reply(500);
const errorAction = [{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
await fetchDiscussionTours()(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
it('dispatches put request, success actions', async () => {
const mockData = discussionTourFactory.build();
mockAxios.onPut(`${url}${1}`)
.reply(200, mockData);
const expectedActions = [
{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
},
{
type: 'userDiscussionsTours/updateUserDiscussionsTourSuccess',
payload: mockData,
},
];
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(expectedActions);
});
it('dispatches update request, and error actions', async () => {
mockAxios.onPut(`${url}${1}`)
.reply(500);
const errorAction = [{
payload: undefined,
type: 'userDiscussionsTours/discussionsTourRequest',
}, {
payload: undefined,
type: 'userDiscussionsTours/discussionsToursRequestError',
}];
await updateTourShowStatus(1)(dispatch);
expect(actualActions)
.toEqual(errorAction);
});
});
describe('toursReducer', () => {
it('handles the discussionsToursRequest action', () => {
const initialState = {
tours: [],
loading: false,
error: null,
};
const state = toursReducer(initialState, discussionsTourRequest());
expect(state)
.toEqual({
tours: [],
loading: RequestStatus.IN_PROGRESS,
error: null,
});
});
it('handles the fetchUserDiscussionsToursSuccess action', () => {
const initialState = {
tours: [],
loading: true,
error: null,
};
const mockData = [{ id: 1 }, { id: 2 }];
const state = toursReducer(initialState, fetchUserDiscussionsToursSuccess(mockData));
expect(state)
.toEqual({
tours: mockData,
loading: RequestStatus.SUCCESSFUL,
error: null,
});
});
it('handles the updateUserDiscussionsTourSuccess action', () => {
const initialState = {
tours: {
tours: [{ id: 1 }, { id: 2 }],
loading: true,
error: null,
},
};
const updatedTour = {
id: 2,
name: 'Updated Tour',
};
const state = toursReducer(initialState, updateUserDiscussionsTourSuccess(updatedTour));
expect(state.tours)
.toEqual({
tours: [{ id: 1 }, updatedTour],
loading: RequestStatus.SUCCESSFUL,
error: null,
});
});
it('handles the discussionsToursRequestError action', () => {
const initialState = {
tours: [],
loading: true,
error: null,
};
const mockError = new Error('Something went wrong');
const state = toursReducer(initialState, discussionsToursRequestError(mockError));
expect(state)
.toEqual({
tours: [],
loading: RequestStatus.FAILED,
error: mockError,
});
});
});
describe('tourSelector', () => {
it('returns the tours list from state', () => {
const state = {
tours: {
tours: [
{ id: 1, tourName: 'not_responded_filter' },
{ id: 2, tourName: 'other_filter' },
],
},
};
const expectedResult = [
{ id: 1, tourName: 'not_responded_filter' },
{ id: 2, tourName: 'other_filter' },
];
expect(selectTours(state)).toEqual(expectedResult);
});
it('returns an empty list if the tours state is not defined', () => {
const state = {
tours: {
tours: [],
},
};
expect(selectTours(state))
.toEqual([]);
});
});

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export const selectTours = (state) => state.tours.tours;

View File

@@ -0,0 +1,44 @@
/* eslint-disable no-param-reassign,import/prefer-default-export */
import { createSlice } from '@reduxjs/toolkit';
import { RequestStatus } from '../../../data/constants';
const userDiscussionsToursSlice = createSlice({
name: 'userDiscussionsTours',
initialState: {
tours: [],
loading: RequestStatus.SUCCESSFUL,
error: null,
},
reducers: {
discussionsTourRequest: (state) => {
state.loading = RequestStatus.IN_PROGRESS;
state.error = null;
},
fetchUserDiscussionsToursSuccess: (state, action) => {
state.tours = action.payload;
state.loading = RequestStatus.SUCCESSFUL;
state.error = null;
},
discussionsToursRequestError: (state, action) => {
state.loading = RequestStatus.FAILED;
state.error = action.payload;
},
updateUserDiscussionsTourSuccess: (state, action) => {
const tourIndex = state.tours.tours.findIndex(tour => tour.id === action.payload.id);
state.tours.tours[tourIndex] = action.payload;
state.tours.loading = RequestStatus.SUCCESSFUL;
state.tours.error = null;
},
},
});
export const {
discussionsTourRequest,
fetchUserDiscussionsToursSuccess,
discussionsToursRequestError,
updateUserDiscussionsTourSuccess,
} = userDiscussionsToursSlice.actions;
export const toursReducer = userDiscussionsToursSlice.reducer;

View File

@@ -0,0 +1,46 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { getDiscssionTours, updateDiscussionTour } from './api';
import {
discussionsTourRequest,
discussionsToursRequestError,
fetchUserDiscussionsToursSuccess,
updateUserDiscussionsTourSuccess,
} from './slices';
/**
* Action thunk to fetch the list of discussion tours for the current user.
* @returns {function} - Thunk that dispatches the request, success, and error actions.
*/
export function fetchDiscussionTours() {
return async (dispatch) => {
try {
dispatch(discussionsTourRequest());
const data = await getDiscssionTours();
dispatch(fetchUserDiscussionsToursSuccess(camelCaseObject(data)));
} catch (error) {
dispatch(discussionsToursRequestError());
logError(error);
}
};
}
/**
* Action thunk to update the show_tour field for a specific discussion tour for the current user.
* @param {number} tourId - The ID of the tour to update.
* @returns {function} - Thunk that dispatches the request, success, and error actions.
*/
export function updateTourShowStatus(tourId) {
return async (dispatch) => {
try {
dispatch(discussionsTourRequest());
const data = await updateDiscussionTour(tourId);
dispatch(updateUserDiscussionsTourSuccess(camelCaseObject(data)));
} catch (error) {
dispatch(discussionsToursRequestError());
logError(error);
}
};
}

View File

@@ -0,0 +1,8 @@
import { Factory } from 'rosie';
const discussionTourFactory = new Factory()
.sequence('id')
.attr('name', ['id'], (id) => `Discussion Tour ${id}`)
.attr('description', ['id'], (id) => `This is the description for Discussion Tour ${id}.`);
export default discussionTourFactory;

View File

@@ -0,0 +1,31 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
advanceButtonText: {
id: 'tour.action.advance',
defaultMessage: 'Next',
description: 'Action to go to next step of tour',
},
dismissButtonText: {
id: 'tour.action.dismiss',
defaultMessage: 'Dismiss',
description: 'Action to dismiss current tour',
},
endButtonText: {
id: 'tour.action.end',
defaultMessage: 'Okay',
description: 'Action to end current tour',
},
notRespondedFilterTourBody: {
id: 'tour.body.notRespondedFilter',
defaultMessage: 'Now you can filter discussions to find posts with no response.',
description: 'Body of the tour for the not responded filter',
},
notRespondedFilterTourTitle: {
id: 'tour.title.notRespondedFilter',
defaultMessage: 'New filtering option!',
description: 'Title of the tour for the not responded filter',
},
});
export default messages;

View File

@@ -281,3 +281,17 @@ export function inBlackoutDateRange(blackoutDateRanges) {
(blackoutDateRange) => dateInDateRange(now, new Date(blackoutDateRange.start), new Date(blackoutDateRange.end)),
);
}
export function handleKeyDown(event) {
const { key } = event;
if (key !== 'ArrowDown' && key !== 'ArrowUp') { return; }
const option = event.target;
let selectedOption;
if (key === 'ArrowDown') { selectedOption = option.nextElementSibling; }
if (key === 'ArrowUp') { selectedOption = option.previousElementSibling; }
if (selectedOption) {
selectedOption.focus();
}
}

View File

@@ -1,182 +1,205 @@
{
"navigation.course.tabs.label": "Course Material",
"learn.course.tabs.navigation.overflow.menu": "More...",
"discussions.comments.comment.addResponse": "Add a response",
"discussions.comments.comment.addComment": "Add a comment",
"discussions.comments.comment.abuseFlaggedMessage": "Content reported for staff to review",
"discussions.actions.back.alt": "Back to list",
"discussions.comments.comment.responseCount": "{num, plural,\n =0 {No responses}\n one {Showing # response}\n other {Showing # responses}\n }",
"discussions.comments.comment.endorsedResponseCount": "{num, plural,\n =0 {No endorsed responses}\n one {Showing # endorsed response}\n other {Showing # endorsed responses}\n }",
"discussions.comments.comment.loadMoreComments": "Load more comments",
"discussions.comments.comment.loadMoreResponses": "Load more responses",
"discussions.comments.comment.visibility": "This post is visible to {group, select,\n null {Everyone}\n other {{group}}\n }.",
"discussions.comments.comment.postedTime": "{postType, select,\n discussion {Discussion}\n question {Question}\n other {{postType}}\n } posted {relativeTime} by",
"discussions.comments.comment.commentTime": "Posted {relativeTime}",
"discussions.comments.comment.answer": "Answer",
"discussions.comments.comment.answeredlabel": "Marked as answered by",
"discussions.comments.comment.endorsed": "Endorsed",
"discussions.comments.comment.endorsedlabel": "Endorsed by",
"discussions.actions.label": "Actions menu",
"discussions.actions.edit": "Edit",
"discussions.actions.pin": "Pin",
"discussions.actions.delete": "Delete",
"discussions.editor.submit": "Submit",
"discussions.editor.submitting": "Submitting",
"discussions.editor.cancel": "Cancel",
"discussions.editor.error.empty": "Post content cannot be empty.",
"discussions.editor.delete.response.title": "Delete response",
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
"discussions.editor.delete.comment.title": "Delete comment",
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
"discussions.editor.comments.editReasonCode": "Reason for editing",
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
"discussions.comment.comments.editedBy": "Edited by",
"discussions.comment.comments.reason": "Reason",
"discussions.post.closedBy": "Post closed by",
"discussion.comment.repliesHeading": "{count} replies for the response added",
"discussion.comment.time": "{time} ago",
"discussions.learner.reported": "{reported} reported",
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
"discussions.learner.loadMostLearners": "Load more",
"discussions.learner.back": "Back",
"discussions.learner.activityForLearner": "Activity for {username}",
"discussions.learner.mostActivity": "Most activity",
"discussions.learner.reportedActivity": "Reported activity",
"discussions.learner.recentActivity": "Recent activity",
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
"discussion.learner.allActivity": "All activity",
"discussion.learner.posts": "Posts",
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.unpin": "Unpin",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
"discussions.actions.unreport": "Unreport",
"discussions.actions.endorse": "Endorse",
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.delete.confirmation.button.cancel": "Cancel",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
"discussions.empty.topic": "All discussion activity for this topic will show up here.",
"discussions.empty.title": "Nothing here yet",
"discussions.empty.noPostSelected": "No post selected",
"discussions.empty.noTopicSelected": "No topic selected",
"discussions.sidebar.noResultsFound": "No results found",
"discussions.sidebar.differentKeywords": "Try searching different keywords",
"discussions.sidebar.removeKeywords": "Try searching different keywords or removing some filters",
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
"discussions.sidebar.removeFilters": "Try removing some filters",
"discussions.empty.iconAlt": "Empty",
"discussions.authors.label.staff": "Staff",
"discussions.authors.label.ta": "TA",
"discussions.learner.loadMostPosts": "Load more posts",
"discussions.post.anonymous.author": "anonymous",
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
"discussion.banner.learnMore": "Learn more",
"discussion.banner.shareFeedback": "Share feedback",
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
"discussions.navigation.breadcrumbMenu.allTopics": "Topics",
"discussions.navigation.breadcrumbMenu.showAll": "Show all",
"discussions.navigation.navigationBar.allPosts": "All posts",
"discussions.navigation.navigationBar.allTopics": "Topics",
"discussions.navigation.navigationBar.myPosts": "My posts",
"discussions.navigation.navigationBar.learners": "Learners",
"discussions.app.title": "Discussions",
"discussions.posts.actionBar.searchAllPosts": "Search all posts",
"discussions.posts.actionBar.search": "{page, select,\n topics {Search topics}\n posts {Search all posts}\n learners {Search learners}\n myPosts {Search all posts}\n other {{page}}\n }",
"discussions.actionBar.searchInfo": "Showing {count} results for \"{text}\"",
"discussions.actionBar.searchRewriteInfo": "No results found for \"{searchString}\". Showing {count} results for \"{textSearchRewrite}\".",
"discussions.actionBar.searchInfoSearching": "Searching...",
"discussions.actionBar.clearSearch": "Clear results",
"discussion.posts.actionBar.add": "Add a post",
"discussion.posts.actionBar.close": "Close",
"discussions.post.editor.type": "Post type",
"discussions.post.editor.addPostHeading": "Add a post",
"discussions.post.editor.editPostHeading": "Edit post",
"discussions.post.editor.typeDescription": "Questions raise issues that need answers. Discussions share ideas and start conversations.",
"discussions.post.editor.required": "Required",
"discussions.post.editor.questionType": "Question",
"discussions.post.editor.questionDescription": "Raise issues that need answers",
"discussions.post.editor.discussionType": "Discussion",
"discussions.post.editor.discussionDescription": "Share ideas and start conversations",
"discussions.post.editor.topicArea": "Topic area",
"discussions.post.editor.topicAreaDescription": "Add your post to a relevant topic to help others find it.",
"discussions.post.editor.cohortVisibility": "Cohort visibility",
"discussions.post.editor.cohortVisibilityAllLearners": "All learners",
"discussions.post.editor.title": "Post title",
"discussions.post.editor.titleDescription": "Add a clear and descriptive title to encourage participation.",
"discussions.post.editor.title.error": "Post title cannot be empty.",
"discussions.post.editor.content.error": "Post content cannot be empty.",
"discussions.post.editor.questionText": "Your question or idea (required)",
"discussions.post.editor.preview": "Preview",
"discussions.post.editor.followPost": "Follow this post",
"discussions.post.editor.anonymousPost": "Post anonymously",
"discussions.post.editor.anonymousToPeersPost": "Post anonymously to peers",
"discussions.editor.posts.editReasonCode": "Reason for editing",
"discussions.editor.posts.showPreview.button": "Show Preview",
"discussions.topic.noName.label": "Unnamed category",
"discussions.subtopic.noName.label": "Unnamed subcategory",
"discussions.posts.filter.showALl": "Show all",
"discussions.posts.filter.discussions": "Discussions",
"discussions.posts.filter.questions": "Questions",
"discussions.posts.filter.message": "Status: {filterBy}",
"discussions.posts.status.filter.anyStatus": "Any status",
"discussions.posts.status.filter.unread": "Unread",
"discussions.posts.status.filter.following": "Following",
"discussions.posts.status.filter.reported": "Reported",
"discussions.posts.status.filter.unanswered": "Unanswered",
"discussions.posts.status.filter.unresponded": "Not responded",
"discussions.posts.filter.myPosts": "My posts",
"discussions.posts.filter.myDiscussions": "My discussions",
"discussions.posts.filter.myQuestions": "My questions",
"discussions.posts.sort.message": "Sorted by {sortBy}",
"discussions.posts.sort.lastActivity": "Recent activity",
"discussions.posts.sort.commentCount": "Most activity",
"discussions.posts.sort.voteCount": "Most likes",
"discussions.posts.sort-filter.sortFilterStatus": "{own, select,\n false {All}\n true {Own}\n other {{own}}\n } {status, select,\n statusAll {}\n statusUnread {unread}\n statusFollowing {followed}\n statusReported {reported}\n statusUnanswered {unanswered}\n statusUnresponded {unresponded}\n other {{status}}\n } {type, select,\n discussion {discussions}\n question {questions}\n all {posts}\n other {{type}}\n } {cohortType, select,\n all {}\n group {in {cohort}}\n other {{cohortType}}\n } sorted by {sort, select,\n lastActivityAt {recent activity}\n commentCount {most activity}\n voteCount {most likes}\n other {{sort}}\n }",
"discussions.post.author.anonymous": "anonymous",
"discussions.post.lastResponse": "Last response {time}",
"discussions.post.postedOn": "Posted {time} by {author} {authorLabel}",
"discussions.post.contentReported": "Reported",
"discussions.post.following": "Following",
"discussions.post.follow": "Follow",
"discussions.post.answered": "Answered",
"discussions.post.unFollow": "Unfollow",
"discussions.post.like": "Like",
"discussions.post.removeLike": "Unlike",
"discussions.post.viewActivity": "View activity",
"discussions.post.closed": "Post closed for responses and comments",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Delete post",
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
"discussions.post.closePostModal.title": "Close post",
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
"discussions.post.closePostModal.reasonCodeInput": "Reason",
"discussions.post.closePostModal.cancel": "Cancel",
"discussions.post.closePostModal.confirm": "Close post",
"discussions.post.label.new": "{count} New",
"discussions.post.editedBy": "Edited by",
"discussions.post.editReason": "Reason",
"discussions.post.postWithoutPreview": "No preview available",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.topics.sort.message": "Sorted by {sortBy}",
"discussions.topics.sort.lastActivity": "Recent activity",
"discussions.topics.sort.commentCount": "Most activity",
"discussions.topics.sort.courseStructure": "Course Structure",
"discussions.topics.find.label": "Search topics",
"discussions.topics.archived.label": "Archived",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
"navigation.course.tabs.label": "مواد المساق",
"learn.course.tabs.navigation.overflow.menu": "المزيد...",
"discussions.comments.comment.addResponse": "إضافة رد",
"discussions.comments.comment.addComment": "إضافة تعليق ",
"discussions.comments.comment.abuseFlaggedMessage": "تم إبلاغ الطاقم عن هذا المحتوى لمراجعته.",
"discussions.actions.back.alt": "العودة إلى القائمة",
"discussions.comments.comment.responseCount": "{num، plural, =0 {دون رد} one {تم إظهار ردّ واحد} two {تم إظهار ردّين} few {تم إظهار # ردود} many {تم إظهار # ردًا} other {تم إظهار # ردود}}",
"discussions.comments.comment.endorsedResponseCount": "{num، plural, =0 {لا ردود معتمدة} one {تم إظهار ردّ واحد معتمد} two {تم إظهار ردّين معتمدين} few {تم إظهار # ردود معتمدة} many {تم إظهار # ردًا معتمدًا} other {تم إظهار # ردود معتمدة}}",
"discussions.comments.comment.loadMoreComments": "تحميل المزيد من التعليقات",
"discussions.comments.comment.loadMoreResponses": "تحميل المزيد من الردود",
"discussions.comments.comment.visibility": "هذه المشاركة تظهر {group، select، null {للجميع} other {لـ {group}}.",
"discussions.comments.comment.postedTime": "تم نشر {postType، select، discussion {المناقشة} question {المنشور} other {{postType}} {relativeTime} من طرف",
"discussions.comments.comment.commentTime": "تم النشر {relativeTime}",
"discussions.comments.comment.answer": "الإجابة",
"discussions.comments.comment.answeredlabel": "تم تعليمها كمُجابة من طرف",
"discussions.comments.comment.endorsed": "معتمد",
"discussions.comments.comment.endorsedlabel": "اعتمده",
"discussions.actions.label": "قائمة الإجراءات",
"discussions.actions.edit": "تعديل",
"discussions.actions.pin": "تثبيت",
"discussions.actions.delete": "حذف",
"discussions.editor.submit": "إرسال",
"discussions.editor.submitting": "الإرسال جارٍ",
"discussions.editor.cancel": "إلغاء",
"discussions.editor.error.empty": "لا يمكن أن يكون محتوى المنشور فارغًا.",
"discussions.editor.delete.response.title": "حذف الرد",
"discussions.editor.delete.response.description": "هل أنت متأكد من رغبتك في حذف هذا الردّ نهائيًا؟",
"discussions.editor.delete.comment.title": "حذف التعليق",
"discussions.editor.delete.comment.description": "هل أنت متأكد من رغبتك في حذف هذا التعليق نهائيا؟",
"discussions.delete.confirmation.button.delete": "حذف",
"discussions.editor.response.response.title": "أتريد الإبلاغ عن محتوى غير لائق؟",
"discussions.editor.response.description": "سيراجع فريق الإشراف على المناقشة هذا المحتوى و يتخذ الإجراء المناسب.",
"discussions.editor.report.comment.title": "أتريد الإبلاغ عن محتوى غير لائق؟",
"discussions.editor.report.comment.description": "سيراجع فريق الإشراف على المناقشة هذا المحتوى ويتخذ الإجراء المناسب.",
"discussions.editor.comments.editReasonCode": "سبب التعديل",
"discussions.editor.posts.editReasonCode.error": "حدد سبب التعديل",
"discussions.comment.comments.editedBy": "عدّله",
"discussions.comment.comments.reason": "السبب",
"discussions.post.closedBy": "تم إقفال المنشور من طرف",
"discussion.comment.repliesHeading": "{count, plural, =0 {لم يضف أي رد} one {أضيف رد واحد} two {أضيف ردان} few {أضيفت # ردود} many {أضيف # ردًا} other {أضيفت # ردود} على الرد",
"discussion.comment.time": "منذ {time}",
"discussion.thread.notFound": "المناقشة غير موجودة",
"discussions.topics.backAlt": "Back to topics list",
"discussions.topics.discussions": "{count، plural, =0 {لا مناقشات} one {مناقشة واحدة} two {مناقشتان} few {# مناقشات} many {# مناقشة} other {# مناقشات}",
"discussions.topics.questions": "{count، plural, =0 {لا مناقشات} one {سؤال واحد} two {سؤالان} few {# اسئلة} many {# سؤالًا} other {# أسئلة}",
"discussions.topics.reported": "تم الإبلاغ عن {reported}",
"discussions.topics.previouslyReported": "تم الإبلاغ عن {previouslyReported} من قبل",
"discussions.topics.find.label": "البحث في المواضيع",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "مؤرشف",
"discussions.learner.reported": "تم الإبلاغ عن {reported}",
"discussions.learner.previouslyReported": "تم الإبلاغ عن {previouslyReported} من قبل",
"discussions.learner.lastLogin": "آخر نشاط {lastActiveTime}",
"discussions.learner.loadMostLearners": "تحميل المزيد",
"discussions.learner.back": "عودة",
"discussions.learner.activityForLearner": "نشاط {username}",
"discussions.learner.mostActivity": "الأكثر نشاطًا",
"discussions.learner.reportedActivity": "النشاطات المبلغ عنها",
"discussions.learner.recentActivity": "الأحدث نشاطًا",
"discussions.learner.sortFilterStatus": "جميع المتعلمين مرتبين حسب {sort, select,\n flagged {النشاطات المبلّغ غنها}\n activity {الأكثر نشاطًا}\n other {{sort}}\n }",
"discussion.learner.allActivity": "كل النشاط",
"discussion.learner.posts": "المنشورات",
"discussions.actions.button.alt": "قائمة الإجراءات",
"discussions.actions.copylink": "نسخ الرابط",
"discussions.actions.unpin": "إلغاء التثبيت",
"discussions.confirmation.button.confirm": "تأكيد",
"discussions.actions.close": "إقفال ",
"discussions.actions.reopen": "إعادة الفتح",
"discussions.actions.report": "إبلاغ",
"discussions.actions.unreport": "سحب البلاغ",
"discussions.actions.endorse": "اعتماد",
"discussions.actions.unendorse": "إلغاء الاعتماد",
"discussions.actions.markAnswered": "وضع علامة \"تمت الإجابة\"",
"discussions.actions.unMarkAnswered": "إزالة علامة \"تمت الإجابة\"",
"discussions.modal.confirmation.button.cancel": "إلغاء",
"discussions.empty.allTopics": "ستظهر هنا جميع مناقشات هذه المواضيع.",
"discussions.empty.allPosts": "ستظهر هنا جميع مناقشات مساقك.",
"discussions.empty.myPosts": "ستظهر المنشورات التي تفاعلت معها هنا.",
"discussions.empty.topic": "ستظهر هنا جميع مناقشات هذا الموضوع.",
"discussions.empty.title": "لا شيء هنا بعد",
"discussions.empty.noPostSelected": "لم يتم تحديد أي منشور",
"discussions.empty.noTopicSelected": "لا موضوع محددًا",
"discussions.sidebar.noResultsFound": "لم يعثر على نتائج",
"discussions.sidebar.differentKeywords": "جرب البحث بكلمات مفتاحية مختلفة",
"discussions.sidebar.removeKeywords": "جرب البحث بكلمات مفتاحية مختلفة أو أزل بعض المرشحات",
"discussions.sidebar.removeKeywordsOnly": "جرب البحث بكلمات مفتاحية مختلفة",
"discussions.sidebar.removeFilters": "جرب إزالة بعض المرشحات",
"discussions.empty.iconAlt": "خالٍ",
"discussions.authors.label.staff": "عضو طاقم",
"discussions.authors.label.ta": "أستاذ مساعد",
"discussions.learner.loadMostPosts": "تواريخ تعطيل نشطة حاليا. لا يمكن النشر في المناقشات خلال هذه الفترة.",
"discussions.post.anonymous.author": "مجهول",
"discussion.banner.welcomeMessage": "🎉 مرحبًا بك في تجربة المناقشات الجديدة والمحسّنة!",
"discussion.banner.learnMore": "معرفة المزيد",
"discussion.banner.shareFeedback": "شاركنا رأيك",
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
"discussions.editor.image.warning.message": "لن تظهر الصور التي يزيد عرضها أو ارتفاعها عن 999 بكسل عند عرض المنشور أو الرد او التعليق باستخدام مناقشات المساق المضمّنة",
"discussions.editor.image.warning.title": "تحذير!",
"discussions.editor.image.warning.dismiss": "حسنًا",
"discussions.navigation.breadcrumbMenu.allTopics": "المواضيع",
"discussions.navigation.breadcrumbMenu.showAll": "عرض الكل",
"discussions.navigation.navigationBar.allPosts": "جميع المنشورات",
"discussions.navigation.navigationBar.allTopics": "المواضيع",
"discussions.navigation.navigationBar.myPosts": "منشوراتي",
"discussions.navigation.navigationBar.learners": "المتعلمون",
"discussions.app.title": "المناقشات",
"discussions.posts.actionBar.searchAllPosts": "البحث في كافّة المنشورات",
"discussions.posts.actionBar.search": "{page، select، topics {مواضيع البحث} posts {بحث في كل المشاركات} learners {بحث عن المتعلمين} myPosts} {بحث في كل المشاركات",
"discussions.actionBar.searchInfo": "{num، plural, =0 {لا توجد نتائج} one {تم إظهار نتيجة واحدة} two {تم إظهار نتيجتين} few {تم إظهار # نتائج} many {تم إظهار # نتيجة} other {تم إظهار # نتائج}} لـ\"{text}\"",
"discussions.actionBar.searchRewriteInfo": "لم يعثر على نتائج لـ \"{searchString}\". {num، plural, =0 {لا توجد نتائج} one {تم إظهار نتيجة واحدة} two {تم إظهار نتيجتين} few {تم إظهار # نتائج} many {تم إظهار # نتيجة} other {تم إظهار # نتائج}} لـ\"{textSearchRewrite}\" ",
"discussions.actionBar.searchInfoSearching": "البحث جارٍ...",
"discussions.actionBar.clearSearch": "مسح النتائج",
"discussion.posts.actionBar.add": "أضف منشورًا",
"discussion.posts.actionBar.close": "إغلاق ",
"discussions.post.editor.type": "نوع المنشور",
"discussions.post.editor.addPostHeading": "أضف منشورًا",
"discussions.post.editor.editPostHeading": "تعديل المنشور",
"discussions.post.editor.typeDescription": "تطرح الأسئلة القضايا تحتاج إلى إجابات. بينما تفتح المناقشات باب مشاركة الأفكار و بدء المحادثات.",
"discussions.post.editor.required": "مطلوب",
"discussions.post.editor.questionType": "سؤال",
"discussions.post.editor.questionDescription": "اطرح القضايا التي تحتاج إجابات",
"discussions.post.editor.discussionType": "مناقشة",
"discussions.post.editor.discussionDescription": "شارك الأفكار و ابدء المحادثات",
"discussions.post.editor.topicArea": "مجال الموضوع:",
"discussions.post.editor.topicAreaDescription": "أضف منشورك إلى موضوع مناسب لمساعدة الآخرين على إيجاده.",
"discussions.post.editor.cohortVisibility": "الظهور للأفواج",
"discussions.post.editor.cohortVisibilityAllLearners": "جميع المتعلّمين",
"discussions.post.editor.title": "عنوان المنشور",
"discussions.post.editor.titleDescription": "أضِف عنوانًا واضحًا ومعبّرًا للتشجيع على المشاركة.",
"discussions.post.editor.title.error": "لا يمكن لعنوان المنشور أن يكون فارغًا.",
"discussions.post.editor.content.error": "لا يمكن لمحتوى المنشور أن يكون فارغًا.",
"discussions.post.editor.questionText": "سؤالك أو فكرتك (مطلوب)",
"discussions.post.editor.preview": "معاينة",
"discussions.post.editor.followPost": "متابعة هذا المنشور",
"discussions.post.editor.anonymousPost": "النشر كمجهول",
"discussions.post.editor.anonymousToPeersPost": "انشر ﻷقرانك كمجهول",
"discussions.editor.posts.editReasonCode": "سبب التعديل",
"discussions.editor.posts.showPreview.button": "عرض المعاينة",
"discussions.topic.noName.label": "تصنيف دون اسم",
"discussions.subtopic.noName.label": "تصنيف فرعي دون اسم",
"discussions.posts.filter.showALl": "عرض الكل",
"discussions.posts.filter.discussions": "المناقشات",
"discussions.posts.filter.questions": "الأسئلة",
"discussions.posts.filter.message": "الحالة: {filterBy}",
"discussions.posts.status.filter.anyStatus": "أي حالة",
"discussions.posts.status.filter.unread": "غير المقروءة",
"discussions.posts.status.filter.following": "التي تتابعها",
"discussions.posts.status.filter.reported": "مبلّغ عنها",
"discussions.posts.status.filter.unanswered": "دون إجابة",
"discussions.posts.status.filter.unresponded": "دون رد",
"discussions.posts.filter.myPosts": "منشوراتي",
"discussions.posts.filter.myDiscussions": "مناقشاتي",
"discussions.posts.filter.myQuestions": "أسئلتي",
"discussions.posts.sort.message": "مرتبة حسب {sortBy}",
"discussions.posts.sort.lastActivity": "الأحدث نشاطًا",
"discussions.posts.sort.commentCount": "الأكثر نشاطًا",
"discussions.posts.sort.voteCount": "الأكثر إعجابًا",
"discussions.posts.sort-filter.sortFilterStatus": "{own, select,\n false {جميع}\n true {ما كتبته من}\n other {{own}}\n } \n {type, select,\ndiscussion {المناقشات}\nquestion {الأسئلة}\nall {المنشورات}\nother {{type}}\n}\n {status, select,\n statusAll {}\n statusUnread {غير المقروءة}\n statusFollowing {التي تتابعها}\n statusReported {المُبلّغ عنها}\n statusUnanswered {دون إجابة}\n statusUnresponded {دون رد}\n other {{status}}\n } و المنشورة {cohortType, select,\n all {}\n group {ضمن {cohort}}\n other {{cohortType}}\n }، مرتبة حسب {sort, select,\n lastActivityAt {أحدث نشاط}\n commentCount {أكثر نشاط}\n voteCount {أكثر إعجاب}\n other {{sort}}\n }",
"discussions.post.author.anonymous": "مجهول",
"discussions.post.lastResponse": "آخر رد {time}",
"discussions.post.postedOn": "منشور في {time} من طرف {author} {authorLabel}",
"discussions.post.contentReported": "تم الإبلاغ",
"discussions.post.following": "جاري المتابعة",
"discussions.post.follow": "متابعة",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "تمّت الإجابة",
"discussions.post.unFollow": "إلغاء المتابعة",
"discussions.post.like": "أعجبني",
"discussions.post.removeLike": "إلغاء الإغجاب",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "عرض النشاط",
"discussions.post.activity": "Activity",
"discussions.post.closed": "منشور مقفل أمام الردود والتعليقات",
"discussions.post.relatedTo": "متعلق بـ",
"discussions.editor.delete.post.title": "حذف المنشور",
"discussions.editor.delete.post.description": "هل أنت متأكد من رغبتك في حذف هذا المنشور نهائيًا؟",
"discussions.post.delete.confirmation.button.delete": "حذف",
"discussions.editor.report.post.title": "أتريد الإبلاغ عن محتوى غير لائق؟",
"discussions.editor.report.post.description": "سيراجع فريق الإشراف على المناقشة هذا المحتوى و يتخذ الإجراء المناسب.",
"discussions.post.closePostModal.title": "إقفال المنشور",
"discussions.post.closePostModal.text": "أدخل سبب إقفال هذه المنشور. سيظهر هذا فقط لبقية المشرفين.",
"discussions.post.closePostModal.reasonCodeInput": "السبب",
"discussions.post.closePostModal.cancel": "إلغاء",
"discussions.post.closePostModal.confirm": "إقفال المنشور",
"discussions.post.label.new": "{count} جديدة",
"discussions.post.editedBy": "عدّله",
"discussions.post.editReason": "السبب",
"discussions.post.postWithoutPreview": "المعاينة غير متاحة",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.topics.sort.message": "مرتبة حسب {sortBy}",
"discussions.topics.sort.lastActivity": "الأحدث نشاطًا",
"discussions.topics.sort.commentCount": "الاكثر نشاطًا",
"discussions.topics.sort.courseStructure": "هيكل المساق",
"discussions.topics.unnamed.label": "فئة بدون اسم",
"discussions.subtopics.unnamed.label": "فئة فرعية بدون اسم"
}

View File

@@ -28,6 +28,11 @@
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
"discussions.editor.delete.comment.title": "Delete comment",
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.editor.response.response.title": "Report inappropriate content?",
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.report.comment.title": "Report inappropriate content?",
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.comments.editReasonCode": "Reason for editing",
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
"discussions.comment.comments.editedBy": "Edited by",
@@ -35,6 +40,20 @@
"discussions.post.closedBy": "Post closed by",
"discussion.comment.repliesHeading": "{count} replies for the response added",
"discussion.comment.time": "{time} ago",
"discussion.thread.notFound": "Thread not found",
"discussions.topics.backAlt": "Back to topics list",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.topics.find.label": "Search topics",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archived",
"discussions.learner.reported": "{reported} reported",
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
@@ -50,6 +69,7 @@
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.unpin": "Unpin",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
@@ -58,8 +78,7 @@
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.delete.confirmation.button.cancel": "Cancel",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
@@ -80,7 +99,7 @@
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
"discussion.banner.learnMore": "Learn more",
"discussion.banner.shareFeedback": "Share feedback",
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
@@ -149,15 +168,23 @@
"discussions.post.contentReported": "Reported",
"discussions.post.following": "Following",
"discussions.post.follow": "Follow",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "Answered",
"discussions.post.unFollow": "Unfollow",
"discussions.post.like": "Like",
"discussions.post.removeLike": "Unlike",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "View activity",
"discussions.post.activity": "Activity",
"discussions.post.closed": "Post closed for responses and comments",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Delete post",
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
"discussions.post.delete.confirmation.button.delete": "Delete",
"discussions.editor.report.post.title": "Report inappropriate content?",
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.post.closePostModal.title": "Close post",
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
"discussions.post.closePostModal.reasonCodeInput": "Reason",
@@ -167,16 +194,12 @@
"discussions.post.editedBy": "Edited by",
"discussions.post.editReason": "Reason",
"discussions.post.postWithoutPreview": "No preview available",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.topics.sort.message": "Sorted by {sortBy}",
"discussions.topics.sort.lastActivity": "Recent activity",
"discussions.topics.sort.commentCount": "Most activity",
"discussions.topics.sort.courseStructure": "Course Structure",
"discussions.topics.find.label": "Search topics",
"discussions.topics.archived.label": "Archived",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
}

View File

@@ -28,6 +28,11 @@
"discussions.editor.delete.response.description": "¿Está seguro de que desea eliminar esta respuesta de forma permanente?",
"discussions.editor.delete.comment.title": "Eliminar comentario",
"discussions.editor.delete.comment.description": "¿Estás seguro de que quieres eliminar este comentario de forma permanente?",
"discussions.delete.confirmation.button.delete": "Borrar",
"discussions.editor.response.response.title": "Report inappropriate content?",
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.report.comment.title": "Report inappropriate content?",
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.comments.editReasonCode": "Razón de la edición",
"discussions.editor.posts.editReasonCode.error": "Seleccione el motivo de la edición",
"discussions.comment.comments.editedBy": "Editado por",
@@ -35,6 +40,20 @@
"discussions.post.closedBy": "Publicación cerrada por",
"discussion.comment.repliesHeading": "{count} respuestas para la respuesta añadida",
"discussion.comment.time": "hace {time}",
"discussion.thread.notFound": "Thread not found",
"discussions.topics.backAlt": "Back to topics list",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} informado",
"discussions.topics.previouslyReported": "{previouslyReported} informado anteriormente",
"discussions.topics.find.label": "Buscar temas",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archivado",
"discussions.learner.reported": "{reported} informado",
"discussions.learner.previouslyReported": "{previouslyReported} informado anteriormente",
"discussions.learner.lastLogin": "Último activo {lastActiveTime}",
@@ -50,6 +69,7 @@
"discussions.actions.button.alt": "Menú de acciones",
"discussions.actions.copylink": "Copiar link",
"discussions.actions.unpin": "Desmarcar",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Cerrar",
"discussions.actions.reopen": "Reabrir",
"discussions.actions.report": "Informar",
@@ -58,8 +78,7 @@
"discussions.actions.unendorse": "Invalidar",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Desmarcar como respondida",
"discussions.delete.confirmation.button.cancel": "Cancelar",
"discussions.delete.confirmation.button.delete": "Borrar",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "Toda la actividad de debate de estos temas se mostrará aquí.",
"discussions.empty.allPosts": "Toda la actividad de debate de su curso se mostrará aquí.",
"discussions.empty.myPosts": "Las publicaciones con las que has interactuado se mostrarán aquí.",
@@ -80,7 +99,7 @@
"discussion.banner.welcomeMessage": "🎉 ¡Bienvenido a la nueva y mejorada experiencia de debates!",
"discussion.banner.learnMore": "Aprender más",
"discussion.banner.shareFeedback": "Compartir comentarios",
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
@@ -149,15 +168,23 @@
"discussions.post.contentReported": "Informado",
"discussions.post.following": "Siguiendo",
"discussions.post.follow": "Seguir",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "Respondido",
"discussions.post.unFollow": "Dejar de seguir",
"discussions.post.like": "Me gusta",
"discussions.post.removeLike": "Dejar de gustar",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "Ver actividad",
"discussions.post.activity": "Activity",
"discussions.post.closed": "Publicación cerrada por respuestas y comentarios.",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Eliminar mensaje",
"discussions.editor.delete.post.description": "¿Seguro que quieres eliminar esta publicación de forma permanente?",
"discussions.post.delete.confirmation.button.delete": "Delete",
"discussions.editor.report.post.title": "Report inappropriate content?",
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.post.closePostModal.title": "Cerrar publicación",
"discussions.post.closePostModal.text": "Escribe un motivo para cerrar esta publicación. Esto solo se mostrará a otros moderadores.",
"discussions.post.closePostModal.reasonCodeInput": "Motivo",
@@ -167,16 +194,12 @@
"discussions.post.editedBy": "Editado por",
"discussions.post.editReason": "Motivo",
"discussions.post.postWithoutPreview": "No hay vista previa disponible",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} informado",
"discussions.topics.previouslyReported": "{previouslyReported} informado anteriormente",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.topics.sort.message": "Ordenado por {sortBy}",
"discussions.topics.sort.lastActivity": "Actividad reciente",
"discussions.topics.sort.commentCount": "La mayoría de la actividad",
"discussions.topics.sort.courseStructure": "Estructura del curso",
"discussions.topics.find.label": "Buscar temas",
"discussions.topics.archived.label": "Archivado",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
}

View File

@@ -28,6 +28,11 @@
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
"discussions.editor.delete.comment.title": "Delete comment",
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.editor.response.response.title": "Report inappropriate content?",
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.report.comment.title": "Report inappropriate content?",
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.comments.editReasonCode": "Reason for editing",
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
"discussions.comment.comments.editedBy": "Édité par",
@@ -35,6 +40,20 @@
"discussions.post.closedBy": "Message fermé par",
"discussion.comment.repliesHeading": "{count} réponses pour la réponse ajoutée",
"discussion.comment.time": "il y a {time}",
"discussion.thread.notFound": "Thread not found",
"discussions.topics.backAlt": "Back to topics list",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.topics.find.label": "Search topics",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archived",
"discussions.learner.reported": "{reported} signalé",
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
@@ -50,6 +69,7 @@
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.unpin": "Unpin",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
@@ -58,8 +78,7 @@
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.delete.confirmation.button.cancel": "Cancel",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
@@ -80,7 +99,7 @@
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
"discussion.banner.learnMore": "Learn more",
"discussion.banner.shareFeedback": "Share feedback",
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
@@ -149,15 +168,23 @@
"discussions.post.contentReported": "Reported",
"discussions.post.following": "Following",
"discussions.post.follow": "Follow",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "Answered",
"discussions.post.unFollow": "Unfollow",
"discussions.post.like": "Like",
"discussions.post.removeLike": "Unlike",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "View activity",
"discussions.post.activity": "Activity",
"discussions.post.closed": "Post closed for responses and comments",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Delete post",
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
"discussions.post.delete.confirmation.button.delete": "Delete",
"discussions.editor.report.post.title": "Report inappropriate content?",
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.post.closePostModal.title": "Close post",
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
"discussions.post.closePostModal.reasonCodeInput": "Reason",
@@ -167,16 +194,12 @@
"discussions.post.editedBy": "Edited by",
"discussions.post.editReason": "Reason",
"discussions.post.postWithoutPreview": "No preview available",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.topics.sort.message": "Sorted by {sortBy}",
"discussions.topics.sort.lastActivity": "Recent activity",
"discussions.topics.sort.commentCount": "Most activity",
"discussions.topics.sort.courseStructure": "Course Structure",
"discussions.topics.find.label": "Search topics",
"discussions.topics.archived.label": "Archived",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
}

View File

@@ -28,6 +28,11 @@
"discussions.editor.delete.response.description": "Êtes-vous sûr de vouloir supprimer définitivement cette réponse ?",
"discussions.editor.delete.comment.title": "Supprimer le commentaire",
"discussions.editor.delete.comment.description": "Êtes-vous sûr de vouloir supprimer définitivement ce commentaire ?",
"discussions.delete.confirmation.button.delete": "Supprimer",
"discussions.editor.response.response.title": "Signaler un contenu inapproprié ?",
"discussions.editor.response.description": "L'équipe de modération de la discussion examinera ce contenu et prendra les mesures appropriées.",
"discussions.editor.report.comment.title": "Signaler un contenu inapproprié ?",
"discussions.editor.report.comment.description": "L'équipe de modération de la discussion examinera ce contenu et prendra les mesures appropriées.",
"discussions.editor.comments.editReasonCode": "Raison de la modification",
"discussions.editor.posts.editReasonCode.error": "Sélectionnez la raison de la modification",
"discussions.comment.comments.editedBy": "Édité par",
@@ -35,6 +40,20 @@
"discussions.post.closedBy": "Message fermé par",
"discussion.comment.repliesHeading": "{count} réponses pour la réponse ajoutée",
"discussion.comment.time": "il y a {time}",
"discussion.thread.notFound": "Sujet introuvable",
"discussions.topics.backAlt": "Retour à la liste des sujets",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} signalé",
"discussions.topics.previouslyReported": "{previouslyReported} signalé précédemment",
"discussions.topics.find.label": "Rechercher des sujets",
"discussions.topics.unnamed.section.label": "Section sans nom",
"discussions.topics.unnamed.subsection.label": "Sous-section sans nom",
"discussions.subtopics.unnamed.topic.label": "Sujet sans nom",
"discussions.topics.title": "Aucun sujet n&#39;existe",
"discussions.topics.createTopic": "Veuillez contacter votre administrateur pour créer un sujet",
"discussions.topics.nothing": "Rien ici encore",
"discussions.topics.archived.label": "Archivé",
"discussions.learner.reported": "{reported} signalé",
"discussions.learner.previouslyReported": "{previouslyReported} signalé précédemment",
"discussions.learner.lastLogin": "Dernier actif {lastActiveTime}",
@@ -44,22 +63,22 @@
"discussions.learner.mostActivity": "La plupart des activités",
"discussions.learner.reportedActivity": "Activité signalée",
"discussions.learner.recentActivity": "Activité récente",
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
"discussions.learner.sortFilterStatus": "Tous les apprenants triés pas {sort, select,\n flagged {reported activity}\n activity {most activity}\n other {{sort}}\n }",
"discussion.learner.allActivity": "Toutes les activités",
"discussion.learner.posts": "Posts",
"discussions.actions.button.alt": "Menu Actions",
"discussions.actions.copylink": "Copier le lien",
"discussions.actions.unpin": "Détacher",
"discussions.confirmation.button.confirm": "Confirmer",
"discussions.actions.close": "Fermer",
"discussions.actions.reopen": "Rouvrir",
"discussions.actions.report": "Signaler",
"discussions.actions.unreport": "Ne pas signaler",
"discussions.actions.endorse": "Approuver",
"discussions.actions.unendorse": "Ne plus approuver",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.markAnswered": "Marquer comme répondu",
"discussions.actions.unMarkAnswered": "Décocher comme répondu",
"discussions.delete.confirmation.button.cancel": "Annuler",
"discussions.delete.confirmation.button.delete": "Supprimer",
"discussions.modal.confirmation.button.cancel": "Annuler",
"discussions.empty.allTopics": "Toutes les activités de discussion pour ces sujets apparaîtront ici.",
"discussions.empty.allPosts": "Toutes les activités de discussion pour votre cours s'afficheront ici.",
"discussions.empty.myPosts": "Les publications avec lesquelles vous avez interagi s'afficheront ici.",
@@ -80,7 +99,7 @@
"discussion.banner.welcomeMessage": "🎉 Bienvenue dans la nouvelle expérience améliorée de discussions !",
"discussion.banner.learnMore": "En savoir plus",
"discussion.banner.shareFeedback": "Partager vos commentaires",
"discussion.blackoutBanner.information": "Les dates d'interdiction sont actuellement actives. La publication dans les discussions n'est pas disponible pour le moment.",
"discussion.blackoutBanner.information": "La publication dans les discussions est temporairement désactivée par l&#39;équipe du cours",
"discussions.editor.image.warning.message": "Les images dont la largeur ou la hauteur est supérieure à 999 pixels ne seront pas visibles lorsque la publication, la réponse ou le commentaire est affiché à l'aide de discussions de cours en ligne",
"discussions.editor.image.warning.title": "Avertissement!",
"discussions.editor.image.warning.dismiss": "Ok",
@@ -149,15 +168,23 @@
"discussions.post.contentReported": "Signalé",
"discussions.post.following": "Suivi",
"discussions.post.follow": "Suivre",
"discussions.post.followed": "Suivi",
"discussions.post.notFollowed": "Non suivi",
"discussions.post.answered": "Répondu",
"discussions.post.unFollow": "Ne plus suivre",
"discussions.post.like": "Aime",
"discussions.post.removeLike": "Contrairement à",
"discussions.post.liked": "aimé",
"discussions.post.likes": "aime",
"discussions.post.viewActivity": "Afficher l'activité",
"discussions.post.activity": "Activité",
"discussions.post.closed": "Message fermé pour réponses et commentaires",
"discussions.post.relatedTo": "Relative à",
"discussions.editor.delete.post.title": "Supprimer le message",
"discussions.editor.delete.post.description": "Êtes-vous sûr de vouloir supprimer définitivement ce message ?",
"discussions.post.delete.confirmation.button.delete": "Supprimer",
"discussions.editor.report.post.title": "Signaler un contenu inapproprié ?",
"discussions.editor.report.post.description": "L'équipe de modération de la discussion examinera ce contenu et prendra les mesures appropriées.",
"discussions.post.closePostModal.title": "Fermer le message",
"discussions.post.closePostModal.text": "Entrez une raison pour fermer ce message. Cela ne sera affiché qu'aux autres modérateurs.",
"discussions.post.closePostModal.reasonCodeInput": "Raison",
@@ -167,16 +194,12 @@
"discussions.post.editedBy": "Édité par",
"discussions.post.editReason": "Raison",
"discussions.post.postWithoutPreview": "Aucun aperçu disponible",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} signalé",
"discussions.topics.previouslyReported": "{previouslyReported} signalé précédemment",
"discussions.post.follow.description": "vous suivez ce post",
"discussions.post.unfollow.description": "vous ne suivez pas ce post",
"discussions.topics.sort.message": "Trié par {sortBy}",
"discussions.topics.sort.lastActivity": "Activité récente",
"discussions.topics.sort.commentCount": "La plupart des activités",
"discussions.topics.sort.courseStructure": "Structure du cours",
"discussions.topics.find.label": "Rechercher des sujets",
"discussions.topics.archived.label": "Archivé",
"discussions.topics.unnamed.label": "Catégorie sans nom",
"discussions.subtopics.unnamed.label": "Sous-catégorie sans nom"
}

View File

@@ -28,6 +28,11 @@
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
"discussions.editor.delete.comment.title": "Delete comment",
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.editor.response.response.title": "Report inappropriate content?",
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.report.comment.title": "Report inappropriate content?",
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.comments.editReasonCode": "Reason for editing",
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
"discussions.comment.comments.editedBy": "Edited by",
@@ -35,6 +40,20 @@
"discussions.post.closedBy": "Post closed by",
"discussion.comment.repliesHeading": "{count} replies for the response added",
"discussion.comment.time": "{time} ago",
"discussion.thread.notFound": "Thread not found",
"discussions.topics.backAlt": "Back to topics list",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.topics.find.label": "Search topics",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archived",
"discussions.learner.reported": "{reported} reported",
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
@@ -50,6 +69,7 @@
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.unpin": "Unpin",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
@@ -58,8 +78,7 @@
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.delete.confirmation.button.cancel": "Cancel",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
@@ -80,7 +99,7 @@
"discussion.banner.welcomeMessage": "🎉 Welcome to the new and improved discussions experience!",
"discussion.banner.learnMore": "Learn more",
"discussion.banner.shareFeedback": "Share feedback",
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
@@ -149,15 +168,23 @@
"discussions.post.contentReported": "Reported",
"discussions.post.following": "Following",
"discussions.post.follow": "Follow",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "Answered",
"discussions.post.unFollow": "Unfollow",
"discussions.post.like": "Like",
"discussions.post.removeLike": "Unlike",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "View activity",
"discussions.post.activity": "Activity",
"discussions.post.closed": "Post closed for responses and comments",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Delete post",
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
"discussions.post.delete.confirmation.button.delete": "Delete",
"discussions.editor.report.post.title": "Report inappropriate content?",
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.post.closePostModal.title": "Close post",
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
"discussions.post.closePostModal.reasonCodeInput": "Reason",
@@ -167,16 +194,12 @@
"discussions.post.editedBy": "Edited by",
"discussions.post.editReason": "Reason",
"discussions.post.postWithoutPreview": "No preview available",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.topics.sort.message": "Sorted by {sortBy}",
"discussions.topics.sort.lastActivity": "Recent activity",
"discussions.topics.sort.commentCount": "Most activity",
"discussions.topics.sort.courseStructure": "Course Structure",
"discussions.topics.find.label": "Search topics",
"discussions.topics.archived.label": "Archived",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
}

View File

@@ -28,6 +28,11 @@
"discussions.editor.delete.response.description": "Sei sicuro di voler eliminare definitivamente questa risposta?",
"discussions.editor.delete.comment.title": "Elimina commento",
"discussions.editor.delete.comment.description": "Sei sicuro di voler eliminare definitivamente questo commento?",
"discussions.delete.confirmation.button.delete": "Cancella",
"discussions.editor.response.response.title": "Report inappropriate content?",
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.report.comment.title": "Report inappropriate content?",
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.comments.editReasonCode": "Motivo della modifica",
"discussions.editor.posts.editReasonCode.error": "Seleziona il motivo per la modifica",
"discussions.comment.comments.editedBy": "A cura di",
@@ -35,6 +40,20 @@
"discussions.post.closedBy": "Post chiuso da",
"discussion.comment.repliesHeading": "{count} risposte per la risposta aggiunta",
"discussion.comment.time": "{time} fa",
"discussion.thread.notFound": "Thread not found",
"discussions.topics.backAlt": "Back to topics list",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.topics.find.label": "Search topics",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archiviati",
"discussions.learner.reported": "{reported} segnalato",
"discussions.learner.previouslyReported": "{previouslyReported} segnalato in precedenza",
"discussions.learner.lastLogin": "Ultimo attivo {lastActiveTime}",
@@ -50,6 +69,7 @@
"discussions.actions.button.alt": "Menù Azioni",
"discussions.actions.copylink": "Copia link",
"discussions.actions.unpin": "Sblocca ",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Chiudi",
"discussions.actions.reopen": "Riaprire",
"discussions.actions.report": "Segnala",
@@ -58,8 +78,7 @@
"discussions.actions.unendorse": "Annulla promozione ",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Deseleziona come risposta",
"discussions.delete.confirmation.button.cancel": "Annulla",
"discussions.delete.confirmation.button.delete": "Cancella",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "Tutte le attività di discussione per questi argomenti verranno visualizzate qui.",
"discussions.empty.allPosts": "Tutte le attività di discussione per il tuo corso verranno visualizzate qui.",
"discussions.empty.myPosts": "I post con cui hai interagito verranno visualizzati qui.",
@@ -80,7 +99,7 @@
"discussion.banner.welcomeMessage": "🎉 Benvenuto nella nuova e avanzata esperienza di discussione!",
"discussion.banner.learnMore": "Per saperne di più",
"discussion.banner.shareFeedback": "Condividi feedback",
"discussion.blackoutBanner.information": "Blackout dates are currently active. Posting in discussions is unavailable at this time.",
"discussion.blackoutBanner.information": "Posting in discussions is temporarily disabled by the course team",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
@@ -149,15 +168,23 @@
"discussions.post.contentReported": "Segnalato ",
"discussions.post.following": "Seguente",
"discussions.post.follow": "Segui ",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "Risposto",
"discussions.post.unFollow": "Annulla Segui ",
"discussions.post.like": "Piace",
"discussions.post.removeLike": "A differenza di",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "Visualizza attività",
"discussions.post.activity": "Activity",
"discussions.post.closed": "Post chiuso per risposte e commenti",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Elimina messaggio",
"discussions.editor.delete.post.description": "Sei sicuro di voler eliminare definitivamente questo post?",
"discussions.post.delete.confirmation.button.delete": "Delete",
"discussions.editor.report.post.title": "Report inappropriate content?",
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.post.closePostModal.title": "Chiudi messaggio",
"discussions.post.closePostModal.text": "Inserisci un motivo per chiudere questo post. Questo verrà mostrato solo agli altri moderatori.",
"discussions.post.closePostModal.reasonCodeInput": "Motivo ",
@@ -167,16 +194,12 @@
"discussions.post.editedBy": "A cura di",
"discussions.post.editReason": "Motivo ",
"discussions.post.postWithoutPreview": "nessuna anteprima disponibile",
"discussions.topics.discussions": "{count, plural,\n =0 {Discussion}\n one {# Discussion}\n other {# Discussions}\n }",
"discussions.topics.questions": "{count, plural,\n =0 {Question}\n one {# Question}\n other {# Questions}\n }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.topics.sort.message": "Ordinato per {sortBy}",
"discussions.topics.sort.lastActivity": "Attività Recente",
"discussions.topics.sort.commentCount": "La maggior parte delle attività",
"discussions.topics.sort.courseStructure": "Struttura Corso",
"discussions.topics.find.label": "Search topics",
"discussions.topics.archived.label": "Archiviati",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory"
}

Some files were not shown because too many files have changed in this diff Show More