Compare commits

...

324 Commits

Author SHA1 Message Date
AhtishamShahid
2db3a5b68e fix: resolved linter issues 2022-12-19 11:25:05 +05:00
AhtishamShahid
202fd3315a fix: resolved misc issues with navigation 2022-12-19 11:25:05 +05:00
AhtishamShahid
fe8ffbd345 fix: resolved linter issues 2022-12-19 11:25:05 +05:00
AhtishamShahid
2913de47ac fix: updated sidebar logic 2022-12-19 11:25:05 +05:00
AhtishamShahid
9a26173b7f fix: updated logic to show sidebar 2022-12-19 11:25:05 +05:00
AhtishamShahid
ad3346ea09 fix: updated logic to show sidebar 2022-12-19 11:25:05 +05:00
AhtishamShahid
545b69376f fix: removed side bar incase there are no posts 2022-12-19 11:25:05 +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
Adolfo R. Brandes
5c3d561152 docs: Include in Open edX release
Commit to including the MFE in Open edX releases going forward.
2022-11-28 13:39:19 +00:00
Adolfo R. Brandes
e16dc59955 fix: Work around Truncate infinite loop
This works around [a known issue](https://github.com/openedx/paragon/issues/1797)
with `Truncate` that in some situations can lead to an infinite loop and
subsequent page hang.
2022-11-28 12:44:46 +00:00
Awais Ansari
fc95d16536 fix: selectTopicsById selector was returning array of undefine (#356) 2022-11-28 17:28:05 +05:00
ayesha waris
9ce791d1d5 Merge pull request #362 from openedx/INF-578
fix: long post titles does not  shift menu position.
2022-11-28 17:07:38 +05:00
ayeshoali
5e12d872b8 fix: long post titles does not shift menu position. 2022-11-28 15:38:52 +05:00
Jenkins
1a9899c696 chore(i18n): update translations 2022-11-27 15:26:36 -05:00
Awais Ansari
ab18806fa6 fix: minor issue after MEF rollout (#358)
* fix: update most votes filter name to most likes

* fix: add sorted word infilters description

* test: fixed post filter test case after sorted word addition
2022-11-24 16:06:08 +05:00
Awais Ansari
459511281d fix: count_flagged param sent only for Privilege users (#359) 2022-11-24 16:05:06 +05:00
Mehak Nasir
d58b104027 style: fixed text truncate for title 2022-11-21 16:19:20 +05:00
ayesha waris
990072e80f Merge pull request #346 from openedx/INF-626
fix: Icons for responses to endorse and mark as answered added
2022-11-21 13:43:49 +05:00
ayeshoali
b92e10e8ae fix: endorse icons colors changed to respective background 2022-11-21 12:42:53 +05:00
ayeshoali
1aadbd9c4f style: icons for responses to endorse and mark as answered added 2022-11-21 12:42:53 +05:00
Jenkins
e2619ef68c chore(i18n): update translations 2022-11-20 15:26:36 -05:00
Awais Ansari
9350922200 fix: incontext modal alignment changes (#353) 2022-11-18 16:21:43 +05:00
Awais Ansari
1c5b0ac581 fix: in-context discussion rendering in sidebar (#351)
* fix: in-context discussion rendering in sidebar

* test: fix the failed test case for post editor
2022-11-17 19:47:20 +05:00
Mehak Nasir
b4da5d35af style: design changes for post summary card 2022-11-17 18:50:08 +05:00
Awais Ansari
1886b22cb3 feat: implement user interactive posts API for my-post tab (#314)
* fix: implement user interactive posts API for my-post tab

* feat: update my-post tab filter and sort according to backend

* refactor: learner posts filter API call

* test: fix post view failed test cases
2022-11-17 18:27:53 +05:00
ayesha waris
db928965e9 Merge pull request #350 from openedx/INF-622
fix: active discussion blackout dates does not load add a post button
2022-11-17 16:53:06 +05:00
ayeshoali
cff01eb9d1 fix: with active discussion blackout dates does not load add a post button 2022-11-17 14:56:43 +05:00
ayesha waris
72df5ecb23 Merge pull request #348 from openedx/revert-341-INF-528
Closing because the original UX change is no more needed hence this fix in design in not needed anymore
2022-11-15 18:04:59 +05:00
ayeshoali
32593f6736 fix: removed extra space besides authorlabel 2022-11-14 18:17:33 +05:00
ayesha waris
f686fb40a1 Revert "Topic information moved next to username" 2022-11-14 18:11:31 +05:00
ayesha waris
257e249532 Merge pull request #341 from openedx/INF-528
Topic information moved next to username
2022-11-14 17:43:41 +05:00
ayeshoali
87209ab169 style: truncation added for long topic namesd 2022-11-14 17:38:53 +05:00
ayeshoali
41f9e5b30a style: Topic info moved next to username 2022-11-14 17:38:53 +05:00
Jenkins
32dc8671b2 chore(i18n): update translations 2022-11-13 15:26:35 -05:00
Mehak Nasir
dfd880d4b3 feat: add unresponded posts filter 2022-11-11 15:05:34 +05:00
Mehak Nasir
6d87bf879d fix: unnamed category and subcatefory strings added 2022-11-11 14:55:44 +05:00
Muhammad Adeel Tajamul
c68ed35c59 feat: added recent sort in learners area (#344)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-11-08 14:07:55 +05:00
Jenkins
4df4aec33a chore(i18n): update translations 2022-11-06 15:26:42 -05:00
Mehak Nasir
c2910affcf feat: added warning for images with large dimensions 2022-11-04 15:09:36 +05:00
Muhammad Adeel Tajamul
7ced4b292c feat: added filter options for learner posts (#340)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-11-03 16:54:07 +05:00
ayesha waris
12dd08d97f Merge pull request #337 from openedx/INF-646
fix: removed highlighted header-action-bar on confirmation pop-up
2022-11-01 18:39:58 +05:00
ayesha waris
deea2aab8e Merge pull request #329 from openedx/INF-372
fix: no responses text does not show while api call in progress
2022-11-01 17:52:11 +05:00
ayeshoali
2203f43052 fix: removed highlighted header-action-bar on confirmation pop-up 2022-11-01 17:51:25 +05:00
ayeshoali
4fa20388dd refactor: removed function responsestext 2022-11-01 17:46:25 +05:00
ayeshoali
0bdd1001a5 refactor: removed redundancy of responses text div using function 2022-11-01 17:46:25 +05:00
ayeshoali
b01272a5ca fix: loading more button does not create glitch in response text 2022-11-01 17:46:25 +05:00
ayeshoali
3ec0b82dce refactor: fixed Lint tests 2022-11-01 17:46:25 +05:00
ayeshoali
375af11a7f fix: no respnses text does not show while api call to load responses is in progress 2022-11-01 17:46:25 +05:00
Mehak Nasir
c5ad7cfca5 fix: more formats for mathjax support added 2022-10-31 14:43:52 +05:00
Jenkins
def74fd9f2 chore(i18n): update translations 2022-10-30 16:26:38 -04:00
Jenkins
69daea0a41 chore(i18n): update translations 2022-10-23 16:26:40 -04:00
Awais Ansari
7965c3e7fc fix: add autoresize plugin in TinyMCE editor (#333)
* fix: add autoresize plugin in tinymce editor

* fix: update author label link check for annoymous
2022-10-20 17:37:37 +05:00
Mehak Nasir
08d1833b39 chore: upgraded paragon to match figma design 2022-10-20 16:36:00 +05:00
Mehak Nasir
0a8d4d17d4 feat: blackout dates information banner added 2022-10-20 16:36:00 +05:00
Awais Ansari
ecae19b4ec fix: convert clickable author label to text in posts summary list (#331)
* fix: convert clickable author label to text in posts summary list
* test: update post link test and add new tests for authorLabel
2022-10-20 15:36:43 +05:00
Jenkins
fd9edd7d77 chore(i18n): update translations 2022-10-20 05:23:46 -04:00
Muhammad Adeel Tajamul
4f74016637 fix: fixed failing tests in transifex (#334)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-10-19 20:32:13 +05:00
Muhammad Adeel Tajamul
0a4f2a41c5 feat: learners area post summary will show reported if any comment is reported (#325)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-10-19 12:11:01 +05:00
SaadYousaf
d7d159dac2 fix: add flag for transifex pull command 2022-10-18 22:41:51 +05:00
Awais Ansari
8ef83c5ae8 fix: replace bodyheight with paragon windowSize (#327) 2022-10-14 15:42:28 +05:00
Mehak Nasir
401d38ce05 fix: learner tab is now visible irrespective of user role 2022-10-14 14:05:23 +05:00
SaadYousaf
cd204c8e05 fix: fix for reported alert banner 2022-10-13 23:28:07 +05:00
Awais Ansari
9c1d68a5ad chore: move feedback banner behin d the DISPLAY_FEEDBACK_BANNER env variable (#324) 2022-10-13 20:44:33 +05:00
Awais Ansari
4fecfabf2e fix: add violates-guidelines edit reason as a default value for global staff (#323)
* fix: remove horizontal scrollbar

* fix: remove violates-guidelines edit reason as a default value for global staff

* fix: global staff can  edit comment/reply
2022-10-13 20:41:16 +05:00
Kshitij Sobti
50b2e15731 feat: Add support for grouping at subsections (#281)
For smaller courses, there is a feature under the new provider
for grouping topics at the subsection level so that when
navigating the course, all topics under a subsection show up in
the sidebar instead of just the current unit.

This implements that functionality by checking if the discussion
is loaded in-context, and if grouping at subsection is enabled,
and if so, displaying the current subsection's topics.
2022-10-12 17:58:04 +05:00
Awais Ansari
947204ff38 fix: remove horizontal scrollbar (#322) 2022-10-11 17:35:13 +05:00
Jenkins
c5bda33bcf chore(i18n): update translations 2022-10-09 16:26:36 -04:00
Mehak Nasir
969df198d5 fix: text search rewrite text added 2022-10-07 20:29:28 +05:00
Awais Ansari
b5ed63c2ed fix: update topics listing UI (#317)
* fix: update topics listing UI

* test: update archived topics test case

* style: remove topic list last divider
2022-10-07 14:32:00 +05:00
Mehak Nasir
150f80412e fix: unnamed topic crash handled on fe 2022-10-06 22:51:01 +05:00
Muhammad Adeel Tajamul
8eb3143c7e feat: remove support for discussion mfe in iframe (#308)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-10-06 06:04:41 +05:00
Awais Ansari
52550149ea chore: upgrade paragon version to 20.12.1 (#315) 2022-10-05 15:08:36 +05:00
Muhammad Adeel Tajamul
35ad0ae0c3 fix: ui refactoring of load more responses and comments button (#310)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-10-05 13:51:20 +05:00
Awais Ansari
11e9ebcfd0 fix: remove editor text persistence (#311)
* fix: remove editor text persistence

* fix: remove text persistence from comment/response editor

* fix: stop comment/response validation on editor cancel button
2022-10-04 18:58:50 +05:00
Muhammad Adeel Tajamul
fc86417444 fix: added hover to stats (#313)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-10-04 18:07:36 +05:00
Mehak Nasir
315da6ba5d fix: ui text changes 2022-10-04 18:00:14 +05:00
Mehak Nasir
f43832187c fix: read/unread status fixed 2022-10-03 18:11:35 +05:00
Kshitij Sobti
8f8c5f279b fix: Only show unread status for posts when the post itself is unread
This commit changes the logic used to mark a post as unread. While previously
any comment on a thread would move a thread from unread to read, now we only
consider a post unread if the post itself hasn't been read.

This is done by checking if the number of unread comments matches the total
comments.

NOTE: this check will also be valid after the first comment, and if all of a
user's read posts get deleted.
2022-10-03 18:11:35 +05:00
Jenkins
4089b90524 chore(i18n): update translations 2022-10-02 16:23:34 -04:00
Muhammad Adeel Tajamul
211323b2b5 fix: removed responses sort from frontend (#307)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-09-30 16:51:20 +05:00
Awais Ansari
52bd342dbf test: fix failed test related to containerSize hook 2022-09-30 13:06:47 +05:00
Awais Ansari
de81833f9a fix: post content vertical scroll issue 2022-09-30 13:06:47 +05:00
Mehak Nasir
59740948c5 revert: paragon downgraded to avoid action menu placement issue (#306) 2022-09-29 23:20:03 +05:00
Mehak Nasir
4e1f3ae2f0 fix: double slash also supported now for mathjax rendering 2022-09-29 13:43:39 +05:00
Awais Ansari
ab2c031bdc fix: remove post sorting from frontend 2022-09-29 11:59:59 +05:00
Muhammad Adeel Tajamul
b256238941 fix: updated header and navigation bar UI (#302)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-09-26 14:11:29 +05:00
Awais Ansari
72eed3d83a chore: upgrade paragon version to 20.11.1 (#296) 2022-09-26 14:00:38 +05:00
Jenkins
c6da71f5f4 chore(i18n): update translations 2022-09-25 16:22:16 -04:00
Muhammad Adeel Tajamul
457ae2823c fix: match discussion header with learning header (#299)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-09-23 16:48:07 +05:00
Mehak Nasir
421bc3df5f style: banner design fix 2022-09-21 22:57:41 +05:00
AsadAzam
62f5566729 Merge pull request #297 from openedx/revert-283-add-response-fix
Revert "fix: add response duplication button fixed"
2022-09-21 20:19:51 +05:00
AsadAzam
61c6f29313 Revert "fix: add response duplication button fixed"
This reverts commit 8baec0bd4b.
2022-09-21 20:14:46 +05:00
Mehak Nasir
576a02bce7 style: buttin style fixed for when learn more is hidden 2022-09-21 14:41:56 +05:00
Ahtisham Shahid
2812dac38c fix: removed extra zero in user stats (#294)
* fix: removed extra zero in user stats

* fix: resolved issue in learner sorting
2022-09-21 12:20:04 +05:00
Mehak Nasir
5e144deff7 fix: permission fixes added for form 2022-09-19 17:34:21 +05:00
Mehak Nasir
34e448d65b test: test added for isAdmin permission 2022-09-19 15:53:35 +05:00
Mehak Nasir
d47c783a70 fix: admin form fix 2022-09-19 15:53:35 +05:00
Mehak Nasir
66f23d07a2 fix: test env loaded 2022-09-19 15:16:02 +05:00
Mehak Nasir
ac6765bbbd fix: moved feedback form links to edx-internal 2022-09-19 15:16:02 +05:00
Mehak Nasir
0f56b492a0 feat: added feedback banner 2022-09-19 15:16:02 +05:00
Mehak Nasir
7017f43b35 fix: edit post action fixed in learner tab 2022-09-19 14:05:28 +05:00
Jenkins
1bcdceff02 chore(i18n): update translations 2022-09-18 16:19:41 -04:00
Ahtisham Shahid
b5dce94200 feat: made violates-guidelines edit reason code default for admin (#288) 2022-09-15 11:17:40 +05:00
Mehak Nasir
5ba964fa70 fix: fixed landing url and filters 2022-09-13 18:36:09 +05:00
Sarina Canelake
3506a231e5 Merge pull request #282 from openedx/tcril/fix-gh-org-url
Fix github url strings (org edx -> openedx)
2022-09-13 09:01:34 -04:00
Muhammad Adeel Tajamul
adcd272700 fix: added insertlink icon (#285)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-09-13 14:04:41 +05:00
Muhammad Adeel Tajamul
b774b6bab1 feat: copy post functionality (#280)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-09-12 12:31:14 +05:00
Jenkins
4ed2f3b510 chore(i18n): update translations 2022-09-11 16:19:49 -04:00
Sarina Canelake
beda7d0cd7 fix: update path to .github workflows to read from openedx org 2022-09-10 18:14:12 -04:00
Mehak Nasir
8baec0bd4b fix: add response duplication button fixed 2022-09-09 15:34:24 +05:00
Sarina Canelake
b49c5d64b9 fix: fix github url strings (org edx -> openedx) 2022-09-07 09:19:07 -04:00
Jenkins
93387f1d5b chore(i18n): update translations 2022-09-04 16:25:01 -04:00
Awais Ansari
77c28bcd15 fix: sort order of responses are changing immediately (#278) 2022-09-03 01:37:28 +05:00
Muhammad Adeel Tajamul
c45c468a19 fix: hash displaying as encoded (#277)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-09-02 17:26:38 +05:00
Mehak Nasir
02202e2c07 fix: learner tab is hidden from learners 2022-09-02 17:14:05 +05:00
Ahtisham Shahid
b0737da689 feat: added blackout dates implementation (#271)
* feat: added blackout dates implementation

Co-authored-by: Mehak Nasir <mehaknasir94@gmail.com>
2022-09-01 19:58:42 +05:00
Awais Ansari
8a73f23cb0 fix: small UI issue and post sorting (#274)
* fix: filter pinned, unpinned posts and render separately

* fix: UI updates for INF-421 and INF-417

* fix: pin icon moves to left for non privilege user

* fix: posts list accessibility issue
2022-08-31 18:29:21 +05:00
Muhammad Adeel Tajamul
3d10b6dbed fix: updated search info and no result design (#246)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-08-31 16:40:39 +05:00
Mehak Nasir
5704b402cd fix: threads lost issue on load more in topics 2022-08-29 15:07:27 +05:00
Ahtisham Shahid
41ee555e88 fix: Updated state logic for reported content (#264) 2022-08-29 11:56:08 +05:00
Muhammad Adeel Tajamul
eb2ece323c fix: vertically centered no results found message (#270)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-08-29 11:09:53 +05:00
Jenkins
2fd1545d21 chore(i18n): update translations 2022-08-28 16:20:11 -04:00
Saad Yousaf
799895512c Revert "Revert "fix: fix discussions MFE Header to LearningHeader""
This reverts commit d1dcf20312.
2022-08-26 18:37:32 +05:00
Muhammad Adeel Tajamul
f878a04057 fix: follow button should not open post (#267)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-08-26 09:51:10 +05:00
SaadYousaf
fab9d91a0b fix: resolve issues with search functionality 2022-08-25 15:41:09 +05:00
SaadYousaf
19666b88d2 fix: handle inIframe query_param when it is False 2022-08-25 14:54:24 +05:00
Muhammad Adeel Tajamul
418f78cfc8 fix: updated reported icon, pointer cursor on search button and no search if empty string (#263)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-08-25 12:47:28 +05:00
Awais Ansari
45c596b770 fix: allow actions according to user role and privileges (#260)
* fix: allow actions according to user role and privileges

* test: fix failed test case for has_moderation_privileges change
2022-08-24 12:31:31 +05:00
Saad Yousaf
d1dcf20312 Revert "fix: fix discussions MFE Header to LearningHeader"
This reverts commit 5d72cc563e.
2022-08-23 17:02:14 +05:00
SaadYousaf
5d72cc563e fix: fix discussions MFE Header to LearningHeader 2022-08-23 16:25:29 +05:00
Mehak Nasir
a45aeee43b fix: no result found test remove when call in progress (#259) 2022-08-23 13:36:38 +05:00
Mehak Nasir
9be71ff92e fix: anonymous user is not clickable (#258)
* fix: anonymous user is not clickable

* fix: link handled for anonymous user everywhere
2022-08-23 13:19:36 +05:00
Muhammad Adeel Tajamul
7ee5a8e157 fix: show searching when api call is in progress (#254)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-08-23 11:22:56 +05:00
Mehak Nasir
332e5f0cd5 MFE fixes (#256)
* fix: add response section closed on switching post

* fix: response and comments actions removed from close post

* fix: undo/redo button moved to first place in editor toolbar

* fix: tests are added for closed posts
2022-08-22 23:29:00 +05:00
Mehak Nasir
7426ee8838 fix: fix added for screen flickering (#257) 2022-08-19 18:45:04 +05:00
Ahtisham Shahid
77748bec22 fix: long post title heading UI updated (#255) 2022-08-18 23:52:18 +05:00
Ahtisham Shahid
928fa20f68 fix: Resolved UI issues in learners tab (#252) 2022-08-17 16:14:41 +05:00
Awais Ansari
027e9c04f9 fix: desktop responsiveness issue (#253) 2022-08-16 16:52:12 +05:00
Awais Ansari
b29f5d7c34 Move clickable username behind the discussions.enable_learners_tab_in_discussions_mfe (#247)
* fix: move clickable user name behind the discussions.enable_learners_tab_in_discussions_mfe flag

* test: fix fail test cases for clickable username
2022-08-15 15:09:53 +05:00
Mehak Nasir
592f63cae9 fix: accessibility fixes (#249) 2022-08-15 14:55:59 +05:00
Mehak Nasir
67618ab732 fix: style issues in add response bottom section (#250) 2022-08-15 14:55:36 +05:00
Ahtisham Shahid
ef9cfd7287 fix: removed learners list footer (#245)
* fix: removed learners list footer
2022-08-12 17:51:15 +05:00
Awais Ansari
22967357df fix: response and comment UI (#244)
* fix: response and comment UI

* fix: revert inIframe conidition
2022-08-12 16:35:35 +05:00
Awais Ansari
3e0e040cb1 fix: style issues for new post and edit post form (#242)
* fix: style issues for new post and edit post form

* fix: post type cards width
2022-08-11 12:37:05 +05:00
Awais Ansari
afe27d2da4 fix: pinned post sorting issue (#243) 2022-08-11 12:35:37 +05:00
SaadYousaf
007a7c8085 fix: fix styles for header and footer 2022-08-11 12:29:14 +05:00
Muhammad Adeel Tajamul
339e37302d fix: added no results bar to topics tab and search info to learners tab (#239)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-08-10 10:25:47 +05:00
Mehak Nasir
e34ebdbeed fix: accessibility fixes (#236) 2022-08-07 01:10:35 +05:00
Mehak Nasir
b7d436fe2f fix: add a response button added at the end of thread (#238) 2022-08-07 01:09:57 +05:00
Mehak Nasir
314b31a3b2 fix: last login time is hidden from FE (#237) 2022-08-07 01:09:42 +05:00
Awais Ansari
3657767b25 feat: add a posts list back button for small devices and improve responsiveness (#230)
* feat: add a posts list back button for small devices and improve mobile responsiveness
2022-08-05 22:06:23 +05:00
Saad Yousaf
fd60e96e1d fix: fix timeago detail for months to be in weeks. (#235)
Co-authored-by: SaadYousaf <saadyousaf@A006-00314.local>
2022-08-05 22:04:09 +05:00
Awais Ansari
7ea3e62d33 fix: clickable username in post, comments and responses (#234)
* fix: clickable username in post, rcomments and responses

* test: fix test case after clickable username
2022-08-05 21:59:36 +05:00
Ahtisham Shahid
b15fd96108 feat: added image upload size limit (#233)
* feat: added image upload size limit
2022-08-05 15:06:38 +05:00
edx-semantic-release
f71e04c868 chore(i18n): update translations 2022-08-04 07:56:27 -04:00
SaadYousaf
edd5b96981 feat: add languages for translations 2022-08-04 15:35:19 +05:00
Awais Ansari
4991eba2b2 fix: display post closed/edited reason to post author (#228)
* fix: display post closed/edited reason to post author
2022-08-04 15:33:02 +05:00
SaadYousaf
5f29bffea7 feat: add header footer for discussion MFE behind inIframe query_param 2022-08-04 15:19:20 +05:00
Mehak Nasir
c90e77d291 feat: nav bar added as a separate component in discussion (#222)
* feat: navbar added as a separate component

* test: added test for nav bar

* fix: navbar style and conditional rendering fix

* fix: store fix for navbar component
2022-08-04 14:11:59 +05:00
Awais Ansari
25cf4ce4c8 fix: display direct link post at top of the list (#226) 2022-08-03 13:56:19 +05:00
Awais Ansari
2405040f08 fix: scrollbar for safari (#224) 2022-07-29 15:51:03 +05:00
Ahtisham Shahid
74e2169768 fix: show deactivated in case of retired user (#216)
* fix: show decativated in case of retired user

* fix: changed font color for retired user

* fix: resolved test failuer
2022-07-29 15:50:33 +05:00
SaadYousaf
97b92a1762 feat: add transifex configuration to allow translation jobs. 2022-07-29 15:01:55 +05:00
Muhammad Adeel Tajamul
76595e5508 feat: added search button in place of clear button (#221)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-07-29 09:08:01 +05:00
Awais Ansari
d3adb8b3e7 fix: response and comment UI according to Figma (#220)
* fix: response and comment UI according to Figma
* test: add test cases for endorse alert banner
2022-07-27 15:32:34 +05:00
Awais Ansari
d71a53d9ee fix: post avatar alignment and size (#214)
* fix: post avatar alignment and size

* refactor: post header scss class
2022-07-26 17:01:48 +05:00
Muhammad Adeel Tajamul
f447be151d fix: added pushpin icon to mfe (#218)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-07-26 14:36:35 +05:00
Muhammad Adeel Tajamul
c30637fcff fix: load thread if thread data doesnot exist in redux (#215)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-07-22 15:07:32 +05:00
Muhammad Adeel Tajamul
98b97d4125 fix: updated pinned post icon (#212)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-07-22 14:33:22 +05:00
Mehak Nasir
12da372fa0 fix: accessibility fix added for the threads section (#213) 2022-07-22 14:22:19 +05:00
Muhammad Adeel Tajamul
c1b0aa0f8c fix: no results found should be displayed afer api call (#211)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2022-07-20 17:01:37 +05:00
SaadYousaf
d61045ea32 fix: add learner partial username search functionality to the new search component. 2022-07-18 15:16:14 +05:00
Awais Ansari
67b634d391 fix: Update post content UI according to Figma (#209)
* fix: Update post content UI according to Figma

* test: update post view test case
2022-07-14 11:57:39 +05:00
Muhammad Adeel Tajamul
bf953354a1 feat: update search in discussions mfe (#207)
* feat: update search in discussions mfe

* fix: updated code design

* fix: searchbar clear and code refactor

* fix: replaced existing search box

* feat: added search info bar

* fix: resolved failing topics test

* fix: resolved intl issue

* fix: resolved il8n extract check

* fix: labels moved to il8n message files

Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2022-07-07 14:31:20 +05:00
Awais Ansari
ca1783a2b6 fix: set default value for cohorts dropdown (#208) 2022-07-06 17:11:37 +05:00
Awais Ansari
70027d0f49 feat: clickable username in post summery card (#201)
* feat: clickable post username
* feat: fetch learner posts based on learner name
2022-06-30 18:19:20 +05:00
AsadAzam
b2f5bd8305 Merge pull request #109 from openedx/kshitij/tnl-9673/archived-topics
feat: Add support for archived topics [BD-38] [TNL-9673]
2022-06-29 18:24:56 +05:00
Kshitij Sobti
dbf6679c9d feat: Add support for archived topics
Topics that belong to deleted sections/subsections/units in a course are considered archived. This commit adds a new UI section for such topics and lists them there.
2022-06-29 08:41:18 +05:30
Awais Ansari
a49f71f717 fix: post editor images and preview images responsiveness (#206) 2022-06-27 15:46:32 +05:00
Mehak Nasir
a089235253 fix: load-more button added in place of infinite scroll (#199)
fix: accessibility issue resolved assigning aria-levels to UI components

fix: learner view loading changes and ui fix

fix: removed unused component

fix: lint fixes
2022-06-24 21:48:14 +05:00
Kshitij Sobti
ae6397ea32 fix: filtering posts in a topic doesn't work when filtered results are empty (#203)
A list of all posts in a topic is stored in an object for quick lookup. This is used when switching between topics to get the list of posts in that topic. When filters change the list of posts in a topics is updated and this is picked up by the UI. If changing filters when browsing a topic results in an empty result, this would cause the original list of topics for that topic to be retained, causing the filter to simply not apply. This commit fixes that by resetting the post list in that case.
2022-06-24 08:56:08 +00:00
Ahtisham Shahid
5719b5ce39 fix: Reported filter for global admin (#200)
* fix: Reported filter for global admin

* fix: udpted failing unit tests

* fix: updated failing unit tests

Co-authored-by: AhtishamShahid <ahtishamshahid@A006-00850.local>
2022-06-23 16:42:55 +05:00
Awais Ansari
8aeceb7c09 fix: post content images overflow 2022-06-23 14:44:11 +05:00
Awais Ansari
59187d2217 feat: implement learners area new UI (#197)
* feat: implement learners area new UI

* fix: learners list UI

* fix: initial learner sort based on role
2022-06-20 16:14:13 +05:00
Mehak Nasir
84148954d0 fix: accessibility issues 2022-06-17 16:44:59 +05:00
Mehak Nasir
945e948d42 style: changes in style for accessibility improvements 2022-06-17 16:43:03 +05:00
Mehak Nasir
b0249539d8 chore: update dep frontend-build 2022-06-17 16:43:03 +05:00
Awais Ansari
275dd99b18 fix: post summary card (#196)
* fix: post summery card
2022-06-16 17:23:45 +05:00
Mehak Nasir
b9cb2f3e2e fix: ignored lint fix 2022-06-13 21:29:20 +05:00
Mehak Nasir
c352030fad chore: upgraded frontend build version 2022-06-13 21:29:20 +05:00
Mehak Nasir
34071b1c69 chore: build fix revert package file 2022-06-13 21:29:20 +05:00
Mehak Nasir
5d342f3898 Revert "fix: post summary card (#186)"
This reverts commit 478799e728.
2022-06-13 18:51:08 +05:00
Mehak Nasir
40572e1363 Revert "chore: generate new package-lock (#190)"
This reverts commit 9cd06ce426.
2022-06-13 18:43:18 +05:00
Awais Ansari
9cd06ce426 chore: generate new package-lock (#190) 2022-06-13 18:06:32 +05:00
Awais Ansari
478799e728 fix: post summary card (#186)
* fix: post summery card
* test: fix post link fail test cases
2022-06-13 17:03:22 +05:00
Mehak Nasir
6d591c935c fix: lint fixes 2022-06-09 23:47:37 +05:00
Mehak Nasir
5e5c286392 chore: package lock issue fix attempt 2022-06-09 23:47:37 +05:00
Mehak Nasir
a2cfbc2b3a fix: tags added for reported/pinned/read/selected posts 2022-06-09 23:47:37 +05:00
Muhammad Adeel Tajamul
d7392af0f8 Merge pull request #185 from openedx/inf-283
fix: show no preview avaiable if preview has image or mathjax
2022-06-09 14:36:30 +05:00
adeel.tajamul
ad51e15409 fix: show no preview avaiable if preview has image or mathjax 2022-06-09 12:23:13 +05:00
Muhammad Adeel Tajamul
c16206111a Merge pull request #184 from openedx/inf-167
fix: following the post while creating
2022-06-09 10:54:51 +05:00
adeel.tajamul
ef966a5700 fix: following the post while creating 2022-06-08 11:55:13 +05:00
Mehak Nasir
c2106423c6 style: show preview button css fix in edit mode 2022-06-06 17:07:39 +05:00
Mehak Nasir
b8b01e98e9 fix: mathjax and mathjax inline syntax fix in regexx 2022-06-06 17:07:39 +05:00
Mehak Nasir
57e1811921 fix: syntax support extended for latex 2022-06-04 02:15:11 +05:00
Mehak Nasir
245f8a0f3d fix: browser support config updated 2022-06-04 01:48:35 +05:00
Mehak Nasir
5226cb3552 feat: added preview option for posts/comments/reponses 2022-06-04 00:50:11 +05:00
Awais Ansari
7502006f01 feat: add cohort filter in filter action bar (#172)
* feat: add cohort filter in the filter action bar

* test: fix posts view fail test cases

* test: add test case for cohort filter
2022-06-01 13:39:33 +05:00
Mehak Nasir
2ea48f6825 Revert build fail (#179)
* fix: dropped support for better-react-mathjax as it is causing build issues

* fix: mathjax package reverted to avoid build issue
2022-06-01 12:29:18 +05:00
Mehak Nasir
524366a3e9 Revert "Temp PR to check build issue. (#177)"
This reverts commit 2f88aae6ce.
2022-06-01 11:50:42 +05:00
Mehak Nasir
2f88aae6ce Temp PR to check build issue. (#177)
* fix: mathjax syntax support extended

* fix: adding mathjax support

* temp: removed mathjax contex to check build issue
2022-05-31 23:20:33 +05:00
Ahtisham Shahid
8d02ad33af fix: show reported label in case of reported response (#173)
* fix: show reported label in case of reported response

* fix: resolved linter issues

Co-authored-by: AhtishamShahid <ahtishamshahid@A006-00850.local>
2022-05-31 13:21:21 +05:00
Mehak Nasir
c80c3b8143 build issue resolution try (#176)
* fix: mathjax syntax support extended

* fix: adding mathjax support

* test: build fail issue
2022-05-30 18:18:15 +05:00
Mehak Nasir
8fd3ac7bd9 mathjax syntax support (#175)
* fix: mathjax syntax support extended

* fix: adding mathjax support
2022-05-30 17:02:11 +05:00
Mehak Nasir
8dd19de8a2 Revert "temp: post preview removed temporarily to move fixes on prod"
This reverts commit 20b9bba5d8.
2022-05-30 14:19:34 +05:00
Mehak Nasir
20b9bba5d8 temp: post preview removed temporarily to move fixes on prod 2022-05-27 15:18:23 +05:00
Mehak Nasir
5de4b440be Revert "fix: mathjax syntax support extended" (#171)
This reverts commit 2355c2fc37.
2022-05-27 15:10:58 +05:00
Mehak Nasir
759974c503 Revert "fix: lockfile regenerated to fix build issue (#169)" (#170)
This reverts commit fef9790930.
2022-05-27 15:05:25 +05:00
Mehak Nasir
fef9790930 fix: lockfile regenerated to fix build issue (#169) 2022-05-27 14:58:30 +05:00
Mehak Nasir
2355c2fc37 fix: mathjax syntax support extended 2022-05-27 11:23:22 +05:00
Muhammad Adeel Tajamul
f6a65f91f5 Merge pull request #162 from openedx/inf-226
fix: change votes to likes in search filter
2022-05-26 15:24:13 +05:00
Mehak Nasir
e6bb13d4c3 fix: image rendering fix for those uploaded using legacy experience (#165) 2022-05-26 15:22:39 +05:00
Muhammad Adeel Tajamul
8f16f7f2de Merge pull request #166 from openedx/inf-211-2
fix: post summary ui update
2022-05-25 18:55:26 +05:00
adeel.tajamul
3d3d5651fd fix: post summary ui update 2022-05-25 18:38:16 +05:00
Mehak Nasir
a6577bffcf feat: added preview option for tinyMCE content (#164) 2022-05-24 11:43:11 +05:00
Mehak Nasir
2d1c41b698 Fix: for rendering latex in comments and responses and support for special symbols in TinyMCE (#160)
* fix: for for rendering latex in comments and responses

* fix: component is seperated for loading html in dom

* feat: added special characters support in tiny-mce
2022-05-23 12:36:10 +05:00
Muhammad Adeel Tajamul
e01d413e47 Merge pull request #161 from openedx/inf-211
fix: updated post summary
2022-05-23 12:21:14 +05:00
Awais Ansari
0269c0d81c fix: mark a post as read in redux when user clicks on a post (#163)
* fix: mark a post as read in redux

* chore: remove POST_MARK_AS_READ_DELAY env variable
2022-05-20 11:40:10 +05:00
adeel.tajamul
1ed1013f6b fix: change votes to likes in search filter 2022-05-19 18:07:03 +05:00
adeel.tajamul
97f60d94f6 fix: updated post summary 2022-05-19 15:55:06 +05:00
Awais Ansari
ee3d1c4061 chore: update discussions MEF POST_MARK_AS_READ_DELAY to 0ms (#159) 2022-05-17 00:15:05 +05:00
Mehak Nasir
a52c370b92 revert: reverting wiris integration (#157) 2022-05-16 23:28:37 +05:00
Tim McCormack
dec3f3e6e4 fix: Fix pull_translations by using correct CLI flag for languages (#158)
Docs: https://developers.transifex.com/docs/using-the-client

Apparently this CLI option changed from singular to plural at some point.
2022-05-13 16:18:47 +00:00
Kshitij Sobti
c843d19ff2 fix: Don't force unread view to only discussions (#149)
Allow using the unread filter with questions.
2022-05-13 06:15:56 +00:00
Mehak Nasir
fd984c6ed6 [INF154, 203]: tinyplugins support for mathjax and preview issues (#156)
* feat: mathjax support issues

fix: preview package removed

* fix: preview issue fixed with npm pac

* fix: unused plugin removed

* fix: package-lock fix

* fix: import fix

* test: increased timeout limit for testcases

* style: fix sidebar summary card heigh issue

* fix: fixed npm package version

* fix: fixed npm package version
2022-05-12 13:09:09 +05:00
Abdurrahman Asad
243274ab10 fix: discussion moderators can't create posts for specific cohorts (#155) 2022-05-11 10:42:23 +05:00
Jawayria
681824432a Merge pull request #154 from openedx/jenkins/version-check-4fa31fe
feat: Add package-lock file version check
2022-05-09 18:04:16 +05:00
Jawayria
3cb5e12ad8 Merge pull request #153 from openedx/drop-12
chore!: Dropped support for Node12
2022-05-09 13:10:38 +05:00
edX requirements bot
007972f61a feat: Add package-lock file version check 2022-04-29 08:47:26 -04:00
Jawayria
2ed6d14b98 chore!: Dropped support for Node12 2022-04-29 17:39:48 +05:00
Muhammad Adeel Tajamul
4fa31fedbb Merge pull request #150 from openedx/inf-132
fix: post summary size fix
2022-04-29 17:31:13 +05:00
adeel.tajamul
3719e08321 fix: post summary size fix 2022-04-29 16:54:02 +05:00
Muhammad Adeel Tajamul
e5886c0e04 Merge pull request #151 from openedx/inf-181
fix: loading symbol remains after successful
2022-04-29 07:42:05 +05:00
adeel.tajamul
786c278a2d fix: loading symbol remains after successful 2022-04-28 15:22:14 +05:00
Abdurrahman Asad
e247bf859a fix: reported filter is missing for discussion moderator roles (#148)
fix: reported filter is missing for discussion moderator roles
2022-04-27 16:40:44 +05:00
Awais Ansari
3be40852eb fix: make the post, comment, response content images responsive (#147) 2022-04-25 15:12:51 +05:00
Awais Ansari
847a3b25ec fix: display comment actions alert in single row (#146) 2022-04-25 12:35:31 +05:00
Abdurrahman Asad
e2407e53e3 fix: related to field not shown on posts (#145) 2022-04-22 21:00:40 +05:00
Muhammad Adeel Tajamul
a550cfd30b Merge pull request #144 from openedx/inf-158
fix: post summary pinned bar
2022-04-22 15:42:56 +05:00
Muhammad Adeel Tajamul
af670ec1ab Merge pull request #143 from openedx/aansari/INF-156
fix: add validation for edit reason code dropdown
2022-04-22 11:15:00 +05:00
adeel.tajamul
4b7145dccd fix: post summary pinned bar 2022-04-22 08:46:44 +05:00
Awais Ansari
11f98d32a1 refactor: structure the edit reason code condition 2022-04-21 23:53:56 +05:00
Awais Ansari
bea2390b4d fix: add validation for edit reason code dropdown 2022-04-21 23:26:14 +05:00
Awais Ansari
a77b947e8a fix: hide edit reason dropdown while adding a comment or response (#142)
* fix: hide edit reason dropdown while adding a comment or response
2022-04-21 22:05:21 +05:00
AsadAzam
34a0ae8939 Merge pull request #131 from openedx/kshitij/tnl-9841/tnl-9844
fix: improve UX to match mockups better [BD-38] [TNL-9841] [TNL-9844]
2022-04-21 11:31:45 +05:00
Kshitij Sobti
dfec88de20 fix: improve UX to match mockups better
- Move the learner header to the home page so it can use full width.
- Remove the menu icon from learner page till there is a menu in place
- Improve styling of header bar and sidebar
2022-04-20 18:04:01 +05:30
Kshitij Sobti
c57dfc1fc5 fix: Fix constant reloading on topics page and posts links on category page [BD-38] [TNL-9868] [TNL-9846] (#128)
This fixes two issues. The first is that the topics page can cause constant requests to threads, and the second, that clicking on a post link when browsing a category can cause the application to crash.
2022-04-20 12:26:03 +00:00
Muhammad Adeel Tajamul
d1dce4f2ea Merge pull request #141 from openedx/inf-41
fix: hyperlinks opening in current tab
2022-04-20 16:58:48 +05:00
Kshitij Sobti
e8a3e4eaa8 feat: add support for pagination on learner page [BD-38] [TNL-9844] (#129)
Adds support for pagination on the learners page, including the learners list, the post, comments and responses lists.
2022-04-20 11:10:27 +00:00
adeel.tajamul
7e5ae2a298 fix: hyperlinks opening in current tab 2022-04-20 15:15:52 +05:00
Kshitij Sobti
36ff2fad27 fix: scroll the post close dialog into view when it appears (#119) 2022-04-19 06:33:57 +00:00
Jawayria
7a864ed14e Merge pull request #140 from openedx/aht007/node-16
fix: Updated dependencies for Node 16 #139
2022-04-18 20:07:15 +05:00
Mohammad Ahtasham ul Hassan
dbade5dbd1 fix: update deps 2022-04-18 19:50:34 +05:00
Muhammad Adeel Tajamul
d9f085279e Merge pull request #130 from openedx/tnl-9851
fix: updated post summary design
2022-04-18 15:15:36 +05:00
Mehak Nasir
c0f675f41a revert: node 16 upgrade is reverted (#132)
* revert: node 16 upgrade is reverted

* revert: reverted frontend build version to 9.1.2

* revert: browsers list

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2022-04-15 23:33:29 +05:00
Mehak Nasir
7f49543c37 Revert "fix: frontend build test fix for stage" (#138)
This reverts commit e426892bb2.
2022-04-15 22:52:20 +05:00
Awais Ansari
1d1b7eb94b Revert "revert: revert to node 12 to test build issue" (#136) 2022-04-15 21:48:45 +05:00
Awais Ansari
a059c50780 Revert "revert: downgraded node to 12 again to resolve build issue (#133)" (#137)
This reverts commit 45b3dd79f3.
2022-04-15 21:43:55 +05:00
Awais Ansari
cf83775052 Merge pull request #135 from openedx/revert-3
revert: revert to node 12 to test build issue
2022-04-15 21:22:43 +05:00
Mehak Nasir
6b98ef6506 revert: revert to node 12 to test build issue 2022-04-15 21:20:22 +05:00
Mehak Nasir
45b3dd79f3 revert: downgraded node to 12 again to resolve build issue (#133) 2022-04-15 21:15:07 +05:00
adeel.tajamul
59f97fff7d fix: updated post summary design 2022-04-15 14:57:42 +05:00
Mehak Nasir
86f7a07ded feat: allow anonymous post support is removed from add post section 2022-04-14 13:06:17 +05:00
Mehak Nasir
4be926d788 style: content sections background color changed 2022-04-14 12:49:25 +05:00
Mehak Nasir
e426892bb2 fix: frontend build test fix for stage 2022-04-13 15:25:07 +05:00
Mehak Nasir
80c3fee3da fix: version change test fix 2022-04-13 14:16:33 +05:00
Adam Stankiewicz
43c03ca2c0 build: use shared browserslist config 2022-04-13 14:16:33 +05:00
Awais Ansari
6bd1561bcb chore: upgrade frontend-build version to 9.1.4 (#124) 2022-04-12 19:03:34 +05:00
Awais Ansari
1f119c1bbb chore: create new package-lock file after node 16 upgrade 2022-04-12 12:14:15 +05:00
Awais Ansari
8fc067e64f chore: add @edx/frontend-build in project dependency (#122) 2022-04-12 02:54:09 +05:00
Mehak Nasir
428f2d2311 feat: upgrade node from 12 to 16 (#121) 2022-04-11 16:13:07 +05:00
Awais Ansari
a51f3ed7c6 fix: switching between subtopics are breaking the UI (#120) 2022-04-07 16:23:29 +05:00
Jawayria
7d6d2c4f79 Merge pull request #117 from openedx/jenkins/npm-8-14fe0d4
chore: Install dependencies using npm 8
2022-04-06 16:39:09 +05:00
edX requirements bot
038b8f8966 chore: Install dependencies using npm 8 2022-04-05 08:12:36 -04:00
Arunmozhi
14fe0d4ea5 feat: adds content area to browse learner contributions (#84)
When the learners tab is enabled and is accessible, the contributions of
a learner can be viewed by sorting them into Posts, Responses and
Comments, by selecting a specific learner. This implements the new
design of authored/reported tabs.

https://openedx.atlassian.net/browse/TNL-8844
2022-04-05 08:37:09 +00:00
182 changed files with 42441 additions and 6286 deletions

5
.env
View File

@@ -15,8 +15,11 @@ LOGO_WHITE_URL=''
FAVICON_URL=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
USER_INFO_COOKIE_NAME=''
SUPPORT_URL=''
TA_FEEDBACK_FORM= ''
STAFF_FEEDBACK_FORM= ''
DISPLAY_FEEDBACK_BANNER='false'

View File

@@ -16,8 +16,11 @@ LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org'
TA_FEEDBACK_FORM='https://learner-form.test'
STAFF_FEEDBACK_FORM='https://staff-form.test'
DISPLAY_FEEDBACK_BANNER='false'

View File

@@ -8,14 +8,17 @@ LMS_BASE_URL='http://localhost:18000'
LEARNING_BASE_URL='http://localhost:2000'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
LOGO_URL='https://edx-cdn.org/v3/default/logo.svg'
LOGO_TRADEMARK_URL='https://edx-cdn.org/v3/default/logo-trademark.svg'
LOGO_WHITE_URL='https://edx-cdn.org/v3/default/logo-white.svg'
FAVICON_URL='https://edx-cdn.org/v3/default/favicon.ico'
MARKETING_SITE_BASE_URL='http://localhost:18000'
ORDER_HISTORY_URL='http://localhost:1996/orders'
POST_MARK_AS_READ_DELAY=2000
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
SEGMENT_KEY=''
SITE_NAME=localhost
SITE_NAME='localhost'
USER_INFO_COOKIE_NAME='edx-user-info'
SUPPORT_URL='https://support.edx.org'
TA_FEEDBACK_FORM='https://learner-form.test'
STAFF_FEEDBACK_FORM='https://staff-form.test'
DISPLAY_FEEDBACK_BANNER='false'

View File

@@ -5,6 +5,9 @@ module.exports = createConfig('eslint',
"plugins": ["simple-import-sort"],
"rules": {
'import/no-extraneous-dependencies': 'off',
'react-hooks/exhaustive-deps': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/no-access-key': 'off',
'simple-import-sort/imports': [
'error', {
groups: [

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node: [12, 14, 16]
node: [16]
steps:
- name: Checkout
uses: actions/checkout@v2
@@ -33,7 +33,5 @@ jobs:
run: npm run build
- name: i18n_extract
run: npm run i18n_extract
- name: is-es5
run: npm run is-es5
- name: Coverage
uses: codecov/codecov-action@v2

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: edx/.github/.github/workflows/commitlint.yml@master
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -0,0 +1,13 @@
#check package-lock file version
name: Lockfile Version check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master

2
.jest/setEnvVars.js Normal file
View File

@@ -0,0 +1,2 @@
process.env.TA_FEEDBACK_FORM= 'https://learner-form.test';
process.env.STAFF_FEEDBACK_FORM= 'https://staff-form.test';

3
.nvmrc
View File

@@ -1,2 +1 @@
12.22.5
16

8
.tx/config Normal file
View File

@@ -0,0 +1,8 @@
[main]
host = https://www.transifex.com
[o:open-edx:p:edx-platform:r:frontend-app-discussions]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -1,5 +1,6 @@
export TRANSIFEX_RESOURCE = frontend-app-discussions
transifex_resource = frontend-app-discussions
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
@@ -10,12 +11,24 @@ tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transi
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
.PHONY: test.npm.*
test.npm.%: validate-no-uncommitted-package-lock-changes
test -d node_modules || $(MAKE) requirements
npm run $(*)
.PHONY: requirements
precommit:
npm run lint
npm audit
requirements:
npm install
requirements: ## install ci requirements
npm ci
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -38,17 +51,17 @@ push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
./node_modules/@edx/reactifex/bash_scripts/get_hashed_strings_v3.sh
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
$(transifex_utils) $(transifex_temp) --comments --v3-scripts-path
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...
git diff --exit-code package-lock.json
git diff --exit-code package-lock.json

View File

@@ -14,7 +14,7 @@ This repository is a React-based micro frontend for the Open edX discussion foru
1. Clone your new repo:
``git clone https://github.com/edx/frontend-app-discussions.git``
``git clone https://github.com/openedx/frontend-app-discussions.git``
2. Install npm dependencies:
@@ -29,7 +29,7 @@ The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
Project Structure
-----------------
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/edx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
The source for this project is organized into nested submodules according to the ADR `Feature-based Application Organization <https://github.com/openedx/frontend-app-discussions/blob/master/docs/decisions/0002-feature-based-application-organization.rst>`_.
Build Process Notes
-------------------
@@ -41,7 +41,7 @@ The production build is created with ``npm run build``.
Internationalization
--------------------
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/edx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
Please see `edx/frontend-platform's i18n module <https://edx.github.io/frontend-platform/module-Internationalization.html>`_ for documentation on internationalization. The documentation explains how to use it, and the `How To <https://github.com/openedx/frontend-i18n/blob/master/docs/how_tos/i18n.rst>`_ has more detail.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-discussions.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-discussions

View File

@@ -2,4 +2,4 @@
React App i18n HOWTO
####################
This document has moved to the frontend-platform repo: https://github.com/edx/frontend-platform/blob/master/docs/how_tos/i18n.rst
This document has moved to the frontend-platform repo: https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst

View File

@@ -2,7 +2,8 @@ const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('jest', {
// setupFilesAfterEnv is used after the jest environment has been loaded. In general this is what you want.
// If you want to add config BEFORE jest loads, use setupFiles instead.
// If you want to add config BEFORE jest loads, use setupFiles instead.
setupFiles: ['<rootDir>/.jest/setEnvVars.js'],
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],

View File

@@ -8,5 +8,4 @@ openedx-release:
# The openedx-release key is described in OEP-10:
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0010-proc-openedx-releases.html
# The FAQ might also be helpful: https://openedx.atlassian.net/wiki/spaces/COMM/pages/1331268879/Open+edX+Release+FAQ
maybe: true # Delete this "maybe" line when you have decided about Open edX inclusion.
ref: master

38261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,14 @@
"description": "Discussions Frontend",
"repository": {
"type": "git",
"url": "git+https://github.com/edx/frontend-app-discussions.git"
"url": "git+https://github.com/openedx/frontend-app-discussions.git"
},
"browserslist": [
"last 2 versions",
"ie 11"
"extends @edx/browserslist-config"
],
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
"is-es5": "es-check es5 ./dist/*.js",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --ext .js --ext .jsx . --fix",
"snapshot": "fedx-scripts jest --updateSnapshot",
@@ -27,17 +25,19 @@
},
"author": "edX",
"license": "AGPL-3.0",
"homepage": "https://github.com/edx/frontend-app-discussions#readme",
"homepage": "https://github.com/openedx/frontend-app-discussions#readme",
"publishConfig": {
"access": "public"
},
"bugs": {
"url": "https://github.com/edx/frontend-app-discussions/issues"
"url": "https://github.com/openedx/frontend-app-discussions/issues"
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-platform": "1.15.4",
"@edx/paragon": "19.10.1",
"@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",
"babel-polyfill": "6.26.0",
@@ -49,6 +49,7 @@
"raw-loader": "4.0.2",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-mathjax-preview": "2.2.6",
"react-redux": "7.2.6",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
@@ -59,18 +60,20 @@
"yup": "0.31.1"
},
"devDependencies": {
"@edx/frontend-build": "9.1.2",
"@edx/browserslist-config": "1.1.0",
"@edx/frontend-build": "11.0.1",
"@edx/reactifex": "1.0.3",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@testing-library/user-event": "13.5.0",
"axios-mock-adapter": "1.20.0",
"babel-plugin-react-intl": "8.2.25",
"codecov": "3.8.3",
"es-check": "6.2.1",
"eslint-plugin-simple-import-sort": "7.0.0",
"glob": "7.2.0",
"husky": "7.0.4",
"jest": "27.5.1",
"reactifex": "1.1.1",
"rosie": "2.1.0"
}
}

View File

@@ -1,12 +1,16 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en-us">
<head>
<title>Discussions | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
<title>Discussions | <%= process.env.SITE_NAME %></title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="shortcut icon"
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon"
/>
</head>
<body class="vh-100 vw-100 overflow-hidden">
<div id="root" class="vh-100 vw-100 small"></div>
<body>
<div id="root" class="small"></div>
</body>
</html>

View File

@@ -0,0 +1,201 @@
/* eslint-disable react/forbid-prop-types */
import React, { useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { capitalize, toString } from 'lodash';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Collapsible, Form, Icon, Spinner,
} from '@edx/paragon';
import { Tune } from '@edx/paragon/icons';
import {
PostsStatusFilter, RequestStatus,
ThreadOrdering, ThreadType,
} from '../data/constants';
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
import messages from '../discussions/posts/post-filter-bar/messages';
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
function FilterBar({
intl,
filters,
selectedFilters,
onFilterChange,
showCohortsFilter,
}) {
const [isOpen, setOpen] = useState(false);
const cohorts = useSelector(selectCourseCohorts);
const { status } = useSelector(state => state.cohorts);
const selectedCohort = useMemo(() => cohorts.find(cohort => (
toString(cohort.id) === selectedFilters.cohort)),
[selectedFilters.cohort]);
const allFilters = [
{
id: 'type-all',
label: intl.formatMessage(messages.allPosts),
value: ThreadType.ALL,
},
{
id: 'type-discussions',
label: intl.formatMessage(messages.filterDiscussions),
value: ThreadType.DISCUSSION,
},
{
id: 'type-questions',
label: intl.formatMessage(messages.filterQuestions),
value: ThreadType.QUESTION,
},
{
id: 'status-any',
label: intl.formatMessage(messages.filterAnyStatus),
value: PostsStatusFilter.ALL,
},
{
id: 'status-unread',
label: intl.formatMessage(messages.filterUnread),
value: PostsStatusFilter.UNREAD,
},
{
id: 'status-reported',
label: intl.formatMessage(messages.filterReported),
value: PostsStatusFilter.REPORTED,
},
{
id: 'status-unanswered',
label: intl.formatMessage(messages.filterUnanswered),
value: PostsStatusFilter.UNANSWERED,
},
{
id: 'status-unresponded',
label: intl.formatMessage(messages.filterUnresponded),
value: PostsStatusFilter.UNRESPONDED,
},
{
id: 'sort-activity',
label: intl.formatMessage(messages.lastActivityAt),
value: ThreadOrdering.BY_LAST_ACTIVITY,
},
{
id: 'sort-comments',
label: intl.formatMessage(messages.commentCount),
value: ThreadOrdering.BY_COMMENT_COUNT,
},
{
id: 'sort-votes',
label: intl.formatMessage(messages.voteCount),
value: ThreadOrdering.BY_VOTE_COUNT,
},
];
return (
<Collapsible.Advanced
open={isOpen}
onToggle={() => setOpen(!isOpen)}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-700 pr-4">
{intl.formatMessage(messages.sortFilterStatus, {
own: false,
type: selectedFilters.postType,
sort: selectedFilters.orderBy,
status: selectedFilters.status,
cohortType: selectedCohort?.name ? 'group' : 'all',
cohort: capitalize(selectedCohort?.name),
})}
</span>
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
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;
})
}
</Form.RadioSet>
))}
</div>
{showCohortsFilter && (
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={selectedFilters.cohort}
onChange={onFilterChange}
>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={selectedFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={toString(cohort.id)}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={selectedFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)}
</Form>
</Collapsible.Body>
</Collapsible.Advanced>
);
}
FilterBar.propTypes = {
intl: intlShape.isRequired,
filters: PropTypes.array.isRequired,
selectedFilters: PropTypes.object.isRequired,
onFilterChange: PropTypes.func.isRequired,
showCohortsFilter: PropTypes.bool,
};
FilterBar.defaultProps = {
showCohortsFilter: false,
};
export default injectIntl(FilterBar);

View File

@@ -0,0 +1,66 @@
import React from 'react';
import PropTypes from 'prop-types';
import MathJax from 'react-mathjax-preview';
const baseConfig = {
showMathMenu: true,
tex2jax: {
inlineMath: [
['$', '$'],
['\\\\(', '\\\\)'],
['\\(', '\\)'],
['[mathjaxinline]', '[/mathjaxinline]'],
['\\begin{math}', '\\end{math}'],
],
displayMath: [
['[mathjax]', '[/mathjax]'],
['$$', '$$'],
['\\\\[', '\\\\]'],
['\\[', '\\]'],
['\\begin{displaymath}', '\\end{displaymath}'],
['\\begin{equation}', '\\end{equation}'],
],
},
skipStartupTypeset: true,
};
function HTMLLoader({ htmlNode, componentId, cssClassName }) {
const isLatex = htmlNode.match(/(\${1,2})((?:\\.|.)*)\1/)
|| htmlNode.match(/(\[mathjax](.+?)\[\/mathjax])+/)
|| htmlNode.match(/(\[mathjaxinline](.+?)\[\/mathjaxinline])+/)
|| htmlNode.match(/(\\begin\{math}(.+?)\\end\{math})+/)
|| htmlNode.match(/(\\begin\{displaymath}(.+?)\\end\{displaymath})+/)
|| htmlNode.match(/(\\begin\{equation}(.+?)\\end\{equation})+/)
|| htmlNode.match(/(\\\[(.+?)\\\])+/)
|| htmlNode.match(/(\\\((.+?)\\\))+/);
return (
isLatex ? (
<MathJax
math={htmlNode}
id={componentId}
className={cssClassName}
sanitizeOptions={{ USE_PROFILES: { html: true } }}
config={baseConfig}
/>
)
// eslint-disable-next-line react/no-danger
: <div className={cssClassName} id={componentId} dangerouslySetInnerHTML={{ __html: htmlNode }} />
);
}
HTMLLoader.propTypes = {
htmlNode: PropTypes.node,
componentId: PropTypes.string,
cssClassName: PropTypes.string,
};
HTMLLoader.defaultProps = {
htmlNode: '',
componentId: null,
cssClassName: '',
};
export default HTMLLoader;

View File

@@ -0,0 +1,64 @@
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { fetchTab } from './data/thunks';
import Tabs from './tabs/Tabs';
import messages from './messages';
import './navBar.scss';
function CourseTabsNavigation({
activeTab, className, intl, courseId, rootSlug,
}) {
const dispatch = useDispatch();
const tabs = useSelector(state => state.courseTabs.tabs);
useEffect(() => {
dispatch(fetchTab(courseId, rootSlug));
}, [courseId]);
return (
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation', className)}>
<div className="container-xl">
{!!tabs.length
&& (
<Tabs
className="nav-underline-tabs"
aria-label={intl.formatMessage(messages.courseMaterial)}
>
{tabs.map(({ url, title, slug }) => (
<a
key={slug}
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTab })}
href={url}
>
{title}
</a>
))}
</Tabs>
)}
</div>
</div>
);
}
CourseTabsNavigation.propTypes = {
activeTab: PropTypes.string,
className: PropTypes.string,
rootSlug: PropTypes.string,
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
CourseTabsNavigation.defaultProps = {
activeTab: undefined,
className: null,
rootSlug: 'outline',
};
export default injectIntl(CourseTabsNavigation);

View File

@@ -0,0 +1,29 @@
/* eslint-disable import/prefer-default-export */
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { API_BASE_URL } from '../../../data/constants';
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata);
return {
...data,
tabs: data.tabs.map(tab => ({
// The API uses "courseware" as a slug for both courseware and the outline tab.
// If needed, we switch it to "outline" here for
// use within the MFE to differentiate between course home and courseware.
slug: tab.tabId === 'courseware' ? rootSlug : tab.tabId,
title: tab.title,
url: tab.url,
})),
isMasquerading: data.originalUserIsStaff && !data.isStaff,
};
}
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
const url = `${API_BASE_URL}/api/course_home/course_metadata/${courseId}`;
// don't know the context of adding timezone in url. hence omitting it
// url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);
return normalizeCourseHomeCourseMetadata(data, rootSlug);
}

View File

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

View File

@@ -0,0 +1,51 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
export const LOADING = 'loading';
export const LOADED = 'loaded';
export const FAILED = 'failed';
export const DENIED = 'denied';
const slice = createSlice({
name: 'courseTabs',
initialState: {
courseStatus: 'loading',
courseId: null,
tabs: [],
courseTitle: null,
courseNumber: null,
org: null,
},
reducers: {
fetchTabDenied: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = DENIED;
},
fetchTabFailure: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = FAILED;
},
fetchTabRequest: (state, { payload }) => {
state.courseId = payload.courseId;
state.courseStatus = LOADING;
},
fetchTabSuccess: (state, { payload }) => {
state.courseId = payload.courseId;
state.targetUserId = payload.targetUserId;
state.tabs = payload.tabs;
state.courseStatus = LOADED;
state.courseTitle = payload.courseTitle;
state.courseNumber = payload.courseNumber;
state.org = payload.org;
},
},
});
export const {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
} = slice.actions;
export const courseTabsReducer = slice.reducer;

View File

@@ -0,0 +1,33 @@
/* eslint-disable import/prefer-default-export, no-unused-expressions */
import { logError } from '@edx/frontend-platform/logging';
import { getCourseHomeCourseMetadata } from './api';
import {
fetchTabDenied,
fetchTabFailure,
fetchTabRequest,
fetchTabSuccess,
} from './slice';
export function fetchTab(courseId, rootSlug) {
return async (dispatch) => {
dispatch(fetchTabRequest({ courseId }));
try {
const courseHomeCourseMetadata = await getCourseHomeCourseMetadata(courseId, rootSlug);
if (!courseHomeCourseMetadata.courseAccess.hasAccess) {
dispatch(fetchTabDenied({ courseId }));
} else {
dispatch(fetchTabSuccess({
courseId,
tabs: courseHomeCourseMetadata.tabs,
org: courseHomeCourseMetadata.org,
courseNumber: courseHomeCourseMetadata.number,
courseTitle: courseHomeCourseMetadata.title,
}));
}
} catch (e) {
dispatch(fetchTabFailure({ courseId }));
logError(e);
}
};
}

View File

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

View File

@@ -0,0 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
courseMaterial: {
id: 'navigation.course.tabs.label',
defaultMessage: 'Course Material',
description: 'The accessible label for course tabs navigation',
},
});
export default messages;

View File

@@ -0,0 +1,47 @@
@import "@edx/brand/paragon/fonts.scss";
@import "@edx/brand/paragon/variables.scss";
@import "@edx/paragon/scss/core/core.scss";
@import "@edx/brand/paragon/overrides.scss";
$fa-font-path: "~font-awesome/fonts";
@import "~font-awesome/scss/font-awesome";
.course-tabs-navigation {
border-bottom: solid 1px #eaeaea;
.nav a,
.nav button {
&:hover {
background-color: $light-400;
}
}
.nav a {
&:not(.active):hover {
background-color: $light-400;
border-bottom: none;
}
}
}
.nav-underline-tabs {
margin: 0 0 -1px;
.nav-link {
border-bottom: 4px solid transparent;
border-top: 4px solid transparent;
color: $gray-700;
// temporary until we can remove .btn class from dropdowns
border-left: 0;
border-right: 0;
border-radius: 0;
&:hover,
&:focus,
&.active {
font-weight: $font-weight-normal;
color: $primary-500;
border-bottom-color: $primary-500;
}
}
}

View File

@@ -0,0 +1,75 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@edx/paragon';
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
export default function Tabs({ children, className, ...attrs }) {
const [
indexOfLastVisibleChild,
containerElementRef,
invisibleStyle,
overflowElementRef,
] = useIndexOfLastVisibleChild();
const tabChildren = useMemo(() => {
const childrenArray = React.Children.toArray(children);
const indexOfOverflowStart = indexOfLastVisibleChild + 1;
// All tabs will be rendered. Those that would overflow are set to invisible.
const wrappedChildren = childrenArray.map((child, index) => React.cloneElement(child, {
style: index > indexOfLastVisibleChild ? invisibleStyle : null,
}));
// Build the list of items to put in the overflow menu
const overflowChildren = childrenArray.slice(indexOfOverflowStart)
.map(overflowChild => React.cloneElement(overflowChild, { className: 'dropdown-item' }));
// Insert the overflow menu at the cut off index (even if it will be hidden
// it so it can be part of measurements)
wrappedChildren.splice(indexOfOverflowStart, 0, (
<div
className="nav-item flex-shrink-0"
style={indexOfOverflowStart >= React.Children.count(children) ? invisibleStyle : null}
ref={overflowElementRef}
key="overflow"
>
<Dropdown className="h-100">
<Dropdown.Toggle variant="link" className="nav-link h-100" id="learn.course.tabs.navigation.overflow.menu">
<FormattedMessage
id="learn.course.tabs.navigation.overflow.menu"
description="The title of the overflow menu for course tabs"
defaultMessage="More..."
/>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">{overflowChildren}</Dropdown.Menu>
</Dropdown>
</div>
));
return wrappedChildren;
}, [children, indexOfLastVisibleChild]);
return (
<nav
{...attrs}
className={classNames('nav flex-nowrap', className)}
ref={containerElementRef}
>
{tabChildren}
</nav>
);
}
Tabs.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
};
Tabs.defaultProps = {
children: null,
className: undefined,
};

View File

@@ -0,0 +1,60 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import Tabs from './Tabs';
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
jest.mock('./useIndexOfLastVisibleChild');
describe('Tabs', () => {
const mockChildren = [...Array(4).keys()].map(i => (<button key={i} type="button">{`Item ${i}`}</button>));
// Only half of the children will be visible. The rest of them will be in the dropdown.
const indexOfLastVisibleChild = mockChildren.length / 2 - 1;
const invisibleStyle = { visibility: 'hidden' };
useIndexOfLastVisibleChild.mockReturnValue([indexOfLastVisibleChild, null, invisibleStyle, null]);
function renderComponent(children = null) {
render(
<IntlProvider locale="en">
<Tabs>
{children}
</Tabs>
</IntlProvider>,
);
}
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
});
it('renders without children', async () => {
renderComponent();
expect(screen.getByRole('button', { text: 'More...', hidden: true })).toBeInTheDocument();
});
it('hides invisible children', async () => {
renderComponent(mockChildren);
// adding hidden property is necessary because everything enclosed in a div with property hidden
const allButtons = screen.getAllByRole('button', { hidden: true });
expect(screen.getAllByRole('button', { hidden: false })).toHaveLength(3);
[...Array(mockChildren.length).keys()].forEach(i => {
if (i <= indexOfLastVisibleChild + 1) {
expect(allButtons[i]).not.toHaveAttribute('style');
} else {
expect(allButtons[i]).toHaveStyle('visibility: hidden;');
}
});
});
});

View File

@@ -0,0 +1,77 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useWindowSize } from '@edx/paragon';
const invisibleStyle = {
position: 'absolute',
left: 0,
pointerEvents: 'none',
visibility: 'hidden',
};
/**
* This hook will find the index of the last child of a containing element
* that fits within its bounding rectangle. This is done by summing the widths
* of the children until they exceed the width of the container.
*
* The hook returns an array containing:
* [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef]
*
* indexOfLastVisibleChild - the index of the last visible child
* containerElementRef - a ref to be added to the containing html node
* invisibleStyle - a set of styles to be applied to child of the containing node
* if it needs to be hidden. These styles remove the element visually, from
* screen readers, and from normal layout flow. But, importantly, these styles
* preserve the width of the element, so that future width calculations will
* still be accurate.
* overflowElementRef - a ref to be added to an html node inside the container
* that is likely to be used to contain a "More" type dropdown or other
* mechanism to reveal hidden children. The width of this element is always
* included when determining which children will fit or not. Usage of this ref
* is optional.
*/
export default function useIndexOfLastVisibleChild() {
const containerElementRef = useRef(null);
const overflowElementRef = useRef(null);
const containingRectRef = useRef({});
const [indexOfLastVisibleChild, setIndexOfLastVisibleChild] = useState(-1);
const windowSize = useWindowSize();
useLayoutEffect(() => {
const containingRect = containerElementRef.current.getBoundingClientRect();
// No-op if the width is unchanged.
// (Assumes tabs themselves don't change count or width).
if (!containingRect.width === containingRectRef.current.width) {
return;
}
// Update for future comparison
containingRectRef.current = containingRect;
// Get array of child nodes from NodeList form
const childNodesArr = Array.prototype.slice.call(containerElementRef.current.children);
const { nextIndexOfLastVisibleChild } = childNodesArr
// filter out the overflow element
.filter(childNode => childNode !== overflowElementRef.current)
// sum the widths to find the last visible element's index
.reduce((acc, childNode, index) => {
// use floor to prevent rounding errors
acc.sumWidth += Math.floor(childNode.getBoundingClientRect().width);
if (acc.sumWidth <= containingRect.width) {
acc.nextIndexOfLastVisibleChild = index;
}
return acc;
}, {
// Include the overflow element's width to begin with. Doing this means
// sometimes we'll show a dropdown with one item in it when it would fit,
// but allowing this case dramatically simplifies the calculations we need
// to do above.
sumWidth: overflowElementRef.current ? overflowElementRef.current.getBoundingClientRect().width : 0,
nextIndexOfLastVisibleChild: -1,
});
setIndexOfLastVisibleChild(nextIndexOfLastVisibleChild);
}, [windowSize, containerElementRef.current]);
return [indexOfLastVisibleChild, containerElementRef, invisibleStyle, overflowElementRef];
}

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import messages from '../discussions/posts/post-editor/messages';
import HTMLLoader from './HTMLLoader';
function PostPreviewPane({
htmlNode, intl, isPost, editExisting,
}) {
const [showPreviewPane, setShowPreviewPane] = useState(false);
return (
<>
{showPreviewPane && (
<div
className={`w-100 p-2 bg-light-200 rounded box-shadow-down-1 post-preview ${isPost ? 'mt-2 mb-5' : 'my-3'}`}
style={{ minHeight: '200px', wordBreak: 'break-word' }}
>
<IconButton
onClick={() => setShowPreviewPane(false)}
alt={intl.formatMessage(messages.actionsAlt)}
src={Close}
iconAs={Icon}
size="inline"
className="float-right p-3"
iconClassNames="icon-size"
/>
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
</div>
)}
<div className="d-flex justify-content-end">
{!showPreviewPane
&& (
<Button
variant="link"
size="md"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
>
{intl.formatMessage(messages.showPreviewButton)}
</Button>
)}
</div>
</>
);
}
PostPreviewPane.propTypes = {
intl: intlShape.isRequired,
htmlNode: PropTypes.node.isRequired,
isPost: PropTypes.bool,
editExisting: PropTypes.bool,
};
PostPreviewPane.defaultProps = {
isPost: false,
editExisting: false,
};
export default injectIntl(PostPreviewPane);

View File

@@ -1,41 +0,0 @@
import React, {
useEffect,
useRef,
} from 'react';
import PropTypes from 'prop-types';
function ScrollThreshold({ onScroll }) {
const elementRef = useRef(null);
useEffect(() => {
if (!elementRef.current) {
return undefined;
}
// create the observer
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
onScroll();
}
},
);
observer.observe(elementRef.current);
// cleanup callback
return () => {
observer.disconnect();
};
}, [elementRef]);
return (
<div ref={elementRef} />
);
}
ScrollThreshold.propTypes = {
onScroll: PropTypes.func.isRequired,
};
export default ScrollThreshold;

86
src/components/Search.jsx Normal file
View File

@@ -0,0 +1,86 @@
import React, { useContext, useEffect } from 'react';
import camelCase from 'lodash/camelCase';
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 '../discussions/common/context';
import { setUsernameSearch } from '../discussions/learners/data';
import { setSearchQuery } from '../discussions/posts/data';
import postsMessages from '../discussions/posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
function Search({ intl }) {
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const postSearch = useSelector(({ threads }) => threads.filters.search);
const topicSearch = useSelector(({ topics }) => topics.filter);
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
const isPostSearch = ['posts', 'my-posts'].includes(page);
const isTopicSearch = 'topics'.includes(page);
let searchValue = '';
let currentValue = '';
if (isPostSearch) {
currentValue = postSearch;
} else if (isTopicSearch) {
currentValue = topicSearch;
} else {
currentValue = learnerSearch;
}
const onClear = () => {
dispatch(setSearchQuery(''));
dispatch(setTopicFilter(''));
dispatch(setUsernameSearch(''));
};
const onChange = (query) => {
searchValue = query;
};
const onSubmit = (query) => {
if (query === '') {
return;
}
if (isPostSearch) {
dispatch(setSearchQuery(query));
} else if (page === 'topics') {
dispatch(setTopicFilter(query));
} else if (page === 'learners') {
dispatch(setUsernameSearch(query));
}
};
useEffect(() => onClear(), [page]);
return (
<>
<SearchField.Advanced
onClear={onClear}
onChange={onChange}
onSubmit={onSubmit}
value={currentValue}
>
<SearchField.Label />
<SearchField.Input
style={{ paddingRight: '1rem' }}
placeholder={intl.formatMessage(postsMessages.search, { page: camelCase(page) })}
/>
<span className="mt-auto mb-auto mr-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={() => onSubmit(searchValue)}
/>
</span>
</SearchField.Advanced>
</>
);
}
Search.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(Search);

View File

@@ -0,0 +1,54 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Icon } from '@edx/paragon';
import { Search } from '@edx/paragon/icons';
import { RequestStatus } from '../data/constants';
import messages from '../discussions/posts/post-actions-bar/messages';
function SearchInfo({
intl,
count,
text,
loadingStatus,
onClear,
textSearchRewrite,
}) {
return (
<div className="d-flex flex-row border-bottom border-light-400">
<Icon src={Search} className="justify-content-start ml-3.5 mr-2 mb-2 mt-2.5" />
<Button variant="" size="inline" className="text-justify p-2">
{loadingStatus === RequestStatus.SUCCESSFUL && (
textSearchRewrite ? intl.formatMessage(messages.searchRewriteInfo, {
searchString: text,
count,
textSearchRewrite,
})
: intl.formatMessage(messages.searchInfo, { count, text })
)}
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
</Button>
<Button variant="link" size="inline" className="ml-auto mr-3" onClick={onClear} style={{ minWidth: '26%' }}>
{intl.formatMessage(messages.clearSearch)}
</Button>
</div>
);
}
SearchInfo.propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
loadingStatus: PropTypes.string.isRequired,
textSearchRewrite: PropTypes.string,
onClear: PropTypes.func,
};
SearchInfo.defaultProps = {
onClear: () => {},
textSearchRewrite: null,
};
export default injectIntl(SearchInfo);

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { useParams } from 'react-router';
@@ -6,6 +6,11 @@ import { useParams } from 'react-router';
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
import tinymce from 'tinymce/tinymce';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, AlertModal, Button } from '@edx/paragon';
import { MAX_UPLOAD_FILE_SIZE } from '../data/constants';
import messages from '../discussions/messages';
import { uploadFile } from '../discussions/posts/data/api';
import 'tinymce/plugins/code';
@@ -17,12 +22,17 @@ import 'tinymce/icons/default';
import 'tinymce/skins/ui/oxide/skin.css';
// importing the plugin js.
import 'tinymce/plugins/autolink';
import 'tinymce/plugins/autoresize';
import 'tinymce/plugins/autosave';
import 'tinymce/plugins/codesample';
import 'tinymce/plugins/image';
import 'tinymce/plugins/imagetools';
import 'tinymce/plugins/link';
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';
@@ -31,6 +41,7 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
// eslint-disable-next-line import/no-unresolved
import contentUiCss from '!!raw-loader!tinymce/skins/ui/oxide/content.min.css';
/* istanbul ignore next */
const setup = (editor) => {
editor.ui.registry.addButton('openedx_code', {
icon: 'sourcecode',
@@ -46,17 +57,29 @@ const setup = (editor) => {
});
};
/* istanbul ignore next */
export default function TinyMCEEditor(props) {
// note that skin and content_css is disabled to avoid the normal
// loading process and is instead loaded as a string via content_style
const { courseId, postId } = useParams();
const [showImageWarning, setShowImageWarning] = useState(false);
const intl = useIntl();
const uploadHandler = async (blobInfo, success, failure) => {
try {
const blob = blobInfo.blob();
const imageSize = blobInfo.blob().size / 1024;
if (imageSize > MAX_UPLOAD_FILE_SIZE) {
failure(`Images size should not exceed ${MAX_UPLOAD_FILE_SIZE} KB`);
return;
}
const filename = blobInfo.filename();
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
const img = new Image();
img.onload = function () {
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
};
img.src = location;
success(location);
} catch (e) {
failure(e.toString(), { remove: true });
@@ -72,30 +95,55 @@ export default function TinyMCEEditor(props) {
}
return (
<Editor
init={{
skin: false,
menubar: false,
branding: false,
contextmenu: false,
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
autosave_restore_when_empty: true,
plugins: 'autosave codesample link lists image imagetools code',
toolbar: 'formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
+ ' | bullist numlist outdent indent'
+ ' | removeformat'
+ ' | openedx_html'
+ ' | undo redo',
content_css: false,
content_style: contentStyle,
body_class: 'm-2',
images_upload_handler: uploadHandler,
setup,
}}
{...props}
/>
<>
<Editor
init={{
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 paste',
toolbar: 'undo redo'
+ ' | formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
+ ' | bullist numlist outdent indent'
+ ' | removeformat'
+ ' | openedx_html'
+ ' | emoticons'
+ ' | charmap',
content_css: false,
content_style: contentStyle,
body_class: 'm-2 text-editor',
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler,
setup,
}}
{...props}
/>
<AlertModal
title={intl.formatMessage(messages.imageWarningModalTitle)}
isOpen={showImageWarning}
onClose={() => setShowImageWarning(false)}
isBlocking
footerNode={(
<ActionRow>
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
{intl.formatMessage(messages.imageWarningDismissButton)}
</Button>
</ActionRow>
)}
>
<p>
{intl.formatMessage(messages.imageWarningMessage)}
</p>
</AlertModal>
</>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
export default function InsertLink() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
export default function Issue() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
fill="none"
viewBox="0 0 28 28"
>
<path
fill="#F2F0EF"
d="M0 14C0 6.268 6.268 0 14 0s14 6.268 14 14-6.268 14-14 14S0 21.732 0 14z"
/>
<path
fill="#2D494E"
d="M14 2.333C7.56 2.333 2.333 7.56 2.333 14c0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.667-5.227 11.667-11.667C25.667 7.56 20.44 2.334 14 2.334z"
/>
<path
fill="#fff"
d="M12.833 22.167h2.334v-2.334h-2.334v2.334zM16.532 14.198l1.05-1.073a3.713 3.713 0 001.085-2.625A4.665 4.665 0 0014 5.833 4.665 4.665 0 009.333 10.5h2.334A2.34 2.34 0 0114 8.167a2.34 2.34 0 012.333 2.333c0 .642-.256 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.334c0-1.75.525-2.45 1.365-3.302z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function People() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 16 16"
>
<path
fill="#707070"
d="M11.072 7.332a1.992 1.992 0 001.993-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm-5.334 0a1.992 1.992 0 001.994-2 1.997 1.997 0 10-3.993 0c0 1.107.893 2 2 2zm0 1.333c-1.553 0-4.666.78-4.666 2.334v1.666h9.333V11c0-1.554-3.113-2.334-4.667-2.334zm5.334 0c-.194 0-.414.014-.647.034.773.56 1.313 1.313 1.313 2.3v1.666h4V11c0-1.554-3.113-2.334-4.666-2.334z"
/>
</svg>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
export default function PushPin() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M16 9V4H18V2H6V4H8V9C8 10.66 6.66 12 5 12V14H10.97V21L11.97 22L12.97 21V14H19V12C17.34 12 16 10.66 16 9Z"
clipRule="evenodd"
/>
</svg>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
export default function Question() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
fill="none"
viewBox="0 0 28 28"
>
<path
fill="#fff"
d="M0 14.001c0-7.732 6.268-14 14-14s14 6.268 14 14-6.268 14-14 14-14-6.268-14-14z"
/>
<path
fill="#2D494E"
d="M14 2.334c-6.44 0-11.667 5.227-11.667 11.667 0 6.44 5.227 11.667 11.667 11.667 6.44 0 11.666-5.227 11.666-11.667 0-6.44-5.226-11.667-11.666-11.667z"
/>
<path
fill="#fff"
d="M12.833 22.168h2.333v-2.334h-2.333v2.334zM16.531 14.2l1.05-1.074a3.712 3.712 0 001.085-2.625A4.665 4.665 0 0014 5.834a4.665 4.665 0 00-4.667 4.667h2.333A2.34 2.34 0 0114 8.168a2.34 2.34 0 012.333 2.333c0 .642-.257 1.225-.688 1.645l-1.447 1.47a4.696 4.696 0 00-1.365 3.302v.583h2.333c0-1.75.525-2.45 1.365-3.302z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function QuestionAnswer() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
fill="none"
viewBox="0 0 21 20"
>
<path
fill="currentColor"
d="M18.737 5h-2.5v7.5H5.404V15h10l3.333 3.333V5zm-4.166 5.833V1.667H2.07v12.5l3.333-3.334h9.166z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function QuestionAnswerOutline() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M12.6 3.267v6.067H4.08l-.512.512-.502.502v-7.08H12.6zm.867-1.733H2.198a.87.87 0 00-.867.867v12.134L4.8 11.068h8.668a.87.87 0 00.866-.867v-7.8a.87.87 0 00-.867-.867zM17.8 5h-1.733v7.8H4.799v1.734c0 .476.39.867.867.867H15.2l3.467 3.466v-13A.87.87 0 0017.8 5z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function StarFilled() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
fill="none"
viewBox="0 0 21 20"
>
<path
fill="currentColor"
d="M10.404 14.392l5.15 3.108-1.367-5.858 4.55-3.942-5.991-.508-2.342-5.525-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function StarOutline() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
d="M18.737 7.7l-5.991-.517-2.342-5.516-2.342 5.525L2.07 7.7l4.55 3.942L5.254 17.5l5.15-3.108 5.15 3.108-1.359-5.858L18.737 7.7zm-8.333 5.133L7.27 14.725l.834-3.567-2.767-2.4 3.65-.316 1.417-3.359 1.425 3.367 3.65.317-2.767 2.4.834 3.566-3.142-1.9z"
/>
</svg>
);
}

View File

@@ -0,0 +1,18 @@
import React from 'react';
export default function ThumbUpFilled() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="21"
height="20"
fill="none"
viewBox="0 0 21 20"
>
<path
fill="currentColor"
d="M12.212.833L6.237 6.817V17.5h10.258l3.075-7.167V6.667h-6.925l.934-4.484-1.367-1.35zM1.237 7.5H4.57v10H1.237v-10z"
/>
</svg>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
export default function ThumbUpOutline() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
viewBox="0 0 20 20"
>
<path
fill="currentColor"
fillRule="evenodd"
d="M19.57 6.667v3.666L16.495 17.5H6.238V6.817L12.212.833l1.367 1.35-.934 4.484h6.925zm-11.666.841v8.325h7.492l2.508-5.841V8.333h-7.309l.925-4.45-3.616 3.625z"
clipRule="evenodd"
/>
<path fill="currentColor" d="M4.57 17.5H1.237v-10H4.57v10z" />
</svg>
);
}

View File

@@ -0,0 +1,11 @@
export { default as InsertLink } from './InsertLink';
export { default as Issue } from './Issue';
export { default as People } from './People';
export { default as PushPin } from './PushPin';
export { default as Question } from './Question';
export { default as QuestionAnswer } from './QuestionAnswer';
export { default as QuestionAnswerOutline } from './QuestionAnswerOutline';
export { default as StarFilled } from './StarFilled';
export { default as StarOutline } from './StarOutline';
export { default as ThumbUpFilled } from './ThumbUpFilled';
export { default as ThumbUpOutline } from './ThumbUpOutline';

View File

@@ -1,2 +1,3 @@
export { default as PostActionsBar } from '../discussions/posts/post-actions-bar/PostActionsBar';
export { default as Search } from './Search';
export { default as TinyMCEEditor } from './TinyMCEEditor';

View File

@@ -45,6 +45,7 @@ export const ContentActions = {
PIN: 'pinned',
ENDORSE: 'endorsed',
CLOSE: 'closed',
COPY_LINK: 'copy_link',
REPORT: 'abuse_flagged',
DELETE: 'delete',
FOLLOWING: 'following',
@@ -73,9 +74,9 @@ export const RequestStatus = {
* @readonly
* @enum {string}
*/
export const AvatarBorderAndLabelColors = {
Staff: 'warning-700',
'Community TA': 'success-700',
export const AvatarOutlineAndLabelColors = {
Staff: 'staff-color',
'Community TA': 'TA-color',
};
/**
@@ -89,16 +90,6 @@ export const ThreadOrdering = {
BY_VOTE_COUNT: 'voteCount',
};
/**
* Enum for thread view status filtering.
* @readonly
* @enum {string}
*/
export const ThreadViewStatus = {
UNREAD: 'unread',
UNANSWERED: 'unanswered',
};
/**
* Enum for filtering posts by status.
* @readonly
@@ -110,6 +101,7 @@ export const PostsStatusFilter = {
FOLLOWING: 'statusFollowing',
REPORTED: 'statusReported',
UNANSWERED: 'statusUnanswered',
UNRESPONDED: 'statusUnresponded',
};
/**
@@ -132,6 +124,7 @@ export const TopicOrdering = {
export const LearnersOrdering = {
BY_FLAG: 'flagged',
BY_LAST_ACTIVITY: 'activity',
BY_RECENCY: 'recency',
};
/**
@@ -151,7 +144,7 @@ export const Routes = {
},
LEARNERS: {
PATH: `${BASE_PATH}/learners`,
LEARNER: `${BASE_PATH}/learners/:learnerUsername`,
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`,
},
POSTS: {
PATH: `${BASE_PATH}/topics/:topicId`,
@@ -163,38 +156,57 @@ export const Routes = {
`${BASE_PATH}`,
],
EDIT_POST: [
`${BASE_PATH}/category/:category/posts/:postId/edit`,
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
`${BASE_PATH}/posts/:postId/edit`,
`${BASE_PATH}/my-posts/:postId/edit`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
],
},
COMMENTS: {
PATH: [
`${BASE_PATH}/category/:category/posts/:postId`,
`${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/posts/:postId`,
`${BASE_PATH}/my-posts/:postId`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
],
PAGE: `${BASE_PATH}/:page`,
PAGES: {
category: `${BASE_PATH}/category/:category/posts/:postId`,
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
posts: `${BASE_PATH}/posts/:postId`,
'my-posts': `${BASE_PATH}/my-posts/:postId`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
},
},
TOPICS: {
PATH: [
`${BASE_PATH}/topics/:topicId?`,
`${BASE_PATH}/category/:category`,
`${BASE_PATH}/topics`,
],
ALL: `${BASE_PATH}/topics`,
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`,
},
};
export const PostsPages = {
category: `${BASE_PATH}/category/:category/posts`,
topics: `${BASE_PATH}/topics/:topicId/posts`,
posts: `${BASE_PATH}/posts`,
'my-posts': `${BASE_PATH}/my-posts`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts`,
};
export const ALL_ROUTES = []
.concat([Routes.TOPICS.CATEGORY])
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
.concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH)
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
.concat([Routes.LEARNERS.LEARNER, Routes.LEARNERS.PATH])
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH])
.concat([Routes.DISCUSSIONS.PATH]);
export const MAX_UPLOAD_FILE_SIZE = 1024;

View File

@@ -8,6 +8,7 @@ import { initializeStore } from '../store';
import { executeThunk } from '../test-utils';
import { getBlocksAPIResponse } from './__factories__';
import { blocksAPIURL } from './api';
import { RequestStatus } from './constants';
import { fetchCourseBlocks } from './thunks';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
@@ -35,7 +36,7 @@ describe('Course blocks data layer tests', () => {
axiosMock.reset();
});
test('successfully processes block data', async () => {
it('successfully processes block data', async () => {
axiosMock.onGet(blocksAPIURL)
.reply(200, getBlocksAPIResponse());
@@ -77,4 +78,29 @@ describe('Course blocks data layer tests', () => {
},
);
});
it('handles network error', async () => {
axiosMock.onGet(blocksAPIURL).networkError();
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.FAILED);
});
it('handles network timeout', async () => {
axiosMock.onGet(blocksAPIURL).timeout();
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.FAILED);
});
it('handles access denied', async () => {
axiosMock.onGet(blocksAPIURL).reply(403, {});
await executeThunk(fetchCourseBlocks(courseId, 'test-user'), store.dispatch, store.getState);
expect(store.getState().blocks.status)
.toBe(RequestStatus.DENIED);
});
});

View File

@@ -2,7 +2,7 @@
import { createSelector } from '@reduxjs/toolkit';
import { selectDiscussionProvider } from '../discussions/data/selectors';
import { selectDiscussionProvider, selectGroupAtSubsection } from '../discussions/data/selectors';
import { DiscussionProvider } from './constants';
export const selectTopicContext = (topicId) => (state) => state.blocks.topics[topicId];
@@ -14,6 +14,18 @@ export const selectorForUnitSubsection = createSelector(
blocks => key => blocks[blocks[key]?.parent],
);
// If subsection grouping is enabled, and the current selection is a unit, then get the current subsection.
export const selectCurrentCategoryGrouping = createSelector(
selectDiscussionProvider,
selectGroupAtSubsection,
selectBlocks,
(provider, groupAtSubsection, blocks) => blockId => (
(provider !== 'openedx' || !groupAtSubsection || blocks[blockId]?.type !== 'vertical')
? blockId
: blocks[blockId].parent
),
);
export const selectChapters = (state) => state.blocks.chapters;
export const selectTopicsUnderCategory = createSelector(
selectDiscussionProvider,
@@ -32,4 +44,10 @@ export const selectSequences = createSelector(
(chapterIds, blocks) => chapterIds?.flatMap(cId => blocks[cId].children.map(seqId => blocks[seqId])) || [],
);
export const selectArchivedTopics = createSelector(
state => state.topics.topics,
state => state.topics.archivedIds || [],
(topics, ids) => ids.map(id => topics[id]),
);
export const selectTopicIds = () => (state) => state.blocks.chapters;

View File

@@ -58,19 +58,21 @@ function normaliseCourseBlocks({
} else {
blocks[verticalId].children?.forEach(discussionId => {
const discussion = camelCaseObject(blocks[discussionId]);
const { topicId } = discussion.studentViewData;
blockData[discussionId] = discussion;
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
chapterData.topics.push(topicId);
blockData[sequentialId].topics.push(topicId);
blockData[verticalId].topics.push(topicId);
// Store the topic's context in the course in a map
topics[topicId] = {
chapterName: blockData[chapterId].displayName,
verticalName: blockData[sequentialId].displayName,
unitName: blockData[verticalId].displayName,
unitLink: blockData[verticalId].lmsWebUrl,
};
const { topicId } = discussion.studentViewData || {};
if (topicId) {
blockData[discussionId] = discussion;
// Add this topic id to the list of topics for the current chapter, sequential, and vertical
chapterData.topics.push(topicId);
blockData[sequentialId].topics.push(topicId);
blockData[verticalId].topics.push(topicId);
// Store the topic's context in the course in a map
topics[topicId] = {
chapterName: blockData[chapterId].displayName,
verticalName: blockData[sequentialId].displayName,
unitName: blockData[verticalId].displayName,
unitLink: blockData[verticalId].lmsWebUrl,
};
}
});
}
});

View File

@@ -1,38 +1,43 @@
import React, { useEffect } from 'react';
import React, { useContext, useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useHistory, useLocation } from 'react-router-dom';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import { EndorsementStatus, ThreadType } from '../../data/constants';
import {
EndorsementStatus, PostsPages, ThreadType,
} from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
import { EmptyPage } from '../empty-posts';
import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors';
import { markThreadAsRead } from '../posts/data/thunks';
import { fetchThread, markThreadAsRead } from '../posts/data/thunks';
import { discussionsPath, filterPosts } from '../utils';
import { selectThreadComments, selectThreadCurrentPage, selectThreadHasMorePages } from './data/selectors';
import { fetchThreadComments } from './data/thunks';
import { Comment, ResponseEditor } from './comment';
import messages from './messages';
ensureConfig(['POST_MARK_AS_READ_DELAY'], 'Comment thread view');
function usePost(postId) {
const dispatch = useDispatch();
const thread = useSelector(selectThread(postId));
useEffect(() => {
const markReadTimer = setTimeout(() => {
if (thread && !thread.read) {
dispatch(markThreadAsRead(postId));
}
}, getConfig().POST_MARK_AS_READ_DELAY);
return () => {
clearTimeout(markReadTimer);
};
if (thread && !thread.read) {
dispatch(markThreadAsRead(postId));
}
}, [postId]);
return thread;
}
@@ -64,6 +69,7 @@ function DiscussionCommentsView({
postId,
intl,
endorsed,
isClosed,
}) {
const {
comments,
@@ -71,41 +77,56 @@ function DiscussionCommentsView({
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
return (
<div className="m-3">
<div className="my-3">
{endorsed === EndorsementStatus.ENDORSED
? intl.formatMessage(messages.endorsedResponseCount, { num: comments.length })
: intl.formatMessage(messages.responseCount, { num: comments.length })}
</div>
{comments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} />
))}
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4"
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
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>
)}
{isLoading
<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>
)}
</div>
{!!sortedComments.length && !isClosed
&& <ResponseEditor postId={postId} addWrappingDiv />}
</div>
</>
);
}
DiscussionCommentsView.propTypes = {
postId: PropTypes.string.isRequired,
postType: PropTypes.string.isRequired,
isClosed: PropTypes.bool.isRequired,
intl: intlShape.isRequired,
endorsed: PropTypes.oneOf([
EndorsementStatus.ENDORSED, EndorsementStatus.UNENDORSED, EndorsementStatus.DISCUSSION,
@@ -113,28 +134,82 @@ DiscussionCommentsView.propTypes = {
};
function CommentsView({ intl }) {
const [isLoading, submitDispatch] = useDispatchWithState();
const { postId } = useParams();
const thread = usePost(postId);
const history = useHistory();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const {
courseId, learnerUsername, category, topicId, page, inContext,
} = useContext(DiscussionContext);
useEffect(() => {
if (!thread) { submitDispatch(fetchThread(postId, courseId, true)); }
}, [postId]);
if (!thread) {
if (!isLoading) {
return (
<EmptyPage title={intl.formatMessage(messages.noThreadFound)} />
);
}
return (
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
);
}
return (
<>
<div className="discussion-comments d-flex flex-column mt-3 mb-0 mx-3 p-4 card">
{!isOnDesktop && (
inContext ? (
<>
<div className="px-4 py-1.5 bg-white">
<Button
variant="plain"
className="px-0 font-weight-light text-primary-500"
iconBefore={ArrowBack}
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
size="sm"
>
{intl.formatMessage(messages.backAlt)}
</Button>
</div>
<div className="border-bottom border-light-400" />
</>
) : (
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
className="ml-4 mt-4"
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
alt={intl.formatMessage(messages.backAlt)}
/>
)
)}
<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,
})}
>
<Post post={thread} />
<ResponseEditor postId={postId} />
{!thread.closed && <ResponseEditor postId={postId} /> }
</div>
{thread.type === ThreadType.DISCUSSION
&& (
{thread.type === ThreadType.DISCUSSION && (
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/>
)}
)}
{thread.type === ThreadType.QUESTION && (
<>
<DiscussionCommentsView
@@ -142,12 +217,14 @@ function CommentsView({ intl }) {
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.ENDORSED}
isClosed={thread.closed}
/>
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.UNENDORSED}
isClosed={thread.closed}
/>
</>
)}

View File

@@ -12,6 +12,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
@@ -81,23 +82,27 @@ function renderComponent(postId) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<DiscussionContent />
<Route
path="*"
render={({ location }) => {
testLocation = location;
return null;
}}
/>
</MemoryRouter>
<DiscussionContext.Provider
value={{ courseId }}
>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<DiscussionContent />
<Route
path="*"
render={({ location }) => {
testLocation = location;
return null;
}}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('CommentsView', () => {
beforeEach(async () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
@@ -142,7 +147,7 @@ describe('CommentsView', () => {
)];
});
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
mockAxiosReturnPagedComments();
mockAxiosReturnPagedCommentsResponses();
});
@@ -155,9 +160,10 @@ describe('CommentsView', () => {
it('should show and hide the editor', async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
const addResponseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: /add a response/i }),
addResponseButtons[0],
);
});
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
@@ -166,15 +172,17 @@ describe('CommentsView', () => {
});
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
});
it('should allow posting a response', async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
const responseButtons = screen.getAllByRole('button', { name: /add a response/i });
await act(async () => {
fireEvent.click(
screen.getByRole('button', { name: /add a response/i }),
responseButtons[0],
);
});
act(() => {
await act(() => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
});
@@ -186,6 +194,13 @@ describe('CommentsView', () => {
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
});
it('should not allow posting a response on a closed post', async () => {
renderComponent(closedPostId);
await waitFor(() => screen.findByText('Thread-2', { exact: false }));
expect(screen.queryByRole('button', { name: /add a response/i })).not.toBeInTheDocument();
});
it('should allow posting a comment', async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
@@ -206,6 +221,17 @@ describe('CommentsView', () => {
expect(screen.queryByTestId('tinymce-editor')).not.toBeInTheDocument();
await waitFor(async () => expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument());
});
it('should not allow posting a comment on a closed post', async () => {
renderComponent(closedPostId);
await waitFor(() => screen.findByText('thread-2', { exact: false }));
await act(async () => {
expect(
screen.queryByRole('button', { name: /add a comment/i }),
).not.toBeInTheDocument();
});
});
it('should allow editing an existing comment', async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
@@ -231,7 +257,7 @@ describe('CommentsView', () => {
async function setupCourseConfig(reasonCodesEnabled = true) {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
user_is_privileged: true,
has_moderation_privileges: true,
reason_codes_enabled: reasonCodesEnabled,
editReasons: [
{ code: 'reason-1', label: 'reason 1' },
@@ -355,6 +381,7 @@ describe('CommentsView', () => {
});
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
});
it('should allow pinning the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
@@ -368,6 +395,7 @@ describe('CommentsView', () => {
});
assertLastUpdateData({ pinned: false });
});
it('should allow reporting the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
@@ -379,6 +407,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 });
});
@@ -397,12 +430,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 });
@@ -412,20 +441,45 @@ 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 });
});
});
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
it('shown spinner when post isn\'t loaded', async () => {
it('shown post not found when post id does not belong to course', async () => {
renderComponent('unloaded-id');
expect(await screen.findByTestId('loading-indicator'))
expect(await screen.findByText('Thread not found', { exact: true }))
.toBeInTheDocument();
});
@@ -598,12 +652,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 });
@@ -613,11 +663,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

@@ -4,16 +4,18 @@ import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { injectIntl } from '@edx/frontend-platform/i18n';
import timeLocale from '../../common/time-locale';
import LikeButton from '../../posts/post/LikeButton';
import { editComment } from '../data/thunks';
function CommentIcons({
comment,
intl,
}) {
const dispatch = useDispatch();
timeago.register('time-locale', timeLocale);
const handleLike = () => dispatch(editComment(comment.id, { voted: !comment.voted }));
return (
<div className="d-flex flex-row align-items-center">
@@ -22,8 +24,8 @@ function CommentIcons({
onClick={handleLike}
voted={comment.voted}
/>
<div className="d-flex flex-fill text-gray-500 justify-content-end mt-2" title={comment.createdAt}>
{timeago.format(comment.createdAt, intl.locale)}
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
{timeago.format(comment.createdAt, 'time-locale')}
</div>
</div>
);
@@ -37,7 +39,6 @@ CommentIcons.propTypes = {
voted: PropTypes.bool,
createdAt: PropTypes.string,
}).isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(CommentIcons);

View File

@@ -1,13 +1,17 @@
import React, { useEffect, useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants';
import { AlertBanner, DeleteConfirmation } from '../../common';
import { AlertBanner, Confirmation, EndorsedAlertBanner } from '../../common';
import { DiscussionContext } from '../../common/context';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import { fetchThread } from '../../posts/data/thunks';
import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
@@ -21,6 +25,8 @@ import Reply from './Reply';
function Comment({
postType,
comment,
showFullThread = true,
isClosedPost,
intl,
}) {
const dispatch = useDispatch();
@@ -29,95 +35,146 @@ 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 userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
const {
courseId,
} = useContext(DiscussionContext);
useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them.
if (hasChildren && !currentPage) {
if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [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 () => {
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }));
await dispatch(fetchThread(comment.threadId));
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
await dispatch(fetchThread(comment.threadId, courseId));
},
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
[ContentActions.REPORT]: () => handleAbusedFlag(),
};
const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
return (
<div className="discussion-comment d-flex flex-column card my-3" data-testid={`comment-${comment.id}`}>
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
/>
<AlertBanner postType={postType} content={comment} />
<div className="d-flex flex-column p-4">
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing
? (
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} />
)
// eslint-disable-next-line react/no-danger
: <div className="comment-body px-2" dangerouslySetInnerHTML={{ __html: comment.renderedBody }} />}
<CommentIcons
comment={comment}
following={comment.following}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
<div className={classNames({ 'py-2 my-3': showFullThread })}>
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
<Confirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
comfirmAction={handleDeleteConfirmation}
closeButtonVaraint="tertiary"
confirmButtonText={intl.formatMessage(messages.deleteConfirmationDelete)}
/>
<div className="d-flex my-2 flex-column">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => (
<Reply
reply={inlineReply}
postType={postType}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
{hasMorePages && (
{!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} />
<CommentHeader comment={comment} actionHandlers={actionHandlers} postType={postType} />
{isEditing
? (
<CommentEditor comment={comment} onCloseEditor={() => setEditing(false)} formClasses="pt-3" />
)
: <HTMLLoader cssClassName="comment-body pt-4 text-primary-500" componentId="comment" htmlNode={comment.renderedBody} />}
<CommentIcons
comment={comment}
following={comment.following}
onLike={() => dispatch(editComment(comment.id, { voted: !comment.voted }))}
createdAt={comment.createdAt}
/>
<div className="sr-only" role="heading" aria-level="3"> {intl.formatMessage(messages.replies, { count: inlineReplies.length })}</div>
<div className="d-flex flex-column" role="list">
{/* Pass along intl since component used here is the one before it's injected with `injectIntl` */}
{inlineReplies.map(inlineReply => (
<Reply
reply={inlineReply}
postType={postType}
key={inlineReply.id}
intl={intl}
/>
))}
</div>
{hasMorePages && (
<Button
onClick={handleLoadMoreComments}
variant="link"
block="true"
className="my-4"
className="mt-4.5 font-size-14 font-style-normal font-family-inter font-weight-500 px-2.5 py-2"
data-testid="load-more-comments-responses"
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.loadMoreResponses)}
{intl.formatMessage(messages.loadMoreComments)}
</Button>
)}
{!isNested
&& (
isReplying
? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
onCloseEditor={() => setReplying(false)}
/>
)
: (
<Button className="d-flex flex-grow " variant="outline-secondary" onClick={() => setReplying(true)}>
{intl.formatMessage(messages.addComment)}
</Button>
)
)}
{!isNested && showFullThread && (
isReplying ? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
) : (
<>
{!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>
)}
</>
)
)}
</div>
</div>
</div>
);
@@ -126,7 +183,14 @@ function Comment({
Comment.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
comment: commentShape.isRequired,
showFullThread: PropTypes.bool,
isClosedPost: PropTypes.bool,
intl: intlShape.isRequired,
};
Comment.defaultProps = {
showFullThread: true,
isClosedPost: false,
};
export default injectIntl(Comment);

View File

@@ -10,8 +10,15 @@ import { AppContext } from '@edx/frontend-platform/react';
import { Button, Form, StatefulButton } from '@edx/paragon';
import { TinyMCEEditor } from '../../../components';
import FormikErrorFeedback from '../../../components/FormikErrorFeedback';
import PostPreviewPane from '../../../components/PostPreviewPane';
import { useDispatchWithState } from '../../../data/hooks';
import { selectModerationSettings, selectUserIsPrivileged } from '../../data/selectors';
import {
selectModerationSettings,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../../data/selectors';
import { formikCompatibleHandler, isFormikFieldInvalid } from '../../utils';
import { addComment, editComment } from '../data/thunks';
import messages from '../messages';
@@ -20,15 +27,49 @@ function CommentEditor({
intl,
comment,
onCloseEditor,
edit,
formClasses,
}) {
const editorRef = useRef(null);
const { authenticatedUser } = useContext(AppContext);
const userIsPrivileged = useSelector(selectUserIsPrivileged);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const editorRef = useRef(null);
const saveUpdatedComment = async (values) => {
const canDisplayEditReason = (reasonCodesEnabled && edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& comment?.author !== authenticatedUser.username
);
const editReasonCodeValidation = canDisplayEditReason && {
editReasonCode: Yup.string().required(intl.formatMessage(messages.editReasonCodeError)),
};
const validationSchema = Yup.object().shape({
comment: Yup.string()
.required(),
...editReasonCodeValidation,
});
const initialValues = {
comment: comment.rawBody,
editReasonCode: comment?.lastEdit?.reasonCode || (userIsStaff ? 'violates-guidelines' : ''),
};
const handleCloseEditor = (resetForm) => {
resetForm({ values: initialValues });
onCloseEditor();
};
const saveUpdatedComment = async (values, { resetForm }) => {
if (comment.id) {
await dispatch(editComment(comment.id, values));
const payload = {
...values,
editReasonCode: values.editReasonCode || undefined,
};
await dispatch(editComment(comment.id, payload));
} else {
await dispatch(addComment(values.comment, comment.threadId, comment.parentId));
}
@@ -36,22 +77,16 @@ function CommentEditor({
if (editorRef.current) {
editorRef.current.plugins.autosave.removeDraft();
}
onCloseEditor();
handleCloseEditor(resetForm);
};
// The editorId is used to autosave contents to localstorage. This format means that the autosave is scoped to
// the current comment id, or the current comment parent or the curren thread.
const editorId = `comment-editor-${comment.id || comment.parentId || comment.threadId}`;
return (
<Formik
initialValues={{ comment: comment.rawBody }}
validationSchema={Yup.object()
.shape({
comment: Yup.string()
.required(),
editReasonCode: Yup.string()
.nullable()
.default(undefined),
})}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={saveUpdatedComment}
>
{({
@@ -61,15 +96,19 @@ function CommentEditor({
handleSubmit,
handleBlur,
handleChange,
resetForm,
}) => (
<Form onSubmit={handleSubmit}>
{(reasonCodesEnabled
&& userIsPrivileged
&& comment.author !== authenticatedUser.username) && (
<Form.Group>
<Form onSubmit={handleSubmit} className={formClasses}>
{canDisplayEditReason && (
<Form.Group
isInvalid={isFormikFieldInvalid('editReasonCode', {
errors,
touched,
})}
>
<Form.Control
name="editReasonCode"
className="mt-2"
className="mt-2 mr-0"
as="select"
value={values.editReasonCode}
onChange={handleChange}
@@ -85,6 +124,7 @@ function CommentEditor({
<option key={code} value={code}>{label}</option>
))}
</Form.Control>
<FormikErrorFeedback name="editReasonCode" />
</Form.Group>
)}
<TinyMCEEditor
@@ -108,10 +148,11 @@ function CommentEditor({
{intl.formatMessage(messages.commentError)}
</Form.Control.Feedback>
)}
<PostPreviewPane htmlNode={values.comment} />
<div className="d-flex py-2 justify-content-end">
<Button
variant="outline-primary"
onClick={onCloseEditor}
onClick={() => handleCloseEditor(resetForm)}
>
{intl.formatMessage(messages.cancel)}
</Button>
@@ -139,9 +180,17 @@ CommentEditor.propTypes = {
parentId: PropTypes.string,
rawBody: PropTypes.string,
author: PropTypes.string,
lastEdit: PropTypes.object,
}).isRequired,
onCloseEditor: PropTypes.func.isRequired,
intl: intlShape.isRequired,
edit: PropTypes.bool,
formClasses: PropTypes.string,
};
CommentEditor.defaultProps = {
edit: true,
formClasses: '',
};
export default injectIntl(CommentEditor);

View File

@@ -1,16 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Avatar, Icon } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { logError } from '@edx/frontend-platform/logging';
import {
Avatar, Icon,
} from '@edx/paragon';
import { AvatarBorderAndLabelColors, ThreadType } from '../../../data/constants';
import { AvatarOutlineAndLabelColors, EndorsementStatus, ThreadType } from '../../../data/constants';
import { AuthorLabel } from '../../common';
import ActionsDropdown from '../../common/ActionsDropdown';
import { useAlertBannerVisible } from '../../data/hooks';
import { selectAuthorAvatars } from '../../posts/data/selectors';
import { useActions } from '../../utils';
import { commentShape } from './proptypes';
function CommentHeader({
@@ -19,22 +24,63 @@ function CommentHeader({
actionHandlers,
}) {
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
const colorClass = AvatarBorderAndLabelColors[comment.authorLabel];
const colorClass = AvatarOutlineAndLabelColors[comment.authorLabel];
const hasAnyAlert = useAlertBannerVisible(comment);
const actions = useActions({
...comment,
postType,
});
const actionIcons = actions.find(({ action }) => action === EndorsementStatus.ENDORSED);
const handleIcons = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
actionFunction();
} else {
logError(`Unknown or unimplemented action ${action}`);
}
};
return (
<div className="d-flex flex-row justify-content-between">
<div className={classNames('d-flex flex-row justify-content-between', {
'mt-2': hasAnyAlert,
})}
>
<div className="align-items-center d-flex flex-row">
<Avatar
className={`m-2 ${colorClass && `border-${colorClass}`}`}
style={{ borderWidth: '2px' }}
className={`border-0 ml-0.5 mr-2.5 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={comment.author}
src={authorAvatars?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
}}
/>
<AuthorLabel
author={comment.author}
authorLabel={comment.authorLabel}
labelColor={colorClass && `text-${colorClass}`}
linkToProfile
/>
<AuthorLabel author={comment.author} authorLabel={comment.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
</div>
<div className="d-flex align-items-center">
{comment.endorsed && (postType === 'question'
? <Icon src={CheckCircle} className="text-success" data-testid="check-icon" />
: <Icon src={Verified} data-testid="verified-icon" />)}
{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>
)}
<ActionsDropdown
commentOrPost={{
...comment,

View File

@@ -7,6 +7,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../../store';
import { DiscussionContext } from '../../common/context';
import CommentHeader from './CommentHeader';
let store;
@@ -15,7 +16,11 @@ function renderComponent(comment, postType, actionHandlers) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
@@ -25,7 +30,7 @@ const mockComment = {
author: 'abc123',
authorLabel: 'ABC 123',
endorsed: true,
editableFields: [],
editableFields: ['endorsed'],
};
describe('Comment Header', () => {
@@ -43,7 +48,7 @@ describe('Comment Header', () => {
it('should render verified icon for endorsed discussion posts', () => {
renderComponent(mockComment, 'discussion', {});
expect(screen.queryAllByTestId('verified-icon')).toHaveLength(1);
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
});
it('should render check icon for endorsed question posts', () => {
renderComponent(mockComment, 'question', {});

View File

@@ -7,10 +7,13 @@ import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, useToggle } from '@edx/paragon';
import { AvatarBorderAndLabelColors, ContentActions } from '../../../data/constants';
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';
import { selectAuthorAvatars } from '../../posts/data/selectors';
import { editComment, removeComment } from '../data/thunks';
import messages from '../messages';
@@ -22,45 +25,94 @@ function Reply({
postType,
intl,
}) {
timeago.register('time-locale', timeLocale);
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(reply.id, { endorsed: !reply.endorsed })),
[ContentActions.ENDORSE]: () => dispatch(editComment(
reply.id,
{ endorsed: !reply.endorsed },
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 = AvatarBorderAndLabelColors[reply.authorLabel];
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
const hasAnyAlert = useAlertBannerVisible(reply);
return (
<div className="d-flex my-2 flex-column" data-testid={`reply-${reply.id}`}>
<DeleteConfirmation
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
<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)}
/>
<div className="d-flex flex-fill ml-6">
<AlertBanner postType={null} content={reply} intl={intl} />
</div>
<div className="d-flex">
{!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">
<Avatar />
</div>
<div className="w-100">
<AlertBanner content={reply} intl={intl} />
</div>
</div>
)}
<div className="d-flex m-3">
<div className="d-flex">
<div className="d-flex mr-3 mt-2.5">
<Avatar
className={`m-2 ${colorClass && `border-${colorClass}`}`}
style={{ borderWidth: '2px' }}
className={`ml-0.5 mt-0.5 border-0 ${colorClass ? `outline-${colorClass}` : 'outline-anonymous'}`}
alt={reply.author}
src={authorAvatars?.imageUrlSmall}
style={{
width: '32px',
height: '32px',
}}
/>
</div>
<div className="rounded bg-light-300 px-4 py-2 flex-fill">
<div className="d-flex flex-row justify-content-between align-items-center">
<AuthorLabel author={reply.author} authorLabel={reply.authorLabel} labelColor={colorClass && `text-${colorClass}`} />
<div
className="bg-light-300 px-4 pb-2 pt-2.5 flex-fill"
style={{ borderRadius: '0rem 0.375rem 0.375rem' }}
>
<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,
@@ -71,13 +123,11 @@ function Reply({
</div>
{isEditing
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
// eslint-disable-next-line react/no-danger
: <div dangerouslySetInnerHTML={{ __html: reply.renderedBody }} />}
: <HTMLLoader componentId="reply" htmlNode={reply.renderedBody} cssClassName="text-primary-500" />}
</div>
</div>
<div className="text-gray-500 align-self-end mt-2" title={reply.createdAt}>
{timeago.format(reply.createdAt, intl.locale)}
{timeago.format(reply.createdAt, 'time-locale')}
</div>
</div>
);

View File

@@ -1,23 +1,49 @@
import React, { useState } from 'react';
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { DiscussionContext } from '../../common/context';
import { useUserCanAddThreadInBlackoutDate } from '../../data/hooks';
import messages from '../messages';
import CommentEditor from './CommentEditor';
function ResponseEditor({
postId,
intl,
addWrappingDiv,
}) {
const { inContext } = useContext(DiscussionContext);
const [addingResponse, setAddingResponse] = useState(false);
const userCanAddThreadInBlackoutDate = useUserCanAddThreadInBlackoutDate();
useEffect(() => {
setAddingResponse(false);
}, [postId]);
return addingResponse
? (
<CommentEditor comment={{ threadId: postId }} onCloseEditor={() => setAddingResponse(false)} />
) : (
<div className="actions d-flex">
<Button variant="primary" onClick={() => setAddingResponse(true)}>
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
<CommentEditor
comment={{ threadId: postId }}
edit={false}
onCloseEditor={() => setAddingResponse(false)}
/>
</div>
)
: 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 })}
onClick={() => setAddingResponse(true)}
style={{
lineHeight: '20px',
}}
>
{intl.formatMessage(messages.addResponse)}
</Button>
</div>
@@ -27,6 +53,11 @@ function ResponseEditor({
ResponseEditor.propTypes = {
postId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
addWrappingDiv: PropTypes.bool,
};
ResponseEditor.defaultProps = {
addWrappingDiv: false,
};
export default injectIntl(ResponseEditor);

View File

@@ -145,6 +145,18 @@ const commentsSlice = createSlice({
state.commentsById[payload.id] = payload;
state.commentDraft = null;
},
updateCommentsList: (state, { payload }) => {
const { id: commentId, threadId, endorsed } = payload;
const commentAddListtype = endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
const commentRemoveListType = !endorsed ? EndorsementStatus.ENDORSED : EndorsementStatus.UNENDORSED;
state.commentsInThreads[threadId][commentRemoveListType] = (
state.commentsInThreads[threadId]?.[commentRemoveListType]?.filter(item => item !== commentId)
);
state.commentsInThreads[threadId][commentAddListtype] = [
...state.commentsInThreads[threadId][commentAddListtype], payload.id,
];
},
deleteCommentRequest: (state) => {
state.postStatus = RequestStatus.IN_PROGRESS;
},
@@ -189,6 +201,7 @@ export const {
updateCommentFailed,
updateCommentRequest,
updateCommentSuccess,
updateCommentsList,
deleteCommentDenied,
deleteCommentFailed,
deleteCommentRequest,

View File

@@ -2,7 +2,7 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import { EndorsementStatus } from '../../../data/constants';
import { ContentActions, EndorsementStatus } from '../../../data/constants';
import { getHttpErrorStatus } from '../../utils';
import {
deleteComment, getCommentResponses, getThreadComments, postComment, updateComment,
@@ -27,6 +27,7 @@ import {
updateCommentDenied,
updateCommentFailed,
updateCommentRequest,
updateCommentsList,
updateCommentSuccess,
} from './slices';
@@ -116,12 +117,15 @@ export function fetchCommentResponses(commentId, { page = 1 } = {}) {
};
}
export function editComment(commentId, comment) {
export function editComment(commentId, comment, action = null) {
return async (dispatch) => {
try {
dispatch(updateCommentRequest({ commentId }));
const data = await updateComment(commentId, comment);
dispatch(updateCommentSuccess(camelCaseObject(data)));
if (action === ContentActions.ENDORSE) {
dispatch(updateCommentsList(camelCaseObject(data)));
}
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(updateCommentDenied());

View File

@@ -16,6 +16,11 @@ const messages = defineMessages({
defaultMessage: 'Content reported for staff to review',
description: 'Alert banner over comment that has been reported for abuse',
},
backAlt: {
id: 'discussions.actions.back.alt',
defaultMessage: 'Back to list',
description: 'Back to Posts list button text',
},
responseCount: {
id: 'discussions.comments.comment.responseCount',
defaultMessage: `{num, plural,
@@ -26,7 +31,7 @@ const messages = defineMessages({
},
endorsedResponseCount: {
id: 'discussions.comments.comment.endorsedResponseCount',
defaultMessage: `{num, plural,
defaultMessage: `{num, plural,
=0 {No endorsed responses}
one {Showing # endorsed response}
other {Showing # endorsed responses}
@@ -55,6 +60,7 @@ const messages = defineMessages({
defaultMessage: `{postType, select,
discussion {Discussion}
question {Question}
other {{postType}}
} posted {relativeTime} by`,
description: 'Timestamp for when a user posted the message followed by username. The relative time is already translated.',
},
@@ -142,11 +148,41 @@ 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',
description: 'Label for field visible to moderators that allows them to select a reason for editing another user\'s response',
},
editReasonCodeError: {
id: 'discussions.editor.posts.editReasonCode.error',
defaultMessage: 'Select reason for editing',
description: 'Error message visible to moderators when they submit the post/response/comment without select reason for editing',
},
editedBy: {
id: 'discussions.comment.comments.editedBy',
defaultMessage: 'Edited by',
@@ -161,6 +197,21 @@ const messages = defineMessages({
id: 'discussions.post.closedBy',
defaultMessage: 'Post closed by',
},
replies: {
id: 'discussion.comment.repliesHeading',
defaultMessage: '{count} replies for the response added',
description: 'Text added for screen reader to understand nesting replies.',
},
time: {
id: 'discussion.comment.time',
defaultMessage: '{time} ago',
description: 'Time text for endorse banner',
},
noThreadFound: {
id: 'discussion.thread.notFound',
defaultMessage: 'Thread not found',
description: 'message to show on screen if the request thread is not found in course',
},
});
export default messages;

View File

@@ -1,18 +1,22 @@
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import {
Button, Dropdown, Icon, IconButton, ModalPopup,
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
} from '@edx/paragon';
import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages';
import { postShape } from '../posts/post/proptypes';
import { useActions } from '../utils';
import { inBlackoutDateRange, useActions } from '../utils';
import { DiscussionContext } from './context';
function ActionsDropdown({
intl,
@@ -20,9 +24,10 @@ function ActionsDropdown({
disabled,
actionHandlers,
}) {
const [isOpen, setOpen] = useState(false);
const dropdownIconRef = React.useRef(null);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const { inContext } = useContext(DiscussionContext);
const handleActions = (action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
@@ -31,48 +36,55 @@ function ActionsDropdown({
logError(`Unknown or unimplemented action ${action}`);
}
};
const blackoutDateRange = useSelector(selectBlackoutDate);
// Find and remove edit action if in blackout date range.
if (inBlackoutDateRange(blackoutDateRange)) {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
return (
<>
<span ref={dropdownIconRef}>
<IconButton
onClick={() => setOpen(!isOpen)}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
/>
</span>
<ModalPopup
onClose={() => setOpen(false)}
positionRef={dropdownIconRef}
isOpen={isOpen}
placement="auto-start"
>
<div
className="bg-white p-1 shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
<IconButton
onClick={open}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size="sm"
ref={setTarget}
/>
<div className="actions-dropdown">
<ModalPopup
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={inContext ? '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={() => {
setOpen(false);
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

@@ -9,6 +9,7 @@ import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/fronte
import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../messages';
import { ACTIONS_LIST } from '../utils';
import ActionsDropdown from './ActionsDropdown';
@@ -126,6 +127,7 @@ describe('ActionsDropdown', () => {
roles: [],
},
});
store = initializeStore();
});
it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => {
@@ -150,6 +152,36 @@ describe('ActionsDropdown', () => {
await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).not.toBeInTheDocument());
});
it('copy link action should be visible on posts', async () => {
const commentOrPost = {
testFor: 'thread',
...camelCaseObject(Factory.build('thread', { editable_fields: ['copy_link'] }, null)),
};
renderComponent(commentOrPost, { disabled: false });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByText('Copy link')).toBeInTheDocument());
});
it('copy link action should not be visible on a comment', async () => {
const commentOrPost = {
testFor: 'comments',
...camelCaseObject(Factory.build('comment', {}, null)),
};
renderComponent(commentOrPost, { disabled: false });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
fireEvent.click(openButton);
});
await waitFor(() => expect(screen.queryByText('Copy link')).not.toBeInTheDocument());
});
describe.each(canPerformActionTestData)('Actions', ({
testFor, action, label, reason, ...commentOrPost
}) => {

View File

@@ -2,86 +2,62 @@ import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { CheckCircle, Error, Verified } from '@edx/paragon/icons';
import { Report } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import { selectModerationSettings, selectUserIsPrivileged } from '../data/selectors';
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
import { postShape } from '../posts/post/proptypes';
import AuthorLabel from './AuthorLabel';
function AlertBanner({
intl,
content,
postType,
}) {
const isQuestion = postType === ThreadType.QUESTION;
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified;
const userIsPrivileged = useSelector(selectUserIsPrivileged);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content?.abuseFlagged;
return (
<>
{content.endorsed && (
<Alert
variant="plain"
className={`p-3 m-0 align-items-center shadow-none ${classes}`}
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
icon={iconClass}
>
<div className="d-flex justify-content-between">
<strong className="lead">{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
<span className="d-flex align-items-center mr-1">
<span className="mr-2">
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<AuthorLabel author={content.endorsedBy} authorLabel={content.endorsedByLabel} />
{timeago.format(content.endorsedAt, intl.locale)}
</span>
</div>
</Alert>
)}
{content.abuseFlagged && (
<Alert icon={Error} variant="danger" className="p-3 m-0 shadow-none mb-1 flex-fill">
{canSeeReportedBanner && (
<Alert icon={Report} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
{intl.formatMessage(messages.abuseFlaggedMessage)}
</Alert>
)}
{reasonCodesEnabled && userIsPrivileged && content.lastEdit?.reason && (
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
<div className="d-flex align-items-center">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
</span>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</div>
</Alert>
)}
{reasonCodesEnabled && content.closed && (
<Alert variant="info" className="p-3 m-0 shadow-none mb-1 bg-light-200">
<div className="d-flex align-items-center">
{intl.formatMessage(messages.closedBy)}
<span className="ml-1 ">
<AuthorLabel author={content.closedBy} linkToProfile />
</span>
<span className="mx-1" />
{intl.formatMessage(messages.reason)}:&nbsp;{content.closeReason}
</div>
</Alert>
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
<>
{content.lastEdit?.reason && (
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap">
{intl.formatMessage(messages.editedBy)}
<span className="ml-1 mr-3">
<AuthorLabel author={content.lastEdit.editorUsername} linkToProfile />
</span>
{intl.formatMessage(messages.reason)}:&nbsp;{content.lastEdit.reason}
</div>
</Alert>
)}
{content.closed && (
<Alert variant="info" className="px-3 shadow-none mb-2 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap">
{intl.formatMessage(messages.closedBy)}
<span className="ml-1 ">
<AuthorLabel author={content.closedBy} linkToProfile />
</span>
<span className="mx-1" />
{content.closeReason && (`${intl.formatMessage(messages.reason)}: ${content.closeReason}`)}
</div>
</Alert>
)}
</>
)}
</>
);
@@ -90,11 +66,6 @@ function AlertBanner({
AlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
postType: PropTypes.string,
};
AlertBanner.defaultProps = {
postType: null,
};
export default injectIntl(AlertBanner);

View File

@@ -9,6 +9,7 @@ import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../comments/messages';
import AlertBanner from './AlertBanner';
import { DiscussionContext } from './context';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
@@ -22,15 +23,17 @@ function buildTestContent(type, buildParams) {
function renderComponent(
content,
postType,
) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AlertBanner
content={content}
postType={postType}
/>
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<AlertBanner
content={content}
/>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
@@ -44,27 +47,6 @@ describe.each([
props: { abuseFlagged: true },
expectText: [messages.abuseFlaggedMessage.defaultMessage],
},
{
label: 'Staff endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
},
{
label: 'TA endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
},
{
label: 'endorsed comment in a discussion thread',
type: 'comment',
postType: ThreadType.DISCUSSION,
props: { endorsed: true, endorsedBy: 'test-user' },
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
},
{
label: 'flagged thread',
type: 'thread',
@@ -76,7 +58,7 @@ describe.each([
label: 'edited content',
type: 'thread',
postType: null,
props: { last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
props: { closed: false, last_edit: { reason: 'test-reason', editorUsername: 'editor-user' } },
expectText: [messages.editedBy.defaultMessage, messages.reason.defaultMessage, 'editor-user', 'test-reason'],
},
{
@@ -87,7 +69,7 @@ describe.each([
expectText: [messages.closedBy.defaultMessage, 'closing-user', 'test-close-reason'],
},
])('AlertBanner', ({
label, type, postType, props, expectText,
label, type, props, expectText,
}) => {
beforeEach(async () => {
initializeMockApp({
@@ -100,12 +82,12 @@ describe.each([
});
store = initializeStore({
config: {
userIsPrivileged: true,
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);
renderComponent(content, postType);
renderComponent(content);
});
it(`should show correct banner for a ${label}`, async () => {

View File

@@ -1,13 +1,18 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { Institution, School } from '@edx/paragon/icons';
import { Routes } from '../../data/constants';
import { useShowLearnersTab } from '../data/hooks';
import messages from '../messages';
import { discussionsPath } from '../utils';
import { DiscussionContext } from './context';
function AuthorLabel({
intl,
@@ -15,9 +20,13 @@ function AuthorLabel({
authorLabel,
linkToProfile,
labelColor,
alert,
}) {
const location = useLocation();
const { courseId } = useContext(DiscussionContext);
let icon = null;
let authorLabelMessage = null;
if (authorLabel === 'Staff') {
icon = Institution;
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
@@ -26,9 +35,26 @@ function AuthorLabel({
icon = School;
authorLabelMessage = intl.formatMessage(messages.authorLabelTA);
}
const isRetiredUser = author ? author.startsWith('retired__user') : false;
const className = classNames('d-flex align-items-center', labelColor);
const showUserNameAsLink = useShowLearnersTab()
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const labelContents = (
<>
<span className="mr-1">{author}</span>
<div className={className}>
<span
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
})}
role="heading"
aria-level="2"
>
{isRetiredUser ? '[Deactivated]' : author }
</span>
{icon && (
<Icon
style={{
@@ -39,30 +65,48 @@ function AuthorLabel({
/>
)}
{authorLabelMessage && (
<span className="mr-3 ml-1">
<span
className={classNames('mr-1 font-size-14 font-style-normal font-family-inter font-weight-500', {
'text-primary-500': !authorLabelMessage && !isRetiredUser && !alert,
'text-gray-700': isRetiredUser,
})}
style={{ marginLeft: '2px' }}
>
{authorLabelMessage}
</span>
)}
</>
</div>
);
const className = classNames('d-flex align-items-center', labelColor);
return linkToProfile
? React.createElement('a', { href: '#nowhere', className }, labelContents)
: React.createElement('div', { className }, labelContents);
return showUserNameAsLink
? (
<Link
data-testid="learner-posts-link"
id="learner-posts-link"
to={discussionsPath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })(location)}
className="text-decoration-none"
style={{ width: 'fit-content' }}
>
{labelContents}
</Link>
)
: <>{labelContents}</>;
}
AuthorLabel.propTypes = {
intl: intlShape,
intl: intlShape.isRequired,
author: PropTypes.string.isRequired,
authorLabel: PropTypes.string,
linkToProfile: PropTypes.bool,
labelColor: PropTypes.string,
alert: PropTypes.bool,
};
AuthorLabel.defaultProps = {
linkToProfile: false,
authorLabel: null,
labelColor: '',
alert: false,
};
export default injectIntl(AuthorLabel);

View File

@@ -0,0 +1,107 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
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 { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import AuthorLabel from './AuthorLabel';
import { DiscussionContext } from './context';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let store;
let axiosMock;
let container;
function renderComponent(author, authorLabel, linkToProfile, labelColor) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<AuthorLabel
author={author}
authorLabel={authorLabel}
linkToProfile={linkToProfile}
labelColor={labelColor}
/>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Author label', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
learners_tab_enabled: true,
has_moderation_privileges: true,
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
});
describe.each([
['anonymous', null, false, ''],
['ta_user', 'Community TA', true, 'text-TA-color'],
['retired__user', null, false, ''],
['staff_user', 'Staff', true, 'text-staff-color'],
['learner_user', null, false, ''],
])('for %s', (
author, authorLabel, linkToProfile, labelColor,
) => {
it('it has author name text',
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
const authorElement = container.querySelector('[role=heading]');
const authorName = author.startsWith('retired__user') ? '[Deactivated]' : author;
expect(authorElement).toHaveTextContent(authorName);
});
it(`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
if (linkToProfile) {
expect(screen.queryByTestId('learner-posts-link')).toBeInTheDocument();
} else {
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
}
});
it(`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
const authorElement = container.querySelector('[role=heading]');
const labelElement = authorElement.parentNode.lastChild;
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
if (linkToProfile) {
expect(authorElement.parentNode).toHaveClass(labelColor);
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
} else {
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
}
});
});
});

View File

@@ -6,16 +6,19 @@ 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}>
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
<ModalDialog.Header>
<ModalDialog.Title>
{title}
@@ -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

@@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { CheckCircle, Verified } from '@edx/paragon/icons';
import { ThreadType } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import AuthorLabel from './AuthorLabel';
import timeLocale from './time-locale';
function EndorsedAlertBanner({
intl,
content,
postType,
}) {
timeago.register('time-locale', timeLocale);
const isQuestion = postType === ThreadType.QUESTION;
const classes = isQuestion ? 'bg-success-500 text-white' : 'bg-dark-500 text-white';
const iconClass = isQuestion ? CheckCircle : Verified;
return (
content.endorsed && (
<Alert
variant="plain"
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
style={{ borderRadius: '0.375rem 0.375rem 0 0' }}
icon={iconClass}
>
<div className="d-flex justify-content-between flex-wrap">
<strong className="lead">{intl.formatMessage(
isQuestion
? messages.answer
: messages.endorsed,
)}
</strong>
<span className="d-flex align-items-center mr-1 flex-wrap">
<span className="mr-1">
{intl.formatMessage(
isQuestion
? messages.answeredLabel
: messages.endorsedLabel,
)}
</span>
<AuthorLabel
author={content.endorsedBy}
authorLabel={content.endorsedByLabel}
linkToProfile
alert={content.endorsed}
/>
{intl.formatMessage(messages.time, { time: timeago.format(content.endorsedAt, 'time-locale') })}
</span>
</div>
</Alert>
)
);
}
EndorsedAlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired]).isRequired,
postType: PropTypes.string,
};
EndorsedAlertBanner.defaultProps = {
postType: null,
};
export default injectIntl(EndorsedAlertBanner);

View File

@@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../comments/messages';
import { DiscussionContext } from './context';
import EndorsedAlertBanner from './EndorsedAlertBanner';
import '../comments/data/__factories__';
import '../posts/data/__factories__';
let store;
function buildTestContent(type, buildParams) {
const buildParamsSnakeCase = snakeCaseObject(buildParams);
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
}
function renderComponent(
content, postType,
) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+DemoX+Demo_Course' }}
>
<EndorsedAlertBanner
content={content}
postType={postType}
/>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe.each([
{
label: 'Staff endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
},
{
label: 'TA endorsed comment in a question thread',
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Community TA' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'TA'],
},
{
label: 'endorsed comment in a discussion thread',
type: 'comment',
postType: ThreadType.DISCUSSION,
props: { endorsed: true, endorsedBy: 'test-user' },
expectText: [messages.endorsed.defaultMessage, messages.endorsedLabel.defaultMessage, 'test-user'],
},
])('EndorsedAlertBanner', ({
label, type, postType, props, expectText,
}) => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: [],
},
});
store = initializeStore({
config: {
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);
renderComponent(content, postType);
});
it(`should show correct banner for a ${label}`, async () => {
expectText.forEach(message => {
expect(screen.queryAllByText(message, { exact: false }).length).toBeGreaterThan(0);
});
});
});

View File

@@ -2,10 +2,11 @@
import React from 'react';
export const DiscussionContext = React.createContext({
page: null,
courseId: null,
postId: null,
category: null,
commentId: null,
learnerUsername: null,
topicId: null,
inContext: false,
category: null,
learnerUsername: null,
});

View File

@@ -1,4 +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

@@ -0,0 +1,19 @@
// eslint-disable-next-line no-unused-vars
export default function timeLocale(number, index, totalSec) {
return [
['just now', 'right now'],
['%ss', 'in %s seconds'],
['1m', 'in 1 minute'],
['%sm', 'in %s minutes'],
['1h', 'in 1 hour'],
['%sh', 'in %s hours'],
['1d', 'in 1 day'],
['%sd', 'in %s days'],
['1w', 'in 1 week'],
['%sw', 'in %s weeks'],
['4w', 'in 1 month'],
[`${number * 4}w`, 'in %s months'],
['1y', 'in 1 year'],
['%sy', 'in %s years'],
][index];
}

View File

@@ -4,6 +4,6 @@ Factory.define('config')
.attrs({
allow_anonymous: false,
allow_anonymous_to_peers: false,
user_is_privileged: false,
has_moderation_privileges: false,
})
.attr('user_roles', ['user_is_privileged'], (userIsPrivileged) => (userIsPrivileged ? ['Student', 'Moderator'] : ['Student']));
.attr('user_roles', ['has_moderation_privileges'], (hasModerationPrivileges) => (hasModerationPrivileges ? ['Student', 'Moderator'] : ['Student']));

View File

@@ -1,19 +1,36 @@
/* eslint-disable import/prefer-default-export */
import { useContext, useEffect, useRef } from 'react';
import {
useContext, useEffect, useRef, useState,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useRouteMatch } from 'react-router';
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 { selectTopics } from '../topics/data/selectors';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics, topicsLoadingStatus } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { discussionsPath, postMessageToParent } from '../utils';
import { selectAreThreadsFiltered, selectPostThreadCount } from './selectors';
import { discussionsPath, inBlackoutDateRange } from '../utils';
import {
selectAreThreadsFiltered,
selectBlackoutDate,
selectIsCourseAdmin,
selectIsCourseStaff,
selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from './selectors';
import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() {
@@ -32,14 +49,27 @@ export function useTotalTopicThreadCount() {
export const useSidebarVisible = () => {
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
const threadsCallStatus = useSelector(threadsLoadingStatus);
const isViewingSpecificTopic = useRouteMatch(Routes.TOPICS.TOPIC);
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const topicsLoading = useSelector(topicsLoadingStatus);
if (
isViewingSpecificTopic
&& isViewingSpecificTopic.isExact
&& totalThreads > 0
&& topicsLoading === RequestStatus.SUCCESSFUL
&& threadsCallStatus === RequestStatus.SUCCESSFUL
) {
return false;
}
if (isFiltered) {
return true;
}
if (isViewingTopics || isViewingLearners) {
if ((isViewingTopics && isViewingTopics.isExact) || isViewingLearners) {
return true;
}
@@ -61,7 +91,7 @@ export function useCourseDiscussionData(courseId) {
}, [courseId]);
}
export function useRedirectToThread(courseId) {
export function useRedirectToThread(courseId, inContext) {
const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
@@ -74,9 +104,10 @@ export function useRedirectToThread(courseId) {
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
const newLocation = discussionsPath(Routes.COMMENTS.PAGES['my-posts'], {
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
})(location);
history.push(newLocation);
}
@@ -85,59 +116,93 @@ export function useRedirectToThread(courseId) {
export function useIsOnDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.large.minWidth;
return windowSize.width >= breakpoints.medium.minWidth;
}
/**
* Given an element this attempts to get the height of the entire UI.
*
* @param element
* @returns {number}
*/
function getOuterHeight(element) {
// This is the height of the entire document body.
const bodyHeight = document.body.offsetHeight;
// This is the height of the container that will scroll.
const elementContainerHeight = element.parentNode.clientHeight;
// The difference between the body height and the container height is the size of the header footer etc.
// Add to that the element's own height and we get the size the UI should be to fit everything.
return bodyHeight - elementContainerHeight + element.scrollHeight;
export function useIsOnXLDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.extraLarge.minWidth;
}
/**
* This hook posts a resize message to the parent window if running in an iframe
* @param refContainer reference to the component whose size is to be measured
*/
export function useContainerSizeForParent(refContainer) {
function postResizeMessage(height) {
postMessageToParent('plugin.resize', { height });
}
export function useContainerSize(refContainer) {
const location = useLocation();
const enabled = window.parent !== window;
const [height, setHeight] = useState();
const resizeObserver = useRef(new ResizeObserver(() => {
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
if (refContainer.current) {
postResizeMessage(getOuterHeight(refContainer.current));
if (refContainer?.current) {
setHeight(refContainer?.current?.clientHeight);
}
}));
useEffect(() => {
const container = refContainer.current;
const observer = resizeObserver.current;
if (container && observer && enabled) {
const container = refContainer?.current;
const observer = resizeObserver?.current;
if (container && observer) {
observer.observe(container);
postResizeMessage(getOuterHeight(container));
setHeight(container.clientHeight);
}
return () => {
if (container && observer && enabled) {
if (container && observer) {
observer.unobserve(container);
// Send a message to reset the size so that navigating to another
// page doesn't cause the size to be retained
postResizeMessage(null);
}
};
}, [refContainer, resizeObserver, location]);
return height;
}
export const useAlertBannerVisible = (content) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content.abuseFlagged;
return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|| (content.abuseFlagged && canSeeReportedBanner)
);
};
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
/**
* React hook that gets the current topic ID from the current topic or category.
* The topicId in the DiscussionContext only return the direct topicId from the URL.
* If the URL has the current block ID it cannot get the topicID from that. This hook
* gets the topic ID from the URL if available, or from the current category otherwise.
* It only returns an ID if a single ID is available, if navigating a subsection it
* returns null.
* @returns {null|string} A topic ID if a single one available in the current context.
*/
export const useCurrentDiscussionTopic = () => {
const { topicId, category } = useContext(DiscussionContext);
const topics = useSelector(selectTopicsUnderCategory)(category);
if (topicId) {
return topicId;
}
if (topics?.length === 1) {
return topics[0];
}
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));
};

View File

@@ -1,68 +1,174 @@
import { useRef } from 'react';
import { render, waitFor } from '@testing-library/react';
import { render } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { Factory } from 'rosie';
import { getConfig, initializeMockApp } from '@edx/frontend-platform';
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 { useContainerSizeForParent } from './hooks';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { courseConfigApiUrl } from './api';
import { useCurrentDiscussionTopic, useUserCanAddThreadInBlackoutDate } from './hooks';
import { fetchCourseConfig } from './thunks';
const courseId = 'course-v1:edX+TestX+Test_Course';
let store;
initializeMockApp();
let axiosMock;
const generateApiResponse = (blackouts = [], isCourseAdmin = false) => ({
blackouts,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin,
isCourseStaff: false,
isUserAdmin: false,
});
describe('Hooks', () => {
function ComponentWithHook() {
const refContainer = useRef(null);
useContainerSizeForParent(refContainer);
return (
<div>
<div ref={refContainer} />
</div>
);
}
describe('useCurrentDiscussionTopic', () => {
function ComponentWithHook() {
const topic = useCurrentDiscussionTopic();
return (
<div>
{String(topic)}
</div>
);
}
function renderComponent() {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
function renderComponent({ topicId, category }) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<MemoryRouter initialEntries={['/']}>
<DiscussionContext.Provider
value={{
topicId,
category,
}}
>
<ComponentWithHook />
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</ResponsiveContext.Provider>
</IntlProvider>,
);
}
</IntlProvider>,
);
}
let parent;
beforeEach(() => {
store = initializeStore();
parent = window.parent;
beforeEach(() => {
initializeMockApp();
store = initializeStore({
blocks: {
blocks: {
'some-unit-key': { topics: ['some-topic-0'], parent: 'some-sequence-key' },
'some-sequence-key': { topics: ['some-topic-0'] },
'another-sequence-key': { topics: ['some-topic-1', 'some-topic-2'] },
'empty-key': { topics: [] },
},
},
config: { provider: 'openedx' },
});
});
test('when topicId is in context', () => {
const { queryByText } = renderComponent({ topicId: 'some-topic' });
expect(queryByText('some-topic')).toBeInTheDocument();
});
test('when the category is a unit', () => {
const { queryByText } = renderComponent({ category: 'some-unit-key' });
expect(queryByText('some-topic-0')).toBeInTheDocument();
});
test('when the category is a sequence with one unit', () => {
const { queryByText } = renderComponent({ category: 'some-sequence-key' });
expect(queryByText('some-topic-0')).toBeInTheDocument();
});
test('when the category is a sequence with multiple units', () => {
const { queryByText } = renderComponent({ category: 'another-sequence-key' });
expect(queryByText('null')).toBeInTheDocument();
});
test('when the category is invalid', () => {
const { queryByText } = renderComponent({ category: 'invalid-key' });
expect(queryByText('null')).toBeInTheDocument();
});
test('when the category has no topics', () => {
const { queryByText } = renderComponent({ category: 'empty-key' });
expect(queryByText('null')).toBeInTheDocument();
});
});
afterEach(() => {
window.parent = parent;
});
test('useContainerSizeForParent enabled', async () => {
delete window.parent;
window.parent = { ...window, postMessage: jest.fn() };
const { unmount } = renderComponent();
// Once for LMS and one for learning MFE
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(2));
// Test that size is reset on unmount
unmount();
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalledTimes(4));
expect(window.parent.postMessage).toHaveBeenLastCalledWith(
{ type: 'plugin.resize', payload: { height: null } },
getConfig().LMS_BASE_URL,
);
});
test('useContainerSizeForParent disabled', async () => {
window.parent.postMessage = jest.fn();
renderComponent();
await waitFor(() => expect(window.parent.postMessage).not.toHaveBeenCalled());
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

@@ -6,14 +6,28 @@ export const selectAnonymousPostingConfig = state => ({
allowAnonymousToPeers: state.config.allowAnonymousToPeers,
});
export const selectUserIsPrivileged = state => state.config.userIsPrivileged;
export const selectUserHasModerationPrivileges = state => state.config.hasModerationPrivileges;
export const selectUserIsStaff = state => state.config.isUserAdmin;
export const selectUserIsGroupTa = state => state.config.isGroupTa;
export const selectconfigLoadingStatus = state => state.config.status;
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
export const selectUserRoles = state => state.config.userRoles;
export const selectDivisionSettings = state => state.config.settings;
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 selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,

View File

@@ -11,7 +11,12 @@ const configSlice = createSlice({
allowAnonymous: false,
allowAnonymousToPeers: false,
userRoles: [],
userIsPrivileged: false,
groupAtSubsection: false,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
learnersTabEnabled: false,
settings: {
divisionScheme: 'none',

View File

@@ -2,6 +2,12 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { logError } from '@edx/frontend-platform/logging';
import {
LearnersOrdering,
PostsStatusFilter,
} from '../../data/constants';
import { setSortedBy } from '../learners/data';
import { setStatusFilter } from '../posts/data';
import { getHttpErrorStatus } from '../utils';
import { getDiscussionsConfig, getDiscussionsSettings } from './api';
import {
@@ -16,13 +22,23 @@ import {
export function fetchCourseConfig(courseId) {
return async (dispatch) => {
try {
let learnerSort = LearnersOrdering.BY_LAST_ACTIVITY;
const postsFilterStatus = PostsStatusFilter.ALL;
dispatch(fetchConfigRequest());
const config = await getDiscussionsConfig(courseId);
if (config.is_user_admin) {
if (config.has_moderation_privileges) {
const settings = await getDiscussionsSettings(courseId);
Object.assign(config, { settings });
}
if ((config.has_moderation_privileges || config.is_group_ta)) {
learnerSort = LearnersOrdering.BY_FLAG;
}
dispatch(fetchConfigSuccess(camelCaseObject(config)));
dispatch(setSortedBy(learnerSort));
dispatch(setStatusFilter(postsFilterStatus));
} catch (error) {
if (getHttpErrorStatus(error) === 403) {
dispatch(fetchConfigDenied());

View File

@@ -0,0 +1,36 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { selectBlackoutDate } from '../data/selectors';
import messages from '../messages';
import { inBlackoutDateRange } from '../utils';
function BlackoutInformationBanner({
intl,
}) {
const isDiscussionsBlackout = inBlackoutDateRange(useSelector(selectBlackoutDate));
const [showBanner, setShowBanner] = useState(true);
return (
<PageBanner
variant="accentB"
show={isDiscussionsBlackout && showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
>
<div className="font-weight-500">
{intl.formatMessage(messages.blackoutDiscussionInformation)}
</div>
</PageBanner>
);
}
BlackoutInformationBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(BlackoutInformationBanner);

View File

@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import BlackoutInformationBanner from './BlackoutInformationBanner';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let activeStartDate = new Date();
activeStartDate.setDate(activeStartDate.getDate() - 2);
let activeEndDate = new Date();
activeEndDate.setDate(activeEndDate.getDate() + 2);
activeStartDate = activeStartDate.toISOString();
activeEndDate = activeEndDate.toISOString();
const getConfigData = (blackouts = []) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: ['Admin', 'Student'],
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: false,
blackouts,
});
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<BlackoutInformationBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Blackout Information Banner', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: ['Student'],
},
});
});
test.each([
{ blackouts: [], visibility: false },
{ blackouts: ['2021-12-31T10:15', '2021-12-31T10:20'], visibility: false },
{ blackouts: [{ start: activeStartDate, end: activeEndDate }], visibility: true },
{ blackouts: [{ start: activeEndDate, end: activeEndDate }], visibility: false },
])('Test Blackout Banner is visible on app load if blackout date is active', async ({ blackouts, visibility }) => {
store = initializeStore();
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
renderComponent();
if (visibility) {
const element = await screen.findByRole('alert');
expect(element).toBeInTheDocument();
expect(element).toHaveTextContent(messages.blackoutDiscussionInformation.defaultMessage);
} else {
const element = await screen.queryByRole('alert');
expect(element).not.toBeInTheDocument();
}
});
});

View File

@@ -1,21 +1,20 @@
import React, { useRef } from 'react';
import React from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { injectIntl } from '@edx/frontend-platform/i18n';
import { Routes } from '../../data/constants';
import { CommentsView } from '../comments';
import { useContainerSizeForParent } from '../data/hooks';
import { PostEditor } from '../posts';
export default function DiscussionContent() {
const refContainer = useRef(null);
function DiscussionContent() {
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
useContainerSizeForParent(refContainer);
return (
<div className="d-flex bg-light-300 flex-column w-75 w-xs-100 w-xl-75 align-items-center h-100 overflow-auto">
<div className="d-flex flex-column w-100 mw-xl" ref={refContainer}>
<div className="d-flex bg-light-400 flex-column w-75 w-xs-100 w-xl-75 align-items-center">
<div className="d-flex flex-column w-100">
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
@@ -34,3 +33,5 @@ export default function DiscussionContent() {
</div>
);
}
export default injectIntl(DiscussionContent);

View File

@@ -1,38 +1,71 @@
import React from 'react';
import React, { useContext, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
Redirect, Route, Switch, useLocation,
} from 'react-router';
import { Routes } from '../../data/constants';
import { LearnersView } from '../learners';
import { useWindowSize } from '@edx/paragon';
import { RequestStatus, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useContainerSize, useIsOnDesktop, useIsOnXLDesktop, useShowLearnersTab,
} from '../data/hooks';
import { selectconfigLoadingStatus } from '../data/selectors';
import { LearnerPostsView, LearnersView } from '../learners';
import { PostsView } from '../posts';
import { TopicsView } from '../topics';
export default function DiscussionSidebar({ displaySidebar }) {
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
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 (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
sidebarRef.current.style.top = `${postActionBarHeight}px`;
}
}, [sidebarRef, postActionBarHeight, inContext]);
return (
<div
className={classNames('flex-column', {
ref={sidebarRef}
className={classNames('flex-column position-sticky', {
'd-none': !displaySidebar,
'd-flex w-25 w-xs-100 w-lg-25 overflow-auto h-100 pb-2': displaySidebar,
'd-flex overflow-auto': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'min-content-height': !inContext,
})}
style={{ minWidth: '30rem' }}
data-testid="sidebar"
>
<Switch>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView showOwnPosts />
</Route>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
@@ -40,6 +73,7 @@ export default function DiscussionSidebar({ displaySidebar }) {
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</div>
);
@@ -47,8 +81,13 @@ export default function DiscussionSidebar({ displaySidebar }) {
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
DiscussionSidebar.propTypes = {
displaySidebar: PropTypes.bool,
postActionBarRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
};

View File

@@ -11,6 +11,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import { threadsApiUrl } from '../posts/data/api';
import DiscussionSidebar from './DiscussionSidebar';
@@ -26,9 +28,11 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<MemoryRouter initialEntries={[location]}>
<DiscussionSidebar displaySidebar={displaySidebar} />
</MemoryRouter>
<DiscussionContext.Provider value={{ courseId }}>
<MemoryRouter initialEntries={[location]}>
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} />
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</ResponsiveContext.Provider>
</IntlProvider>,
@@ -51,6 +55,7 @@ describe('DiscussionSidebar', () => {
store = initializeStore({
blocks: { blocks: { 'test-usage-key': { topics: ['some-topic-2', 'some-topic-0'] } } },
});
store.dispatch(fetchConfigSuccess({}));
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
@@ -73,7 +78,10 @@ describe('DiscussionSidebar', () => {
test('User will be redirected to "All Posts" by default', async () => {
axiosMock.onGet(threadsApiUrl)
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
threadAttrs: {
title: `Thread by ${params.author || 'other users'}`,
previewBody: 'thread preview body',
},
})]);
renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());
@@ -85,7 +93,10 @@ describe('DiscussionSidebar', () => {
axiosMock.onGet(threadsApiUrl)
.reply(({ params }) => [200, Factory.build('threadsResult', {}, {
count: postCount,
threadAttrs: { title: `Thread by ${params.author || 'other users'}` },
threadAttrs: {
title: `Thread by ${params.author || 'other users'}`,
previewBody: 'thread preview body',
},
})]);
renderComponent();
await act(async () => expect(await screen.findAllByText('Thread by other users')).toBeTruthy());

View File

@@ -1,34 +1,47 @@
import React, { useEffect } from 'react';
import React, { useEffect, useRef } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
Route, Switch, useLocation, useRouteMatch,
} from 'react-router';
import Footer from '@edx/frontend-component-footer';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { getConfig } from '@edx/frontend-platform';
import { PostActionsBar } from '../../components';
import { CourseTabsNavigation } from '../../components/NavigationBar';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
import { EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationsBanner';
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 { 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,
@@ -37,14 +50,13 @@ export default function DiscussionsHome() {
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;
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
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.
@@ -53,7 +65,7 @@ export default function DiscussionsHome() {
const provider = useSelector(selectDiscussionProvider);
useCourseDiscussionData(courseId);
useRedirectToThread(courseId);
useRedirectToThread(courseId, inContext);
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
@@ -71,38 +83,50 @@ export default function DiscussionsHome() {
learnerUsername,
}}
>
<main className="container-fluid d-flex flex-column p-0 h-100 w-100 overflow-hidden">
{!inContext && <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} />}
<div
className="d-flex flex-row justify-content-between navbar fixed-top"
style={{ boxShadow: '0px 2px 4px rgb(0 0 0 / 15%), 0px 2px 8px rgb(0 0 0 / 15%)' }}
className={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
ref={postActionBarRef}
>
{!inContext && (
<Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />
)}
<PostActionsBar inContext={inContext} />
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-2.5 py-1.5': inContext,
})}
>
{!inContext && <Route path={Routes.DISCUSSIONS.PATH} component={NavigationBar} />}
<PostActionsBar inContext={inContext} />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<BlackoutInformationBanner />
</div>
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
<div className="d-flex flex-row overflow-hidden flex-grow-1">
<DiscussionSidebar displaySidebar={displaySidebar} />
{!inContext && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
)}
<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.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/>
</Switch>
<Switch>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/>
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} /> }
</Switch>
)}
</div>
</main>
{!inContext && <Footer />}
</DiscussionContext.Provider>
);
}

View File

@@ -87,4 +87,11 @@ describe('DiscussionsHome', () => {
await waitFor(() => expect(window.parent.postMessage).toHaveBeenCalled());
window.parent = parent;
});
test('header, course navigation bar and footer are visible', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,136 @@
import { render, screen } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import InformationBanner from './InformationsBanner';
import '../posts/data/__factories__';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const getConfigData = (isAdmin = true, roles = []) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: roles,
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: isAdmin,
});
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<InformationBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('Information Banner learner view', () => {
let element;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: false,
roles: ['Student'],
},
});
store = initializeStore();
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student'])));
renderComponent(true);
element = await screen.findByRole('alert');
});
test('Test Banner is visible on app load', async () => {
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
});
test('Test Banner do not have learn more button', async () => {
expect(element).not.toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
});
test('Test Banner has share feedback button', async () => {
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
});
});
describe('Information Banner moderators/staff/admin view', () => {
let element;
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student', 'Moderator'])));
renderComponent(true);
element = await screen.findByRole('alert');
});
test('Test Banner is visible on app load', async () => {
expect(element).toHaveTextContent(messages.bannerMessage.defaultMessage);
});
test('Test Banner has learn more button', async () => {
expect(element).toHaveTextContent(messages.learnMoreBannerLink.defaultMessage);
});
test('Test Banner has share feedback button', async () => {
expect(element).toHaveTextContent(messages.shareFeedback.defaultMessage);
});
});
describe('User is redirected according to url according to role', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
test('TAs are redirected to learners feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Community TA'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.TA_FEEDBACK_FORM);
});
test('moderators/administrators are redirected to moderators feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(false, ['Student', 'Moderator', 'Administrator'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
});
test('user with only isAdmin true are redirected to moderators feedback form', async () => {
store.dispatch(fetchConfigSuccess(getConfigData(true, ['Student'])));
renderComponent(true);
expect(screen.getByText(messages.shareFeedback.defaultMessage)
.closest('a'))
.toHaveAttribute('href', process.env.STAFF_FEEDBACK_FORM);
});
});

View File

@@ -0,0 +1,64 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Hyperlink, PageBanner } from '@edx/paragon';
import { selectUserIsStaff, selectUserRoles } from '../data/selectors';
import messages from '../messages';
function InformationBanner({
intl,
}) {
const [showBanner, setShowBanner] = useState(true);
const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff);
const learnMoreLink = 'https://openedx.atlassian.net/wiki/spaces/COMM/pages/3509551260/Overview+New+discussions+experience';
const TAFeedbackLink = process.env.TA_FEEDBACK_FORM;
const staffFeedbackLink = process.env.STAFF_FEEDBACK_FORM;
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
return (
<PageBanner
variant="light"
show={showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
>
<div className="font-weight-500">
{intl.formatMessage(messages.bannerMessage)}
{!hideLearnMoreButton
&& (
<Hyperlink
destination={learnMoreLink}
target="_blank"
showLaunchIcon={false}
className="pl-2.5"
variant="muted"
isInline
>
{intl.formatMessage(messages.learnMoreBannerLink)}
</Hyperlink>
)}
<Hyperlink
destination={showStaffLink ? staffFeedbackLink : TAFeedbackLink}
target="_blank"
showLaunchIcon={false}
variant="muted"
className="pl-2.5"
isInline
>
{intl.formatMessage(messages.shareFeedback)}
</Hyperlink>
</div>
</PageBanner>
);
}
InformationBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(InformationBanner);

View File

@@ -0,0 +1,25 @@
import React from 'react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks';
import messages from '../messages';
import EmptyPage from './EmptyPage';
function EmptyLearners({ intl }) {
const isOnDesktop = useIsOnDesktop();
if (!isOnDesktop) {
return null;
}
return (
<EmptyPage title={intl.formatMessage(messages.emptyTitle)} />
);
}
EmptyLearners.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyLearners);

View File

@@ -15,8 +15,8 @@ function EmptyPage({
fullWidth = false,
}) {
const containerClasses = classNames(
'justify-content-center align-items-center d-flex w-100 flex-column pt-5',
{ 'bg-light-300': !fullWidth },
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column pt-5',
{ 'bg-light-400': !fullWidth },
);
return (

View File

@@ -1,3 +1,4 @@
export { default as EmptyLearners } from './EmptyLearners';
export { default as EmptyPage } from './EmptyPage';
export { default as EmptyPosts } from './EmptyPosts';
export { default as EmptyTopics } from './EmptyTopics';

View File

@@ -0,0 +1,123 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import {
RequestStatus,
Routes,
} from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import {
selectAllThreads,
selectThreadNextPage,
threadsLoadingStatus,
} from '../posts/data/selectors';
import { clearPostsPages } from '../posts/data/slices';
import NoResults from '../posts/NoResults';
import { PostLink } from '../posts/post';
import { discussionsPath, filterPosts } from '../utils';
import { fetchUserPosts } from './data/thunks';
import LearnerPostFilterBar from './learner-post-filter-bar/LearnerPostFilterBar';
import messages from './messages';
function LearnerPostsView({ intl }) {
const location = useLocation();
const history = useHistory();
const dispatch = useDispatch();
const posts = useSelector(selectAllThreads);
const loadingStatus = useSelector(threadsLoadingStatus());
const postFilter = useSelector(state => state.learners.postFilter);
const { courseId, learnerUsername: username } = useContext(DiscussionContext);
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const loadMorePosts = (pageNum = undefined) => {
const params = {
author: username,
page: pageNum,
filters: postFilter,
orderBy: postFilter.orderBy,
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
};
dispatch(fetchUserPosts(courseId, params));
};
useEffect(() => {
dispatch(clearPostsPages());
loadMorePosts();
}, [courseId, postFilter, username]);
const checkIsSelected = (id) => window.location.pathname.includes(id);
const pinnedPosts = useMemo(() => filterPosts(posts, 'pinned'), [posts]);
const unpinnedPosts = useMemo(() => filterPosts(posts, 'unpinned'), [posts]);
const postInstances = useCallback((sortedPosts) => (
sortedPosts.map((post, idx) => (
<PostLink
post={post}
key={post.id}
isSelected={checkIsSelected}
idx={idx}
showDivider={(sortedPosts.length - 1) !== idx}
/>
))
), []);
return (
<div className="discussion-posts d-flex flex-column">
<div className="d-flex align-items-center justify-content-between px-2.5">
<IconButton
src={ArrowBack}
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
alt={intl.formatMessage(messages.back)}
/>
<div className="text-primary-500 font-style-normal font-family-inter font-weight-bold py-2.5">
{intl.formatMessage(messages.activityForLearner, { username: capitalize(username) })}
</div>
<div style={{ padding: '18px' }} />
</div>
<div className="bg-light-400 border border-light-300" />
<LearnerPostFilterBar />
<div className="border-bottom border-light-400" />
<div className="list-group list-group-flush">
{postInstances(pinnedPosts)}
{postInstances(unpinnedPosts)}
{loadingStatus !== RequestStatus.IN_PROGRESS && posts?.length === 0 && <NoResults />}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
<Button onClick={() => loadMorePosts(nextPage)} variant="primary" size="md">
{intl.formatMessage(messages.loadMore)}
</Button>
)
)}
</div>
</div>
);
}
LearnerPostsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnerPostsView);

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
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 { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
import { courseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import { coursesApiUrl } from './data/api';
import LearnerPostsView from './LearnerPostsView';
import './data/__factories__';
let store;
let axiosMock;
const courseId = 'course-v1:edX+TestX+Test_Course';
const username = 'abc123';
function renderComponent(path = `/${courseId}/learners/${username}/posts`) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route path={path}>
<LearnerPostsView />
</Route>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
describe('LearnerPostsView', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username,
administrator: true,
roles: [],
},
});
store = initializeStore();
Factory.resetAll();
const learnerPosts = Factory.build('learnerPosts', {}, {
abuseFlaggedCount: 1,
});
const apiUrl = `${coursesApiUrl}${courseId}/learner/`;
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(apiUrl, { username, count_flagged: true })
.reply(() => [200, learnerPosts]);
});
describe('Basic', () => {
test('Reported icon is visible to moderator for post with reported comment', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true,
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await act(async () => {
renderComponent();
});
expect(screen.queryAllByTestId('reported-post')[0]).toBeInTheDocument();
});
test('Reported icon is not visible to learner for post with reported comment', async () => {
await renderComponent();
expect(screen.queryByTestId('reported-post')).not.toBeInTheDocument();
});
});
});

View File

@@ -5,49 +5,70 @@ import {
Redirect, useLocation, useParams,
} from 'react-router';
import { Spinner } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import ScrollThreshold from '../../components/ScrollThreshold';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus, Routes } from '../../data/constants';
import { selectconfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
import NoResults from '../posts/NoResults';
import {
learnersLoadingStatus,
selectAllLearners,
selectLearnerNextPage,
selectLearnerSorting,
selectUsernameSearch,
} from './data/selectors';
import { setUsernameSearch } from './data/slices';
import { fetchLearners } from './data/thunks';
import { LearnerCard } from './learner';
import { LearnerCard, LearnerFilterBar } from './learner';
import messages from './messages';
function LearnersView() {
const {
courseId,
} = useParams();
function LearnersView({ intl }) {
const { courseId } = useParams();
const location = useLocation();
const dispatch = useDispatch();
const orderBy = useSelector(selectLearnerSorting());
const nextPage = useSelector(selectLearnerNextPage());
const loadingStatus = useSelector(learnersLoadingStatus());
const usernameSearch = useSelector(selectUsernameSearch());
const courseConfigLoadingStatus = useSelector(selectconfigLoadingStatus);
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
const learners = useSelector(selectAllLearners);
useEffect(() => {
if (learnersTabEnabled) {
dispatch(fetchLearners(courseId, { orderBy }));
if (usernameSearch) {
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
} else {
dispatch(fetchLearners(courseId, { orderBy }));
}
}
}, [courseId, orderBy, learnersTabEnabled]);
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
const loadPage = async () => {
if (nextPage) {
dispatch(fetchLearners(courseId, {
orderBy,
page: nextPage,
usernameSearch,
}));
}
};
return (
<div className="d-flex flex-column">
<div className="list-group list-group-flush">
<div className="d-flex flex-column border-right border-light-400">
{!usernameSearch && <LearnerFilterBar /> }
<div className="border-bottom border-light-400" />
{usernameSearch && (
<SearchInfo
text={usernameSearch}
count={learners.length}
loadingStatus={loadingStatus}
onClear={() => dispatch(setUsernameSearch(''))}
/>
)}
<div className="list-group list-group-flush learner" role="list">
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
<Redirect
to={{
@@ -56,21 +77,31 @@ function LearnersView() {
}}
/>
)}
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
<LearnerCard learner={learner} key={learner.username} courseId={courseId} />
))}
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL
&& learnersTabEnabled
&& learners.map((learner, index) => (
// eslint-disable-next-line react/no-array-index-key
<LearnerCard learner={learner} key={index} courseId={courseId} />
))}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
nextPage && (
<ScrollThreshold onScroll={loadPage} />
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
<Button onClick={() => loadPage()} variant="primary" size="md">
{intl.formatMessage(messages.loadMore)}
</Button>
)
)}
{ usernameSearch !== '' && learners.length === 0 && loadingStatus === RequestStatus.SUCCESSFUL && <NoResults />}
</div>
</div>
);
}
export default LearnersView;
LearnersView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(LearnersView);

View File

@@ -85,7 +85,6 @@ describe('LearnersView', () => {
await act(async () => {
await renderComponent();
});
expect(screen.queryAllByText(/Last active/i, { exact: false }).length).toBeGreaterThan(0);
});
});
});

View File

@@ -3,11 +3,12 @@ import { Factory } from 'rosie';
Factory.define('learner')
.sequence('id')
.attr('username', ['id'], (id) => `leaner-${id}`)
.option('activeFlags', null, null)
.attr('active_flags', ['activeFlags'], (activeFlags) => activeFlags)
.attrs({
threads: 1,
replies: 0,
responses: 3,
active_flags: null,
inactive_flags: null,
});
@@ -16,19 +17,18 @@ Factory.define('learnersResult')
.option('page', null, 1)
.option('pageSize', null, 5)
.option('courseId', null, 'course-v1:Test+TestX+Test_Course')
.option('activeFlags', null, 0)
.attr(
'pagination',
['courseId', 'count', 'page', 'pageSize'],
(courseId, count, page, pageSize) => {
const numPages = Math.ceil(count / pageSize);
const next = page < numPages
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
page + 1
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page + 1
}`
: null;
const prev = page > 1
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${
page - 1
? `http://test.site/api/discussion/v1/courses/course-v1:edX+DemoX+Demo_Course/activity_stats?page=${page - 1
}`
: null;
return {
@@ -41,12 +41,26 @@ Factory.define('learnersResult')
)
.attr(
'results',
['count', 'pageSize', 'page', 'courseId'],
(count, pageSize, page, courseId) => {
['count', 'pageSize', 'page', 'courseId', 'activeFlags'],
(count, pageSize, page, courseId, activeFlags) => {
const attrs = { course_id: courseId };
Object.keys(attrs).forEach((key) => (attrs[key] === undefined ? delete attrs[key] : {}));
const len = pageSize * page <= count ? pageSize : count % pageSize;
return Factory.buildList('learner', len, attrs);
let learners = [];
if (activeFlags && activeFlags <= len) {
learners = Factory.buildList('learner', len - activeFlags, attrs);
learners = learners.concat(
Factory.buildList(
'learner',
activeFlags,
{ ...attrs, active_flags: Math.floor(Math.random() * 10) + 1 },
),
);
} else {
learners = Factory.buildList('learner', len, attrs);
}
return learners;
},
);
@@ -68,6 +82,67 @@ Factory.define('learnersProfile')
},
last_login: new Date(Date.now() - 1000 * 60).toISOString(),
username: user,
name: 'Test User',
}));
return profiles;
});
Factory.define('learnerPosts')
.option('abuseFlaggedCount', null, null)
.option('courseId', null, 'course-v1:edX+TestX+Test_Course')
.attr(
'results',
['abuseFlaggedCount', 'courseId'],
(abuseFlaggedCount, courseId) => {
const threads = [];
for (let i = 0; i < 2; i++) {
threads.push({
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
editable_fields: [
'abuse_flagged',
'following',
'group_id',
'raw_body',
'closed',
'read',
'title',
'topic_id',
'type',
'voted',
'pinned',
],
id: `post_id${i}`,
author: 'test_user',
author_label: 'Staff',
abuse_flagged: false,
can_delete: true,
voted: false,
vote_count: 1,
title: `Title ${i}`,
raw_body: `<p>body ${i}</p>`,
preview_body: `<p>body ${i}</p>`,
course_id: courseId,
group_id: null,
group_name: null,
abuse_flagged_count: abuseFlaggedCount,
following: false,
comment_count: 8,
unread_comment_count: 0,
endorsed_comment_list_url: null,
non_endorsed_comment_list_url: null,
read: false,
has_endorsed: false,
pinned: false,
topic_id: 'topic',
});
}
return threads;
},
)
.attr('pagination', [], () => ({
next: null,
prev: null,
count: 2,
num_pages: 1,
}));

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