Compare commits

...

187 Commits

Author SHA1 Message Date
Awais Ansari
1fbc755957 fix: learning header constant height 2023-07-25 16:17:57 +05:00
Awais Ansari
bd42521f6b style: add important in post type card border (#550) 2023-07-18 14:23:27 +05:00
edX requirements bot
445caca4e4 Merge pull request #540 from DmytroAlipov/fix-discussion-search
Fix bug with a repeated search query
2023-07-13 06:04:53 -04:00
alipov_d
4a2b32494d fix: issue with a repeated search query 2023-07-12 18:19:56 +02:00
Mashal Malik
a16bd783a0 build: update react-redux (#549) 2023-07-12 19:37:03 +05:00
Mashal Malik
df1a16ee85 feat: update react & react-dom to v17 (#537)
* feat: update react & react-dom to v17

* build: update pkgs

* fix: fix test

* build: remove ^ from pkgs

---------

Co-authored-by: Bilal Qamar <59555732+BilalQamar95@users.noreply.github.com>
2023-07-12 15:04:13 +05:00
Jenkins
656935336e chore(i18n): update translations 2023-07-09 16:27:12 -04:00
sundasnoreen12
2498f74556 chore: added renovate file structure based on provided template (#546)
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-07-06 15:09:03 +05:00
ayesha waris
99ad3aff53 test: fixed postcommentsview test cases (#543)
* test: fixed postcommentsview test cases

* test: fixed hovercard failed tests
2023-06-21 18:21:11 +05:00
Awais Ansari
e2bb68a1cd chore: update codecov ref from edx to openedx (#544)
* chore: update codecov ref from edx to openedx

* refactor: fix typo
2023-06-20 17:50:42 +05:00
Jenkins
f694b480b5 chore(i18n): update translations 2023-06-18 16:27:07 -04:00
Dmytro
228a771a39 fix: error 400 editing comment (#533) 2023-06-12 17:03:45 +05:00
edX requirements bot
c821033a64 Merge pull request #505 from igobranco/igobranco/new-translation-languages
chore(i18n): add languages
2023-06-12 06:04:52 -04:00
Ivo Branco
7ce4566df3 chore(i18n): add languages
Add new languages: pt-PT, uk, ru,hi, cs, es-AR, es-ES, fa-IR
2023-06-12 10:57:05 +01:00
Jenkins
a02771f96f chore(i18n): update translations 2023-06-11 16:27:04 -04:00
ayesha waris
8c53a7a19e feat: integrated backend discussions restriction with MFE (#529)
* feat: integrated backend discussions restriction with MFE

* test: fixes failed test cases

* refactor: fixed lint issues

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: SundasNoreen <sundas.noreen@arbisoft.com>
2023-06-06 14:18:53 +05:00
Eugene Dyudyunov
c8500a0c1e fix: post sharing URL (#445) 2023-05-31 14:01:12 +05:00
Jenkins
f7ad94997d chore(i18n): update translations 2023-05-28 16:26:59 -04:00
Omar Al-Ithawi
733a74d9e4 feat: use atlas in make pull_translations (#502)
Changes
-------
 - Move all i18n imports into `src/i18n/index.js` so `intl-imports.js` can
   override it with latest translations
 - Add `atlas` into `make pull_translations` when `OPENEDX_ATLAS_PULL`
   environment variable is set.

Refs: [FC-0012 project](https://openedx.atlassian.net/l/cp/XGS0iCcQ) implementing Translation Infrastructure OEP-58.
2023-05-25 18:38:29 +05:00
Bilal Qamar
b2b33b76f7 feat: upgraded to node v18, added .nvmrc and updated workflows (#471)
* feat: upgraded to node v18, added .nvmrc and updated workflows

* refactor: updated packages

* refactor: resolved eslint issues
2023-05-25 13:16:53 +05:00
Bilal Qamar
70f6541585 build: edx namespace packages upgrade & resolved respective eslint issue (#508)
* refactor: updated frontend-build, frontend-platform, header & footer packages

* fix: resolved eslint issues post frontend-build upgrade

* refactor: resolved eslint issues

* refactor: pinned frontend-build & changed suggested function definitions
2023-05-24 11:55:28 +05:00
Muhammad Adeel Tajamul
822854953f fix: switch to use PUBLIC_PATH for routes (#525) 2023-05-23 12:21:25 +05:00
Awais Ansari
0854ee3a8b test: resolved console warnings in test cases (#523)
* test: resolve TopicsView test cases console errors

* test: resolve EmptyPage test cases console errors

* test: resolve inContext TopicsView test cases console errors

* test: resolve LearnerPostsView test cases console errors

* test: resolved TopicStats component console erros
2023-05-22 17:20:40 +05:00
ayesha waris
7aaa1fbd93 test: fixed test cases after MFE optimization (#522)
* test: fixed test cases after optimization

* test: fixed HoverCard and PostCommentsView failed test cases

* test: fixed PostCommentsView failed test cases

* test: remove spinner wait from PostCommentsView test case

* test: add await in PostCommentsView test cases

* refactor: updated variable name in HoverCard test cases

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-05-22 16:02:09 +05:00
Awais Ansari
4f0b5f9c3d test: fixed test cases after MFE optimization (#521)
* test: fixed ActionDropDown test cases

* test: fixed AlertBanner and EndorsedAlertBanner test cases

* test: fixed LearnerFooter test cases

* test: fixed LearnersView failed test cases

* test: fixed PostFooter failed test cases

* test: fixed PostLink failed test cases

* test: fixed LearnerPostsView failed test cases

* test: fixed console error and warnings in PostEditor

* test: fixed Post anonymously test condition
2023-05-16 14:45:40 +05:00
Awais Ansari
6d6c61dec2 fix: direct link thread no found issue (#516) 2023-05-10 15:01:28 +05:00
Awais Ansari
84e18b9ed6 fix: after optimization issues (#515)
* style: fix height and width style issues

* fix: endorse and answericon issue

* fix: like and unlike isssue

* chore: remove profiler configuration
2023-05-08 19:38:54 +05:00
Awais Ansari
3dc7f74fa4 fix: postLink footer icon size (#514) 2023-05-08 18:15:53 +05:00
Awais Ansari
0844ee6875 Perf: improved discussions MFE's components re-rendering and loading time (#513)
* chore: configure WDYR for react profiling

* perf: reduced post content re-rendering

* perf: post content view and it child optimization

* perf: add memoization in post editor

* perf: add memoization in postCommnetsView

* perf: improved endorsed comment view rendering

* perf: improved re-rendering in reply component

* fix: uncomment questionType commentsView

* fix: removed console errors in postContent area

* perf: reduced postType and postId dependancy

* perf: improved re-rendering in discussionHome

* perf: improved re-rendering of postsList and its child components

* perf: improved re-rendering of legacyTopic and learner sidebar

* fix: postFilterBar filter was not updating

* fix: resolve duplicate comment posts issue

* fix: memory leaking issue in comments view

* fix: duplicate topic posts in inContext sidebar

* perf: add lazy loading

* chore: remove WDYR configuration

* fix: alert banner padding

* chore: update package-lock file

* fix: bind tour API call with buttons
2023-05-08 16:21:29 +05:00
Awais Ansari
7b7c249abd Revert "Perf: improved discussions MFE's components re-rendering and loading time (#485)" (#512)
This reverts commit 59b4366edd.
2023-05-08 15:34:43 +05:00
Awais Ansari
59b4366edd Perf: improved discussions MFE's components re-rendering and loading time (#485)
* chore: configure WDYR for react profiling

* perf: reduced post content re-rendering

* perf: post content view and it child optimization

* perf: add memoization in post editor

* perf: add memoization in postCommnetsView

* perf: improved endorsed comment view rendering

* perf: improved re-rendering in reply component

* fix: uncomment questionType commentsView

* fix: removed console errors in postContent area

* perf: reduced postType and postId dependancy

* perf: improved re-rendering in discussionHome

* perf: improved re-rendering of postsList and its child components

* perf: improved re-rendering of legacyTopic and learner sidebar

* fix: postFilterBar filter was not updating

* fix: resolve duplicate comment posts issue

* fix: memory leaking issue in comments view

* fix: duplicate topic posts in inContext sidebar

* perf: add lazy loading

* chore: remove WDYR configuration

* fix: alert banner padding

* chore: update package-lock file
2023-05-08 15:14:53 +05:00
Awais Ansari
8e68d3ab60 chore: console react version (#511) 2023-05-08 14:53:05 +05:00
Awais Ansari
7ceaea1fc1 chore: enable profiler on production build (#510) 2023-05-08 12:28:38 +05:00
Awais Ansari
2bf608655c chore: enable profiler on production for data collection (#509) 2023-05-05 16:42:36 +05:00
Jenkins
59349f48bd chore(i18n): update translations 2023-04-30 16:26:56 -04:00
Awais Ansari
1eb4c653c5 Revert "[FC-0014] update frontend-platform version to v4.2.0" (#506) 2023-04-28 17:36:42 +05:00
Adolfo R. Brandes
7ef4d2dec7 Merge pull request #501 from raccoongang/sagirov/tCRIL_GA-58
[FC-0014] update frontend-platform version to v4.2.0
2023-04-27 13:53:35 -03:00
Sagirov Eugeniy
0871217f95 chore: update frontend-platform version to v4.2.0 2023-04-27 13:08:48 +03:00
sundasnoreen12
ee382b70af fix: action dropdown UI issues (#500)
* fix: action dropdown UI issues

* refactor: fixed comment sort dropdown issue

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-20 17:05:31 +05:00
sundasnoreen12
f1a9922d29 fix: added role for editedby and closedby user (#498)
* fix: changed the title of new edx provider

* fix: added role for editedby and closedby user

* refactor: added left margin for endorsed time

* refactor: added AlertBar component to avoid duplicate code

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-19 15:58:10 +05:00
sundasnoreen12
009417bb57 fix: fixed feedback widget position issue (#499)
Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-17 13:04:21 +05:00
Muhammad Adeel Tajamul
f13b34c6c7 fix: updated post actions dropdown design (#495)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-04-07 14:30:54 +05:00
ayesha waris
1ba5b938c4 fix: typeset failed: Cannot read properties of undefined (#496) 2023-04-05 17:22:19 +05:00
sundasnoreen12
c1478dbb41 fix: fixed dynamic learner profile issue (#493)
* fix: fixed dynamic URL issue

* refactor: removed unused selectors

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-05 16:21:25 +05:00
sundasnoreen12
5cc5156b2b test: added test cases of navigation bar (#487) (#489)
* test: added test cases of navigation bar

* test: added test case for navigation bar api

---------

Co-authored-by: ayesha waris <73840786+ayeshoali@users.noreply.github.com>
Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-04-04 16:22:13 +05:00
ayesha waris
8c28d46dce fix: typeset failed: window.MathJax.typesetClear is not a function (#494) 2023-04-04 15:48:42 +05:00
ayesha waris
c16843fd4c temp: fix tends to resolve increased script error (#492) 2023-04-04 12:09:49 +05:00
ayesha waris
1117ed0387 fix: changes empty state image and text to monochrome (#490) 2023-04-03 13:51:48 +05:00
Jenkins
242740da23 chore(i18n): update translations 2023-04-02 16:26:50 -04:00
ayesha waris
2013dcf7cb style: tooltips aligned to the side in topic stats (#488) 2023-03-30 14:59:16 +05:00
ayesha waris
3cdf0825b4 fix: removes outline around comment cards (#487) 2023-03-29 16:48:59 +05:00
ayesha waris
d0d80a2b17 fix: removes double line between tabs (#486) 2023-03-29 15:34:06 +05:00
Ahtisham Shahid
5240d0d5a4 feat: added support for dual feedback forms (#478)
* feat: added support for dual feedback forms

* fix: resolved linter errors
2023-03-29 14:08:24 +05:00
ayesha waris
b171de291e fix: lock is used for post close or reopen (#484) 2023-03-29 12:57:18 +05:00
ayesha waris
24163d15c0 fix: hover color removed when filter buttons are clicked (#483) 2023-03-28 21:00:50 +05:00
ayesha waris
58b6b69cb0 fix: typeset failed: window.MathJax.typesetClear is not a function (#482) 2023-03-28 16:30:34 +05:00
ayesha waris
025edf7b66 temp: fix for typeset failed:e.typesetPromise is not a function (#479) 2023-03-27 14:56:40 +05:00
sundasnoreen12
1e47d102a3 test: added newly cases to cover codeCov (#474)
* test: added newly cases to cover codeCov

* test: added new cases of empty posts

* test: added test cases of legacy topics

* refactor: removed extra lines and extra const objects

* test: added test cases for postPreviewpane

* test: added test cases for post comment view api

* refactor: removed extra lines and code

* refactor: fixed issues identified during code review

* refactor: changed description of one test case

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-27 14:05:40 +05:00
ayesha waris
39da42ee3f fix: fix post height and remove overstate from author label (#472)
* fix: fixed post length according to figma

* fix: remove hoverstate from author label and author icon

* style: adds tooltip in hovercard for endorsemment icon

* test: fix test cases

* test: adds test cases for descreaased coverage

* refactor: updated api call to a method for reusability

* refactor: changed test descriptions
2023-03-22 17:19:15 +05:00
ayesha waris
15aee6a534 temp: fix for resize observer loop limit exceeded (#477) 2023-03-22 16:17:14 +05:00
ayesha waris
cd2d67e137 fix: fix typeset failed error (#475)
* fix: fix typeset failed error

* test: adds test cases for decreased coverage

* test: adds test cases for reply component
2023-03-21 19:17:48 +05:00
Jenkins
c4f861c24f chore(i18n): update translations 2023-03-19 16:26:48 -04:00
sundasnoreen12
78a85255e4 test: added test cases of learners and post (#470)
* test: added test cases of learners and post

* refactor: fixed reviewed issues

* refactor: changed data test id names for load more buttons

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-16 14:48:00 +05:00
Stanislav
b21048e4e6 fix: Fix for code block formatting on the post preview and published post view (#450) 2023-03-14 12:18:33 +05:00
sundasnoreen12
45dea79a87 test: added test cases for learner post view (#466)
* test: added test cases for learner post view

* refactor: fixed requested changes for code optimization

* refactor: added url change

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-13 16:44:46 +05:00
sundasnoreen12
530f2cec82 test: added test cases for learner view (#465)
* test: added test cases for learner view

* refactor: fixed changes for code optimization

* refactor: added url changes

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-03-13 13:18:09 +05:00
Mashal Malik
91cb347456 refactor: remove unused tranisfex v2 url (#463) 2023-03-13 10:41:40 +05:00
Jenkins
04745d6429 chore(i18n): update translations 2023-03-12 16:26:50 -04:00
Muhammad Adeel Tajamul
aad6702339 feat: sort comments based on sort order dropdown (#468)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-03-10 21:38:05 +05:00
Ahtisham Shahid
d39a196cdf feat: added product tour for response sort (#462)
* feat: added product tour for response sort
2023-03-10 12:23:13 +05:00
SaadYousaf
7b000f1974 feat: send enableInContextSidebar param to backend to identify source of content for events 2023-03-09 09:02:10 +05:00
sundasnoreen12
627390c4e3 test: added testcases of redux, selector and api (#459)
* test: added testcases of redux, selector and api

* refactor: fixe recommanded issue and improve code cov

* refactor: added cases for filter statuses

* refactor: updated test description

* refactor: add common method of mock data for learner and post

* refactor: code and moved test utils in learners folder

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-03-08 14:25:54 +05:00
Muhammad Adeel Tajamul
1db94718c8 fix: more actions dropdown was not visible (#461) 2023-03-07 06:22:31 +05:00
ayesha waris
24d02350a8 fix: fix topic info for course-wide discussion topics (#458)
* fix: fix topic info for course-wide discussion topics

* refactor: removed const and used url directly

* test: adds test cases for topic info

* test: updated test cases
2023-03-06 21:10:49 +05:00
Jenkins
f66cdda1b6 chore(i18n): update translations 2023-03-05 15:26:48 -05:00
ayesha waris
07b56e6070 style: add border on focused post (#460) 2023-03-03 16:14:52 +05:00
ayesha waris
be1a2ccaab fix: fixed post coment actions menu accessibilty for keyboard (#456) 2023-03-01 21:46:23 +05:00
Sarina Canelake
ed0c73e051 Merge pull request #454 from openedx/repo_checks/ensure_workflows
Update standard workflow files.
2023-02-28 09:40:08 -05:00
Feanil Patel
1041b3e45f build: Updating a missing workflow file add-depr-ticket-to-depr-board.yml.
The .github/workflows/add-depr-ticket-to-depr-board.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 09:34:03 -05:00
Feanil Patel
493a0610ca build: Creating a missing workflow file add-remove-label-on-comment.yml.
The .github/workflows/add-remove-label-on-comment.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 09:34:03 -05:00
Feanil Patel
679e21c270 build: Creating a missing workflow file self-assign-issue.yml.
The .github/workflows/self-assign-issue.yml workflow is missing or needs an update to stay in
sync with the current standard for this workflow as defined in the
`.github` repo of the `openedx` GitHub org.
2023-02-28 09:34:03 -05:00
sundasnoreen12
62eb9f5e02 test: Added test cases for noncourseware and courseware topic posts (#452)
* test: Added test cases for noncourseware and courseware topic posts

* refactor: optimized code for post view list

* refactor: updated selector tag

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-02-24 12:39:46 +05:00
Muhammad Adeel Tajamul
dedbc25358 fix: incontext crashing (#453)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-23 19:05:39 +05:00
Mehak Nasir
0f2ad8b7b4 fix: conditionally skipped some API calls and deffered script loading to improve performance 2023-02-23 14:26:02 +05:00
Ahtisham Shahid
61581ff474 fix: resolved data retention issue in add a post form (#451)
* fix: resolved data retention issue in adding a post form

* test: added unit test for post editor
2023-02-22 22:58:23 +05:00
Ahtisham Shahid
3afce17a32 feat: added event tracking on load more response (#442)
* feat: added event tracking on load more response
2023-02-21 16:30:30 +05:00
Muhammad Adeel Tajamul
7e36e9f14c fix: post loading slow (#447)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-21 15:09:44 +05:00
sundasnoreen12
c662310b08 test: added test cases for v3 Topics and units list page (#440)
* test: added  test cases for v3 Topics and units list page

* refactor: v3 topics unit test cases

* refactor: v3 topics unit test cases

* refactor: v3 topics unit test cases

* refactor: removed my added commented line and also optimized the code to get stats for section

---------

Co-authored-by: sundasnoreen12 <sundasnoreen12@ggmail.com>
2023-02-21 12:45:08 +05:00
Muhammad Adeel Tajamul
682a118a9b fix: reduced padding for topic names (#443)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-21 11:36:05 +05:00
Awais Ansari
d34d0ebbbc feat: hide the comments sort feature (#441)
* feat: hide the comments sort feature

* test: temporarily comment the commnets/responses test cases

* refactor: remove comments sort commented test cases
2023-02-20 16:07:58 +05:00
Awais Ansari
6afb7c7763 temp: testing getFeedback widget response time (#444) 2023-02-20 14:47:07 +05:00
Jenkins
7dca99dfe3 chore(i18n): update translations 2023-02-19 15:26:47 -05:00
Muhammad Adeel Tajamul
137795f254 feat: added stats at subsection level in topics tab (#437)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-17 08:29:06 +05:00
ayesha waris
1c2da56e3b fix: fix filter and sorting effect in discussion sidebar (#438)
* fix: fix filter and sorting effect in discussion sidebar

* test: fix test cases
2023-02-17 00:45:18 +05:00
Awais Ansari
e99c30f213 fix: unnamed topic/unit issue in inContext topics (#432)
* fix: unnamed topic/unit issue in incontext topics

* fix: sync topics and posts list loading for better UX
2023-02-16 20:54:53 +05:00
Muhammad Adeel Tajamul
eacc16b7f1 fix: copy link not working in in-context discussion (#439)
Co-authored-by: adeel.tajamul <adeel.tajamul@arbisoft.com>
2023-02-16 15:33:23 +05:00
Mehak Nasir
f8800de766 feat: added delay in post preview to avoid flashing of content 2023-02-15 17:06:48 +05:00
ayesha waris
f7740de54a fix: topic info is fixed from posts in incontext discussions (#433) 2023-02-14 18:17:53 +05:00
Mehak Nasir
4ca25c14d9 fix: restrict tiny mce from converting url 2023-02-14 17:35:37 +05:00
sundasnoreen12
8389d09f22 Merge pull request #430 from openedx/sundas/INF-725
test: added redux, api and selector test cases for in context topics
2023-02-13 16:37:28 +05:00
sundasnoreen12
3233d7044d test: added redux test cases of in context topics 2023-02-13 15:02:47 +05:00
Awais Ansari
22474f4b1e feat: add comments sorted by ascending and descending functionality (#408)
* feat: comments sorting option

* feat: create new dropdown for comment sort

* test: fix comments failing test cases

* refactor: update comment sort filter param

* test: update comment sort param in test cases

* refactor: resolve lint issue

* test: add comment sort param in hover test case

* refactor: update comments folder structure

* test: add new test cases for comments sort
2023-02-13 14:25:43 +05:00
Jenkins
b5d4213074 chore(i18n): update translations 2023-02-12 15:26:44 -05:00
ayesha waris
71931c83de refactor: hovercard hover state refactoring (#425)
* fix: peoples icon fixed to proper height and width

* refactor: reduced lines of codes aand changed some classes

* fix: fixed side bar peoples icon height and width

* test: fix test cases

* style: add wrapper around add response button

* test: add and fix test cases for hover card

* refactor: change line height class and view to hovercard
2023-02-09 23:30:16 +05:00
ayesha waris
f9ca375853 style: in-context discussions style updates (#426)
* style: change size of postactionbar in incontext discussions

* style: change postfilterbar text color and style

* style: change side bar icon sizes accoording to figma

* style: change margin for post aacording to figmaa

* style: change add a post style aaccording to figma

* refactor: remove unnecessary spaces

* refactor: use lineheight24 aand fontstyle class

---------

Co-authored-by: Mehak Nasir <67791278+mehaknasir@users.noreply.github.com>
2023-02-09 16:08:22 +05:00
Mehak Nasir
3d8353dc87 docs: updated read me and catalog info files (#428)
* docs: updated read me and catallog info files

* chore: added pull request template
2023-02-08 18:19:52 +05:00
ayesha waris
21bec39c0f fix: fix mathjax text overflowing (#429) 2023-02-08 15:17:44 +05:00
Jenkins
a479ba5955 chore(i18n): update translations 2023-02-05 15:26:45 -05:00
Ahtisham Shahid
bafb55afa9 fix: use threads api in case of search (#419)
* fix: use threads api in case of search
2023-02-01 14:17:43 +05:00
ayesha waris
62ebd4450f style: post content style updates (#407)
* style: post content design updates

* fix: fixing test cases

* fix: preview p changed from capital to small and 2px focus state border

* style: comment time moved next to author name

* fix: fixed post style according to figma

* test: added test cases for hover card component

* refactor: added utility func to check if last element of list

* fix: fixed blur event for actions dropdown

* fix: review fixees

* test: fixed test cases post mathjax-v3 merge

---------

Co-authored-by: Mehak Nasir <mehaknasir94@gmail.com>
2023-01-30 17:30:12 +05:00
Jenkins
04e0bb3264 chore(i18n): update translations 2023-01-29 15:26:44 -05:00
Mehak Nasir
2a187ca1df Mathjax v3 fix (#423)
* feat: added mathjax v3 support in platform

* fix: overwriting of comments and responses fixed
2023-01-27 16:49:38 +05:00
AsadAzam
7de274a73e Revert "feat: added mathjax v3 support in platform (#420)" (#421)
This reverts commit ad640611a1.
2023-01-26 20:27:55 +05:00
Mehak Nasir
ad640611a1 feat: added mathjax v3 support in platform (#420) 2023-01-26 17:43:07 +05:00
Mehak Nasir
42fa6a62c6 fix: changed ref to id for mathjax rendering div (#418)
fix: changed ref to id for mathjax rendering div

fix: fix lint error of unused element
2023-01-25 17:24:23 +05:00
Mashal Malik
4a49c13f14 Moving code coverage from codecov package to CI (#414)
* fix: removed depreciated package codecov and update version in ci

* refactor: remove deprecated es-check pacakge
2023-01-25 16:59:57 +05:00
Mehak Nasir
19e775737c fix: output format changed for mathjax script (#417) 2023-01-25 16:14:36 +05:00
Mehak Nasir
b7e5dd0e28 fix: testing mathjax issue fix with script change (#416) 2023-01-25 13:47:54 +05:00
Mehak Nasir
f33e3c3e3d fix: wrapped all content under mathjax script provider (#411)
* fix: wrapped all content under mathjax script provider

* fix: removed third party package from package json file
2023-01-24 18:44:35 +05:00
Ahtisham Shahid
4e659f8293 fix: resolve tour state update error (#415) 2023-01-24 13:36:37 +05:00
Jenkins
02cf34a467 chore(i18n): update translations 2023-01-22 15:26:44 -05:00
ayesha waris
722da9616f fix: page width does not increase due to long text (#412) 2023-01-20 13:15:44 +05:00
Ahtisham Shahid
6948a9fa5e feat: added tour for not responded filter (#406)
* feat: added tour for not responded filter

* fix: resolved linter errors

* refactor: added translations, removed redundant code, fixed tests

* refactor: made tour component generic

* fix: update isEmpty logic
2023-01-17 20:55:36 +05:00
Awais Ansari
e1d8af4498 fix: editedBy and closedBy banner will be visible to global staff (#410)
* fix: editedBy and closedBy banner will be visible to global staff

* refactor: rename isAdmin to userIsGlobalStaff
2023-01-17 17:41:32 +05:00
Jenkins
2a50edc334 chore(i18n): update translations 2023-01-15 15:26:43 -05:00
Mehak Nasir
a218c29831 style: fixes add/edit post section redesigned 2023-01-11 16:59:04 +05:00
Awais Ansari
6e03d5bfe8 fix: update post delete function to async (#404) 2023-01-09 16:55:19 +05:00
Awais Ansari
fd4dbef2e0 fix: deleting a post from sidebar renders entire MFE in the sidebar (#403) 2023-01-09 14:35:54 +05:00
Jenkins
f614c42cc5 chore(i18n): update translations 2023-01-08 15:26:40 -05:00
Mehak Nasir
928108e96c style: username space issue post summary 2023-01-05 20:06:52 +05:00
Mehak Nasir
aade749d54 fix: tooltip text updates according to new requirements 2023-01-05 20:06:52 +05:00
Mehak Nasir
1494741fae fix: text update for blackout dates banner 2023-01-05 14:32:14 +05:00
Awais Ansari
01547730a8 fix: inContext topics screen center issue on every loading (#399) 2023-01-04 14:38:28 +05:00
Awais Ansari
58e724d724 feat: display archived topics is post editor and topics list (#397)
* fix: display subsection name when there is no units in subsection

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

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

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

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

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

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

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

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

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

View File

@@ -1,9 +1,10 @@
const { createConfig } = require('@edx/frontend-build');
module.exports = createConfig('eslint',
{
"plugins": ["simple-import-sort"],
"rules": {
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',
@@ -25,7 +26,6 @@ module.exports = createConfig('eslint',
},
],
'simple-import-sort/exports': 'error',
}
}
},
},
);

24
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,24 @@
### Description
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
#### How Has This Been Tested?
Please describe in detail how you tested your changes.
#### Screenshots/sandbox (optional):
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|Before|After|
|-------|-----|
| | |
#### Merge Checklist
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
* [ ] Is there adequate test coverage for your changes?
#### Post-merge Checklist
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/edx-infinity** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

@@ -16,4 +16,4 @@ jobs:
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -0,0 +1,20 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -9,18 +9,17 @@ on:
jobs:
tests:
runs-on: ubuntu-latest
strategy:
matrix:
node: [16]
steps:
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
@@ -34,4 +33,4 @@ jobs:
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v2
uses: codecov/codecov-action@v3

View File

@@ -10,4 +10,4 @@ on:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfileversion-check.yml@master
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master

12
.github/workflows/self-assign-issue.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

2
.nvmrc
View File

@@ -1 +1 @@
16
18

View File

@@ -1,12 +1,11 @@
export TRANSIFEX_RESOURCE = frontend-app-discussions
transifex_resource = frontend-app-discussions
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT"
transifex_langs = "ar,fr,es_419,zh_CN,tr_TR,pl,fr_CA,fr_FR,de_DE,it_IT,pt_PT,uk,ru,hi,cs,es_AR,es_ES,fa_IR"
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
@@ -57,9 +56,24 @@ push_translations:
# Pushing comments to Transifex...
./node_modules/@edx/reactifex/bash_scripts/put_comments_v3.sh
ifeq ($(OPENEDX_ATLAS_PULL),)
# Pulls translations from Transifex.
pull_translations:
tx pull -t -f --mode reviewed --languages=$(transifex_langs)
else
# Experimental: OEP-58 Pulls translations using atlas
pull_translations:
rm -rf src/i18n/messages
mkdir src/i18n/messages
cd src/i18n/messages \
&& atlas pull --filter=$(transifex_langs) \
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
translations/paragon/src/i18n/messages:paragon \
translations/frontend-app-discussions/src/i18n/messages:frontend-app-discussions
$(intl_imports) frontend-component-header frontend-component-footer paragon frontend-app-discussions
endif
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,16 +1,20 @@
|Build Status| |Codecov| |license|
|Codecov| |license|
.. |Codecov| image:: https://codecov.io/gh/openedx/frontend-app-discussions/branch/master/graph/badge.svg?token=3z7XvuzTq3
:target: https://codecov.io/gh/openedx/frontend-app-discussions
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
:target: https://github.com/openedx/frontend-app-discussions/blob/master/LICENSE
frontend-app-discussions
========================
Please tag **@edx/fedx-team** on any PRs or issues. Thanks.
Introduction
------------
This repository is a React-based micro frontend for the Open edX discussion forums.
**Installation and Startup**
Getting Started
---------------
1. Clone your new repo:
@@ -26,6 +30,44 @@ This repository is a React-based micro frontend for the Open edX discussion foru
The dev server is running at `http://localhost:2002 <http://localhost:2002>`_.
Getting Help
------------
Please tag **@openedx/edx-infinity ** on any PRs or issues. Thanks.
If you're having trouble, we have discussion forums at https://discuss.openedx.org where you can connect with others in the community.
For anything non-trivial, the best path is to open an issue in this repository with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-app-discussions/issues
For more information about these options, see the `Getting Help`_ page.
.. _Getting Help: https://openedx.org/getting-help
How to Contribute
-----------------
Details about how to become a contributor to the Open edX project may be found in the wiki at `How to contribute`_
.. _How to contribute: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/process/index.html
PR description template should be automatically applied if you are sending PR from github interface; otherwise you
can find it it at `PULL_REQUEST_TEMPLATE.md <https://github.com/openedx/frontend-app-discussions/blob/master/.github/pull_request_template.md>`_
This project is currently accepting all types of contributions, bug fixes and security fixes
The Open edX Code of Conduct
----------------------------
All community members should familiarize themselves with the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
People
------
The assigned maintainers for this component and other project details may be found in Backstage or from inspecting catalog-info.yaml.
Reporting Security Issues
-------------------------
Please do not report security issues in public. Please email security@edx.org.
Project Structure
-----------------
@@ -42,10 +84,3 @@ 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/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
.. |Codecov| image:: https://codecov.io/gh/edx/frontend-app-discussions/branch/master/graph/badge.svg
:target: https://codecov.io/gh/edx/frontend-app-discussions
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-discussions.svg
:target: @edx/frontend-app-discussions

18
catalog-info.yaml Normal file
View File

@@ -0,0 +1,18 @@
# This file records information about this repo. Its use is described in OEP-55:
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: 'frontend-app-discussions'
description: "The discussion forum for openEdx discussions"
links:
- url: "https://github.com/openedx/frontend-app-discussions"
title: "Frontend app discussions"
icon: "Web"
annotations:
openedx.org/arch-interest-groups: ""
spec:
owner: group:edx-infinity
type: 'website'
lifecycle: 'production'

34005
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -34,23 +34,23 @@
},
"dependencies": {
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "11.2.0",
"@edx/frontend-component-header": "3.2.0",
"@edx/frontend-platform": "2.6.1",
"@edx/paragon": "20.15.0",
"@edx/frontend-component-footer": "12.1.0",
"@edx/frontend-component-header": "4.3.0",
"@edx/frontend-platform": "4.6.0",
"@edx/paragon": "20.44.0",
"@reduxjs/toolkit": "1.8.0",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
"classnames": "2.3.1",
"core-js": "3.21.1",
"dompurify": "^2.4.3",
"formik": "2.2.9",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"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": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.9",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"redux": "4.1.2",
@@ -61,15 +61,13 @@
},
"devDependencies": {
"@edx/browserslist-config": "1.1.0",
"@edx/frontend-build": "11.0.1",
"@edx/frontend-build": "12.8.27",
"@edx/reactifex": "1.0.3",
"@testing-library/jest-dom": "5.16.2",
"@testing-library/react": "12.1.4",
"@testing-library/react": "12.1.5",
"@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",

View File

@@ -9,8 +9,178 @@
href="<%=htmlWebpackPlugin.options.FAVICON_URL%>"
type="image/x-icon"
/>
<script defer>
window.MathJax = {
tex: {
inlineMath: [
["$", "$"],
["\\\\(", "\\\\)"],
["\\(", "\\)"],
["[mathjaxinline]", "[/mathjaxinline]"],
["\\begin{math}", "\\end{math}"],
],
displayMath: [
["[mathjax]", "[/mathjax]"],
["$$", "$$"],
["\\\\[", "\\\\]"],
["\\[", "\\]"],
["\\begin{displaymath}", "\\end{displaymath}"],
["\\begin{equation}", "\\end{equation}"],
],
processEscapes: true,
processEnvironments: true,
autoload: {
color: [],
colorv2: ["color"],
},
packages: { "[+]": ["noerrors"] },
},
options: {
ignoreHtmlClass: "tex2jax_ignore",
processHtmlClass: "tex2jax_process",
},
loader: {
load: ["input/asciimath", "[tex]/noerrors"],
},
};
</script>
<script
defer
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"
id="MathJax-script"
></script>
</head>
<body>
<div id="root" class="small"></div>
<!-- begin usabilla live embed code -->
<script defer type="text/javascript">
window.lightningjs ||
(function (n) {
var e = "lightningjs";
function t(e, t) {
var r, i, a, o, d, c;
return (
t && (t += (/\?/.test(t) ? "&" : "?") + "lv=1"),
n[e] ||
((r = window),
(i = document),
(a = e),
(o = i.location.protocol),
(d = "load"),
(c = 0),
(function () {
n[a] = function () {
var t = arguments,
i = this,
o = ++c,
d = (i && i != r && i.id) || 0;
function s() {
return (s.id = o), n[a].apply(s, arguments);
}
return (
(e.s = e.s || []).push([o, d, t]),
(s.then = function (n, t, r) {
var i = (e.fh[o] = e.fh[o] || []),
a = (e.eh[o] = e.eh[o] || []),
d = (e.ph[o] = e.ph[o] || []);
return (
n && i.push(n), t && a.push(t), r && d.push(r), s
);
}),
s
);
};
var e = (n[a]._ = {});
function s() {
e.P(d), (e.w = 1), n[a]("_load");
}
(e.fh = {}),
(e.eh = {}),
(e.ph = {}),
(e.l = t
? t.replace(/^\/\//, ("https:" == o ? o : "http:") + "//")
: t),
(e.p = { 0: +new Date() }),
(e.P = function (n) {
e.p[n] = new Date() - e.p[0];
}),
e.w && s(),
r.addEventListener
? r.addEventListener(d, s, !1)
: r.attachEvent("onload", s);
var l = function () {
function n() {
return [
"<!DOCTYPE ",
o,
"><",
o,
"><head></head><",
t,
"><",
r,
' src="',
e.l,
'"></',
r,
"></",
t,
"></",
o,
">",
].join("");
}
var t = "body",
r = "script",
o = "html",
d = i[t];
if (!d) return setTimeout(l, 100);
e.P(1);
var c,
s = i.createElement("div"),
h = s.appendChild(i.createElement("div")),
u = i.createElement("iframe");
(s.style.display = "none"),
(d.insertBefore(s, d.firstChild).id = "lightningjs-" + a),
(u.frameBorder = "0"),
(u.id = "lightningjs-frame-" + a),
/MSIE[ ]+6/.test(navigator.userAgent) &&
(u.src = "javascript:false"),
(u.allowTransparency = "true"),
h.appendChild(u);
try {
u.contentWindow.document.open();
} catch (n) {
(e.domain = i.domain),
(c =
"javascript:var d=document.open();d.domain='" +
i.domain +
"';"),
(u.src = c + "void(0);");
}
try {
var p = u.contentWindow.document;
p.write(n()), p.close();
} catch (e) {
u.src =
c +
'd.write("' +
n().replace(/"/g, String.fromCharCode(92) + '"') +
'");d.close();';
}
e.P(2);
};
e.l && l();
})()),
(n[e].lv = "1"),
n[e]
);
}
var r = (window.lightningjs = t(e));
(r.require = t), (r.modules = n);
})({});
</script>
<!-- end usabilla live embed code -->
</body>
</html>

View File

@@ -1,9 +1,33 @@
{
"extends": [
"config:base"
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
],
"patch": {
"automerge": true
},
"rebaseStalePrs": true
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"
}

48
src/assets/Empty.jsx Normal file
View File

@@ -0,0 +1,48 @@
const Empty = () => (
<svg width="246" height="204" viewBox="0 0 246 204" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.966 80.706C1.93 101.534-5.315 125.86 4.558 148.18c17.989 40.665 116.015 29.236 158.702 13.267 120.227-44.976 62.415-162.99-67.596-133.809-36.401 8.17-65.661 32.239-79.698 53.068Z" fill="#D7D3D1" fillOpacity="0.3" />
<path d="M95.6307 23.4976C101.102 13.3476 109.932 6.31365 123.036 10.536C124.191 11.0005 125.403 11.311 126.639 11.4597C127.895 11.386 129.146 11.2369 130.384 11.0131C136.292 10.5259 140.656 16.2505 143.529 21.427C148.867 31.0796 158.622 44.1833 156.724 55.7847C155.252 64.7776 146.797 71.0097 138.261 74.1562C128.636 77.7063 118.244 78.655 108.136 76.9069C103.061 76.034 97.813 74.3288 94.159 70.6443C89.084 65.5084 88.069 57.6016 88.4547 50.3443C88.9926 42.2345 91.1749 31.8104 95.6307 23.4976Z" fill="#EEEAE9" />
<path d="M117.991 78.2569C114.66 78.2567 111.336 77.9715 108.054 77.4043C101.649 76.3081 97.0006 74.2172 93.8439 71.0098C89.4084 66.554 87.4393 59.652 87.9772 50.3952C88.5558 40.7933 91.2557 30.6534 95.2142 23.2845C99.8528 14.657 108.46 5.35964 123.137 10.0895L123.837 10.3331C124.73 10.6795 125.665 10.9015 126.618 10.9929C127.348 10.9968 128.077 10.9219 128.79 10.7696C129.278 10.6884 129.805 10.597 130.303 10.5564C135.165 10.1403 139.752 13.7334 143.894 21.2038C144.848 22.9293 145.924 24.7563 147.091 26.6949C152.278 35.3732 158.733 46.1728 157.139 55.866C155.871 63.712 148.877 70.7155 138.392 74.5928C131.864 77.0187 124.955 78.2596 117.991 78.2569ZM96.0972 23.721C92.1996 30.9782 89.5302 40.976 88.9922 50.4459C88.4543 59.3982 90.332 66.0972 94.5544 70.3602C97.5994 73.4053 102.076 75.4352 108.277 76.4502C118.3 78.1754 128.602 77.223 138.139 73.6894C148.289 69.9238 155.079 63.1842 156.307 55.6732C157.84 46.325 151.486 35.6777 146.38 27.1212C145.223 25.1826 144.127 23.3556 143.163 21.6199C139.214 14.5149 134.941 11.0741 130.465 11.4699C129.988 11.4699 129.521 11.5917 129.034 11.6729C128.218 11.8545 127.384 11.9464 126.547 11.947C125.503 11.8549 124.477 11.6122 123.502 11.2263L122.812 10.9929C108.744 6.45585 100.482 15.4284 95.9957 23.7413L96.0972 23.721Z" fill="#454545" />
<path d="M182.845 167.75C182.845 167.75 178.596 94.152 167.857 84.3978C152.409 70.4314 97.5788 67.5184 86.4443 70.3909C66.7533 75.4659 53.0508 106.22 53.0508 106.22L85.7135 115.985C85.7845 141.248 88.3119 167.75 88.3119 167.75H182.845Z" fill="#B5AFAB" />
<path d="M84.2307 146.058C84.1627 146.053 84.0963 146.035 84.0354 146.004C83.9746 145.973 83.9204 145.93 83.8761 145.878C83.8317 145.827 83.7981 145.766 83.7771 145.702C83.7561 145.637 83.7481 145.568 83.7537 145.5L88.1689 87.6451C88.1741 87.5757 88.1928 87.5081 88.2242 87.446C88.2556 87.3839 88.299 87.3286 88.3518 87.2833C88.4046 87.238 88.4659 87.2037 88.532 87.1821C88.5982 87.1606 88.668 87.1524 88.7373 87.1579C88.8063 87.163 88.8736 87.1819 88.9352 87.2134C88.9967 87.2449 89.0514 87.2884 89.0959 87.3414C89.1403 87.3944 89.1737 87.4557 89.1941 87.5219C89.2144 87.588 89.2213 87.6575 89.2144 87.7263L84.7991 145.581C84.794 145.65 84.7752 145.718 84.7437 145.779C84.7122 145.841 84.6686 145.895 84.6156 145.94C84.5627 145.984 84.5013 146.018 84.4352 146.038C84.3691 146.058 84.2995 146.065 84.2307 146.058Z" fill="#5C524D" />
<path d="M114.163 90.7217C117.828 95.279 123.298 98.527 129.196 98.6184C129.959 98.6607 130.724 98.5574 131.449 98.3139C132.442 97.8894 133.288 97.1838 133.885 96.2839C136.544 92.6299 136.656 87.7071 135.986 83.2411C135.509 80.1048 134.595 76.8162 132.22 74.7151C129.429 72.2487 125.379 72.0152 121.654 71.9137C117.929 71.8122 109.291 70.7262 107.911 75.314C106.622 79.5059 111.707 87.6665 114.163 90.7217Z" fill="#5C524D" />
<path d="M129.42 99.0646H129.187C123.553 98.9732 117.809 95.9587 113.82 91.0055C111.17 87.7169 106.146 79.536 107.476 75.1816C108.805 70.8273 115.9 71.1216 120.123 71.3855L121.676 71.4566C125.786 71.5784 129.704 71.8829 132.516 74.3696C135.327 76.8564 136.028 80.5307 136.424 83.1697C137.286 88.874 136.556 93.3197 134.252 96.5372C133.611 97.5159 132.692 98.2797 131.613 98.7296C130.908 98.9745 130.165 99.0881 129.42 99.0646ZM116.662 72.1671C113.089 72.1671 109.181 72.6847 108.339 75.4455C107.11 79.4142 112.064 87.4022 114.51 90.4472C118.336 95.1974 123.827 98.08 129.197 98.1714C129.908 98.2085 130.62 98.1157 131.298 97.8973C132.211 97.5007 132.988 96.8442 133.531 96.0094C135.683 93.0456 136.363 88.7623 135.561 83.3016C135.185 80.7946 134.404 77.2116 131.947 75.0395C129.491 72.8674 125.604 72.4614 121.666 72.3498C121.209 72.3498 120.65 72.3498 120.102 72.2787C119.067 72.228 117.88 72.1671 116.662 72.1671Z" fill="#454545" />
<path d="M137.874 43.808C138.249 42.5429 138.885 41.3706 139.742 40.3671C140.181 39.8733 140.761 39.5266 141.404 39.374C142.047 39.2215 142.721 39.2706 143.335 39.5145C143.762 39.7688 144.12 40.1237 144.379 40.5484C144.637 40.973 144.787 41.4544 144.817 41.9505C144.867 42.9487 144.701 43.9458 144.33 44.8737C143.765 46.6382 142.787 48.2423 141.477 49.5529C140.827 50.204 140.05 50.7154 139.195 51.0558C138.34 51.3961 137.424 51.5582 136.504 51.5321" fill="#EEEAE9" />
<path d="M136.89 51.9282H136.474C136.367 51.9179 136.268 51.8667 136.198 51.7854C136.128 51.7041 136.093 51.5989 136.098 51.4917C136.101 51.4379 136.114 51.3851 136.138 51.3366C136.161 51.2881 136.195 51.245 136.235 51.2098C136.276 51.1747 136.324 51.1482 136.375 51.1321C136.427 51.116 136.481 51.1106 136.535 51.1162C137.403 51.1458 138.268 50.9971 139.076 50.6794C139.884 50.3617 140.619 49.8816 141.234 49.2689C142.496 48.0042 143.436 46.4553 143.975 44.7521C144.329 43.8802 144.488 42.9414 144.442 42.0015C144.417 41.5693 144.287 41.1497 144.062 40.7798C143.837 40.4098 143.525 40.1009 143.152 39.8801C142.615 39.6774 142.028 39.6427 141.47 39.7804C140.911 39.9181 140.408 40.2218 140.026 40.6515C139.218 41.6241 138.614 42.7494 138.25 43.9604C138.232 44.0102 138.204 44.0559 138.168 44.0947C138.132 44.1336 138.089 44.1648 138.041 44.1866C137.992 44.2083 137.94 44.2202 137.887 44.2214C137.834 44.2227 137.782 44.2133 137.732 44.1939C137.632 44.1572 137.55 44.0822 137.504 43.9852C137.458 43.8883 137.453 43.7772 137.489 43.6762C137.886 42.3539 138.557 41.1298 139.458 40.0831C139.958 39.5304 140.615 39.1452 141.342 38.9799C142.069 38.8146 142.828 38.8773 143.518 39.1595C144.012 39.44 144.429 39.8394 144.73 40.3214C145.031 40.8034 145.208 41.353 145.243 41.9203C145.299 42.9602 145.126 43.9997 144.736 44.9653C144.16 46.8097 143.143 48.4856 141.772 49.8474C140.468 51.1359 138.723 51.8799 136.89 51.9282Z" fill="#454545" />
<path d="M130.902 61.4082L132.475 77.0595C132.475 77.0595 131.714 85.961 121.929 84.1645C119.055 83.6096 116.34 82.4241 113.979 80.6936C111.619 78.963 109.671 76.7304 108.277 74.1566L108.643 46.0715C108.582 44.9246 110.967 40.9965 109.84 31.0698C113.195 30.7268 116.441 29.6886 119.371 28.0214C122.302 26.3542 124.853 24.0946 126.862 21.3867C126.862 21.3867 131.399 26.9895 133.307 28.7252C134.839 30.0073 136.203 31.477 137.367 33.0998C140.412 37.6673 140.28 45.1174 139.549 50.3548C138.839 54.6584 136.342 61.5198 130.902 61.4082Z" fill="#EEEAE9" />
<path d="M124.375 84.8953C123.527 84.8897 122.681 84.8082 121.847 84.6517C118.893 84.0752 116.102 82.8527 113.676 81.0717C111.249 79.2906 109.246 76.9951 107.81 74.3495C107.795 74.2826 107.795 74.2133 107.81 74.1465C107.81 74.1465 108.175 46.2441 108.175 46.0716C108.218 45.5697 108.323 45.0751 108.49 44.5998C109.058 42.651 110.226 38.591 109.383 31.1206C109.377 31.0608 109.382 31.0004 109.4 30.9428C109.417 30.8852 109.446 30.8318 109.485 30.7857C109.519 30.736 109.565 30.6948 109.617 30.6649C109.67 30.6351 109.729 30.6174 109.789 30.6131C113.072 30.2679 116.248 29.2475 119.118 27.6161C121.988 25.9847 124.49 23.7774 126.466 21.133C126.507 21.0737 126.562 21.0246 126.625 20.9894C126.688 20.9542 126.759 20.9339 126.831 20.93C126.904 20.9272 126.977 20.9414 127.044 20.9713C127.111 21.0012 127.17 21.0461 127.217 21.1026C127.217 21.1026 131.754 26.6952 133.621 28.3801C135.158 29.6918 136.522 31.1924 137.681 32.8461C141.193 38.1647 140.422 46.8836 139.935 50.3853C139.346 54.5773 136.89 61.5503 131.439 61.865L132.952 77.0189C132.709 79.4527 131.521 81.6941 129.643 83.2612C128.121 84.3844 126.265 84.9603 124.375 84.8953ZM108.693 74.045C110.072 76.5357 111.98 78.6941 114.282 80.3687C116.585 82.0433 119.226 83.1935 122.02 83.7382C124.943 84.266 127.308 83.86 129.054 82.5304C130.698 81.131 131.753 79.1626 132.008 77.0189L130.444 61.459C130.439 61.393 130.447 61.3266 130.468 61.2637C130.489 61.2009 130.522 61.143 130.566 61.0936C130.611 61.0479 130.665 61.0118 130.724 60.9874C130.784 60.963 130.847 60.9508 130.911 60.9515H131.033C136.108 60.9515 138.433 54.2423 138.991 50.2534C139.478 46.8531 140.229 38.4185 136.89 33.3638C135.757 31.7708 134.431 30.3252 132.941 29.0602C130.797 26.8597 128.764 24.5527 126.851 22.148C122.766 27.2984 116.828 30.6462 110.307 31.4759C111.068 38.8346 109.901 42.8845 109.292 44.8536C109.159 45.2422 109.067 45.6436 109.018 46.0513C109.018 46.0513 109.058 46.0817 108.693 74.045Z" fill="#454545" />
<path d="M131.438 66.5538C128.868 67.0035 126.223 66.7221 123.805 65.7418C118.233 63.7828 117.908 59.9766 117.908 59.9766C117.908 59.9766 122.597 62.9201 130.9 61.3773L131.438 66.5538Z" fill="#5C524D" />
<path d="M129.176 67.2139C127.292 67.1821 125.427 66.8391 123.655 66.1989C117.879 64.1689 117.483 60.2206 117.463 60.1089C117.455 60.025 117.471 59.9405 117.508 59.8652C117.546 59.7899 117.605 59.7268 117.677 59.6833C117.749 59.6397 117.832 59.6175 117.916 59.6192C118.001 59.6209 118.083 59.6464 118.153 59.6928C118.153 59.6928 122.812 62.5246 130.82 61.0326C130.945 61.0116 131.073 61.0408 131.176 61.1138C131.226 61.15 131.268 61.1971 131.298 61.2516C131.327 61.306 131.345 61.3664 131.348 61.4284L131.876 66.5034C131.898 66.5877 131.895 66.6764 131.868 66.7591C131.841 66.8418 131.791 66.9151 131.724 66.9703C131.68 67.0105 131.629 67.0411 131.573 67.0603C131.517 67.0795 131.458 67.0869 131.399 67.082C130.664 67.1971 129.92 67.2413 129.176 67.2139ZM118.59 60.86C119.036 62.0273 120.315 64.0674 123.959 65.3463C126.186 66.196 128.589 66.4786 130.952 66.1685L130.516 61.9359C124.406 62.9611 120.315 61.6822 118.59 60.86Z" fill="#454545" />
<path d="M124.061 36.0742C124.061 36.0742 129.846 43.0879 128.78 44.519C127.715 45.9502 124.284 45.4122 124.284 45.4122" fill="#EEEAE9" />
<path d="M125.714 45.9592C125.211 45.9586 124.71 45.9247 124.212 45.8577C124.106 45.8253 124.017 45.7561 123.958 45.6627C123.9 45.5693 123.878 45.4581 123.895 45.3494C123.912 45.2408 123.968 45.142 124.052 45.0713C124.137 45.0006 124.244 44.9627 124.354 44.9645C125.237 45.1066 127.683 45.2385 128.414 44.2438C128.87 43.6348 126.668 39.9504 123.694 36.3573C123.657 36.312 123.628 36.2598 123.611 36.2036C123.594 36.1475 123.588 36.0885 123.594 36.03C123.6 35.9716 123.617 35.9148 123.644 35.863C123.672 35.8112 123.709 35.7653 123.755 35.728C123.849 35.6544 123.968 35.6197 124.087 35.631C124.206 35.6423 124.316 35.6988 124.394 35.7889C126.201 37.9813 130.281 43.239 129.134 44.7818C128.414 45.7562 126.881 45.9592 125.714 45.9592Z" fill="#454545" />
<path d="M131.302 37.6836C131.774 37.6204 132.06 36.8441 131.941 35.9495C131.821 35.055 131.341 34.381 130.869 34.4442C130.397 34.5074 130.111 35.2838 130.23 36.1783C130.35 37.0729 130.83 37.7468 131.302 37.6836Z" fill="#454545" />
<path d="M117.067 37.7043C117.543 37.6987 117.921 36.9626 117.911 36.0602C117.9 35.1577 117.505 34.4306 117.029 34.4362C116.552 34.4418 116.175 35.1779 116.185 36.0803C116.196 36.9828 116.59 37.7099 117.067 37.7043Z" fill="#454545" />
<path d="M111.798 41.8686C111.449 40.5288 110.853 39.2657 110.042 38.1436C109.575 37.5067 108.964 36.9903 108.258 36.6373C107.551 36.2842 106.771 36.1047 105.982 36.1136C105.494 36.1326 105.016 36.2622 104.586 36.4925C104.155 36.7228 103.782 37.0479 103.495 37.4432C103.153 38.0128 102.938 38.6501 102.866 39.3108C102.348 42.7923 103.515 47.3293 106.794 49.1259C107.347 49.4737 107.988 49.6582 108.641 49.6582C109.295 49.6582 109.935 49.4737 110.488 49.1259" fill="#" />
<path d="M108.865 50.0604C108.072 50.0422 107.296 49.8333 106.601 49.4514C103.171 47.5635 101.932 42.8844 102.47 39.2202C102.547 38.5008 102.783 37.8074 103.161 37.1902C103.481 36.7428 103.898 36.3741 104.382 36.1121C104.865 35.8501 105.402 35.7016 105.952 35.6779C106.81 35.6717 107.657 35.8702 108.423 36.2567C109.189 36.6433 109.852 37.2069 110.357 37.9007C111.203 39.0603 111.823 40.3687 112.184 41.7577C112.212 41.86 112.2 41.9693 112.148 42.0623C112.097 42.1552 112.012 42.2244 111.91 42.2551C111.86 42.2722 111.807 42.2789 111.754 42.2747C111.702 42.2706 111.651 42.2557 111.604 42.2309C111.557 42.2062 111.516 42.1721 111.483 42.1309C111.45 42.0897 111.426 42.0421 111.413 41.9912C111.077 40.6998 110.506 39.4817 109.728 38.3981C109.306 37.8037 108.747 37.3198 108.099 36.9876C107.45 36.6554 106.731 36.4847 106.003 36.4899C105.578 36.5057 105.162 36.617 104.786 36.8156C104.41 37.0141 104.083 37.2948 103.83 37.6368C103.52 38.1565 103.329 38.7391 103.272 39.342C102.765 42.722 103.881 47.0357 106.987 48.7409C107.466 49.0597 108.025 49.2365 108.599 49.2509C109.174 49.2653 109.741 49.1166 110.235 48.8221C110.321 48.7589 110.428 48.7311 110.534 48.7443C110.639 48.7575 110.736 48.8108 110.804 48.8932C110.869 48.9781 110.899 49.0855 110.885 49.192C110.872 49.2986 110.817 49.3955 110.732 49.4616C110.193 49.8617 109.536 50.0722 108.865 50.0604Z" fill="#454545" />
<path d="M166.029 161.964C165.906 161.964 165.786 161.92 165.691 161.84C165.597 161.76 165.533 161.65 165.512 161.528C162.03 139.797 155.362 98.3744 155.057 97.1158C155.023 96.9813 155.043 96.8387 155.114 96.719C155.184 96.5994 155.299 96.5122 155.433 96.4764C155.568 96.4454 155.711 96.4683 155.83 96.5402C155.95 96.6121 156.036 96.7275 156.072 96.8621C156.468 98.4759 166.131 158.777 166.547 161.355C166.567 161.492 166.533 161.632 166.451 161.744C166.37 161.856 166.247 161.932 166.11 161.954L166.029 161.964Z" fill="#454545" />
<path d="M103.112 184.842L102.977 184.655H102.747H55.0401C50.0527 184.655 45.9305 180.138 45.9305 174.453V116.416C45.9305 110.737 50.0532 106.208 55.0401 106.208H181.016C186.003 106.208 190.134 110.738 190.165 116.419V174.453C190.165 180.138 186.037 184.655 181.049 184.655H131.929H131.726L131.592 184.807L115.869 202.609L103.112 184.842Z" fill="#EEEAE9" stroke="#454545" strokeWidth="0.9" />
<path d="M126.486 55.034C118.985 55.034 116.59 45.1377 116.488 44.7114C116.464 44.5944 116.486 44.4726 116.551 44.372C116.615 44.2715 116.717 44.2002 116.833 44.1735C116.89 44.1599 116.95 44.1579 117.008 44.1674C117.066 44.1769 117.122 44.1979 117.172 44.229C117.222 44.2601 117.265 44.3009 117.299 44.3488C117.334 44.3968 117.358 44.451 117.371 44.5084C117.371 44.5998 119.695 54.1408 126.506 54.1408C127.212 54.168 127.916 54.0394 128.567 53.764C129.217 53.4885 129.8 53.0731 130.272 52.5472C132.616 49.8778 131.987 44.7013 131.977 44.6505C131.968 44.5918 131.972 44.532 131.986 44.4745C132.001 44.417 132.027 44.3631 132.063 44.3158C132.099 44.2686 132.144 44.229 132.196 44.1993C132.247 44.1696 132.304 44.1505 132.363 44.143C132.48 44.131 132.598 44.1642 132.692 44.2358C132.787 44.3074 132.85 44.4121 132.87 44.5287C132.87 44.7622 133.57 50.162 130.952 53.1359C130.395 53.7634 129.705 54.2592 128.932 54.5875C128.16 54.9157 127.324 55.0682 126.486 55.034Z" fill="#454545" />
<path d="M70.1771 103.176C73.7637 101.377 78.12 99.9134 81.6644 101.817C83.3455 102.866 84.7632 104.287 85.8078 105.971C88.0669 109.136 90.1082 112.451 91.9175 115.893C92.3914 116.848 92.7954 118.019 92.0581 118.706C91.16 119.551 89.7377 118.638 88.9482 117.701C87.5236 116.028 86.3296 114.173 85.3981 112.183C87.2805 114.936 88.7735 117.936 89.8348 121.097C90.0177 121.471 90.0817 121.891 90.0181 122.302C89.8722 122.667 89.588 122.96 89.2274 123.116C88.8667 123.273 88.4587 123.281 88.0922 123.139C87.3497 122.858 86.7046 122.367 86.2347 121.728C84.1638 119.239 82.3741 116.53 80.8983 113.649C82.3828 116.657 83.8796 119.745 84.2431 123.078C84.3088 123.353 84.3162 123.639 84.2649 123.917C84.2135 124.196 84.1045 124.46 83.9449 124.694C83.7065 124.904 83.4062 125.031 83.0894 125.055C82.7726 125.079 82.4565 124.999 82.1891 124.828C81.6793 124.464 81.2568 123.992 80.952 123.445L75.6783 115.778C77.0141 118.725 77.9793 121.827 78.5518 125.012C78.6234 125.274 78.623 125.551 78.5505 125.813C78.2358 126.713 76.8342 126.341 76.1662 125.662C75.1107 124.468 74.2905 123.084 73.7489 121.585C72.0643 117.734 70.4974 113.712 68.6538 109.896C67.1293 106.761 66.4761 105.033 70.1771 103.176Z" fill="#EEEAE9" />
<path d="M77.958 126.765L78.0684 126.749C78.2758 126.704 78.4681 126.606 78.6266 126.465C78.785 126.324 78.9042 126.144 78.9726 125.944C79.0614 125.607 79.0675 125.254 78.9905 124.914C78.6521 123.052 78.1839 121.216 77.5894 119.419L80.5096 123.697C80.8645 124.309 81.3549 124.832 81.9432 125.225C82.3005 125.453 82.7244 125.554 83.1464 125.512C83.5684 125.471 83.964 125.288 84.2695 124.994C84.4767 124.712 84.621 124.389 84.6929 124.047C84.7648 123.705 84.7626 123.352 84.6863 123.01C84.5274 121.793 84.252 120.594 83.864 119.429C84.4984 120.359 85.1577 121.183 85.8586 122.011C86.357 122.728 87.0556 123.282 87.867 123.604C88.1454 123.691 88.4383 123.721 88.7284 123.693C89.0186 123.665 89.3002 123.578 89.5565 123.44C89.7497 123.337 89.9205 123.198 90.0587 123.029C90.197 122.859 90.2999 122.664 90.3614 122.455C90.4574 121.958 90.4043 121.445 90.2087 120.979C89.9753 120.255 89.7117 119.535 89.4297 118.828C89.9032 119.214 90.4782 119.455 91.0854 119.521C91.3167 119.539 91.5492 119.507 91.7672 119.428C91.9853 119.348 92.184 119.224 92.3501 119.062C92.871 118.572 93.2855 117.585 92.2992 115.692C90.4856 112.233 88.4385 108.901 86.1719 105.721C85.0837 103.975 83.6092 102.503 81.8619 101.418C78.8572 99.8217 74.971 100.259 69.9541 102.778C65.9002 104.812 66.657 106.884 68.2137 110.097C69.4397 112.62 70.5521 115.275 71.623 117.791C72.1819 119.102 72.7308 120.415 73.2481 121.722C73.8155 123.272 74.6668 124.702 75.7585 125.94C76.0393 126.234 76.3829 126.461 76.7638 126.604C77.1447 126.747 77.5529 126.802 77.958 126.765ZM75.6082 115.306C75.5536 115.304 75.4993 115.313 75.4476 115.33C75.3469 115.387 75.271 115.48 75.2346 115.59C75.1982 115.7 75.2039 115.819 75.2507 115.925C76.5744 118.833 77.527 121.896 78.0858 125.042C78.1393 125.224 78.1475 125.416 78.1096 125.603C78.0928 125.657 78.061 125.706 78.0176 125.744C77.9743 125.781 77.9213 125.806 77.8647 125.814C77.6113 125.827 77.3581 125.786 77.1214 125.695C76.8847 125.604 76.6697 125.464 76.4903 125.284C75.481 124.129 74.6936 122.798 74.1677 121.357C73.6003 120.057 73.0514 118.744 72.5442 117.435C71.4641 114.858 70.3387 112.186 69.0996 109.643C67.6013 106.545 67.1227 105.222 70.4321 103.558C75.1432 101.188 78.7608 100.74 81.5158 102.179C83.1359 103.197 84.5009 104.573 85.5059 106.202C87.7534 109.348 89.7816 112.644 91.5763 116.068C91.8963 116.687 92.3545 117.808 91.808 118.333C91.7304 118.406 91.6381 118.462 91.5372 118.497C91.4363 118.532 91.3291 118.545 91.2228 118.535C90.4804 118.382 89.8188 117.964 89.3603 117.361C88.9613 116.898 88.5854 116.452 88.2259 115.911C87.5179 114.519 86.7231 113.172 85.8463 111.879C85.7786 111.784 85.6785 111.717 85.5648 111.691C85.451 111.665 85.3317 111.681 85.2292 111.737C85.1284 111.796 85.0535 111.891 85.0192 112.002C84.9848 112.114 84.9936 112.234 85.0438 112.34C85.7112 113.761 86.5088 115.118 87.4264 116.392C88.209 117.951 88.8768 119.565 89.4243 121.221C89.5572 121.517 89.6069 121.843 89.5678 122.165C89.5075 122.344 89.3789 122.493 89.2099 122.579C89.0614 122.67 88.8961 122.731 88.7237 122.757C88.5513 122.783 88.3754 122.774 88.2064 122.731C87.5704 122.454 87.0251 122.003 86.6327 121.431C84.5887 118.966 82.8207 116.284 81.3603 113.434C81.2978 113.338 81.2021 113.269 81.0916 113.239C80.981 113.209 80.8633 113.22 80.7607 113.271C80.6582 113.322 80.578 113.41 80.5355 113.516C80.493 113.622 80.491 113.741 80.53 113.848C81.9552 116.804 83.4258 119.855 83.8218 123.132C83.8773 123.341 83.89 123.56 83.8592 123.774C83.8284 123.989 83.7546 124.195 83.6423 124.381C83.4755 124.509 83.2729 124.582 83.0626 124.59C82.8523 124.598 82.6447 124.541 82.4685 124.426C82.0062 124.101 81.6199 123.68 81.3361 123.192L76.0639 115.534C76.0203 115.454 75.9533 115.389 75.8718 115.349C75.7904 115.308 75.6984 115.293 75.6082 115.306Z" fill="#454545" />
<path d="M207.372 99.7322C207.43 99.7275 207.486 99.7122 207.538 99.6872C215.214 94.2396 220.02 88.2106 221.846 81.7353C229.021 77.1588 232.984 70.6532 232.606 63.8795C231.975 51.8119 217.685 42.7708 200.833 43.6732C183.98 44.6076 170.809 55.1665 171.449 67.2315C172.089 79.2964 186.367 88.3304 203.184 87.4163C205.507 87.2898 207.816 86.9722 210.087 86.4668C209.922 90.8412 208.779 95.1232 206.742 98.998C206.69 99.085 206.665 99.1856 206.671 99.2869C206.677 99.3882 206.712 99.4854 206.774 99.5662C206.835 99.6469 206.919 99.7074 207.016 99.7398C207.112 99.7722 207.215 99.7751 207.313 99.7481L207.372 99.7322ZM200.922 44.7007C217.2 43.8164 230.955 52.4343 231.59 63.933C231.952 70.3746 228.141 76.5868 221.138 80.9804C221.033 81.051 220.954 81.1535 220.912 81.2727C219.332 87.1032 215.203 92.6024 208.622 97.6282C210.263 93.9118 211.129 89.9003 211.169 85.8383C211.169 85.7615 211.151 85.6857 211.117 85.6166C211.084 85.5475 211.035 85.4869 210.975 85.4395C210.914 85.392 210.844 85.3589 210.769 85.3426C210.694 85.3263 210.616 85.3272 210.541 85.3453C208.13 85.9195 205.672 86.2772 203.197 86.4139C186.902 87.3133 173.124 78.6909 172.49 67.1922C171.855 55.6935 184.6 45.6178 200.892 44.7086L200.922 44.7007Z" fill="#D3CECB" />
<path d="M75.9248 170.151C75.6912 170.236 75.5706 170.494 75.6553 170.728C75.7401 170.961 75.9982 171.082 76.2319 170.997L75.9248 170.151ZM105.694 146.36L105.325 146.103L105.694 146.36ZM108.515 125.736L108.549 126.184L108.515 125.736ZM96.4601 134.933L96.8292 135.19L96.4601 134.933ZM151.042 128.024L151.44 128.233L151.042 128.024ZM149.712 113.888L149.745 114.337L149.712 113.888ZM133.632 131.3L133.202 131.17L133.202 131.17L133.632 131.3ZM133.617 131.352L134.047 131.482L134.047 131.482L133.617 131.352ZM157.054 147.878C157.291 147.802 157.421 147.548 157.345 147.312C157.268 147.075 157.015 146.945 156.778 147.021L157.054 147.878ZM133.202 131.17L133.186 131.221L134.047 131.482L134.063 131.431L133.202 131.17ZM136.528 147.943C142.241 142.272 147.672 135.394 151.44 128.233L150.643 127.814C146.927 134.878 141.557 141.683 135.893 147.305L136.528 147.943ZM151.44 128.233C152.08 127.017 153.347 123.307 153.658 119.949C153.814 118.276 153.74 116.614 153.172 115.382C152.884 114.756 152.461 114.228 151.866 113.877C151.271 113.526 150.542 113.375 149.678 113.44L149.745 114.337C150.465 114.283 151.003 114.413 151.409 114.652C151.815 114.892 152.126 115.263 152.355 115.759C152.821 116.77 152.913 118.236 152.762 119.866C152.461 123.114 151.225 126.708 150.643 127.814L151.44 128.233ZM149.678 113.44C145.534 113.75 141.938 116.475 139.139 119.927C136.333 123.388 134.271 127.646 133.202 131.17L134.063 131.431C135.102 128.005 137.114 123.854 139.838 120.494C142.568 117.126 145.959 114.62 149.745 114.337L149.678 113.44ZM133.186 131.221C132.448 133.654 131.751 137 131.92 140.149C132.09 143.295 133.133 146.344 135.983 148.012L136.438 147.236C133.964 145.788 132.981 143.108 132.819 140.101C132.657 137.097 133.325 133.865 134.047 131.482L133.186 131.221ZM96.091 134.675C93.9783 137.703 91.552 142.707 90.6804 147.575C89.8122 152.425 90.4654 157.33 94.7866 159.86L95.2412 159.083C91.4248 156.849 90.7184 152.47 91.5663 147.734C92.411 143.016 94.7766 138.132 96.8292 135.19L96.091 134.675ZM108.481 125.287C105.71 125.494 103.304 126.859 101.256 128.647C99.2081 130.434 97.4895 132.671 96.091 134.675L96.8292 135.19C98.2148 133.204 99.8836 131.039 101.848 129.325C103.811 127.611 106.04 126.372 108.549 126.184L108.481 125.287ZM106.063 146.617C107.294 144.848 110.281 139.483 111.829 134.629C112.599 132.216 113.042 129.845 112.667 128.091C112.477 127.198 112.067 126.431 111.349 125.919C110.635 125.41 109.678 125.197 108.481 125.287L108.549 126.184C109.611 126.105 110.337 126.302 110.827 126.652C111.313 126.999 111.63 127.541 111.787 128.279C112.109 129.786 111.735 131.961 110.972 134.355C109.454 139.114 106.511 144.399 105.325 146.103L106.063 146.617ZM95.3309 159.791C99.3824 155.769 102.824 151.27 106.063 146.617L105.325 146.103C102.099 150.737 98.6929 155.185 94.6969 159.152L95.3309 159.791ZM76.2319 170.997C83.5488 168.342 89.6767 165.404 95.3309 159.791L94.6969 159.152C89.1766 164.632 83.1888 167.515 75.9248 170.151L76.2319 170.997ZM94.7866 159.86C101.288 163.666 109.486 162.902 117.124 160.117C124.773 157.327 131.961 152.476 136.528 147.943L135.893 147.305C131.416 151.749 124.338 156.528 116.816 159.271C109.282 162.019 101.403 162.69 95.2412 159.083L94.7866 159.86ZM135.983 148.012C139.071 149.82 142.811 150.274 146.52 150.039C150.233 149.803 153.958 148.876 157.054 147.878L156.778 147.021C153.72 148.007 150.074 148.912 146.463 149.14C142.848 149.369 139.312 148.918 136.438 147.236L135.983 148.012Z" fill="#D7D3D1" />
<path d="M34.1208 1.67272L34.1208 1.67271C39.9029 0.930545 45.3366 1.92561 49.4422 4.11327C53.5488 6.30152 56.2929 9.65956 56.8045 13.6458C57.3161 17.632 55.5083 21.5751 52.0884 24.7305C48.6694 27.8851 43.6661 30.2212 37.8913 30.9639L37.8912 30.9639C36.1449 31.1889 34.3819 31.2561 32.6236 31.1648L32.0474 31.1349L32.1587 31.7011C32.6982 34.4435 33.7686 37.0476 35.3035 39.3693C29.2428 36.2215 26.3184 32.6297 24.9143 29.7128L24.8379 29.5541L24.6742 29.4894C19.3949 27.4043 15.8095 23.6168 15.215 18.9913L15.2149 18.991C14.6999 15.0042 16.5008 11.0607 19.9169 7.90532C23.3321 4.7507 28.3352 2.4146 34.1208 1.67272Z" stroke="#D3CECB" strokeWidth="0.9" />
<path d="M61.0231 135.858L48.305 133.863L49.2502 146.414L57.0239 149.239L65.7092 152.397L63.3431 144.054L61.0231 135.858Z" fill="#EEEAE9" />
<path d="M66.0579 152.457C66.0648 152.408 66.0613 152.357 66.0477 152.309L61.3616 135.77C61.3421 135.706 61.305 135.649 61.2544 135.604C61.2038 135.56 61.1417 135.529 61.075 135.517L48.3569 133.522C48.3039 133.516 48.2503 133.521 48.1995 133.537C48.1487 133.552 48.1018 133.578 48.0616 133.612C48.0239 133.647 47.9944 133.69 47.9751 133.737C47.9558 133.784 47.9471 133.835 47.9496 133.887L48.9053 146.418C48.9097 146.484 48.9336 146.547 48.9742 146.6C49.0148 146.653 49.0702 146.693 49.1337 146.716L65.5927 152.7C65.6534 152.724 65.7197 152.73 65.7838 152.719C65.848 152.708 65.9075 152.679 65.9554 152.636C66.0052 152.587 66.0406 152.525 66.0579 152.457ZM60.7765 136.167L65.2501 151.856L49.6494 146.181L48.7496 134.284L60.7765 136.167Z" fill="#454545" />
<path d="M55.3166 137.493L51.3945 141.013L15.4608 102.75L19.3828 99.2312L55.3166 137.493Z" fill="#EEEAE9" />
<path d="M55.6719 137.552L55.6801 137.504C55.6838 137.459 55.6774 137.413 55.6612 137.37C55.645 137.327 55.6195 137.289 55.5863 137.256L19.6493 98.9922C19.6177 98.9586 19.5797 98.9313 19.5375 98.912C19.4953 98.8927 19.4496 98.8817 19.4032 98.8797C19.3104 98.8779 19.2205 98.9099 19.1509 98.9694L15.2269 102.5C15.1925 102.529 15.1645 102.565 15.1448 102.606C15.1251 102.646 15.1141 102.69 15.1124 102.735C15.1107 102.78 15.1183 102.825 15.1349 102.867C15.1515 102.909 15.1766 102.948 15.2087 102.98L51.1388 141.243C51.2014 141.311 51.2888 141.353 51.382 141.358C51.4752 141.364 51.5665 141.333 51.6361 141.273L55.56 137.743C55.6186 137.693 55.6581 137.626 55.6719 137.552ZM19.3681 99.7079L54.811 137.466L51.4142 140.519L15.9632 102.767L19.3681 99.7079Z" fill="#5C524D" />
<path d="M15.465 102.744L13.3205 108.151L49.2506 146.414L51.4021 141.008L15.465 102.744Z" fill="#5C524D" />
<path d="M51.7505 141.068C51.7591 141.015 51.7552 140.961 51.7392 140.91C51.7231 140.858 51.6954 140.811 51.658 140.772L15.728 102.509C15.6862 102.466 15.6344 102.434 15.5772 102.416C15.52 102.398 15.4592 102.395 15.4004 102.405C15.3415 102.416 15.2864 102.441 15.2401 102.478C15.1938 102.515 15.1578 102.563 15.1352 102.617L12.9907 108.024C12.966 108.084 12.9591 108.15 12.9709 108.214C12.9827 108.278 13.0127 108.337 13.0575 108.386L48.9945 146.65C49.0356 146.693 49.0869 146.724 49.1438 146.742C49.2006 146.761 49.261 146.764 49.3195 146.753C49.3779 146.742 49.4325 146.717 49.4781 146.68C49.5237 146.643 49.5589 146.595 49.5803 146.541L51.7318 141.135L51.7505 141.068ZM15.5906 103.389L50.994 141.085L49.1238 145.775L13.7226 108.108L15.5906 103.389Z" fill="#5C524D" />
<path d="M25.0873 97.5939L19.3945 99.228L55.3245 137.491L61.0174 135.857L25.0873 97.5939Z" fill="#5C524D" />
<path d="M61.367 135.946C61.3741 135.893 61.3695 135.839 61.3535 135.788C61.3376 135.737 61.3107 135.69 61.2745 135.65L25.3492 97.3593C25.3025 97.3132 25.2443 97.2799 25.1805 97.2627C25.1166 97.2455 25.0494 97.2452 24.9856 97.2616L19.2858 98.8944C19.2279 98.9097 19.1755 98.9401 19.1341 98.9824C19.0927 99.0246 19.0639 99.0771 19.0507 99.1344C19.0349 99.1901 19.034 99.2491 19.0482 99.3055C19.0623 99.362 19.091 99.414 19.1315 99.4567L55.0616 137.72C55.1107 137.764 55.1717 137.793 55.2371 137.805C55.3026 137.816 55.3699 137.809 55.431 137.783L61.1296 136.157C61.1865 136.14 61.2379 136.109 61.279 136.067C61.3201 136.025 61.3496 135.974 61.3647 135.918L61.367 135.946ZM25.01 97.9949L60.4135 135.691L55.4693 137.103L20.0345 99.381L25.01 97.9949Z" fill="#454545" />
<path d="M63.3443 144.054L60.8645 144.117L57.3153 147.305L57.0251 149.239L65.7104 152.397L63.3443 144.054Z" fill="#B5AFAB" />
<path d="M66.0577 152.457C66.0646 152.408 66.0611 152.357 66.0475 152.308L63.6825 143.958C63.6602 143.885 63.6136 143.82 63.5499 143.775C63.4863 143.73 63.4092 143.706 63.3308 143.708L60.8499 143.779C60.7667 143.779 60.687 143.811 60.6267 143.866L57.0786 147.048C57.0218 147.104 56.9823 147.175 56.9644 147.252L56.6801 149.194C56.6674 149.271 56.6828 149.35 56.7237 149.418C56.7646 149.485 56.8284 149.536 56.9037 149.562L65.589 152.72C65.6497 152.744 65.716 152.751 65.7801 152.74C65.8443 152.728 65.9038 152.7 65.9517 152.657C66.0061 152.602 66.043 152.533 66.0577 152.457ZM63.0822 144.402L65.1872 151.845L57.4136 149.019L57.6438 147.475L61.0336 144.462L63.0822 144.402Z" fill="#454545" />
<path d="M73.8776 59.2742L68.5595 58.375L64.808 62.2459L64.0142 56.9138L59.1738 54.5463L64.0001 52.1437L64.7589 46.8115L68.5384 50.6543L73.8425 49.727L71.3485 54.5112L73.8776 59.2742Z" fill="#D3CECB" />
<path d="M31.6856 197.783L25.1642 196.68L24.9049 196.636L24.7218 196.825L20.1199 201.574L19.146 195.033L19.1073 194.773L18.8712 194.657L12.9346 191.753L18.8544 188.806L19.0896 188.689L19.1266 188.429L20.0574 181.888L24.6946 186.603L24.8786 186.79L25.1372 186.745L31.6422 185.607L28.5826 191.477L28.461 191.71L28.5844 191.942L31.6856 197.783Z" stroke="#D3CECB" />
<path d="M245.799 173.629L238.276 172.357L232.97 177.832L231.847 170.29L225 166.941L231.827 163.542L232.9 156L238.246 161.436L245.749 160.124L242.221 166.891L245.799 173.629Z" fill="#BAB5B1" />
<path d="M220.924 155.81L219.388 153.342L219.249 153.119L218.986 153.107L216.081 152.974L217.952 150.748L218.121 150.547L218.051 150.293L217.279 147.493L219.973 148.584L220.217 148.683L220.436 148.538L222.862 146.939L222.66 149.84L222.642 150.102L222.847 150.266L225.116 152.077L222.291 152.779L222.036 152.842L221.943 153.089L220.924 155.81Z" stroke="#D3CECB" />
<path d="M46.3063 94.4625L41.8463 90.7153L41.665 90.5631L41.4369 90.6262L35.8235 92.1786L38.0027 86.777L38.0912 86.5576L37.9606 86.3603L34.7482 81.5065L40.5584 81.9101L40.7943 81.9264L40.9416 81.7415L44.5671 77.1896L45.9847 82.8389L46.0422 83.068L46.2635 83.1509L51.7106 85.1897L46.7684 88.2814L46.5677 88.407L46.5575 88.6435L46.3063 94.4625Z" stroke="#D3CECB" strokeWidth="0.9" />
</svg>
);
export default Empty;

View File

@@ -1,44 +0,0 @@
<svg width="246" height="204" viewBox="0 0 246 204" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.9664 80.7056C1.9299 101.534 -5.31519 125.861 4.55784 148.18C22.5467 188.845 120.573 177.416 163.26 161.447C283.487 116.471 225.675 -1.54332 95.6636 27.6382C59.2626 35.8086 30.003 59.877 15.9664 80.7056Z" fill="#E1DDDB" fill-opacity="0.3"/>
<path d="M95.6307 23.4976C101.102 13.3476 109.932 6.31365 123.036 10.536C124.191 11.0005 125.403 11.311 126.639 11.4597C127.895 11.386 129.146 11.2369 130.384 11.0131C136.292 10.5259 140.656 16.2505 143.529 21.427C148.867 31.0796 158.622 44.1833 156.724 55.7847C155.252 64.7776 146.797 71.0097 138.261 74.1562C128.636 77.7063 118.244 78.655 108.136 76.9069C103.061 76.034 97.813 74.3288 94.159 70.6443C89.084 65.5084 88.069 57.6016 88.4547 50.3443C88.9926 42.2345 91.1749 31.8104 95.6307 23.4976Z" fill="white"/>
<path d="M117.991 78.2569C114.66 78.2567 111.336 77.9715 108.054 77.4043C101.649 76.3081 97.0006 74.2172 93.8439 71.0098C89.4084 66.554 87.4393 59.652 87.9772 50.3952C88.5558 40.7933 91.2557 30.6534 95.2142 23.2845C99.8528 14.657 108.46 5.35964 123.137 10.0895L123.837 10.3331C124.73 10.6795 125.665 10.9015 126.618 10.9929C127.348 10.9968 128.077 10.9219 128.79 10.7696C129.278 10.6884 129.805 10.597 130.303 10.5564C135.165 10.1403 139.752 13.7334 143.894 21.2038C144.848 22.9293 145.924 24.7563 147.091 26.6949C152.278 35.3732 158.733 46.1728 157.139 55.866C155.871 63.712 148.877 70.7155 138.392 74.5928C131.864 77.0187 124.955 78.2596 117.991 78.2569ZM96.0972 23.721C92.1996 30.9782 89.5302 40.976 88.9922 50.4459C88.4543 59.3982 90.332 66.0972 94.5544 70.3602C97.5994 73.4053 102.076 75.4352 108.277 76.4502C118.3 78.1754 128.602 77.223 138.139 73.6894C148.289 69.9238 155.079 63.1842 156.307 55.6732C157.84 46.325 151.486 35.6777 146.38 27.1212C145.223 25.1826 144.127 23.3556 143.163 21.6199C139.214 14.5149 134.941 11.0741 130.465 11.4699C129.988 11.4699 129.521 11.5917 129.034 11.6729C128.218 11.8545 127.384 11.9464 126.547 11.947C125.503 11.8549 124.477 11.6122 123.502 11.2263L122.812 10.9929C108.744 6.45585 100.482 15.4284 95.9957 23.7413L96.0972 23.721Z" fill="#454545"/>
<path d="M182.845 167.75C182.845 167.75 178.596 94.152 167.857 84.3978C152.409 70.4314 97.5788 67.5184 86.4443 70.3909C66.7533 75.4659 53.0508 106.22 53.0508 106.22L85.7135 115.985C85.7845 141.248 88.3119 167.75 88.3119 167.75H182.845Z" fill="#03C7E8"/>
<path d="M84.2307 146.058C84.1627 146.053 84.0963 146.035 84.0354 146.004C83.9746 145.973 83.9204 145.93 83.8761 145.878C83.8317 145.827 83.7981 145.766 83.7771 145.702C83.7561 145.637 83.7481 145.568 83.7537 145.5L88.1689 87.6451C88.1741 87.5757 88.1928 87.5081 88.2242 87.446C88.2556 87.3839 88.299 87.3286 88.3518 87.2833C88.4046 87.238 88.4659 87.2037 88.532 87.1821C88.5982 87.1606 88.668 87.1524 88.7373 87.1579C88.8063 87.163 88.8736 87.1819 88.9352 87.2134C88.9967 87.2449 89.0514 87.2884 89.0959 87.3414C89.1403 87.3944 89.1737 87.4557 89.1941 87.5219C89.2144 87.588 89.2213 87.6575 89.2144 87.7263L84.7991 145.581C84.794 145.65 84.7752 145.718 84.7437 145.779C84.7122 145.841 84.6686 145.895 84.6156 145.94C84.5627 145.984 84.5013 146.018 84.4352 146.038C84.3691 146.058 84.2995 146.065 84.2307 146.058Z" fill="#454545"/>
<path d="M114.163 90.7217C117.828 95.279 123.298 98.527 129.196 98.6184C129.959 98.6607 130.724 98.5574 131.449 98.3139C132.442 97.8894 133.288 97.1838 133.885 96.2839C136.544 92.6299 136.656 87.7071 135.986 83.2411C135.509 80.1048 134.595 76.8162 132.22 74.7151C129.429 72.2487 125.379 72.0152 121.654 71.9137C117.929 71.8122 109.291 70.7262 107.911 75.314C106.622 79.5059 111.707 87.6665 114.163 90.7217Z" fill="#454545"/>
<path d="M129.42 99.0646H129.187C123.553 98.9732 117.809 95.9587 113.82 91.0055C111.17 87.7169 106.146 79.536 107.476 75.1816C108.805 70.8273 115.9 71.1216 120.123 71.3855L121.676 71.4566C125.786 71.5784 129.704 71.8829 132.516 74.3696C135.327 76.8564 136.028 80.5307 136.424 83.1697C137.286 88.874 136.556 93.3197 134.252 96.5372C133.611 97.5159 132.692 98.2797 131.613 98.7296C130.908 98.9745 130.165 99.0881 129.42 99.0646ZM116.662 72.1671C113.089 72.1671 109.181 72.6847 108.339 75.4455C107.11 79.4142 112.064 87.4022 114.51 90.4472C118.336 95.1974 123.827 98.08 129.197 98.1714C129.908 98.2085 130.62 98.1157 131.298 97.8973C132.211 97.5007 132.988 96.8442 133.531 96.0094C135.683 93.0456 136.363 88.7623 135.561 83.3016C135.185 80.7946 134.404 77.2116 131.947 75.0395C129.491 72.8674 125.604 72.4614 121.666 72.3498C121.209 72.3498 120.65 72.3498 120.102 72.2787C119.067 72.228 117.88 72.1671 116.662 72.1671Z" fill="#454545"/>
<path d="M137.874 43.808C138.249 42.5429 138.885 41.3706 139.742 40.3671C140.181 39.8733 140.761 39.5266 141.404 39.374C142.047 39.2215 142.721 39.2706 143.335 39.5145C143.762 39.7688 144.12 40.1237 144.379 40.5484C144.637 40.973 144.787 41.4544 144.817 41.9505C144.867 42.9487 144.701 43.9458 144.33 44.8737C143.765 46.6382 142.787 48.2423 141.477 49.5529C140.827 50.204 140.05 50.7154 139.195 51.0558C138.34 51.3961 137.424 51.5582 136.504 51.5321" fill="white"/>
<path d="M136.89 51.9282H136.474C136.367 51.9179 136.268 51.8667 136.198 51.7854C136.128 51.7041 136.093 51.5989 136.098 51.4917C136.101 51.4379 136.114 51.3851 136.138 51.3366C136.161 51.2881 136.195 51.245 136.235 51.2098C136.276 51.1747 136.324 51.1482 136.375 51.1321C136.427 51.116 136.481 51.1106 136.535 51.1162C137.403 51.1458 138.268 50.9971 139.076 50.6794C139.884 50.3617 140.619 49.8816 141.234 49.2689C142.496 48.0042 143.436 46.4553 143.975 44.7521C144.329 43.8802 144.488 42.9414 144.442 42.0015C144.417 41.5693 144.287 41.1497 144.062 40.7798C143.837 40.4098 143.525 40.1009 143.152 39.8801C142.615 39.6774 142.028 39.6427 141.47 39.7804C140.911 39.9181 140.408 40.2218 140.026 40.6515C139.218 41.6241 138.614 42.7494 138.25 43.9604C138.232 44.0102 138.204 44.0559 138.168 44.0947C138.132 44.1336 138.089 44.1648 138.041 44.1866C137.992 44.2083 137.94 44.2202 137.887 44.2214C137.834 44.2227 137.782 44.2133 137.732 44.1939C137.632 44.1572 137.55 44.0822 137.504 43.9852C137.458 43.8883 137.453 43.7772 137.489 43.6762C137.886 42.3539 138.557 41.1298 139.458 40.0831C139.958 39.5304 140.615 39.1452 141.342 38.9799C142.069 38.8146 142.828 38.8773 143.518 39.1595C144.012 39.44 144.429 39.8394 144.73 40.3214C145.031 40.8034 145.208 41.353 145.243 41.9203C145.299 42.9602 145.126 43.9997 144.736 44.9653C144.16 46.8097 143.143 48.4856 141.772 49.8474C140.468 51.1359 138.723 51.8799 136.89 51.9282Z" fill="#454545"/>
<path d="M130.902 61.4082L132.475 77.0595C132.475 77.0595 131.714 85.961 121.929 84.1645C119.055 83.6096 116.34 82.4241 113.979 80.6936C111.619 78.963 109.671 76.7304 108.277 74.1566L108.643 46.0715C108.582 44.9246 110.967 40.9965 109.84 31.0698C113.195 30.7268 116.441 29.6886 119.371 28.0214C122.302 26.3542 124.853 24.0946 126.862 21.3867C126.862 21.3867 131.399 26.9895 133.307 28.7252C134.839 30.0073 136.203 31.477 137.367 33.0998C140.412 37.6673 140.28 45.1174 139.549 50.3548C138.839 54.6584 136.342 61.5198 130.902 61.4082Z" fill="white"/>
<path d="M124.375 84.8953C123.527 84.8897 122.681 84.8082 121.847 84.6517C118.893 84.0752 116.102 82.8527 113.676 81.0717C111.249 79.2906 109.246 76.9951 107.81 74.3495C107.795 74.2826 107.795 74.2133 107.81 74.1465C107.81 74.1465 108.175 46.2441 108.175 46.0716C108.218 45.5697 108.323 45.0751 108.49 44.5998C109.058 42.651 110.226 38.591 109.383 31.1206C109.377 31.0608 109.382 31.0004 109.4 30.9428C109.417 30.8852 109.446 30.8318 109.485 30.7857C109.519 30.736 109.565 30.6948 109.617 30.6649C109.67 30.6351 109.729 30.6174 109.789 30.6131C113.072 30.2679 116.248 29.2475 119.118 27.6161C121.988 25.9847 124.49 23.7774 126.466 21.133C126.507 21.0737 126.562 21.0246 126.625 20.9894C126.688 20.9542 126.759 20.9339 126.831 20.93C126.904 20.9272 126.977 20.9414 127.044 20.9713C127.111 21.0012 127.17 21.0461 127.217 21.1026C127.217 21.1026 131.754 26.6952 133.621 28.3801C135.158 29.6918 136.522 31.1924 137.681 32.8461C141.193 38.1647 140.422 46.8836 139.935 50.3853C139.346 54.5773 136.89 61.5503 131.439 61.865L132.952 77.0189C132.709 79.4527 131.521 81.6941 129.643 83.2612C128.121 84.3844 126.265 84.9603 124.375 84.8953ZM108.693 74.045C110.072 76.5357 111.98 78.6941 114.282 80.3687C116.585 82.0433 119.226 83.1935 122.02 83.7382C124.943 84.266 127.308 83.86 129.054 82.5304C130.698 81.131 131.753 79.1626 132.008 77.0189L130.444 61.459C130.439 61.393 130.447 61.3266 130.468 61.2637C130.489 61.2009 130.522 61.143 130.566 61.0936C130.611 61.0479 130.665 61.0118 130.724 60.9874C130.784 60.963 130.847 60.9508 130.911 60.9515H131.033C136.108 60.9515 138.433 54.2423 138.991 50.2534C139.478 46.8531 140.229 38.4185 136.89 33.3638C135.757 31.7708 134.431 30.3252 132.941 29.0602C130.797 26.8597 128.764 24.5527 126.851 22.148C122.766 27.2984 116.828 30.6462 110.307 31.4759C111.068 38.8346 109.901 42.8845 109.292 44.8536C109.159 45.2422 109.067 45.6436 109.018 46.0513C109.018 46.0513 109.058 46.0817 108.693 74.045Z" fill="#454545"/>
<path d="M131.438 66.5538C128.868 67.0035 126.223 66.7221 123.805 65.7418C118.233 63.7828 117.908 59.9766 117.908 59.9766C117.908 59.9766 122.597 62.9201 130.9 61.3773L131.438 66.5538Z" fill="#454545"/>
<path d="M129.176 67.2139C127.292 67.1821 125.427 66.8391 123.655 66.1989C117.879 64.1689 117.483 60.2206 117.463 60.1089C117.455 60.025 117.471 59.9405 117.508 59.8652C117.546 59.7899 117.605 59.7268 117.677 59.6833C117.749 59.6397 117.832 59.6175 117.916 59.6192C118.001 59.6209 118.083 59.6464 118.153 59.6928C118.153 59.6928 122.812 62.5246 130.82 61.0326C130.945 61.0116 131.073 61.0408 131.176 61.1138C131.226 61.15 131.268 61.1971 131.298 61.2516C131.327 61.306 131.345 61.3664 131.348 61.4284L131.876 66.5034C131.898 66.5877 131.895 66.6764 131.868 66.7591C131.841 66.8418 131.791 66.9151 131.724 66.9703C131.68 67.0105 131.629 67.0411 131.573 67.0603C131.517 67.0795 131.458 67.0869 131.399 67.082C130.664 67.1971 129.92 67.2413 129.176 67.2139ZM118.59 60.86C119.036 62.0273 120.315 64.0674 123.959 65.3463C126.186 66.196 128.589 66.4786 130.952 66.1685L130.516 61.9359C124.406 62.9611 120.315 61.6822 118.59 60.86Z" fill="#454545"/>
<path d="M124.061 36.0742C124.061 36.0742 129.846 43.0879 128.78 44.519C127.715 45.9502 124.284 45.4122 124.284 45.4122" fill="white"/>
<path d="M125.714 45.9592C125.211 45.9586 124.71 45.9247 124.212 45.8577C124.106 45.8253 124.017 45.7561 123.958 45.6627C123.9 45.5693 123.878 45.4581 123.895 45.3494C123.912 45.2408 123.968 45.142 124.052 45.0713C124.137 45.0006 124.244 44.9627 124.354 44.9645C125.237 45.1066 127.683 45.2385 128.414 44.2438C128.87 43.6348 126.668 39.9504 123.694 36.3573C123.657 36.312 123.628 36.2598 123.611 36.2036C123.594 36.1475 123.588 36.0885 123.594 36.03C123.6 35.9716 123.617 35.9148 123.644 35.863C123.672 35.8112 123.709 35.7653 123.755 35.728C123.849 35.6544 123.968 35.6197 124.087 35.631C124.206 35.6423 124.316 35.6988 124.394 35.7889C126.201 37.9813 130.281 43.239 129.134 44.7818C128.414 45.7562 126.881 45.9592 125.714 45.9592Z" fill="#454545"/>
<path d="M131.302 37.6836C131.774 37.6204 132.06 36.8441 131.941 35.9495C131.821 35.055 131.341 34.381 130.869 34.4442C130.397 34.5074 130.111 35.2838 130.23 36.1783C130.35 37.0729 130.83 37.7468 131.302 37.6836Z" fill="#454545"/>
<path d="M117.067 37.7043C117.543 37.6987 117.921 36.9626 117.911 36.0602C117.9 35.1577 117.505 34.4306 117.029 34.4362C116.552 34.4418 116.175 35.1779 116.185 36.0803C116.196 36.9828 116.59 37.7099 117.067 37.7043Z" fill="#454545"/>
<path d="M111.798 41.8686C111.449 40.5288 110.853 39.2657 110.042 38.1436C109.575 37.5067 108.964 36.9903 108.258 36.6373C107.551 36.2842 106.771 36.1047 105.982 36.1136C105.494 36.1326 105.016 36.2622 104.586 36.4925C104.155 36.7228 103.782 37.0479 103.495 37.4432C103.153 38.0128 102.938 38.6501 102.866 39.3108C102.348 42.7923 103.515 47.3293 106.794 49.1259C107.347 49.4737 107.988 49.6582 108.641 49.6582C109.295 49.6582 109.935 49.4737 110.488 49.1259" fill="white"/>
<path d="M108.865 50.0604C108.072 50.0422 107.296 49.8333 106.601 49.4514C103.171 47.5635 101.932 42.8844 102.47 39.2202C102.547 38.5008 102.783 37.8074 103.161 37.1902C103.481 36.7428 103.898 36.3741 104.382 36.1121C104.865 35.8501 105.402 35.7016 105.952 35.6779C106.81 35.6717 107.657 35.8702 108.423 36.2567C109.189 36.6433 109.852 37.2069 110.357 37.9007C111.203 39.0603 111.823 40.3687 112.184 41.7577C112.212 41.86 112.2 41.9693 112.148 42.0623C112.097 42.1552 112.012 42.2244 111.91 42.2551C111.86 42.2722 111.807 42.2789 111.754 42.2747C111.702 42.2706 111.651 42.2557 111.604 42.2309C111.557 42.2062 111.516 42.1721 111.483 42.1309C111.45 42.0897 111.426 42.0421 111.413 41.9912C111.077 40.6998 110.506 39.4817 109.728 38.3981C109.306 37.8037 108.747 37.3198 108.099 36.9876C107.45 36.6554 106.731 36.4847 106.003 36.4899C105.578 36.5057 105.162 36.617 104.786 36.8156C104.41 37.0141 104.083 37.2948 103.83 37.6368C103.52 38.1565 103.329 38.7391 103.272 39.342C102.765 42.722 103.881 47.0357 106.987 48.7409C107.466 49.0597 108.025 49.2365 108.599 49.2509C109.174 49.2653 109.741 49.1166 110.235 48.8221C110.321 48.7589 110.428 48.7311 110.534 48.7443C110.639 48.7575 110.736 48.8108 110.804 48.8932C110.869 48.9781 110.899 49.0855 110.885 49.192C110.872 49.2986 110.817 49.3955 110.732 49.4616C110.193 49.8617 109.536 50.0722 108.865 50.0604Z" fill="#454545"/>
<path d="M166.029 161.964C165.906 161.964 165.786 161.92 165.691 161.84C165.597 161.76 165.533 161.65 165.512 161.528C162.03 139.797 155.362 98.3744 155.057 97.1158C155.023 96.9813 155.043 96.8387 155.114 96.719C155.184 96.5994 155.299 96.5122 155.433 96.4764C155.568 96.4454 155.711 96.4683 155.83 96.5402C155.95 96.6121 156.036 96.7275 156.072 96.8621C156.468 98.4759 166.131 158.777 166.547 161.355C166.567 161.492 166.533 161.632 166.451 161.744C166.37 161.856 166.247 161.932 166.11 161.954L166.029 161.964Z" fill="#454545"/>
<path d="M103.112 184.842L102.977 184.655H102.747H55.0401C50.0527 184.655 45.9305 180.138 45.9305 174.453V116.416C45.9305 110.737 50.0532 106.208 55.0401 106.208H181.016C186.003 106.208 190.134 110.738 190.165 116.419V174.453C190.165 180.138 186.037 184.655 181.049 184.655H131.929H131.726L131.592 184.807L115.869 202.609L103.112 184.842Z" fill="white" stroke="#454545" stroke-width="0.9"/>
<path d="M126.486 55.034C118.985 55.034 116.59 45.1377 116.488 44.7114C116.464 44.5944 116.486 44.4726 116.551 44.372C116.615 44.2715 116.717 44.2002 116.833 44.1735C116.89 44.1599 116.95 44.1579 117.008 44.1674C117.066 44.1769 117.122 44.1979 117.172 44.229C117.222 44.2601 117.265 44.3009 117.299 44.3488C117.334 44.3968 117.358 44.451 117.371 44.5084C117.371 44.5998 119.695 54.1408 126.506 54.1408C127.212 54.168 127.916 54.0394 128.567 53.764C129.217 53.4885 129.8 53.0731 130.272 52.5472C132.616 49.8778 131.987 44.7013 131.977 44.6505C131.968 44.5918 131.972 44.532 131.986 44.4745C132.001 44.417 132.027 44.3631 132.063 44.3158C132.099 44.2686 132.144 44.229 132.196 44.1993C132.247 44.1696 132.304 44.1505 132.363 44.143C132.48 44.131 132.598 44.1642 132.692 44.2358C132.787 44.3074 132.85 44.4121 132.87 44.5287C132.87 44.7622 133.57 50.162 130.952 53.1359C130.395 53.7634 129.705 54.2592 128.932 54.5875C128.16 54.9157 127.324 55.0682 126.486 55.034Z" fill="#454545"/>
<path d="M70.1771 103.176C73.7637 101.377 78.12 99.9134 81.6644 101.817C83.3455 102.866 84.7632 104.287 85.8078 105.971C88.0669 109.136 90.1082 112.451 91.9175 115.893C92.3914 116.848 92.7954 118.019 92.0581 118.706C91.16 119.551 89.7377 118.638 88.9482 117.701C87.5236 116.028 86.3296 114.173 85.3981 112.183C87.2805 114.936 88.7735 117.936 89.8348 121.097C90.0177 121.471 90.0817 121.891 90.0181 122.302C89.8722 122.667 89.588 122.96 89.2274 123.116C88.8667 123.273 88.4587 123.281 88.0922 123.139C87.3497 122.858 86.7046 122.367 86.2347 121.728C84.1638 119.239 82.3741 116.53 80.8983 113.649C82.3828 116.657 83.8796 119.745 84.2431 123.078C84.3088 123.353 84.3162 123.639 84.2649 123.917C84.2135 124.196 84.1045 124.46 83.9449 124.694C83.7065 124.904 83.4062 125.031 83.0894 125.055C82.7726 125.079 82.4565 124.999 82.1891 124.828C81.6793 124.464 81.2568 123.992 80.952 123.445L75.6783 115.778C77.0141 118.725 77.9793 121.827 78.5518 125.012C78.6234 125.274 78.623 125.551 78.5505 125.813C78.2358 126.713 76.8342 126.341 76.1662 125.662C75.1107 124.468 74.2905 123.084 73.7489 121.585C72.0643 117.734 70.4974 113.712 68.6538 109.896C67.1293 106.761 66.4761 105.033 70.1771 103.176Z" fill="white"/>
<path d="M77.958 126.765L78.0684 126.749C78.2758 126.704 78.4681 126.606 78.6266 126.465C78.785 126.324 78.9042 126.144 78.9726 125.944C79.0614 125.607 79.0675 125.254 78.9905 124.914C78.6521 123.052 78.1839 121.216 77.5894 119.419L80.5096 123.697C80.8645 124.309 81.3549 124.832 81.9432 125.225C82.3005 125.453 82.7244 125.554 83.1464 125.512C83.5684 125.471 83.964 125.288 84.2695 124.994C84.4767 124.712 84.621 124.389 84.6929 124.047C84.7648 123.705 84.7626 123.352 84.6863 123.01C84.5274 121.793 84.252 120.594 83.864 119.429C84.4984 120.359 85.1577 121.183 85.8586 122.011C86.357 122.728 87.0556 123.282 87.867 123.604C88.1454 123.691 88.4383 123.721 88.7284 123.693C89.0186 123.665 89.3002 123.578 89.5565 123.44C89.7497 123.337 89.9205 123.198 90.0587 123.029C90.197 122.859 90.2999 122.664 90.3614 122.455C90.4574 121.958 90.4043 121.445 90.2087 120.979C89.9753 120.255 89.7117 119.535 89.4297 118.828C89.9032 119.214 90.4782 119.455 91.0854 119.521C91.3167 119.539 91.5492 119.507 91.7672 119.428C91.9853 119.348 92.184 119.224 92.3501 119.062C92.871 118.572 93.2855 117.585 92.2992 115.692C90.4856 112.233 88.4385 108.901 86.1719 105.721C85.0837 103.975 83.6092 102.503 81.8619 101.418C78.8572 99.8217 74.971 100.259 69.9541 102.778C65.9002 104.812 66.657 106.884 68.2137 110.097C69.4397 112.62 70.5521 115.275 71.623 117.791C72.1819 119.102 72.7308 120.415 73.2481 121.722C73.8155 123.272 74.6668 124.702 75.7585 125.94C76.0393 126.234 76.3829 126.461 76.7638 126.604C77.1447 126.747 77.5529 126.802 77.958 126.765ZM75.6082 115.306C75.5536 115.304 75.4993 115.313 75.4476 115.33C75.3469 115.387 75.271 115.48 75.2346 115.59C75.1982 115.7 75.2039 115.819 75.2507 115.925C76.5744 118.833 77.527 121.896 78.0858 125.042C78.1393 125.224 78.1475 125.416 78.1096 125.603C78.0928 125.657 78.061 125.706 78.0176 125.744C77.9743 125.781 77.9213 125.806 77.8647 125.814C77.6113 125.827 77.3581 125.786 77.1214 125.695C76.8847 125.604 76.6697 125.464 76.4903 125.284C75.481 124.129 74.6936 122.798 74.1677 121.357C73.6003 120.057 73.0514 118.744 72.5442 117.435C71.4641 114.858 70.3387 112.186 69.0996 109.643C67.6013 106.545 67.1227 105.222 70.4321 103.558C75.1432 101.188 78.7608 100.74 81.5158 102.179C83.1359 103.197 84.5009 104.573 85.5059 106.202C87.7534 109.348 89.7816 112.644 91.5763 116.068C91.8963 116.687 92.3545 117.808 91.808 118.333C91.7304 118.406 91.6381 118.462 91.5372 118.497C91.4363 118.532 91.3291 118.545 91.2228 118.535C90.4804 118.382 89.8188 117.964 89.3603 117.361C88.9613 116.898 88.5854 116.452 88.2259 115.911C87.5179 114.519 86.7231 113.172 85.8463 111.879C85.7786 111.784 85.6785 111.717 85.5648 111.691C85.451 111.665 85.3317 111.681 85.2292 111.737C85.1284 111.796 85.0535 111.891 85.0192 112.002C84.9848 112.114 84.9936 112.234 85.0438 112.34C85.7112 113.761 86.5088 115.118 87.4264 116.392C88.209 117.951 88.8768 119.565 89.4243 121.221C89.5572 121.517 89.6069 121.843 89.5678 122.165C89.5075 122.344 89.3789 122.493 89.2099 122.579C89.0614 122.67 88.8961 122.731 88.7237 122.757C88.5513 122.783 88.3754 122.774 88.2064 122.731C87.5704 122.454 87.0251 122.003 86.6327 121.431C84.5887 118.966 82.8207 116.284 81.3603 113.434C81.2978 113.338 81.2021 113.269 81.0916 113.239C80.981 113.209 80.8633 113.22 80.7607 113.271C80.6582 113.322 80.578 113.41 80.5355 113.516C80.493 113.622 80.491 113.741 80.53 113.848C81.9552 116.804 83.4258 119.855 83.8218 123.132C83.8773 123.341 83.89 123.56 83.8592 123.774C83.8284 123.989 83.7546 124.195 83.6423 124.381C83.4755 124.509 83.2729 124.582 83.0626 124.59C82.8523 124.598 82.6447 124.541 82.4685 124.426C82.0062 124.101 81.6199 123.68 81.3361 123.192L76.0639 115.534C76.0203 115.454 75.9533 115.389 75.8718 115.349C75.7904 115.308 75.6984 115.293 75.6082 115.306Z" fill="#454545"/>
<path d="M207.372 99.7322C207.43 99.7275 207.486 99.7122 207.538 99.6872C215.214 94.2396 220.02 88.2106 221.846 81.7353C229.021 77.1588 232.984 70.6532 232.606 63.8795C231.975 51.8119 217.685 42.7708 200.833 43.6732C183.98 44.6076 170.809 55.1665 171.449 67.2315C172.089 79.2964 186.367 88.3304 203.184 87.4163C205.507 87.2898 207.816 86.9722 210.087 86.4668C209.922 90.8412 208.779 95.1232 206.742 98.998C206.69 99.085 206.665 99.1856 206.671 99.2869C206.677 99.3882 206.712 99.4854 206.774 99.5662C206.835 99.6469 206.919 99.7074 207.016 99.7398C207.112 99.7722 207.215 99.7751 207.313 99.7481L207.372 99.7322ZM200.922 44.7007C217.2 43.8164 230.955 52.4343 231.59 63.933C231.952 70.3746 228.141 76.5868 221.138 80.9804C221.033 81.051 220.954 81.1535 220.912 81.2727C219.332 87.1032 215.203 92.6024 208.622 97.6282C210.263 93.9118 211.129 89.9003 211.169 85.8383C211.169 85.7615 211.151 85.6857 211.117 85.6166C211.084 85.5475 211.035 85.4869 210.975 85.4395C210.914 85.392 210.844 85.3589 210.769 85.3426C210.694 85.3263 210.616 85.3272 210.541 85.3453C208.13 85.9195 205.672 86.2772 203.197 86.4139C186.902 87.3133 173.124 78.6909 172.49 67.1922C171.855 55.6935 184.6 45.6178 200.892 44.7086L200.922 44.7007Z" fill="#D3CECB"/>
<path d="M75.9248 170.151C75.6912 170.236 75.5706 170.494 75.6553 170.728C75.7401 170.961 75.9982 171.082 76.2319 170.997L75.9248 170.151ZM105.694 146.36L105.325 146.103L105.694 146.36ZM108.515 125.736L108.549 126.184L108.515 125.736ZM96.4601 134.933L96.8292 135.19L96.4601 134.933ZM151.042 128.024L151.44 128.233L151.042 128.024ZM149.712 113.888L149.745 114.337L149.712 113.888ZM133.632 131.3L133.202 131.17L133.202 131.17L133.632 131.3ZM133.617 131.352L134.047 131.482L134.047 131.482L133.617 131.352ZM157.054 147.878C157.291 147.802 157.421 147.548 157.345 147.312C157.268 147.075 157.015 146.945 156.778 147.021L157.054 147.878ZM133.202 131.17L133.186 131.221L134.047 131.482L134.063 131.431L133.202 131.17ZM136.528 147.943C142.241 142.272 147.672 135.394 151.44 128.233L150.643 127.814C146.927 134.878 141.557 141.683 135.893 147.305L136.528 147.943ZM151.44 128.233C152.08 127.017 153.347 123.307 153.658 119.949C153.814 118.276 153.74 116.614 153.172 115.382C152.884 114.756 152.461 114.228 151.866 113.877C151.271 113.526 150.542 113.375 149.678 113.44L149.745 114.337C150.465 114.283 151.003 114.413 151.409 114.652C151.815 114.892 152.126 115.263 152.355 115.759C152.821 116.77 152.913 118.236 152.762 119.866C152.461 123.114 151.225 126.708 150.643 127.814L151.44 128.233ZM149.678 113.44C145.534 113.75 141.938 116.475 139.139 119.927C136.333 123.388 134.271 127.646 133.202 131.17L134.063 131.431C135.102 128.005 137.114 123.854 139.838 120.494C142.568 117.126 145.959 114.62 149.745 114.337L149.678 113.44ZM133.186 131.221C132.448 133.654 131.751 137 131.92 140.149C132.09 143.295 133.133 146.344 135.983 148.012L136.438 147.236C133.964 145.788 132.981 143.108 132.819 140.101C132.657 137.097 133.325 133.865 134.047 131.482L133.186 131.221ZM96.091 134.675C93.9783 137.703 91.552 142.707 90.6804 147.575C89.8122 152.425 90.4654 157.33 94.7866 159.86L95.2412 159.083C91.4248 156.849 90.7184 152.47 91.5663 147.734C92.411 143.016 94.7766 138.132 96.8292 135.19L96.091 134.675ZM108.481 125.287C105.71 125.494 103.304 126.859 101.256 128.647C99.2081 130.434 97.4895 132.671 96.091 134.675L96.8292 135.19C98.2148 133.204 99.8836 131.039 101.848 129.325C103.811 127.611 106.04 126.372 108.549 126.184L108.481 125.287ZM106.063 146.617C107.294 144.848 110.281 139.483 111.829 134.629C112.599 132.216 113.042 129.845 112.667 128.091C112.477 127.198 112.067 126.431 111.349 125.919C110.635 125.41 109.678 125.197 108.481 125.287L108.549 126.184C109.611 126.105 110.337 126.302 110.827 126.652C111.313 126.999 111.63 127.541 111.787 128.279C112.109 129.786 111.735 131.961 110.972 134.355C109.454 139.114 106.511 144.399 105.325 146.103L106.063 146.617ZM95.3309 159.791C99.3824 155.769 102.824 151.27 106.063 146.617L105.325 146.103C102.099 150.737 98.6929 155.185 94.6969 159.152L95.3309 159.791ZM76.2319 170.997C83.5488 168.342 89.6767 165.404 95.3309 159.791L94.6969 159.152C89.1766 164.632 83.1888 167.515 75.9248 170.151L76.2319 170.997ZM94.7866 159.86C101.288 163.666 109.486 162.902 117.124 160.117C124.773 157.327 131.961 152.476 136.528 147.943L135.893 147.305C131.416 151.749 124.338 156.528 116.816 159.271C109.282 162.019 101.403 162.69 95.2412 159.083L94.7866 159.86ZM135.983 148.012C139.071 149.82 142.811 150.274 146.52 150.039C150.233 149.803 153.958 148.876 157.054 147.878L156.778 147.021C153.72 148.007 150.074 148.912 146.463 149.14C142.848 149.369 139.312 148.918 136.438 147.236L135.983 148.012Z" fill="#D7D3D1"/>
<path d="M34.1208 1.67272L34.1208 1.67271C39.9029 0.930545 45.3366 1.92561 49.4422 4.11327C53.5488 6.30152 56.2929 9.65956 56.8045 13.6458C57.3161 17.632 55.5083 21.5751 52.0884 24.7305C48.6694 27.8851 43.6661 30.2212 37.8913 30.9639L37.8912 30.9639C36.1449 31.1889 34.3819 31.2561 32.6236 31.1648L32.0474 31.1349L32.1587 31.7011C32.6982 34.4435 33.7686 37.0476 35.3035 39.3693C29.2428 36.2215 26.3184 32.6297 24.9143 29.7128L24.8379 29.5541L24.6742 29.4894C19.3949 27.4043 15.8095 23.6168 15.215 18.9913L15.2149 18.991C14.6999 15.0042 16.5008 11.0607 19.9169 7.90532C23.3321 4.7507 28.3352 2.4146 34.1208 1.67272Z" stroke="#D3CECB" stroke-width="0.9"/>
<path d="M61.0231 135.858L48.305 133.863L49.2502 146.414L57.0239 149.239L65.7092 152.397L63.3431 144.054L61.0231 135.858Z" fill="white"/>
<path d="M66.0579 152.457C66.0648 152.408 66.0613 152.357 66.0477 152.309L61.3616 135.77C61.3421 135.706 61.305 135.649 61.2544 135.604C61.2038 135.56 61.1417 135.529 61.075 135.517L48.3569 133.522C48.3039 133.516 48.2503 133.521 48.1995 133.537C48.1487 133.552 48.1018 133.578 48.0616 133.612C48.0239 133.647 47.9944 133.69 47.9751 133.737C47.9558 133.784 47.9471 133.835 47.9496 133.887L48.9053 146.418C48.9097 146.484 48.9336 146.547 48.9742 146.6C49.0148 146.653 49.0702 146.693 49.1337 146.716L65.5927 152.7C65.6534 152.724 65.7197 152.73 65.7838 152.719C65.848 152.708 65.9075 152.679 65.9554 152.636C66.0052 152.587 66.0406 152.525 66.0579 152.457ZM60.7765 136.167L65.2501 151.856L49.6494 146.181L48.7496 134.284L60.7765 136.167Z" fill="#454545"/>
<path d="M55.3166 137.493L51.3945 141.013L15.4608 102.75L19.3828 99.2312L55.3166 137.493Z" fill="white"/>
<path d="M55.6719 137.552L55.6801 137.504C55.6838 137.459 55.6774 137.413 55.6612 137.37C55.645 137.327 55.6195 137.289 55.5863 137.256L19.6493 98.9922C19.6177 98.9586 19.5797 98.9313 19.5375 98.912C19.4953 98.8927 19.4496 98.8817 19.4032 98.8797C19.3104 98.8779 19.2205 98.9099 19.1509 98.9694L15.2269 102.5C15.1925 102.529 15.1645 102.565 15.1448 102.606C15.1251 102.646 15.1141 102.69 15.1124 102.735C15.1107 102.78 15.1183 102.825 15.1349 102.867C15.1515 102.909 15.1766 102.948 15.2087 102.98L51.1388 141.243C51.2014 141.311 51.2888 141.353 51.382 141.358C51.4752 141.364 51.5665 141.333 51.6361 141.273L55.56 137.743C55.6186 137.693 55.6581 137.626 55.6719 137.552ZM19.3681 99.7079L54.811 137.466L51.4142 140.519L15.9632 102.767L19.3681 99.7079Z" fill="#454545"/>
<path d="M15.465 102.744L13.3205 108.151L49.2506 146.414L51.4021 141.008L15.465 102.744Z" fill="#454545"/>
<path d="M51.7505 141.068C51.7591 141.015 51.7552 140.961 51.7392 140.91C51.7231 140.858 51.6954 140.811 51.658 140.772L15.728 102.509C15.6862 102.466 15.6344 102.434 15.5772 102.416C15.52 102.398 15.4592 102.395 15.4004 102.405C15.3415 102.416 15.2864 102.441 15.2401 102.478C15.1938 102.515 15.1578 102.563 15.1352 102.617L12.9907 108.024C12.966 108.084 12.9591 108.15 12.9709 108.214C12.9827 108.278 13.0127 108.337 13.0575 108.386L48.9945 146.65C49.0356 146.693 49.0869 146.724 49.1438 146.742C49.2006 146.761 49.261 146.764 49.3195 146.753C49.3779 146.742 49.4325 146.717 49.4781 146.68C49.5237 146.643 49.5589 146.595 49.5803 146.541L51.7318 141.135L51.7505 141.068ZM15.5906 103.389L50.994 141.085L49.1238 145.775L13.7226 108.108L15.5906 103.389Z" fill="#454545"/>
<path d="M25.0873 97.5939L19.3945 99.228L55.3245 137.491L61.0174 135.857L25.0873 97.5939Z" fill="#454545"/>
<path d="M61.367 135.946C61.3741 135.893 61.3695 135.839 61.3535 135.788C61.3376 135.737 61.3107 135.69 61.2745 135.65L25.3492 97.3593C25.3025 97.3132 25.2443 97.2799 25.1805 97.2627C25.1166 97.2455 25.0494 97.2452 24.9856 97.2616L19.2858 98.8944C19.2279 98.9097 19.1755 98.9401 19.1341 98.9824C19.0927 99.0246 19.0639 99.0771 19.0507 99.1344C19.0349 99.1901 19.034 99.2491 19.0482 99.3055C19.0623 99.362 19.091 99.414 19.1315 99.4567L55.0616 137.72C55.1107 137.764 55.1717 137.793 55.2371 137.805C55.3026 137.816 55.3699 137.809 55.431 137.783L61.1296 136.157C61.1865 136.14 61.2379 136.109 61.279 136.067C61.3201 136.025 61.3496 135.974 61.3647 135.918L61.367 135.946ZM25.01 97.9949L60.4135 135.691L55.4693 137.103L20.0345 99.381L25.01 97.9949Z" fill="#454545"/>
<path d="M63.3443 144.054L60.8645 144.117L57.3153 147.305L57.0251 149.239L65.7104 152.397L63.3443 144.054Z" fill="#03C7E8"/>
<path d="M66.0577 152.457C66.0646 152.408 66.0611 152.357 66.0475 152.308L63.6825 143.958C63.6602 143.885 63.6136 143.82 63.5499 143.775C63.4863 143.73 63.4092 143.706 63.3308 143.708L60.8499 143.779C60.7667 143.779 60.687 143.811 60.6267 143.866L57.0786 147.048C57.0218 147.104 56.9823 147.175 56.9644 147.252L56.6801 149.194C56.6674 149.271 56.6828 149.35 56.7237 149.418C56.7646 149.485 56.8284 149.536 56.9037 149.562L65.589 152.72C65.6497 152.744 65.716 152.751 65.7801 152.74C65.8443 152.728 65.9038 152.7 65.9517 152.657C66.0061 152.602 66.043 152.533 66.0577 152.457ZM63.0822 144.402L65.1872 151.845L57.4136 149.019L57.6438 147.475L61.0336 144.462L63.0822 144.402Z" fill="#454545"/>
<path d="M73.8776 59.2742L68.5595 58.375L64.808 62.2459L64.0142 56.9138L59.1738 54.5463L64.0001 52.1437L64.7589 46.8115L68.5384 50.6543L73.8425 49.727L71.3485 54.5112L73.8776 59.2742Z" fill="#D3CECB"/>
<path d="M31.6856 197.783L25.1642 196.68L24.9049 196.636L24.7218 196.825L20.1199 201.574L19.146 195.033L19.1073 194.773L18.8712 194.657L12.9346 191.753L18.8544 188.806L19.0896 188.689L19.1266 188.429L20.0574 181.888L24.6946 186.603L24.8786 186.79L25.1372 186.745L31.6422 185.607L28.5826 191.477L28.461 191.71L28.5844 191.942L31.6856 197.783Z" stroke="#D3CECB"/>
<path d="M245.799 173.629L238.276 172.357L232.97 177.832L231.847 170.29L225 166.941L231.827 163.542L232.9 156L238.246 161.436L245.749 160.124L242.221 166.891L245.799 173.629Z" fill="#D7D3D1"/>
<path d="M220.924 155.81L219.388 153.342L219.249 153.119L218.986 153.107L216.081 152.974L217.952 150.748L218.121 150.547L218.051 150.293L217.279 147.493L219.973 148.584L220.217 148.683L220.436 148.538L222.862 146.939L222.66 149.84L222.642 150.102L222.847 150.266L225.116 152.077L222.291 152.779L222.036 152.842L221.943 153.089L220.924 155.81Z" stroke="#D3CECB"/>
<path d="M46.3063 94.4625L41.8463 90.7153L41.665 90.5631L41.4369 90.6262L35.8235 92.1786L38.0027 86.777L38.0912 86.5576L37.9606 86.3603L34.7482 81.5065L40.5584 81.9101L40.7943 81.9264L40.9416 81.7415L44.5671 77.1896L45.9847 82.8389L46.0422 83.068L46.2635 83.1509L51.7106 85.1897L46.7684 88.2814L46.5677 88.407L46.5575 88.6435L46.3063 94.4625Z" stroke="#D3CECB" stroke-width="0.9"/>
</svg>

Before

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -19,19 +19,21 @@ 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({
const 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 selectedCohort = useMemo(
() => cohorts.find(cohort => (
toString(cohort.id) === selectedFilters.cohort)),
[selectedFilters.cohort],
);
const allFilters = [
{
@@ -120,28 +122,27 @@ function FilterBar({
<div className="d-flex flex-row py-2 justify-content-between">
{filters.map((value) => (
<Form.RadioSet
key={value.name}
name={value.name}
className="d-flex flex-column list-group list-group-flush"
value={selectedFilters[value.name]}
onChange={onFilterChange}
>
{
value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})
}
{value.filters.map(filterName => {
const element = allFilters.find(obj => obj.id === filterName);
if (element) {
return (
<ActionItem
key={element.id}
id={element.id}
label={element.label}
value={element.value}
selected={selectedFilters[value.name]}
/>
);
}
return false;
})}
</Form.RadioSet>
))}
</div>
@@ -184,7 +185,7 @@ function FilterBar({
</Collapsible.Body>
</Collapsible.Advanced>
);
}
};
FilterBar.propTypes = {
intl: intlShape.isRequired,

View File

@@ -5,31 +5,26 @@ import { getIn, useFormikContext } from 'formik';
import { Form, TransitionReplace } from '@edx/paragon';
function FormikErrorFeedback({ name }) {
const {
touched,
errors,
} = useFormikContext();
const FormikErrorFeedback = ({ name }) => {
const { touched, errors } = useFormikContext();
const fieldTouched = getIn(touched, name);
const fieldError = getIn(errors, name);
return (
<TransitionReplace>
{fieldTouched && fieldError
? (
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}>
{fieldError}
</Form.Control.Feedback>
)
: (
<React.Fragment key={`${name}-no-error-feedback`} />
)}
{fieldTouched && fieldError ? (
<Form.Control.Feedback type="invalid" hasIcon={false} key={`${name}-error-feedback`}>
{fieldError}
</Form.Control.Feedback>
) : (
<React.Fragment key={`${name}-no-error-feedback`} />
)}
</TransitionReplace>
);
}
};
FormikErrorFeedback.propTypes = {
name: PropTypes.string.isRequired,
};
export default FormikErrorFeedback;
export default React.memo(FormikErrorFeedback);

View File

@@ -1,66 +1,66 @@
import React from 'react';
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import MathJax from 'react-mathjax-preview';
import DOMPurify from 'dompurify';
const baseConfig = {
showMathMenu: true,
tex2jax: {
inlineMath: [
['$', '$'],
['\\\\(', '\\\\)'],
['\\(', '\\)'],
['[mathjaxinline]', '[/mathjaxinline]'],
['\\begin{math}', '\\end{math}'],
],
displayMath: [
['[mathjax]', '[/mathjax]'],
['$$', '$$'],
['\\\\[', '\\\\]'],
['\\[', '\\]'],
['\\begin{displaymath}', '\\end{displaymath}'],
['\\begin{equation}', '\\end{equation}'],
],
},
import { logError } from '@edx/frontend-platform/logging';
skipStartupTypeset: true,
import { useDebounce } from '../discussions/data/hooks';
const defaultSanitizeOptions = {
USE_PROFILES: { html: true },
ADD_ATTR: ['columnalign'],
};
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(/(\\\((.+?)\\\))+/);
const HTMLLoader = ({
htmlNode, componentId, cssClassName, testId, delay,
}) => {
const sanitizedMath = DOMPurify.sanitize(htmlNode, { ...defaultSanitizeOptions });
const previewRef = useRef(null);
const debouncedPostContent = useDebounce(htmlNode, delay);
useEffect(() => {
let promise = Promise.resolve(); // Used to hold chain of typesetting calls
function typeset(code) {
promise = promise.then(() => {
if (typeof window?.MathJax !== 'undefined' && typeof window?.MathJax.startup !== 'undefined') {
window.MathJax.startup.defaultPageReady().then((window.MathJax?.typesetPromise(code())));
}
return null;
})
.catch((err) => logError(`Typeset failed: ${err.message}`));
return promise;
}
if (debouncedPostContent) {
typeset(() => {
if (previewRef.current !== null && typeof window?.MathJax !== 'undefined') {
previewRef.current.innerHTML = sanitizedMath;
}
});
}
}, [debouncedPostContent]);
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 }} />
<div ref={previewRef} className={cssClassName} id={componentId} data-testid={testId} />
);
}
};
HTMLLoader.propTypes = {
htmlNode: PropTypes.node,
componentId: PropTypes.string,
cssClassName: PropTypes.string,
testId: PropTypes.string,
delay: PropTypes.number,
};
HTMLLoader.defaultProps = {
htmlNode: '',
componentId: null,
cssClassName: '',
testId: '',
delay: 0,
};
export default HTMLLoader;
export default React.memo(HTMLLoader);

View File

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { fetchTab } from './data/thunks';
import Tabs from './tabs/Tabs';
@@ -12,47 +12,44 @@ import messages from './messages';
import './navBar.scss';
function CourseTabsNavigation({
activeTab, className, intl, courseId, rootSlug,
}) {
const CourseTabsNavigation = ({
activeTab, className, courseId, rootSlug,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
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 id="courseTabsNavigation" className={classNames('course-tabs-navigation px-4', className)}>
{!!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>
);
}
};
CourseTabsNavigation.propTypes = {
activeTab: PropTypes.string,
className: PropTypes.string,
rootSlug: PropTypes.string,
courseId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
};
CourseTabsNavigation.defaultProps = {
@@ -61,4 +58,4 @@ CourseTabsNavigation.defaultProps = {
rootSlug: 'outline',
};
export default injectIntl(CourseTabsNavigation);
export default React.memo(CourseTabsNavigation);

View File

@@ -0,0 +1 @@
import './navigationBar.factory';

View File

@@ -0,0 +1,50 @@
import { Factory } from 'rosie';
import { getApiBaseUrl } from '../../../../data/constants';
Factory.define('navigationBar')
.attr('can_show_upgrade_sock', null, false)
.attr('can_view_certificate', null, false)
.attr('celebrations', null, {
first_section: false, streak_discount_enabled: false, streak_length_to_celebrate: null, weekly_goal: false,
})
.option('hasCourseAccess', null, true)
.attr('course_access', ['hasCourseAccess'], (hasCourseAccess) => ({
additional_context_user_message: null,
developer_message: null,
error_code: null,
has_access: hasCourseAccess,
user_fragment: null,
user_message: null,
}))
.option('course_id', null, 'course-v1:edX+DemoX+Demo_Course')
.attr('is_enrolled', null, false)
.attr('is_self_paced', null, false)
.attr('is_staff', null, true)
.attr('number', null, 'DemoX')
.attr('org', null, 'edX')
.attr('original_user_is_staff', null, true)
.attr('title', null, 'Demonstration Course')
.attr('username', null, 'edx')
.attr('tabs', ['course_id'], (idx, courseId) => [
{
tab_id: 'courseware',
title: 'Course',
url: `${getApiBaseUrl}/course/${courseId}/home`,
},
{
tab_id: 'progress',
title: 'Progress',
url: `${getApiBaseUrl}/course/${courseId}/progress`,
},
{
tab_id: 'discussion',
title: 'Discussion',
url: `${getApiBaseUrl}/course/${courseId}/discussion/forum/`,
},
{
tab_id: 'instructor',
title: 'Instructor',
url: `${getApiBaseUrl}/course/${courseId}/instructor`,
}]);

View File

@@ -2,7 +2,9 @@
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { API_BASE_URL } from '../../../data/constants';
import { getApiBaseUrl } from '../../../data/constants';
export const getCourseMetadataApiUrl = (courseId) => `${getApiBaseUrl()}/api/course_home/course_metadata/${courseId}`;
function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
const data = camelCaseObject(metadata);
@@ -21,7 +23,7 @@ function normalizeCourseHomeCourseMetadata(metadata, rootSlug) {
}
export async function getCourseHomeCourseMetadata(courseId, rootSlug) {
const url = `${API_BASE_URL}/api/course_home/course_metadata/${courseId}`;
const url = getCourseMetadataApiUrl(courseId);
// don't know the context of adding timezone in url. hence omitting it
// url = appendBrowserTimezoneToUrl(url);
const { data } = await getAuthenticatedHttpClient().get(url);

View File

@@ -0,0 +1,67 @@
import MockAdapter from 'axios-mock-adapter';
import { Factory } from 'rosie';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { initializeMockApp } from '@edx/frontend-platform/testing';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { getCourseMetadataApiUrl } from './api';
import { fetchTab } from './thunks';
import './__factories__';
const courseId = 'course-v1:edX+TestX+Test_Course';
let axiosMock = null;
let store;
describe('Navigation bar api tests', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
});
afterEach(() => {
axiosMock.reset();
});
it('Successfully get navigation tabs', async () => {
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1)));
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
expect(store.getState().courseTabs.tabs).toHaveLength(4);
expect(store.getState().courseTabs.courseStatus).toEqual('loaded');
});
it('Failed to get navigation tabs', async () => {
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(404);
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
expect(store.getState().courseTabs.courseStatus).toEqual('failed');
});
it('Denied to get navigation tabs', async () => {
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(403, {});
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
expect(store.getState().courseTabs.courseStatus).toEqual('denied');
});
it('Denied to get navigation bar when user has no access on course', async () => {
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(
200,
(Factory.build('navigationBar', 1, { hasCourseAccess: false })),
);
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
expect(store.getState().courseTabs.courseStatus).toEqual('denied');
});
});

View File

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

View File

@@ -1,6 +1,7 @@
/* eslint-disable import/prefer-default-export, no-unused-expressions */
import { logError } from '@edx/frontend-platform/logging';
import { getHttpErrorStatus } from '../../../discussions/utils';
import { getCourseHomeCourseMetadata } from './api';
import {
fetchTabDenied,
@@ -26,7 +27,11 @@ export function fetchTab(courseId, rootSlug) {
}));
}
} catch (e) {
dispatch(fetchTabFailure({ courseId }));
if (getHttpErrorStatus(e) === 403) {
dispatch(fetchTabDenied({ courseId }));
} else {
dispatch(fetchTabFailure({ courseId }));
}
logError(e);
}
};

View File

@@ -8,7 +8,7 @@ import { Dropdown } from '@edx/paragon';
import useIndexOfLastVisibleChild from './useIndexOfLastVisibleChild';
export default function Tabs({ children, className, ...attrs }) {
const Tabs = ({ children, className, ...attrs }) => {
const [
indexOfLastVisibleChild,
containerElementRef,
@@ -31,25 +31,28 @@ export default function Tabs({ children, className, ...attrs }) {
// 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>
));
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]);
@@ -62,7 +65,7 @@ export default function Tabs({ children, className, ...attrs }) {
{tabChildren}
</nav>
);
}
};
Tabs.propTypes = {
children: PropTypes.node,
@@ -73,3 +76,5 @@ Tabs.defaultProps = {
children: null,
className: undefined,
};
export default Tabs;

View File

@@ -1,16 +1,17 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } 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 PostPreviewPanel = ({
htmlNode, isPost, editExisting,
}) => {
const intl = useIntl();
const [showPreviewPane, setShowPreviewPane] = useState(false);
return (
@@ -28,18 +29,28 @@ function PostPreviewPane({
size="inline"
className="float-right p-3"
iconClassNames="icon-size"
data-testid="hide-preview-button"
/>
<HTMLLoader htmlNode={htmlNode} cssClassName="text-primary" />
{htmlNode && (
<HTMLLoader
htmlNode={htmlNode}
cssClassName="text-primary"
componentId="post-preview"
testId="post-preview"
delay={500}
/>
)}
</div>
)}
<div className="d-flex justify-content-end">
{!showPreviewPane
&& (
{!showPreviewPane && (
<Button
variant="link"
size="md"
size="sm"
onClick={() => setShowPreviewPane(true)}
className={`text-primary-500 px-0 ${editExisting && 'mb-4.5'}`}
className={`text-primary-500 font-style p-0 ${editExisting && 'mb-4.5'}`}
style={{ lineHeight: '26px' }}
data-testid="show-preview-button"
>
{intl.formatMessage(messages.showPreviewButton)}
</Button>
@@ -47,18 +58,18 @@ function PostPreviewPane({
</div>
</>
);
}
};
PostPreviewPane.propTypes = {
intl: intlShape.isRequired,
htmlNode: PropTypes.node.isRequired,
PostPreviewPanel.propTypes = {
htmlNode: PropTypes.node,
isPost: PropTypes.bool,
editExisting: PropTypes.bool,
};
PostPreviewPane.defaultProps = {
PostPreviewPanel.defaultProps = {
htmlNode: '',
isPost: false,
editExisting: false,
};
export default injectIntl(PostPreviewPane);
export default React.memo(PostPreviewPanel);

View File

@@ -1,9 +1,11 @@
import React, { useContext, useEffect } from 'react';
import React, {
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import camelCase from 'lodash/camelCase';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
@@ -13,7 +15,8 @@ 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 Search = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { page } = useContext(DiscussionContext);
const postSearch = useSelector(({ threads }) => threads.filters.search);
@@ -21,8 +24,10 @@ function Search({ intl }) {
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
const isPostSearch = ['posts', 'my-posts'].includes(page);
const isTopicSearch = 'topics'.includes(page);
let searchValue = '';
const [searchValue, setSearchValue] = useState('');
const previousSearchValueRef = useRef('');
let currentValue = '';
if (isPostSearch) {
currentValue = postSearch;
} else if (isTopicSearch) {
@@ -31,20 +36,22 @@ function Search({ intl }) {
currentValue = learnerSearch;
}
const onClear = () => {
const onClear = useCallback(() => {
dispatch(setSearchQuery(''));
dispatch(setTopicFilter(''));
dispatch(setUsernameSearch(''));
};
previousSearchValueRef.current = '';
}, [previousSearchValueRef]);
const onChange = (query) => {
searchValue = query;
};
const onChange = useCallback((query) => {
setSearchValue(query);
}, []);
const onSubmit = (query) => {
if (query === '') {
const onSubmit = useCallback((query) => {
if (query === '' || query === previousSearchValueRef.current) {
return;
}
if (isPostSearch) {
dispatch(setSearchQuery(query));
} else if (page === 'topics') {
@@ -52,35 +59,37 @@ function Search({ intl }) {
} else if (page === 'learners') {
dispatch(setUsernameSearch(query));
}
};
previousSearchValueRef.current = query;
}, [page, searchValue, previousSearchValueRef]);
const handleIconClick = useCallback((e) => {
e.preventDefault();
onSubmit(searchValue);
}, [searchValue]);
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,
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="py-auto px-2.5 pointer-cursor-hover">
<Icon
src={SearchIcon}
onClick={handleIconClick}
data-testid="search-icon"
/>
</span>
</SearchField.Advanced>
);
};
export default injectIntl(Search);
export default React.memo(Search);

View File

@@ -1,32 +1,36 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } 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,
const SearchInfo = ({
count,
text,
loadingStatus,
onClear,
textSearchRewrite,
}) {
}) => {
const intl = useIntl();
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 })
textSearchRewrite ? (
intl.formatMessage(messages.searchRewriteInfo, {
searchString: text,
count,
textSearchRewrite,
})
) : (
intl.formatMessage(messages.searchInfo, { count, text })
)
)}
{loadingStatus !== RequestStatus.SUCCESSFUL && intl.formatMessage(messages.searchInfoSearching)}
</Button>
@@ -35,10 +39,9 @@ function SearchInfo({
</Button>
</div>
);
}
};
SearchInfo.propTypes = {
intl: intlShape.isRequired,
count: PropTypes.number.isRequired,
text: PropTypes.string.isRequired,
loadingStatus: PropTypes.string.isRequired,
@@ -51,4 +54,4 @@ SearchInfo.defaultProps = {
textSearchRewrite: null,
};
export default injectIntl(SearchInfo);
export default React.memo(SearchInfo);

View File

@@ -0,0 +1,11 @@
import React from 'react';
import { Spinner as ParagonSpinner } from '@edx/paragon';
const Spinner = () => (
<div className="spinner-container" data-testid="spinner">
<ParagonSpinner animation="border" variant="primary" size="lg" />
</div>
);
export default React.memo(Spinner);

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { useParams } from 'react-router';
@@ -32,6 +32,7 @@ import 'tinymce/plugins/lists';
import 'tinymce/plugins/emoticons';
import 'tinymce/plugins/emoticons/js/emojis';
import 'tinymce/plugins/charmap';
import 'tinymce/plugins/paste';
/* eslint import/no-webpack-loader-syntax: off */
// eslint-disable-next-line import/no-unresolved
import edxBrandCss from '!!raw-loader!sass-loader!../index.scss';
@@ -41,30 +42,31 @@ import contentCss from '!!raw-loader!tinymce/skins/content/default/content.min.c
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',
onAction: () => {
editor.execCommand('CodeSample');
},
});
editor.ui.registry.addButton('openedx_html', {
text: 'HTML',
onAction: () => {
editor.execCommand('mceCodeEditor');
},
});
};
/* istanbul ignore next */
export default function TinyMCEEditor(props) {
const 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) => {
/* istanbul ignore next */
const setup = useCallback((editor) => {
editor.ui.registry.addButton('openedx_code', {
icon: 'sourcecode',
onAction: () => {
editor.execCommand('CodeSample');
},
});
editor.ui.registry.addButton('openedx_html', {
text: 'HTML',
onAction: () => {
editor.execCommand('mceCodeEditor');
},
});
}, []);
const uploadHandler = useCallback(async (blobInfo, success, failure) => {
try {
const blob = blobInfo.blob();
const imageSize = blobInfo.blob().size / 1024;
@@ -75,7 +77,7 @@ export default function TinyMCEEditor(props) {
const filename = blobInfo.filename();
const { location } = await uploadFile(blob, filename, courseId, postId || 'root');
const img = new Image();
img.onload = function () {
img.onload = () => {
if (img.height > 999 || img.width > 999) { setShowImageWarning(true); }
};
img.src = location;
@@ -83,7 +85,11 @@ export default function TinyMCEEditor(props) {
} catch (e) {
failure(e.toString(), { remove: true });
}
};
}, [courseId, postId]);
const handleClose = useCallback(() => {
setShowImageWarning(false);
}, []);
let contentStyle;
// In the test environment this causes an error so set styles to empty since they aren't needed for testing.
@@ -100,12 +106,13 @@ export default function TinyMCEEditor(props) {
skin: false,
menubar: false,
branding: false,
paste_data_images: false,
contextmenu: false,
browser_spellcheck: true,
a11y_advanced_options: true,
autosave_interval: '1s',
autosave_restore_when_empty: false,
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap',
plugins: 'autoresize autosave codesample link lists image imagetools code emoticons charmap paste',
toolbar: 'undo redo'
+ ' | formatselect | bold italic underline'
+ ' | link blockquote openedx_code image'
@@ -117,6 +124,8 @@ export default function TinyMCEEditor(props) {
content_css: false,
content_style: contentStyle,
body_class: 'm-2 text-editor',
convert_urls: false,
relative_urls: false,
default_link_target: '_blank',
target_list: false,
images_upload_handler: uploadHandler,
@@ -127,21 +136,22 @@ export default function TinyMCEEditor(props) {
<AlertModal
title={intl.formatMessage(messages.imageWarningModalTitle)}
isOpen={showImageWarning}
onClose={() => setShowImageWarning(false)}
onClose={handleClose}
isBlocking
footerNode={(
<ActionRow>
<Button variant="danger" onClick={() => setShowImageWarning(false)}>
<Button variant="danger" onClick={handleClose}>
{intl.formatMessage(messages.imageWarningDismissButton)}
</Button>
</ActionRow>
)}
)}
>
<p>
{intl.formatMessage(messages.imageWarningMessage)}
</p>
</AlertModal>
</>
);
}
};
export default React.memo(TinyMCEEditor);

View File

@@ -0,0 +1,114 @@
/* eslint react/prop-types: 0 */
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
import {
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
} from '../discussions/data/selectors';
import messages from '../discussions/in-context-topics/messages';
const TopicStats = ({
threadCounts,
activeFlags,
inactiveFlags,
}) => {
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
return (
<div className="d-flex align-items-center mt-2.5" style={{ marginBottom: '2px' }}>
<OverlayTrigger
id="discussion-topic-stats"
placement="right"
overlay={(
<Tooltip id="discussion-topic-stats">
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.discussions, {
count: threadCounts?.discussion || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={PostOutline} className="icon-size mr-2" />
{threadCounts?.discussion || 0}
</div>
</OverlayTrigger>
<OverlayTrigger
id="question-topic-stats"
placement="right"
overlay={(
<Tooltip id="question-topic-stats">
<div className="d-flex flex-column align-items-start">
{intl.formatMessage(messages.questions, {
count: threadCounts?.question || 0,
})}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center mr-3.5">
<Icon src={HelpOutline} className="icon-size mr-2" />
{threadCounts?.question || 0}
</div>
</OverlayTrigger>
{Boolean(canSeeReportedStats) && (
<OverlayTrigger
id="reported-topic-stats"
placement="right"
overlay={(
<Tooltip id="reported-topic-stats">
<div className="d-flex flex-column align-items-start">
{Boolean(activeFlags) && (
<span>
{intl.formatMessage(messages.reported, { reported: activeFlags })}
</span>
)}
{Boolean(inactiveFlags) && (
<span>
{intl.formatMessage(messages.previouslyReported, { previouslyReported: inactiveFlags })}
</span>
)}
</div>
</Tooltip>
)}
>
<div className="d-flex align-items-center">
<Icon src={Report} className="icon-size mr-2 text-danger" />
{activeFlags}{Boolean(inactiveFlags) && `/${inactiveFlags}`}
</div>
</OverlayTrigger>
)}
</div>
);
};
TopicStats.propTypes = {
threadCounts: PropTypes.shape({
discussions: PropTypes.number,
questions: PropTypes.number,
}),
activeFlags: PropTypes.number,
inactiveFlags: PropTypes.number,
};
TopicStats.defaultProps = {
threadCounts: {
discussions: 0,
questions: 0,
},
activeFlags: null,
inactiveFlags: null,
};
export default React.memo(TopicStats);

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function InsertLink() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function Issue() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function People() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function PushPin() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function Question() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function QuestionAnswer() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function QuestionAnswerOutline() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function StarFilled() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function StarOutline() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function ThumbUpFilled() {
return (
<svg

View File

@@ -1,5 +1,6 @@
import React from 'react';
// eslint-disable-next-line react/function-component-definition
export default function ThumbUpOutline() {
return (
<svg

View File

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

View File

@@ -1,9 +1,9 @@
/* eslint-disable import/prefer-default-export */
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { API_BASE_URL } from './constants';
import { getApiBaseUrl } from './constants';
export const blocksAPIURL = `${API_BASE_URL}/api/courses/v1/blocks/`;
export const getBlocksAPIURL = () => `${getApiBaseUrl()}/api/courses/v1/blocks/`;
export async function getCourseBlocks(courseId, username) {
const params = {
course_id: courseId,
@@ -14,6 +14,6 @@ export async function getCourseBlocks(courseId, username) {
student_view_data: 'discussion',
};
const { data } = await getAuthenticatedHttpClient()
.get(blocksAPIURL, { params });
.get(getBlocksAPIURL(), { params });
return data;
}

View File

@@ -1,6 +1,6 @@
import { getConfig } from '@edx/frontend-platform';
export const API_BASE_URL = getConfig().LMS_BASE_URL;
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
/**
* Enum for thread types.
@@ -63,6 +63,7 @@ export const ContentActions = {
* @enum {string}
*/
export const RequestStatus = {
IDLE: 'idle',
IN_PROGRESS: 'in-progress',
SUCCESSFUL: 'successful',
FAILED: 'failed',
@@ -136,7 +137,7 @@ export const DiscussionProvider = {
OPEN_EDX: 'openedx',
};
const BASE_PATH = '/:courseId';
const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
export const Routes = {
DISCUSSIONS: {
@@ -190,6 +191,8 @@ export const Routes = {
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
TOPIC: `${BASE_PATH}/topics/:topicId`,
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
},
};

View File

@@ -18,11 +18,13 @@ import { useDispatch } from 'react-redux';
export function useDispatchWithState() {
const dispatch = useDispatch();
const [isDispatching, setDispatching] = useState(false);
const dispatchWithState = async (thunk) => {
setDispatching(true);
await dispatch(thunk);
setDispatching(false);
};
return [
isDispatching,
dispatchWithState,

View File

@@ -7,10 +7,11 @@ import { initializeMockApp } from '@edx/frontend-platform/testing';
import { initializeStore } from '../store';
import { executeThunk } from '../test-utils';
import { getBlocksAPIResponse } from './__factories__';
import { blocksAPIURL } from './api';
import { getBlocksAPIURL } from './api';
import { RequestStatus } from './constants';
import { fetchCourseBlocks } from './thunks';
const blocksAPIURL = getBlocksAPIURL();
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let axiosMock;

View File

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

View File

@@ -6,9 +6,7 @@ ensureConfig([
'LMS_BASE_URL',
], 'Comments API service');
const apiBaseUrl = getConfig().LMS_BASE_URL;
export const getCohortsApiUrl = courseId => `${apiBaseUrl}/api/cohorts/v1/courses/${courseId}/cohorts/`;
export const getCohortsApiUrl = courseId => `${getConfig().LMS_BASE_URL}/api/cohorts/v1/courses/${courseId}/cohorts/`;
export async function getCourseCohorts(courseId) {
const params = snakeCaseObject({ courseId });

View File

@@ -1,230 +0,0 @@
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 { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, Spinner,
} from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import {
EndorsementStatus, PostsPages, ThreadType,
} from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
import { Post } from '../posts';
import { selectThread } from '../posts/data/selectors';
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';
function usePost(postId) {
const dispatch = useDispatch();
const thread = useSelector(selectThread(postId));
useEffect(() => {
if (thread && !thread.read) {
dispatch(markThreadAsRead(postId));
}
}, [postId]);
return thread;
}
function usePostComments(postId, endorsed = null) {
const [isLoading, dispatch] = useDispatchWithState();
const comments = useSelector(selectThreadComments(postId, endorsed));
const hasMorePages = useSelector(selectThreadHasMorePages(postId, endorsed));
const currentPage = useSelector(selectThreadCurrentPage(postId, endorsed));
const handleLoadMoreResponses = async () => dispatch(fetchThreadComments(postId, {
endorsed,
page: currentPage + 1,
}));
useEffect(() => {
dispatch(fetchThreadComments(postId, {
endorsed,
page: 1,
}));
}, [postId]);
return {
comments,
hasMorePages,
isLoading,
handleLoadMoreResponses,
};
}
function DiscussionCommentsView({
postType,
postId,
intl,
endorsed,
isClosed,
}) {
const {
comments,
hasMorePages,
isLoading,
handleLoadMoreResponses,
} = usePostComments(postId, endorsed);
const sortedComments = useMemo(() => [...filterPosts(comments, 'endorsed'),
...filterPosts(comments, 'unendorsed')], [comments]);
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>
)}
<div className="mx-4" role="list">
{sortedComments.map(comment => (
<Comment comment={comment} key={comment.id} postType={postType} isClosedPost={isClosed} />
))}
{hasMorePages && !isLoading && (
<Button
onClick={handleLoadMoreResponses}
variant="link"
block="true"
className="card p-4 mb-4 font-weight-500 font-size-14"
style={{
lineHeight: '20px',
}}
data-testid="load-more-comments"
>
{intl.formatMessage(messages.loadMoreResponses)}
</Button>
)}
{isLoading
&& (
<div className="card my-4 p-4 d-flex align-items-center">
<Spinner animation="border" variant="primary" />
</div>
)}
{!!sortedComments.length && !isClosed
&& <ResponseEditor postId={postId} addWrappingDiv />}
</div>
</>
);
}
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,
]).isRequired,
};
function CommentsView({ intl }) {
const { postId } = useParams();
const thread = usePost(postId);
const dispatch = useDispatch();
const history = useHistory();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const {
courseId, learnerUsername, category, topicId, page, inContext,
} = useContext(DiscussionContext);
if (!thread) {
dispatch(fetchThread(postId, true));
return (
<Spinner animation="border" variant="primary" data-testid="loading-indicator" />
);
}
return (
<>
{!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} />
{!thread.closed && <ResponseEditor postId={postId} /> }
</div>
{thread.type === ThreadType.DISCUSSION && (
<DiscussionCommentsView
postId={postId}
intl={intl}
postType={thread.type}
endorsed={EndorsementStatus.DISCUSSION}
isClosed={thread.closed}
/>
)}
{thread.type === ThreadType.QUESTION && (
<>
<DiscussionCommentsView
postId={postId}
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}
/>
</>
)}
</>
);
}
CommentsView.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(CommentsView);

View File

@@ -1,679 +0,0 @@
import {
act, fireEvent, render, screen, waitFor, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { camelCaseObject, 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 DiscussionContent from '../discussions-home/DiscussionContent';
import { threadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { commentsApiUrl } from './data/api';
import '../posts/data/__factories__';
import './data/__factories__';
const discussionPostId = 'thread-1';
const questionPostId = 'thread-2';
const closedPostId = 'thread-2';
const courseId = 'course-v1:edX+TestX+Test_Course';
let store;
let axiosMock;
let testLocation;
function mockAxiosReturnPagedComments() {
[null, false, true].forEach(endorsed => {
const postId = endorsed === null ? discussionPostId : questionPostId;
[1, 2].forEach(page => {
axiosMock
.onGet(commentsApiUrl, {
params: {
thread_id: postId,
page,
page_size: undefined,
requested_fields: 'profile_image',
endorsed,
},
})
.reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId: postId,
page,
pageSize: 1,
count: 2,
endorsed,
childCount: page === 1 ? 2 : 0,
}));
});
});
}
function mockAxiosReturnPagedCommentsResponses() {
const parentId = 'comment-1';
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
const paramsTemplate = {
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
};
for (let page = 1; page <= 2; page++) {
axiosMock
.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } })
.reply(200, Factory.build('commentsResult', null, {
parentId,
page,
pageSize: 1,
count: 2,
}));
}
}
function renderComponent(postId) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<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 () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(threadsApiUrl)
.reply(200, Factory.build('threadsResult'));
axiosMock.onPatch(new RegExp(`${commentsApiUrl}*`)).reply(({
url,
data,
}) => {
const commentId = url.match(/comments\/(?<id>[a-z1-9-]+)\//).groups.id;
const {
rawBody,
} = camelCaseObject(JSON.parse(data));
return [200, Factory.build('comment', {
id: commentId,
rendered_body: rawBody,
raw_body: rawBody,
})];
});
axiosMock.onPost(commentsApiUrl)
.reply(({ data }) => {
const {
rawBody,
threadId,
} = camelCaseObject(JSON.parse(data));
return [200, Factory.build(
'comment',
{
rendered_body: rawBody,
raw_body: rawBody,
thread_id: threadId,
},
)];
});
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
mockAxiosReturnPagedComments();
mockAxiosReturnPagedCommentsResponses();
});
describe('for all post types', () => {
function assertLastUpdateData(data) {
expect(JSON.parse(axiosMock.history.patch[axiosMock.history.patch.length - 1].data)).toMatchObject(data);
}
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(
addResponseButtons[0],
);
});
expect(screen.queryByTestId('tinymce-editor')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
});
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(
responseButtons[0],
);
});
await act(() => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
});
await act(async () => {
fireEvent.click(
screen.getByText(/submit/i),
);
});
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 }));
await act(async () => {
fireEvent.click(
screen.getAllByRole('button', { name: /add a comment/i })[0],
);
});
act(() => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
});
await act(async () => {
fireEvent.click(
screen.getByText(/submit/i),
);
});
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 }));
await act(async () => {
fireEvent.click(
// The first edit menu is for the post, the second will be for the first comment.
screen.getAllByRole('button', { name: /actions menu/i })[1],
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
});
act(() => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
await waitFor(async () => {
expect(await screen.findByText('testing123', { exact: false })).toBeInTheDocument();
});
});
async function setupCourseConfig(reasonCodesEnabled = true) {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true,
reason_codes_enabled: reasonCodesEnabled,
editReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
],
postCloseReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
],
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
it('should show reason codes when editing an existing comment', async () => {
setupCourseConfig();
renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 1', { exact: false }));
await act(async () => {
fireEvent.click(
// The first edit menu is for the post, the second will be for the first comment.
screen.getAllByRole('button', { name: /actions menu/i })[1],
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
});
expect(screen.queryByRole('combobox', { name: /reason for editing/i })).toBeInTheDocument();
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
await act(async () => {
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: null } });
});
await act(async () => {
fireEvent.change(screen.queryByRole('combobox', { name: /reason for editing/i }), { target: { value: 'reason-1' } });
});
await act(async () => {
fireEvent.change(screen.getByTestId('tinymce-editor'), { target: { value: 'testing123' } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /submit/i }));
});
assertLastUpdateData({ edit_reason_code: 'reason-1' });
});
it('should show reason codes when closing a post', async () => {
setupCourseConfig();
renderComponent(discussionPostId);
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
screen.getAllByRole('button', {
name: /actions menu/i,
})[0],
);
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).toBeInTheDocument();
expect(screen.queryByRole('combobox', { name: /reason/i })).toBeInTheDocument();
expect(screen.getAllByRole('option', { name: /reason \d/i })).toHaveLength(2);
await act(async () => {
fireEvent.change(screen.queryByRole('combobox', { name: /reason/i }), { target: { value: 'reason-1' } });
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close post/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
assertLastUpdateData({ closed: true, close_reason_code: 'reason-1' });
});
it('should close the post directly if reason codes are not enabled', async () => {
setupCourseConfig(false);
renderComponent(discussionPostId);
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
assertLastUpdateData({ closed: true });
});
it.each([true, false])(
'should reopen the post directly when reason codes enabled=%s',
async (reasonCodesEnabled) => {
setupCourseConfig(reasonCodesEnabled);
renderComponent(closedPostId);
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /reopen/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
assertLastUpdateData({ closed: false });
},
);
it('should show the editor if the post is edited', async () => {
setupCourseConfig(false);
renderComponent(discussionPostId);
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
});
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
});
it('should allow pinning the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /pin/i }));
});
assertLastUpdateData({ pinned: false });
});
it('should allow reporting the post', async () => {
renderComponent(discussionPostId);
await act(async () => {
fireEvent.click(
// The first edit menu is for the post
screen.getAllByRole('button', { name: /actions menu/i })[0],
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /report/i }));
});
assertLastUpdateData({ abuse_flagged: true });
});
it('handles liking a comment', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await screen.findByText('comment number 7', { exact: false });
const view = screen.getByTestId('comment-comment-1');
const likeButton = within(view).getByRole('button', { name: /like/i });
await act(async () => {
fireEvent.click(likeButton);
});
expect(axiosMock.history.patch).toHaveLength(2);
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) => {
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: buttonLabel }));
});
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
});
});
describe('for discussion thread', () => {
const findLoadMoreCommentsButton = () => screen.findByTestId('load-more-comments');
it('shown spinner when post isn\'t loaded', async () => {
renderComponent('unloaded-id');
expect(await screen.findByTestId('loading-indicator'))
.toBeInTheDocument();
});
it('initially loads only the first page', async () => {
renderComponent(discussionPostId);
expect(await screen.findByText('comment number 1', { exact: false }))
.toBeInTheDocument();
expect(screen.queryByText('comment number 2', { exact: false }))
.not
.toBeInTheDocument();
});
it('pressing load more button will load next page of comments', async () => {
renderComponent(discussionPostId);
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByText('comment number 1', { exact: false });
await screen.findByText('comment number 2', { exact: false });
});
it('newly loaded comments are appended to the old ones', async () => {
renderComponent(discussionPostId);
const loadMoreButton = await findLoadMoreCommentsButton();
fireEvent.click(loadMoreButton);
await screen.findByText('comment number 1', { exact: false });
// check that comments from the first page are also displayed
expect(screen.queryByText('comment number 2', { exact: false }))
.toBeInTheDocument();
});
it('load more button is hidden when no more comments pages to load', async () => {
const totalPages = 2;
renderComponent(discussionPostId);
const loadMoreButton = await findLoadMoreCommentsButton();
for (let page = 1; page < totalPages; page++) {
fireEvent.click(loadMoreButton);
}
await screen.findByText('comment number 2', { exact: false });
await expect(findLoadMoreCommentsButton())
.rejects
.toThrow();
});
});
describe('for question thread', () => {
const findLoadMoreCommentsButtons = () => screen.findAllByTestId('load-more-comments');
it('initially loads only the first page', async () => {
act(() => renderComponent(questionPostId));
expect(await screen.findByText('comment number 3', { exact: false }))
.toBeInTheDocument();
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
.toBeInTheDocument();
expect(screen.queryByText('comment number 4', { exact: false }))
.not
.toBeInTheDocument();
});
it('pressing load more button will load next page of comments', async () => {
act(() => {
renderComponent(questionPostId);
});
const [loadMoreButtonEndorsed, loadMoreButtonUnendorsed] = await findLoadMoreCommentsButtons();
// Both load more buttons should show
expect(await findLoadMoreCommentsButtons()).toHaveLength(2);
expect(await screen.findByText('unendorsed comment number 3', { exact: false }))
.toBeInTheDocument();
expect(await screen.findByText('endorsed comment number 5', { exact: false }))
.toBeInTheDocument();
// Comments from next page should not be loaded yet.
expect(await screen.queryByText('endorsed comment number 6', { exact: false }))
.not
.toBeInTheDocument();
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
.not
.toBeInTheDocument();
await act(async () => {
fireEvent.click(loadMoreButtonEndorsed);
});
// Endorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByText('endorsed comment number 6', { exact: false }))
.toBeInTheDocument());
// Unendorsed comment from next page should not be loaded yet.
expect(await screen.queryByText('unendorsed comment number 4', { exact: false }))
.not
.toBeInTheDocument();
// Now only one load more buttons should show, for unendorsed comments
expect(await findLoadMoreCommentsButtons()).toHaveLength(1);
await act(async () => {
fireEvent.click(loadMoreButtonUnendorsed);
});
// Unendorsed comment from next page should be loaded now.
await waitFor(() => expect(screen.queryByText('unendorsed comment number 4', { exact: false }))
.toBeInTheDocument());
await expect(findLoadMoreCommentsButtons()).rejects.toThrow();
});
});
describe('comments responses', () => {
const findLoadMoreCommentsResponsesButton = () => screen.findByTestId('load-more-comments-responses');
it('initially loads only the first page', async () => {
renderComponent(discussionPostId);
await waitFor(() => screen.findByText('comment number 7', { exact: false }));
expect(screen.queryByText('comment number 8', { exact: false })).not.toBeInTheDocument();
});
it('pressing load more button will load next page of responses', async () => {
renderComponent(discussionPostId);
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
await act(async () => {
fireEvent.click(loadMoreButton);
});
await screen.findByText('comment number 8', { exact: false });
});
it('newly loaded responses are appended to the old ones', async () => {
renderComponent(discussionPostId);
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
await act(async () => {
fireEvent.click(loadMoreButton);
});
await screen.findByText('comment number 8', { exact: false });
// check that comments from the first page are also displayed
expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument();
});
it('load more button is hidden when no more responses pages to load', async () => {
const totalPages = 2;
renderComponent(discussionPostId);
const loadMoreButton = await findLoadMoreCommentsResponsesButton();
for (let page = 1; page < totalPages; page++) {
act(() => {
fireEvent.click(loadMoreButton);
});
}
await screen.findByText('comment number 8', { exact: false });
await expect(findLoadMoreCommentsResponsesButton())
.rejects
.toThrow();
});
it('handles liking a comment', async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await screen.findByText('comment number 7', { exact: false });
const view = screen.getByTestId('comment-comment-1');
const likeButton = within(view).getByRole('button', { name: /like/i });
await act(async () => {
fireEvent.click(likeButton);
});
expect(axiosMock.history.patch).toHaveLength(2);
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) => {
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: buttonLabel }));
});
expect(axiosMock.history.patch).toHaveLength(2);
expect(JSON.parse(axiosMock.history.patch[1].data)).toMatchObject(patchData);
});
});
describe.each([
{ component: 'post', testId: 'post-thread-1' },
{ component: 'comment', testId: 'comment-comment-1' },
{ component: 'reply', testId: 'reply-comment-7' },
])('delete confirmation modal', ({
component,
testId,
}) => {
test(`for ${component}`, async () => {
renderComponent(discussionPostId);
// Wait for the content to load
await waitFor(() => expect(screen.queryByText('comment number 7', { exact: false })).toBeInTheDocument());
const content = screen.getByTestId(testId);
const actionsButton = within(content).getAllByRole('button', { name: /actions menu/i })[0];
await act(async () => {
fireEvent.click(actionsButton);
});
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
const deleteButton = within(content).queryByRole('button', { name: /delete/i });
await act(async () => {
fireEvent.click(deleteButton);
});
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByRole('button', { name: /delete/i }));
});
expect(screen.queryByRole('dialog', { name: /delete \w+/i, exact: false })).not.toBeInTheDocument();
});
});
});

View File

@@ -1,44 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux';
import * as timeago from 'timeago.js';
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,
}) {
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">
<LikeButton
count={comment.voteCount}
onClick={handleLike}
voted={comment.voted}
/>
<div className="d-flex flex-fill text-gray-500 justify-content-end" title={comment.createdAt}>
{timeago.format(comment.createdAt, 'time-locale')}
</div>
</div>
);
}
CommentIcons.propTypes = {
comment: PropTypes.shape({
id: PropTypes.string,
voteCount: PropTypes.number,
following: PropTypes.bool,
voted: PropTypes.bool,
createdAt: PropTypes.string,
}).isRequired,
};
export default injectIntl(CommentIcons);

View File

@@ -1,166 +0,0 @@
import React, { 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, EndorsedAlertBanner } from '../../common';
import { selectBlackoutDate } from '../../data/selectors';
import { fetchThread } from '../../posts/data/thunks';
import { inBlackoutDateRange } from '../../utils';
import CommentIcons from '../comment-icons/CommentIcons';
import { selectCommentCurrentPage, selectCommentHasMorePages, selectCommentResponses } from '../data/selectors';
import { editComment, fetchCommentResponses, removeComment } from '../data/thunks';
import messages from '../messages';
import CommentEditor from './CommentEditor';
import CommentHeader from './CommentHeader';
import { commentShape } from './proptypes';
import Reply from './Reply';
function Comment({
postType,
comment,
showFullThread = true,
isClosedPost,
intl,
}) {
const dispatch = useDispatch();
const hasChildren = comment.childCount > 0;
const isNested = Boolean(comment.parentId);
const inlineReplies = useSelector(selectCommentResponses(comment.id));
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReplying, setReplying] = useState(false);
const hasMorePages = useSelector(selectCommentHasMorePages(comment.id));
const currentPage = useSelector(selectCommentCurrentPage(comment.id));
const blackoutDateRange = useSelector(selectBlackoutDate);
useEffect(() => {
// If the comment has a parent comment, it won't have any children, so don't fetch them.
if (hasChildren && !currentPage && showFullThread) {
dispatch(fetchCommentResponses(comment.id, { page: 1 }));
}
}, [comment.id]);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: async () => {
await dispatch(editComment(comment.id, { endorsed: !comment.endorsed }, ContentActions.ENDORSE));
await dispatch(fetchThread(comment.threadId));
},
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(comment.id, { flagged: !comment.abuseFlagged })),
};
const handleLoadMoreComments = () => (
dispatch(fetchCommentResponses(comment.id, { page: currentPage + 1 }))
);
return (
<div className={classNames({ 'py-2 my-3': showFullThread })}>
<div className="d-flex flex-column card" data-testid={`comment-${comment.id}`} role="listitem">
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteResponseTitle)}
description={intl.formatMessage(messages.deleteResponseDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(comment.id));
hideDeleteConfirmation();
}}
/>
<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="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.loadMoreComments)}
</Button>
)}
{!isNested && showFullThread && (
isReplying ? (
<CommentEditor
comment={{
threadId: comment.threadId,
parentId: comment.id,
}}
edit={false}
onCloseEditor={() => setReplying(false)}
/>
) : (
<>
{(!isClosedPost && !inBlackoutDateRange(blackoutDateRange))
&& (
<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>
);
}
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

@@ -1,102 +0,0 @@
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 { logError } from '@edx/frontend-platform/logging';
import {
Avatar, Icon,
} from '@edx/paragon';
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({
comment,
postType,
actionHandlers,
}) {
const authorAvatars = useSelector(selectAuthorAvatars(comment.author));
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={classNames('d-flex flex-row justify-content-between', {
'mt-2': hasAnyAlert,
})}
>
<div className="align-items-center d-flex flex-row">
<Avatar
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
/>
</div>
<div className="d-flex align-items-center">
{actionIcons && (
<span className="btn-icon btn-icon-sm mr-1 align-items-center">
<Icon
data-testid="check-icon"
onClick={
() => {
handleIcons(actionIcons.action);
}
}
src={actionIcons.icon}
className={['endorse', 'unendorse'].includes(actionIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
/>
</span>
)}
<ActionsDropdown
commentOrPost={{
...comment,
postType,
}}
actionHandlers={actionHandlers}
/>
</div>
</div>
);
}
CommentHeader.propTypes = {
comment: commentShape.isRequired,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
postType: PropTypes.oneOf([ThreadType.QUESTION, ThreadType.DISCUSSION]).isRequired,
};
export default injectIntl(CommentHeader);

View File

@@ -1,57 +0,0 @@
import React from 'react';
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 CommentHeader from './CommentHeader';
let store;
function renderComponent(comment, postType, actionHandlers) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<CommentHeader comment={comment} postType={postType} actionHandlers={actionHandlers} />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
const mockComment = {
author: 'abc123',
authorLabel: 'ABC 123',
endorsed: true,
editableFields: ['endorsed'],
};
describe('Comment Header', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
});
it('should render verified icon for endorsed discussion posts', () => {
renderComponent(mockComment, 'discussion', {});
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
});
it('should render check icon for endorsed question posts', () => {
renderComponent(mockComment, 'question', {});
expect(screen.queryAllByTestId('check-icon')).toHaveLength(1);
});
});

View File

@@ -1,112 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Avatar, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { AvatarOutlineAndLabelColors, ContentActions } from '../../../data/constants';
import {
ActionsDropdown, AlertBanner, AuthorLabel, DeleteConfirmation,
} 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';
import CommentEditor from './CommentEditor';
import { commentShape } from './proptypes';
function Reply({
reply,
postType,
intl,
}) {
timeago.register('time-locale', timeLocale);
const dispatch = useDispatch();
const [isEditing, setEditing] = useState(false);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const actionHandlers = {
[ContentActions.EDIT_CONTENT]: () => setEditing(true),
[ContentActions.ENDORSE]: () => dispatch(editComment(
reply.id,
{ endorsed: !reply.endorsed },
ContentActions.ENDORSE,
)),
[ContentActions.DELETE]: showDeleteConfirmation,
[ContentActions.REPORT]: () => dispatch(editComment(reply.id, { flagged: !reply.abuseFlagged })),
};
const authorAvatars = useSelector(selectAuthorAvatars(reply.author));
const colorClass = AvatarOutlineAndLabelColors[reply.authorLabel];
const hasAnyAlert = useAlertBannerVisible(reply);
return (
<div className="d-flex flex-column mt-4.5" data-testid={`reply-${reply.id}`} role="listitem">
<DeleteConfirmation
isOpen={isDeleting}
title={intl.formatMessage(messages.deleteCommentTitle)}
description={intl.formatMessage(messages.deleteCommentDescription)}
onClose={hideDeleteConfirmation}
onDelete={() => {
dispatch(removeComment(reply.id));
hideDeleteConfirmation();
}}
/>
{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">
<div className="d-flex mr-3 mt-2.5">
<Avatar
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="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,
postType,
}}
actionHandlers={actionHandlers}
/>
</div>
{isEditing
? <CommentEditor comment={reply} onCloseEditor={() => setEditing(false)} />
: <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, 'time-locale')}
</div>
</div>
);
}
Reply.propTypes = {
postType: PropTypes.oneOf(['discussion', 'question']).isRequired,
reply: commentShape.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(Reply);

View File

@@ -1,66 +0,0 @@
import React, { useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { DiscussionContext } from '../../common/context';
import { selectBlackoutDate } from '../../data/selectors';
import { inBlackoutDateRange } from '../../utils';
import messages from '../messages';
import CommentEditor from './CommentEditor';
function ResponseEditor({
postId,
intl,
addWrappingDiv,
}) {
const { inContext } = useContext(DiscussionContext);
const [addingResponse, setAddingResponse] = useState(false);
useEffect(() => {
setAddingResponse(false);
}, [postId]);
const blackoutDateRange = useSelector(selectBlackoutDate);
return addingResponse
? (
<div className={classNames({ 'bg-white p-4 mb-4 rounded': addWrappingDiv })}>
<CommentEditor
comment={{ threadId: postId }}
edit={false}
onCloseEditor={() => setAddingResponse(false)}
/>
</div>
)
: !inBlackoutDateRange(blackoutDateRange) && (
<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>
);
}
ResponseEditor.propTypes = {
postId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
addWrappingDiv: PropTypes.bool,
};
ResponseEditor.defaultProps = {
addWrappingDiv: false,
};
export default injectIntl(ResponseEditor);

View File

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

View File

@@ -1,9 +1,11 @@
import React, { useContext, useState } from 'react';
import React, {
useCallback, useMemo, useRef, useState,
} from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { logError } from '@edx/frontend-platform/logging';
import {
Button, Dropdown, Icon, IconButton, ModalPopup, useToggle,
@@ -11,91 +13,117 @@ import {
import { MoreHoriz } from '@edx/paragon/icons';
import { ContentActions } from '../../data/constants';
import { commentShape } from '../comments/comment/proptypes';
import { selectBlackoutDate } from '../data/selectors';
import { selectIsPostingEnabled } from '../data/selectors';
import messages from '../messages';
import { postShape } from '../posts/post/proptypes';
import { inBlackoutDateRange, useActions } from '../utils';
import { DiscussionContext } from './context';
import { useActions } from '../utils';
function ActionsDropdown({
intl,
commentOrPost,
disabled,
const ActionsDropdown = ({
actionHandlers,
}) {
contentType,
disabled,
dropDownIconSize,
iconSize,
id,
}) => {
const buttonRef = useRef();
const intl = useIntl();
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState(null);
const actions = useActions(commentOrPost);
const { inContext } = useContext(DiscussionContext);
const handleActions = (action) => {
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const actions = useActions(contentType, id);
const handleActions = useCallback((action) => {
const actionFunction = actionHandlers[action];
if (actionFunction) {
actionFunction();
} else {
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);
}
}, [actionHandlers]);
// Find and remove edit action if in Posting is disabled.
useMemo(() => {
if (!isPostingEnabled) {
actions.splice(actions.findIndex(action => action.id === 'edit'), 1);
}
}, [actions, isPostingEnabled]);
const onClickButton = useCallback(() => {
setTarget(buttonRef.current);
open();
}, [open]);
const onCloseModal = useCallback(() => {
close();
setTarget(null);
}, [close]);
return (
<>
<IconButton
onClick={open}
onClick={onClickButton}
alt={intl.formatMessage(messages.actionsAlt)}
src={MoreHoriz}
iconAs={Icon}
disabled={disabled}
size="sm"
ref={setTarget}
size={iconSize}
ref={buttonRef}
iconClassNames={dropDownIconSize ? 'dropdown-icon-dimentions' : ''}
/>
<ModalPopup
onClose={close}
positionRef={target}
isOpen={isOpen}
placement={inContext ? 'left' : 'auto-start'}
>
<div
className="bg-white p-1 shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
<div className="actions-dropdown">
<ModalPopup
onClose={onCloseModal}
positionRef={target}
isOpen={isOpen}
placement="bottom-end"
>
{actions.map(action => (
<React.Fragment key={action.id}>
{action.action === ContentActions.DELETE
&& <Dropdown.Divider />}
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start py-1.5 mr-4"
>
<Icon src={action.icon} className="mr-1" /> {intl.formatMessage(action.label)}
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
<div
className="bg-white shadow d-flex flex-column"
data-testid="actions-dropdown-modal-popup"
>
{actions.map(action => (
<React.Fragment key={action.id}>
{(action.action === ContentActions.DELETE) && <Dropdown.Divider />}
<Dropdown.Item
as={Button}
variant="tertiary"
size="inline"
onClick={() => {
close();
handleActions(action.action);
}}
className="d-flex justify-content-start actions-dropdown-item"
>
<Icon
src={action.icon}
className="icon-size-24"
/>
<span className="font-weight-normal font-xl ml-2">
{intl.formatMessage(action.label)}
</span>
</Dropdown.Item>
</React.Fragment>
))}
</div>
</ModalPopup>
</div>
</>
);
}
};
ActionsDropdown.propTypes = {
intl: intlShape.isRequired,
commentOrPost: PropTypes.oneOfType([commentShape, postShape]).isRequired,
id: PropTypes.string.isRequired,
disabled: PropTypes.bool,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
iconSize: PropTypes.string,
dropDownIconSize: PropTypes.bool,
contentType: PropTypes.oneOf(['POST', 'COMMENT']).isRequired,
};
ActionsDropdown.defaultProps = {
disabled: false,
iconSize: 'sm',
dropDownIconSize: false,
};
export default injectIntl(ActionsDropdown);
export default ActionsDropdown;

View File

@@ -1,117 +1,172 @@
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp, snakeCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { ContentActions } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import messages from '../messages';
import { getCommentsApiUrl } from '../post-comments/data/api';
import { addComment, fetchThreadComments } from '../post-comments/data/thunks';
import { PostCommentsContext } from '../post-comments/postCommentsContext';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThread } from '../posts/data/thunks';
import { ACTIONS_LIST } from '../utils';
import ActionsDropdown from './ActionsDropdown';
import '../comments/data/__factories__';
import '../post-comments/data/__factories__';
import '../posts/data/__factories__';
let store;
let axiosMock;
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const courseId = 'course-v1:edX+TestX+Test_Course';
const discussionThreadId = 'thread-1';
const questionThreadId = 'thread-2';
const commentContent = 'This is a comment for thread-1';
let discussionThread;
let questionThread;
let comment;
function buildTestContent(buildParams, testMeta) {
const buildTestContent = (buildParams, testMeta) => {
const buildParamsSnakeCase = snakeCaseObject(buildParams);
return [
{
testFor: 'comments',
...camelCaseObject(Factory.build('comment', { ...buildParamsSnakeCase }, null)),
...testMeta,
},
{
testFor: 'question threads',
...camelCaseObject(Factory.build('thread', { ...buildParamsSnakeCase, type: 'question' }, null)),
...testMeta,
},
{
discussionThread = Factory.build('thread', { ...buildParamsSnakeCase, id: discussionThreadId }, null);
questionThread = Factory.build('thread', { ...buildParamsSnakeCase, id: questionThreadId }, null);
comment = Factory.build('comment', { ...buildParamsSnakeCase, thread_id: discussionThreadId }, null);
return {
discussion: {
testFor: 'discussion threads',
...camelCaseObject(Factory.build('thread', { ...buildParamsSnakeCase, type: 'discussion' }, null)),
contentType: 'POST',
...camelCaseObject(discussionThread),
...testMeta,
},
];
}
question: {
testFor: 'question threads',
contentType: 'POST',
...camelCaseObject(questionThread),
...testMeta,
},
comment: {
testFor: 'comments',
contentType: 'COMMENT',
type: 'discussion',
...camelCaseObject(comment),
...testMeta,
},
};
};
const canPerformActionTestData = ACTIONS_LIST
.map(({ action, conditions, label: { defaultMessage } }) => {
const buildParams = {
const mockThreadAndComment = async (response) => {
axiosMock.onGet(`${threadsApiUrl}${discussionThreadId}/`).reply(200, response);
axiosMock.onGet(`${threadsApiUrl}${questionThreadId}/`).reply(200, response);
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult'));
axiosMock.onPost(commentsApiUrl).reply(200, response);
await executeThunk(fetchThread(discussionThreadId), store.dispatch, store.getState);
await executeThunk(fetchThread(questionThreadId), store.dispatch, store.getState);
await executeThunk(fetchThreadComments(discussionThreadId), store.dispatch, store.getState);
await executeThunk(addComment(commentContent, discussionThreadId, null), store.dispatch, store.getState);
};
const canPerformActionTestData = ACTIONS_LIST.flatMap(({
id, action, conditions, label: { defaultMessage },
}) => {
const buildParams = { editable_fields: [action] };
if (conditions) {
Object.entries(conditions).forEach(([conditionKey, conditionValue]) => {
buildParams[conditionKey] = conditionValue;
});
}
const testContent = buildTestContent(buildParams, { label: defaultMessage, action });
switch (id) {
case 'answer':
case 'unanswer':
return [testContent.question];
case 'endorse':
case 'unendorse':
return [testContent.comment, testContent.discussion];
default:
return [testContent.discussion, testContent.question, testContent.comment];
}
});
const canNotPerformActionTestData = ACTIONS_LIST.flatMap(({ action, conditions, label: { defaultMessage } }) => {
const label = defaultMessage;
if (!conditions) {
const content = buildTestContent({ editable_fields: [] }, { reason: 'field is not editable', label: defaultMessage });
return [content.discussion, content.question, content.comment];
}
const reversedConditions = Object.fromEntries(Object.entries(conditions).map(([key, value]) => [key, !value]));
const content = {
// can edit field, but doesn't pass conditions
...buildTestContent({
editable_fields: [action],
};
if (conditions) {
Object.entries(conditions)
.forEach(([conditionKey, conditionValue]) => {
buildParams[conditionKey] = conditionValue;
});
}
return buildTestContent(buildParams, { label: defaultMessage, action });
})
.flat();
...reversedConditions,
}, { reason: 'field is editable but does not pass condition', label, action }),
const canNotPerformActionTestData = ACTIONS_LIST
.map(({ action, conditions, label: { defaultMessage } }) => {
const label = defaultMessage;
let content;
if (!conditions) {
content = buildTestContent({ editable_fields: [] }, { reason: 'field is not editable', label: defaultMessage });
} else {
const reversedConditions = Object.keys(conditions)
.reduce(
(results, key) => ({
...results,
[key]: !conditions[key],
}),
{},
);
// passes conditions, but can't edit field
...(action === ContentActions.DELETE ? {} : buildTestContent({
editable_fields: [],
...conditions,
}, { reason: 'passes conditions but field is not editable', label, action })),
content = [
// can edit field, but doesn't pass conditions
...buildTestContent({
editable_fields: [action],
...reversedConditions,
}, { reason: 'field is editable but does not pass condition', label, action }),
// passes conditions, but can't edit field
...(action === ContentActions.DELETE
? []
: buildTestContent({
editable_fields: [],
...conditions,
}, { reason: 'passes conditions but field is not editable', label, action })
),
// can't edit field, and doesn't pass conditions
...buildTestContent({
editable_fields: [],
...reversedConditions,
}, { reason: 'can not edit field and does not match conditions', label, action }),
];
}
return content;
})
.flat();
// can't edit field, and doesn't pass conditions
...buildTestContent({
editable_fields: [],
...reversedConditions,
}, { reason: 'can not edit field and does not match conditions', label, action }),
};
function renderComponent(
commentOrPost,
{ disabled = false, actionHandlers = {} } = {},
) {
return [content.discussion, content.question, content.comment];
});
const renderComponent = ({
id = '',
contentType = 'POST',
closed = false,
type = 'discussion',
postId = '',
disabled = false,
actionHandlers = {},
} = {}) => {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<ActionsDropdown
commentOrPost={commentOrPost}
disabled={disabled}
actionHandlers={actionHandlers}
/>
<PostCommentsContext.Provider value={{
isClosed: closed,
postType: type,
postId,
}}
>
<ActionsDropdown
id={id}
disabled={disabled}
actionHandlers={actionHandlers}
contentType={contentType}
/>
</PostCommentsContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
};
const findOpenActionsDropdownButton = async () => (
screen.findByRole('button', { name: messages.actionsAlt.defaultMessage })
@@ -128,10 +183,18 @@ describe('ActionsDropdown', () => {
},
});
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`)
.reply(200, { isPostingEnabled: true });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
});
it.each(buildTestContent())('can open drop down if enabled', async (commentOrPost) => {
renderComponent(commentOrPost, { disabled: false });
it.each(Object.values(buildTestContent()))('can open drop down if enabled', async (commentOrPost) => {
await mockThreadAndComment(commentOrPost);
renderComponent({ ...commentOrPost });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
@@ -141,8 +204,9 @@ describe('ActionsDropdown', () => {
await waitFor(() => expect(screen.queryByTestId('actions-dropdown-modal-popup')).toBeInTheDocument());
});
it.each(buildTestContent())('can not open drop down if disabled', async (commentOrPost) => {
renderComponent(commentOrPost, { disabled: true });
it.each(Object.values(buildTestContent()))('can not open drop down if disabled', async (commentOrPost) => {
await mockThreadAndComment(commentOrPost);
renderComponent({ ...commentOrPost, disabled: true });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
@@ -153,11 +217,9 @@ describe('ActionsDropdown', () => {
});
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 discussionObject = buildTestContent({ editable_fields: ['copy_link'] }).discussion;
await mockThreadAndComment(discussionObject);
renderComponent({ ...camelCaseObject(discussionObject) });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
@@ -168,11 +230,9 @@ describe('ActionsDropdown', () => {
});
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 commentObject = buildTestContent().comment;
await mockThreadAndComment(commentObject);
renderComponent({ ...camelCaseObject(commentObject) });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
@@ -183,15 +243,13 @@ describe('ActionsDropdown', () => {
});
describe.each(canPerformActionTestData)('Actions', ({
testFor, action, label, reason, ...commentOrPost
testFor, action, label, ...commentOrPost
}) => {
describe(`for ${testFor}`, () => {
it(`can "${label}" when allowed`, async () => {
await mockThreadAndComment(commentOrPost);
const mockHandler = jest.fn();
renderComponent(
commentOrPost,
{ actionHandlers: { [action]: mockHandler } },
);
renderComponent({ ...commentOrPost, actionHandlers: { [action]: mockHandler } });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {
@@ -214,7 +272,8 @@ describe('ActionsDropdown', () => {
}) => {
describe(`for ${testFor}`, () => {
it(`can't "${label}" when ${reason}`, async () => {
renderComponent(commentOrPost);
await mockThreadAndComment(commentOrPost);
renderComponent({ ...commentOrPost });
const openButton = await findOpenActionsDropdownButton();
await act(async () => {

View File

@@ -4,68 +4,95 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import { Error } from '@edx/paragon/icons';
import { Report } from '@edx/paragon/icons';
import { commentShape } from '../comments/comment/proptypes';
import messages from '../comments/messages';
import { selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../data/selectors';
import { postShape } from '../posts/post/proptypes';
import AuthorLabel from './AuthorLabel';
import { AvatarOutlineAndLabelColors } from '../../data/constants';
import {
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
} from '../data/selectors';
import messages from '../post-comments/messages';
import AlertBar from './AlertBar';
function AlertBanner({
intl,
content,
}) {
const AlertBanner = ({
author,
abuseFlagged,
lastEdit,
closed,
closedBy,
closeReason,
editByLabel,
closedByLabel,
}) => {
const intl = useIntl();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsGlobalStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content?.abuseFlagged;
const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeReportedBanner = abuseFlagged;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
|| userIsGlobalStaff || userIsContentAuthor
);
const editByLabelColor = AvatarOutlineAndLabelColors[editByLabel];
const closedByLabelColor = AvatarOutlineAndLabelColors[closedByLabel];
return (
<>
{canSeeReportedBanner && (
<Alert icon={Error} variant="danger" className="px-3 mb-2 py-10px shadow-none flex-fill">
<Alert icon={Report} variant="danger" className="px-3 mb-1 py-10px shadow-none flex-fill">
{intl.formatMessage(messages.abuseFlaggedMessage)}
</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>
{lastEdit?.reason && (
<AlertBar
message={intl.formatMessage(messages.editedBy)}
author={lastEdit.editorUsername}
authorLabel={editByLabel}
labelColor={editByLabelColor && `text-${editByLabelColor}`}
reason={lastEdit.reason}
/>
)}
{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>
{closed && (
<AlertBar
message={intl.formatMessage(messages.closedBy)}
author={closedBy}
authorLabel={closedByLabel}
labelColor={closedByLabelColor && `text-${closedByLabelColor}`}
reason={closeReason}
/>
)}
</>
)}
</>
);
}
AlertBanner.propTypes = {
intl: intlShape.isRequired,
content: PropTypes.oneOfType([commentShape.isRequired, postShape.isRequired]).isRequired,
};
export default injectIntl(AlertBanner);
AlertBanner.propTypes = {
author: PropTypes.string.isRequired,
abuseFlagged: PropTypes.bool,
closed: PropTypes.bool,
closedBy: PropTypes.string,
closedByLabel: PropTypes.string,
closeReason: PropTypes.string,
editByLabel: PropTypes.string,
lastEdit: PropTypes.shape({
editorUsername: PropTypes.string,
reason: PropTypes.string,
}),
};
AlertBanner.defaultProps = {
abuseFlagged: false,
closed: undefined,
closedBy: undefined,
closedByLabel: undefined,
closeReason: undefined,
editByLabel: undefined,
lastEdit: {},
};
export default React.memo(AlertBanner);

View File

@@ -7,11 +7,11 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../comments/messages';
import messages from '../post-comments/messages';
import AlertBanner from './AlertBanner';
import { DiscussionContext } from './context';
import '../comments/data/__factories__';
import '../post-comments/data/__factories__';
import '../posts/data/__factories__';
let store;
@@ -31,7 +31,14 @@ function renderComponent(
value={{ courseId: 'course-v1:edX+TestX+Test_Course' }}
>
<AlertBanner
content={content}
author={content.author}
abuseFlagged={content.abuseFlagged}
lastEdit={content.lastEdit}
closed={content.closed}
closedBy={content.closedBy}
closeReason={content.closeReason}
editByLabel={content.editByLabel}
closedByLabel={content.closedByLabel}
/>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Alert } from '@edx/paragon';
import messages from '../post-comments/messages';
import AuthorLabel from './AuthorLabel';
const AlertBar = ({
message,
author,
authorLabel,
labelColor,
reason,
}) => {
const intl = useIntl();
return (
<Alert variant="info" className="px-3 shadow-none mb-1 py-10px bg-light-200">
<div className="d-flex align-items-center flex-wrap text-gray-700 font-style">
{message}
<span className="ml-1">
<AuthorLabel
author={author}
authorLabel={authorLabel}
labelColor={labelColor}
linkToProfile
postOrComment
/>
</span>
<span
className="mr-1.5 font-size-8 font-style text-light-700"
style={{ lineHeight: '15px' }}
>
{intl.formatMessage(messages.fullStop)}
</span>
{reason && (`${intl.formatMessage(messages.reason)}: ${reason}`)}
</div>
</Alert>
);
};
AlertBar.propTypes = {
message: PropTypes.string,
author: PropTypes.string,
authorLabel: PropTypes.string,
labelColor: PropTypes.string,
reason: PropTypes.string,
};
AlertBar.defaultProps = {
message: '',
author: '',
authorLabel: '',
labelColor: '',
reason: '',
};
export default React.memo(AlertBar);

View File

@@ -1,28 +1,33 @@
import React, { useContext } from 'react';
import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Link, useLocation } from 'react-router-dom';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import * as timeago from 'timeago.js';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon } from '@edx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } 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';
import timeLocale from './time-locale';
function AuthorLabel({
intl,
const AuthorLabel = ({
author,
authorLabel,
linkToProfile,
labelColor,
alert,
}) {
const location = useLocation();
postCreatedAt,
authorToolTip,
postOrComment,
}) => {
timeago.register('time-locale', timeLocale);
const intl = useIntl();
const { courseId } = useContext(DiscussionContext);
let icon = null;
let authorLabelMessage = null;
@@ -31,75 +36,109 @@ function AuthorLabel({
icon = Institution;
authorLabelMessage = intl.formatMessage(messages.authorLabelStaff);
}
if (authorLabel === 'Community TA') {
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 showTextPrimary = !authorLabelMessage && !isRetiredUser && !alert;
const className = classNames('d-flex align-items-center', { 'mb-0.5': !postOrComment }, labelColor);
const showUserNameAsLink = useShowLearnersTab()
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
&& linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const labelContents = (
<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"
const authorName = useMemo(() => (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser,
})}
role="heading"
aria-level="2"
>
{isRetiredUser ? '[Deactivated]' : author}
</span>
), [author, authorLabelMessage, isRetiredUser]);
const labelContents = useMemo(() => (
<>
<OverlayTrigger
overlay={(
<Tooltip id={`endorsed-by-${author}-tooltip`}>
{author}
</Tooltip>
)}
trigger={['hover', 'focus']}
>
{isRetiredUser ? '[Deactivated]' : author }
</span>
{icon && (
<Icon
style={{
width: '1rem',
height: '1rem',
}}
src={icon}
/>
)}
{authorLabelMessage && (
<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' }}
<div className={classNames('d-flex flex-row align-items-center', {
'disable-div': !authorToolTip,
})}
>
{authorLabelMessage}
<Icon
style={{
width: '1rem',
height: '1rem',
}}
src={icon}
data-testid="author-icon"
/>
{authorLabelMessage && (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
'text-primary-500': showTextPrimary,
'text-gray-700': isRetiredUser,
})}
style={{ marginLeft: '2px' }}
>
{authorLabelMessage}
</span>
)}
</div>
</OverlayTrigger>
{postCreatedAt && (
<span
title={postCreatedAt}
className={classNames('font-family-inter align-content-center', {
'text-white': alert,
'text-gray-500': !alert,
})}
style={{ lineHeight: '20px', fontSize: '12px', marginBottom: '-2.3px' }}
>
{timeago.format(postCreatedAt, 'time-locale')}
</span>
)}
</div>
);
</>
), [author, authorLabelMessage, authorToolTip, icon, isRetiredUser, postCreatedAt, showTextPrimary, alert]);
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' }}
>
<div className={className}>
<Link
data-testid="learner-posts-link"
id="learner-posts-link"
to={generatePath(Routes.LEARNERS.POSTS, { learnerUsername: author, courseId })}
className="text-decoration-none"
style={{ width: 'fit-content' }}
>
{!alert && authorName}
</Link>
{labelContents}
</Link>
</div>
)
: <>{labelContents}</>;
}
: <div className={className}>{authorName}{labelContents}</div>;
};
AuthorLabel.propTypes = {
intl: intlShape.isRequired,
author: PropTypes.string.isRequired,
authorLabel: PropTypes.string,
linkToProfile: PropTypes.bool,
labelColor: PropTypes.string,
alert: PropTypes.bool,
postCreatedAt: PropTypes.string,
authorToolTip: PropTypes.bool,
postOrComment: PropTypes.bool,
};
AuthorLabel.defaultProps = {
@@ -107,6 +146,9 @@ AuthorLabel.defaultProps = {
authorLabel: null,
labelColor: '',
alert: false,
postCreatedAt: null,
authorToolTip: false,
postOrComment: false,
};
export default injectIntl(AuthorLabel);
export default React.memo(AuthorLabel);

View File

@@ -10,12 +10,13 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { courseConfigApiUrl } from '../data/api';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import AuthorLabel from './AuthorLabel';
import { DiscussionContext } from './context';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const courseConfigApiUrl = getCourseConfigApiUrl();
let store;
let axiosMock;
let container;
@@ -65,19 +66,20 @@ describe('Author label', () => {
['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',
])('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}`,
it(
`it is "${!linkToProfile && 'not'}" clickable when linkToProfile is ${!!linkToProfile}`,
async () => {
renderComponent(author, authorLabel, linkToProfile, labelColor);
@@ -86,22 +88,26 @@ describe('Author label', () => {
} else {
expect(screen.queryByTestId('learner-posts-link')).not.toBeInTheDocument();
}
});
},
);
it(`it has "${!linkToProfile && 'not'}" label text and label color when linkToProfile is ${!!linkToProfile}`,
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 labelParentNode = authorElement.parentNode.parentNode;
const labelElement = labelParentNode.lastChild.lastChild;
const label = ['TA', 'Staff'].includes(labelElement.textContent) && labelElement.textContent;
if (linkToProfile) {
expect(authorElement.parentNode).toHaveClass(labelColor);
expect(authorElement.parentNode.lastChild).toHaveTextContent(label);
expect(labelParentNode).toHaveClass(labelColor);
expect(labelElement).toHaveTextContent(label);
} else {
expect(authorElement.parentNode.lastChild).not.toHaveTextContent(label, { exact: true });
expect(authorElement.parentNode).not.toHaveClass(labelColor, { exact: true });
}
});
},
);
});
});

View File

@@ -1,19 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ActionRow, Button, ModalDialog } from '@edx/paragon';
import messages from '../messages';
function DeleteConfirmation({
intl,
const Confirmation = ({
isOpen,
title,
description,
onClose,
onDelete,
}) {
comfirmAction,
closeButtonVaraint,
confirmButtonVariant,
confirmButtonText,
}) => {
const intl = useIntl();
return (
<ModalDialog title={title} isOpen={isOpen} hasCloseButton={false} onClose={onClose} zIndex={5000}>
<ModalDialog.Header>
@@ -26,25 +30,33 @@ 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>
</ModalDialog>
);
}
DeleteConfirmation.propTypes = {
intl: intlShape.isRequired,
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
onDelete: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
};
export default injectIntl(DeleteConfirmation);
Confirmation.propTypes = {
isOpen: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
comfirmAction: PropTypes.func.isRequired,
title: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
closeButtonVaraint: PropTypes.string,
confirmButtonVariant: PropTypes.string,
confirmButtonText: PropTypes.string,
};
Confirmation.defaultProps = {
closeButtonVaraint: 'default',
confirmButtonVariant: 'primary',
confirmButtonText: '',
};
export default React.memo(Confirmation);

View File

@@ -1,73 +1,80 @@
import React from 'react';
import React, { useContext } 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 { useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Icon } 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 messages from '../post-comments/messages';
import { PostCommentsContext } from '../post-comments/postCommentsContext';
import AuthorLabel from './AuthorLabel';
import timeLocale from './time-locale';
function EndorsedAlertBanner({
intl,
content,
postType,
}) {
const EndorsedAlertBanner = ({
endorsed,
endorsedAt,
endorsedBy,
endorsedByLabel,
}) => {
timeago.register('time-locale', timeLocale);
const intl = useIntl();
const { postType } = useContext(PostCommentsContext);
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 && (
endorsed && (
<Alert
variant="plain"
className={`px-3 mb-0 py-10px align-items-center shadow-none ${classes}`}
className={`px-2.5 mb-0 py-8px 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}
<div className="d-flex align-items-center">
<Icon
src={iconClass}
style={{
width: '21px',
height: '20px',
}}
/>
<strong className="ml-2 font-family-inter">
{intl.formatMessage(isQuestion ? messages.answer : messages.endorsed)}
</strong>
</div>
<span className="d-flex align-items-center align-items-center flex-wrap" style={{ marginRight: '-1px' }}>
<AuthorLabel
author={endorsedBy}
authorLabel={endorsedByLabel}
linkToProfile
alert={endorsed}
postCreatedAt={endorsedAt}
authorToolTip
postOrComment
/>
{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,
endorsed: PropTypes.bool.isRequired,
endorsedAt: PropTypes.string,
endorsedBy: PropTypes.string,
endorsedByLabel: PropTypes.string,
};
EndorsedAlertBanner.defaultProps = {
postType: null,
endorsedAt: null,
endorsedBy: null,
endorsedByLabel: null,
};
export default injectIntl(EndorsedAlertBanner);
export default React.memo(EndorsedAlertBanner);

View File

@@ -7,11 +7,12 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import messages from '../comments/messages';
import messages from '../post-comments/messages';
import { PostCommentsContext } from '../post-comments/postCommentsContext';
import { DiscussionContext } from './context';
import EndorsedAlertBanner from './EndorsedAlertBanner';
import '../comments/data/__factories__';
import '../post-comments/data/__factories__';
import '../posts/data/__factories__';
let store;
@@ -21,24 +22,30 @@ function buildTestContent(type, buildParams) {
return camelCaseObject(Factory.build(type, { ...buildParamsSnakeCase }, null));
}
function renderComponent(
content, postType,
) {
const 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}
/>
<PostCommentsContext.Provider value={{
postType,
}}
>
<EndorsedAlertBanner
endorsed={content.endorsed}
endorsedAt={content.endorsedAt}
endorsedBy={content.endorsedBy}
endorsedByLabel={content.endorsedByLabel}
/>
</PostCommentsContext.Provider>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
};
describe.each([
{
@@ -46,21 +53,21 @@ describe.each([
type: 'comment',
postType: ThreadType.QUESTION,
props: { endorsed: true, endorsedBy: 'test-user', endorsedByLabel: 'Staff' },
expectText: [messages.answer.defaultMessage, messages.answeredLabel.defaultMessage, 'test-user', 'Staff'],
expectText: [messages.answer.defaultMessage, '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'],
expectText: [messages.answer.defaultMessage, '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'],
expectText: [messages.endorsed.defaultMessage],
},
])('EndorsedAlertBanner', ({
label, type, postType, props, expectText,

View File

@@ -0,0 +1,142 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Button, Icon, IconButton, OverlayTrigger, Tooltip,
} from '@edx/paragon';
import {
StarFilled, StarOutline, ThumbUpFilled, ThumbUpOutline,
} from '../../components/icons';
import { useUserPostingEnabled } from '../data/hooks';
import { PostCommentsContext } from '../post-comments/postCommentsContext';
import ActionsDropdown from './ActionsDropdown';
import { DiscussionContext } from './context';
const HoverCard = ({
id,
contentType,
actionHandlers,
handleResponseCommentButton,
addResponseCommentButtonMessage,
onLike,
onFollow,
voted,
following,
endorseIcons,
}) => {
const intl = useIntl();
const { enableInContextSidebar } = useContext(DiscussionContext);
const { isClosed } = useContext(PostCommentsContext);
const isUserPrivilagedInPostingRestriction = useUserPostingEnabled();
return (
<div
className="flex-fill justify-content-end align-items-center hover-card mr-n4 position-absolute"
data-testid={`hover-card-${id}`}
id={`hover-card-${id}`}
>
{isUserPrivilagedInPostingRestriction && (
<div className="d-flex">
<Button
variant="tertiary"
className={classNames(
'px-2.5 py-2 border-0 font-style text-gray-700 font-size-12',
{ 'w-100': enableInContextSidebar },
)}
onClick={() => handleResponseCommentButton()}
disabled={isClosed}
style={{ lineHeight: '20px' }}
>
{addResponseCommentButtonMessage}
</Button>
</div>
)}
{endorseIcons && (
<div className="hover-button">
<OverlayTrigger
overlay={(
<Tooltip id="endorsed-icon-tooltip">
{intl.formatMessage(endorseIcons.label)}
</Tooltip>
)}
trigger={['hover', 'focus']}
>
<IconButton
src={endorseIcons.icon}
iconAs={Icon}
onClick={() => {
const actionFunction = actionHandlers[endorseIcons.action];
actionFunction();
}}
className={['endorse', 'unendorse'].includes(endorseIcons.id) ? 'text-dark-500' : 'text-success-500'}
size="sm"
alt="Endorse"
/>
</OverlayTrigger>
</div>
)}
<div className="hover-button">
<IconButton
src={voted ? ThumbUpFilled : ThumbUpOutline}
iconAs={Icon}
size="sm"
alt="Like"
iconClassNames="like-icon-dimentions"
onClick={(e) => {
e.preventDefault();
onLike();
}}
/>
</div>
{following !== undefined && (
<div className="hover-button">
<IconButton
src={following ? StarFilled : StarOutline}
iconAs={Icon}
size="sm"
alt="Follow"
iconClassNames="follow-icon-dimentions"
onClick={(e) => {
e.preventDefault();
onFollow();
}}
/>
</div>
)}
<div className="hover-button ml-auto">
<ActionsDropdown
id={id}
contentType={contentType}
actionHandlers={actionHandlers}
dropDownIconSize
/>
</div>
</div>
);
};
HoverCard.propTypes = {
id: PropTypes.string.isRequired,
contentType: PropTypes.string.isRequired,
actionHandlers: PropTypes.objectOf(PropTypes.func).isRequired,
handleResponseCommentButton: PropTypes.func.isRequired,
addResponseCommentButtonMessage: PropTypes.string.isRequired,
onLike: PropTypes.func.isRequired,
voted: PropTypes.bool.isRequired,
// eslint-disable-next-line react/forbid-prop-types
endorseIcons: PropTypes.objectOf(PropTypes.any),
onFollow: PropTypes.func,
following: PropTypes.bool,
};
HoverCard.defaultProps = {
onFollow: () => null,
endorseIcons: null,
following: undefined,
};
export default React.memo(HoverCard);

View File

@@ -0,0 +1,144 @@
import {
render, screen, waitFor, within,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import DiscussionContent from '../discussions-home/DiscussionContent';
import { getCommentsApiUrl } from '../post-comments/data/api';
import { fetchCommentResponses } from '../post-comments/data/thunks';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { DiscussionContext } from './context';
import '../posts/data/__factories__';
import '../post-comments/data/__factories__';
const commentsApiUrl = getCommentsApiUrl();
const threadsApiUrl = getThreadsApiUrl();
const discussionPostId = 'thread-1';
const courseId = 'course-v1:edX+TestX+Test_Course';
let store;
let axiosMock;
let container;
async function mockAxiosReturnPagedCommentsResponses() {
const parentId = 'comment-1';
const commentsResponsesApiUrl = `${commentsApiUrl}${parentId}/`;
const paramsTemplate = {
page: undefined,
page_size: undefined,
requested_fields: 'profile_image',
reverse_order: true,
};
[1, 2].forEach(async (page) => {
axiosMock.onGet(commentsResponsesApiUrl, { params: { ...paramsTemplate, page } }).reply(
200,
Factory.build('commentsResult', null, {
parentId,
page,
pageSize: 1,
count: 2,
}),
);
await executeThunk(fetchCommentResponses(parentId), store.dispatch, store.getState);
});
}
function renderComponent(postId) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{ courseId, postId }}
>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<DiscussionContent />
<Route
path="*"
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
container = wrapper.container;
return container;
}
describe('HoverCard', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
isPostingEnabled: true,
},
});
store = initializeStore();
Factory.resetAll();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(threadsApiUrl).reply(200, Factory.build('threadsResult'));
axiosMock.onGet(`${getCourseConfigApiUrl()}${courseId}/`).reply(200, { isPostingEnabled: true });
axiosMock.onGet(commentsApiUrl).reply(200, Factory.build('commentsResult', { can_delete: true }, {
threadId: discussionPostId,
endorsed: false,
pageSize: 1,
count: 2,
childCount: 2,
}));
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
await mockAxiosReturnPagedCommentsResponses();
});
test('it should have hover card on post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = screen.getByTestId('post-thread-1');
expect(within(post).getByTestId('hover-card-thread-1')).toBeInTheDocument();
});
test('it should have hover card on comment', async () => {
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
expect(within(comment).getByTestId('hover-card-comment-1')).toBeInTheDocument();
});
test('it should show add response, like, follow and actions menu for hovered post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = screen.getByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
expect(within(hoverCard).queryByRole('button', { name: /Add response/i })).toBeInTheDocument();
expect(within(hoverCard).getByRole('button', { name: /like/i })).toBeInTheDocument();
expect(within(hoverCard).queryByRole('button', { name: /follow/i })).toBeInTheDocument();
expect(within(hoverCard).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
});
test('it should show add comment, Endorse, like and actions menu Buttons for hovered comment', async () => {
await waitFor(() => renderComponent(discussionPostId));
const comment = await waitFor(() => screen.findByTestId('comment-comment-1'));
const hoverCard = within(comment).getByTestId('hover-card-comment-1');
expect(within(hoverCard).queryByRole('button', { name: /Add comment/i })).toBeInTheDocument();
expect(within(hoverCard).getByRole('button', { name: /Endorse/i })).toBeInTheDocument();
expect(within(hoverCard).queryByRole('button', { name: /like/i })).toBeInTheDocument();
expect(within(hoverCard).queryByRole('button', { name: /actions menu/i })).toBeInTheDocument();
});
});

View File

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

View File

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

View File

@@ -7,17 +7,14 @@ ensureConfig([
'LMS_BASE_URL',
], 'Posts API service');
const apiBaseUrl = getConfig().LMS_BASE_URL;
export const courseConfigApiUrl = `${apiBaseUrl}/api/discussion/v1/courses/`;
export const getCourseConfigApiUrl = () => `${getConfig().LMS_BASE_URL}/api/discussion/v1/courses/`;
export const getDiscussionsConfigUrl = (courseId) => `${getCourseConfigApiUrl()}${courseId}/`;
/**
* Get discussions course config
* @param {string} courseId
*/
export async function getDiscussionsConfig(courseId) {
const url = `${courseConfigApiUrl}${courseId}/`;
const { data } = await getAuthenticatedHttpClient().get(url);
const { data } = await getAuthenticatedHttpClient().get(getDiscussionsConfigUrl(courseId));
return data;
}
@@ -26,7 +23,7 @@ export async function getDiscussionsConfig(courseId) {
* @param {string} courseId
*/
export async function getDiscussionsSettings(courseId) {
const url = `${courseConfigApiUrl}${courseId}/settings`;
const url = `${getDiscussionsConfigUrl(courseId)}settings`;
const { data } = await getAuthenticatedHttpClient().get(url);
return data;
}

View File

@@ -0,0 +1,12 @@
import { selectCommentOrResponseById } from '../post-comments/data/selectors';
import { selectThread } from '../posts/data/selectors';
export const ContentSelectors = {
POST: selectThread,
COMMENT: selectCommentOrResponseById,
};
export const ContentTypes = {
POST: 'POST',
COMMENT: 'COMMENT',
};

View File

@@ -1,60 +1,72 @@
/* eslint-disable import/prefer-default-export */
import {
useContext, useEffect, useRef, useState,
useCallback,
useContext, useEffect, useMemo, 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 { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { breakpoints, useWindowSize } from '@edx/paragon';
import { Routes } from '../../data/constants';
import { RequestStatus, Routes } from '../../data/constants';
import { selectTopicsUnderCategory } from '../../data/selectors';
import { fetchCourseBlocks } from '../../data/thunks';
import { DiscussionContext } from '../common/context';
import { clearRedirect } from '../posts/data';
import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import tourCheckpoints from '../tours/constants';
import { selectTours } from '../tours/data/selectors';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath } from '../utils';
import {
selectAreThreadsFiltered, selectLearnersTabEnabled,
selectAreThreadsFiltered,
selectEnableInContext,
selectIsCourseAdmin,
selectIsCourseStaff,
selectIsPostingEnabled,
selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
selectUserIsStaff,
} from './selectors';
import { fetchCourseConfig } from './thunks';
export function useTotalTopicThreadCount() {
const topics = useSelector(selectTopics);
const count = useMemo(
() => (
Object.keys(topics)?.reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0)),
[],
);
if (!topics) {
return 0;
}
return Object.keys(topics).reduce((total, topicId) => {
const topic = topics[topicId];
return total + topic.threadCounts.discussion + topic.threadCounts.question;
}, 0);
return count;
}
export const useSidebarVisible = () => {
const enableInContext = useSelector(selectEnableInContext);
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isViewingTopics = useRouteMatch(Routes.TOPICS.PATH);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
if (isFiltered) {
if (isIncontextTopicsView) {
return true;
}
if (isViewingTopics || isViewingLearners) {
return true;
}
return totalThreads > 0;
return !hideSidebar;
};
export function useCourseDiscussionData(courseId) {
@@ -64,7 +76,6 @@ export function useCourseDiscussionData(courseId) {
useEffect(() => {
async function fetchBaseData() {
await dispatch(fetchCourseConfig(courseId));
await dispatch(fetchCourseTopics(courseId));
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
}
@@ -72,20 +83,21 @@ export function useCourseDiscussionData(courseId) {
}, [courseId]);
}
export function useRedirectToThread(courseId, inContext) {
export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
);
const history = useHistory();
const location = useLocation();
return useEffect(() => {
const redirectToThread = useSelector(
(state) => state.threads.redirectToThread,
);
useEffect(() => {
// After posting a new thread we'd like to redirect users to it, the topic and post id are temporarily
// stored in redirectToThread
if (redirectToThread) {
dispatch(clearRedirect());
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[inContext ? 'topics' : 'my-posts'], {
const newLocation = discussionsPath(Routes.COMMENTS.PAGES[enableInContextSidebar ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
@@ -97,7 +109,7 @@ export function useRedirectToThread(courseId, inContext) {
export function useIsOnDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.large.minWidth;
return windowSize.width >= breakpoints.medium.minWidth;
}
export function useIsOnXLDesktop() {
@@ -115,9 +127,11 @@ export function useContainerSize(refContainer) {
const resizeObserver = useRef(new ResizeObserver(() => {
/* istanbul ignore if: ResizeObserver isn't available in the testing env */
if (refContainer?.current) {
setHeight(refContainer?.current?.clientHeight);
}
window.requestAnimationFrame(() => {
if (refContainer?.current) {
setHeight(refContainer?.current?.clientHeight);
}
});
}));
useEffect(() => {
@@ -138,17 +152,20 @@ export function useContainerSize(refContainer) {
return height;
}
export const useAlertBannerVisible = (content) => {
export const useAlertBannerVisible = (
{
author, abuseFlagged, lastEdit, closed,
} = {},
) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === content.author;
const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = content.abuseFlagged;
const canSeeReportedBanner = abuseFlagged;
return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (content.lastEdit?.reason || content.closed))
|| (content.abuseFlagged && canSeeReportedBanner)
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
);
};
@@ -174,3 +191,72 @@ export const useCurrentDiscussionTopic = () => {
}
return null;
};
export const useUserPostingEnabled = () => {
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const isUserAdmin = useSelector(selectUserIsStaff);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff);
const isPrivileged = isUserAdmin || userHasModerationPrivileges || isUserGroupTA || isCourseAdmin || isCourseStaff;
return (isPostingEnabled || isPrivileged);
};
function camelToConstant(string) {
return string.replace(/[A-Z]/g, (match) => `_${match}`).toUpperCase();
}
export const useTourConfiguration = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { enableInContextSidebar } = useContext(DiscussionContext);
const tours = useSelector(selectTours);
const handleOnDismiss = useCallback((id) => (
dispatch(updateTourShowStatus(id))
), []);
const handleOnEnd = useCallback((id) => (
dispatch(updateTourShowStatus(id))
), []);
const toursConfig = useMemo(() => (
tours?.map((tour) => (
{
tourId: tour.tourName,
advanceButtonText: intl.formatMessage(messages.advanceButtonText),
dismissButtonText: intl.formatMessage(messages.dismissButtonText),
endButtonText: intl.formatMessage(messages.endButtonText),
enabled: tour && Boolean(tour.enabled && tour.showTour && !enableInContextSidebar),
onDismiss: () => handleOnDismiss(tour.id),
onEnd: () => handleOnEnd(tour.id),
checkpoints: tourCheckpoints(intl)[camelToConstant(tour.tourName)],
}
))
), [tours, enableInContextSidebar]);
return toursConfig;
};
export const useDebounce = (value, delay) => {
// State and setters for debounced value
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(
() => {
// Update debounced value after delay
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Cancel the timeout if value changes (also on delay change or unmount)
// This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted.
return () => {
clearTimeout(handler);
};
},
[value, delay], // Only re-call effect if value or delay changes
);
return debouncedValue;
};

View File

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

View File

@@ -20,10 +20,16 @@ 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 selectEnableInContext = state => state.config.enableInContext;
export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,
@@ -47,7 +53,7 @@ export function selectAreThreadsFiltered(state) {
export function selectTopicThreadCount(topicId) {
return state => {
const topic = state.topics.topics[topicId];
const topic = topicId && state.topics?.topics[topicId];
if (!topic) {
return 0;
}

View File

@@ -7,15 +7,17 @@ const configSlice = createSlice({
name: 'config',
initialState: {
status: RequestStatus.IN_PROGRESS,
blackouts: [],
allowAnonymous: false,
allowAnonymousToPeers: false,
userRoles: [],
groupAtSubsection: false,
hasModerationPrivileges: false,
isGroupTa: false,
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
learnersTabEnabled: false,
isPostingEnabled: false,
settings: {
divisionScheme: 'none',
alwaysDivideInlineDiscussions: false,
@@ -25,6 +27,7 @@ const configSlice = createSlice({
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
enableInContext: false,
},
reducers: {
fetchConfigRequest: (state) => {

View File

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

View File

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

@@ -1,37 +1,39 @@
import React from 'react';
import React, { lazy, Suspense } from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { injectIntl } from '@edx/frontend-platform/i18n';
import Spinner from '../../components/Spinner';
import { Routes } from '../../data/constants';
import { CommentsView } from '../comments';
import { PostEditor } from '../posts';
function DiscussionContent() {
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
const DiscussionContent = () => {
const postEditorVisible = useSelector((state) => state.threads.postEditorVisible);
return (
<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 />
</Route>
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
<Suspense fallback={(<Spinner />)}>
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<CommentsView />
</Route>
</Switch>
)}
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<PostCommentsView />
</Route>
</Switch>
)}
</Suspense>
</div>
</div>
);
}
};
export default injectIntl(DiscussionContent);
export default DiscussionContent;

View File

@@ -1,4 +1,6 @@
import React, { useContext, useEffect, useRef } from 'react';
import React, {
lazy, Suspense, useContext, useEffect, useRef,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -9,79 +11,101 @@ import {
import { useWindowSize } from '@edx/paragon';
import Spinner from '../../components/Spinner';
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';
import { selectconfigLoadingStatus, selectEnableInContext } from '../data/selectors';
export default function DiscussionSidebar({ displaySidebar, postActionBarRef }) {
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
const LearnerPostsView = lazy(() => import('../learners/LearnerPostsView'));
const LearnersView = lazy(() => import('../learners/LearnersView'));
const PostsView = lazy(() => import('../posts/PostsView'));
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const enableInContext = useSelector(selectEnableInContext);
const configStatus = useSelector(selectconfigLoadingStatus);
const redirectToLearnersTab = useShowLearnersTab();
const sidebarRef = useRef(null);
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
const { inContext } = useContext(DiscussionContext);
useEffect(() => {
if (sidebarRef && postActionBarHeight && !inContext) {
if (sidebarRef && postActionBarHeight && !enableInContextSidebar) {
if (isOnDesktop) {
sidebarRef.current.style.maxHeight = `${windowHeight - postActionBarHeight}px`;
}
sidebarRef.current.style.minHeight = `${windowHeight - postActionBarHeight}px`;
sidebarRef.current.style.top = `${postActionBarHeight}px`;
}
}, [sidebarRef, postActionBarHeight, inContext]);
}, [sidebarRef, postActionBarHeight, enableInContextSidebar]);
return (
<div
ref={sidebarRef}
className={classNames('flex-column position-sticky', {
className={classNames('flex-column position-sticky', {
'd-none': !displaySidebar,
'd-flex overflow-auto': displaySidebar,
'd-flex overflow-auto box-shadow-centered-1': displaySidebar,
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'min-content-height': !inContext,
'min-content-height': !enableInContextSidebar,
})}
data-testid="sidebar"
>
<Switch>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY, Routes.POSTS.MY_POSTS]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={TopicsView} />
{redirectToLearnersTab && (
<Suspense fallback={(<Spinner />)}>
<Switch>
{enableInContext && !enableInContextSidebar && (
<Route
path={Routes.TOPICS.ALL}
component={InContextTopicsView}
exact
/>
)}
{enableInContext && !enableInContextSidebar && (
<Route
path={[
Routes.TOPICS.TOPIC,
Routes.TOPICS.CATEGORY,
Routes.TOPICS.TOPIC_POST,
Routes.TOPICS.TOPIC_POST_EDIT,
]}
component={TopicPostsView}
exact
/>
)}
<Route
path={[Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS, Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
<Route path={Routes.TOPICS.PATH} component={LegacyTopicsView} />
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.POSTS} component={LearnerPostsView} />
)}
{redirectToLearnersTab && (
)}
{redirectToLearnersTab && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</Suspense>
</div>
);
}
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
DiscussionSidebar.propTypes = {
@@ -91,3 +115,10 @@ DiscussionSidebar.propTypes = {
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
};
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
export default React.memo(DiscussionSidebar);

View File

@@ -13,7 +13,7 @@ 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 { getThreadsApiUrl } from '../posts/data/api';
import DiscussionSidebar from './DiscussionSidebar';
import '../posts/data/__factories__';
@@ -21,6 +21,7 @@ import '../posts/data/__factories__';
let store;
let container;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const threadsApiUrl = getThreadsApiUrl();
let axiosMock;
function renderComponent(displaySidebar = true, location = `/${courseId}/`) {

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useRef } from 'react';
/* eslint-disable react/jsx-no-constructed-context-values */
import React, { lazy, Suspense, useRef } from 'react';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
@@ -6,113 +7,120 @@ 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 { Spinner } from '../../components';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useShowLearnersTab, useSidebarVisible,
} from '../data/hooks';
import { selectDiscussionProvider } from '../data/selectors';
import { selectDiscussionProvider, selectEnableInContext } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
import messages from '../messages';
import { BreadcrumbMenu, LegacyBreadcrumbMenu, NavigationBar } from '../navigation';
import { postMessageToParent } from '../utils';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionContent from './DiscussionContent';
import DiscussionSidebar from './DiscussionSidebar';
import InformationBanner from './InformationsBanner';
import { selectPostEditorVisible } from '../posts/data/selectors';
import useFeedbackWrapper from './FeedbackWrapper';
export default function DiscussionsHome() {
const Footer = lazy(() => import('@edx/frontend-component-footer'));
const PostActionsBar = lazy(() => import('../posts/post-actions-bar/PostActionsBar'));
const CourseTabsNavigation = lazy(() => import('../../components/NavigationBar/CourseTabsNavigation'));
const LegacyBreadcrumbMenu = lazy(() => import('../navigation/breadcrumb-menu/LegacyBreadcrumbMenu'));
const NavigationBar = lazy(() => import('../navigation/navigation-bar/NavigationBar'));
const DiscussionsProductTour = lazy(() => import('../tours/DiscussionsProductTour'));
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
const DiscussionContent = lazy(() => import('./DiscussionContent'));
const DiscussionSidebar = lazy(() => import('./DiscussionSidebar'));
const InformationBanner = lazy(() => import('./InformationBanner'));
const 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 postEditorVisible = useSelector(selectPostEditorVisible);
const provider = useSelector(selectDiscussionProvider);
const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
const { params } = useRouteMatch(ALL_ROUTES);
const isRedirectToLearners = useShowLearnersTab();
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId,
postId,
topicId,
category,
learnerUsername,
} = params;
const inContext = new URLSearchParams(location.search).get('inContext') !== null;
// Display the content area if we are currently viewing/editing a post or creating one.
const displayContentArea = postId || postEditorVisible || (learnerUsername && postId);
let displaySidebar = useSidebarVisible();
const isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
const isFeedbackBannerVisible = getConfig().DISPLAY_FEEDBACK_BANNER === 'true';
const {
courseId, postId, topicId, category, learnerUsername,
} = params;
const { courseNumber, courseTitle, org } = useSelector((state) => state.courseTabs);
if (displayContentArea) {
// If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
// However, for smaller screens or embeds, only show the sidebar if the content area isn't displayed.
displaySidebar = isOnDesktop;
}
const provider = useSelector(selectDiscussionProvider);
useCourseDiscussionData(courseId);
useRedirectToThread(courseId, inContext);
useEffect(() => {
if (path && path !== 'undefined') {
postMessageToParent('discussions.navigate', { path });
}
}, [path]);
useRedirectToThread(courseId, enableInContextSidebar);
useFeedbackWrapper();
/* Display the content area if we are currently viewing/editing a post or creating one.
If the window is larger than a particular size, show the sidebar for navigating between posts/topics.
However, for smaller screens or embeds, onlyshow the sidebar if the content area isn't displayed. */
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; }
return (
<DiscussionContext.Provider value={{
page,
courseId,
postId,
topicId,
inContext,
category,
learnerUsername,
}}
>
{!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={classNames('header-action-bar', { 'shadow-none border-light-300 border-bottom': inContext })}
ref={postActionBarRef}
>
<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>
{!inContext && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={provider === DiscussionProvider.LEGACY ? LegacyBreadcrumbMenu : BreadcrumbMenu}
/>
<Suspense fallback={(<Spinner />)}>
<DiscussionContext.Provider value={{
page,
courseId,
postId,
topicId,
enableInContextSidebar,
category,
learnerUsername,
}}
>
{!enableInContextSidebar && (
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
)}
<div className="d-flex flex-row">
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
{displayContentArea && <DiscussionContent />}
{!displayContentArea && (
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
{!enableInContextSidebar && <CourseTabsNavigation activeTab="discussion" courseId={courseId} />}
<div
className={classNames('header-action-bar', {
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
})}
ref={postActionBarRef}
>
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-3 py-0': enableInContextSidebar,
})}
>
{!enableInContextSidebar && (
<NavigationBar />
)}
<PostActionsBar />
</div>
{isFeedbackBannerVisible && <InformationBanner />}
<DiscussionsRestrictionBanner />
</div>
{provider === DiscussionProvider.LEGACY && (
<Suspense fallback={(<Spinner />)}>
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={LegacyBreadcrumbMenu}
/>
</Suspense>
)}
<div className="d-flex flex-row position-relative">
<Suspense fallback={(<Spinner />)}>
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={postActionBarRef} />
</Suspense>
{displayContentArea && (
<Suspense fallback={(<Spinner />)}>
<DiscussionContent />
</Suspense>
)}
{!displayContentArea && (
<Switch>
<Route path={Routes.TOPICS.PATH} component={EmptyTopics} />
<Route
path={Routes.TOPICS.PATH}
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
/>
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
@@ -121,12 +129,18 @@ export default function DiscussionsHome() {
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} /> }
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
</Switch>
)}
</div>
{!enableInContextSidebar && (
<DiscussionsProductTour />
)}
</div>
</main>
{!inContext && <Footer />}
</DiscussionContext.Provider>
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>
</Suspense>
);
}
};
export default React.memo(DiscussionsHome);

View File

@@ -1,23 +1,45 @@
import {
fireEvent, render, screen, waitFor,
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { getCourseMetadataApiUrl } from '../../components/NavigationBar/data/api';
import { fetchTab } from '../../components/NavigationBar/data/thunks';
import { getApiBaseUrl } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCourseConfigApiUrl, getDiscussionsConfigUrl } from '../data/api';
import { fetchCourseConfig } from '../data/thunks';
import { getCourseTopicsApiUrl } from '../in-context-topics/data/api';
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
import navigationBarMessages from '../navigation/navigation-bar/messages';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import { fetchCourseTopics } from '../topics/data/thunks';
import DiscussionsHome from './DiscussionsHome';
import '../posts/data/__factories__/threads.factory';
import '../in-context-topics/data/__factories__/inContextTopics.factory';
import '../topics/data/__factories__/topics.factory';
import '../../components/NavigationBar/data/__factories__/navigationBar.factory';
const courseConfigApiUrl = getCourseConfigApiUrl();
let axiosMock;
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
let container;
function renderComponent(location = `/${courseId}/`) {
render(
const wrapper = render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
@@ -28,6 +50,7 @@ function renderComponent(location = `/${courseId}/`) {
</ResponsiveContext.Provider>
</IntlProvider>,
);
container = wrapper.container;
}
describe('DiscussionsHome', () => {
@@ -40,10 +63,20 @@ describe('DiscussionsHome', () => {
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
store = initializeStore();
});
async function setUpV1TopicsMockResponse() {
axiosMock
.onGet(`${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`)
.reply(200, {
courseware_topics: Factory.buildList('category', 2),
non_courseware_topics: Factory.buildList('topic', 3, {}, { topicPrefix: 'ncw' }),
});
await executeThunk(fetchCourseTopics(courseId), store.dispatch, store.getState);
}
test('clicking "All Topics" button renders topics view', async () => {
renderComponent();
@@ -63,7 +96,9 @@ describe('DiscussionsHome', () => {
});
test('in-context view should show close button', async () => {
renderComponent(`/${courseId}/topics?inContext`);
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
renderComponent(`/${courseId}/topics?inContextSidebar`);
expect(screen.queryByText(navigationBarMessages.allTopics.defaultMessage))
.not
@@ -73,10 +108,12 @@ describe('DiscussionsHome', () => {
});
test('the close button should post a message', async () => {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, { provider: 'openedx' });
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
const { parent } = window;
delete window.parent;
window.parent = { ...window, postMessage: jest.fn() };
renderComponent(`/${courseId}/topics?inContext`);
renderComponent(`/${courseId}/topics?inContextSidebar`);
const closeButton = screen.queryByRole('button', { name: 'Close' });
@@ -88,10 +125,139 @@ describe('DiscussionsHome', () => {
window.parent = parent;
});
test('header, course navigation bar and footer are visible', async () => {
test('header, course navigation bar and footer are only visible in Discussions MFE', async () => {
renderComponent();
expect(screen.queryByRole('banner')).toBeInTheDocument();
waitFor(() => expect(screen.queryByRole('banner')).toBeInTheDocument());
expect(document.getElementById('courseTabsNavigation')).toBeInTheDocument();
expect(screen.queryByRole('contentinfo')).toBeInTheDocument();
});
it.each([
{ searchByEndPoint: 'category/unit-1' },
{ searchByEndPoint: 'topics/topic-1' },
])('should display add a post message for inContext empty topics %s', async ({ searchByEndPoint }) => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/${searchByEndPoint}`);
expect(screen.queryByText('Add a post')).toBeInTheDocument();
});
it.each([
{ searchByEndPoint: 'category/section-topic-1', result: 'Add a post' },
{ searchByEndPoint: 'topics/topic-1', result: 'No post selected' },
])(`should display No post selected message on posts pages when user has yet to select a post to display
for incontext topics %s`, async ({ searchByEndPoint, result }) => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
axiosMock.onGet(getThreadsApiUrl())
.reply(() => {
const threadAttrs = { previewBody: 'thread preview body' };
return [200, Factory.build('threadsResult', {}, {
topicId: 'noncourseware-topic-1',
threadAttrs,
count: 3,
})];
});
await executeThunk(fetchThreads(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/${searchByEndPoint}`);
expect(screen.queryByText(result)).toBeInTheDocument();
});
it.each([
{ searchByEndPoint: 'category/section-topic-1' },
{ searchByEndPoint: 'topics' },
])(
'should display No Topic selected message on inContext topic pages when user has yet to select a topic %s',
async ({ searchByEndPoint }) => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
enableInContext: true, provider: 'openedx', hasModerationPrivileges: true,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
axiosMock.onGet(`${getCourseTopicsApiUrl()}${courseId}`)
.reply(200, (Factory.buildList('topic', 1, null, {
topicPrefix: 'noncourseware-topic',
enabledInContext: true,
topicNamePrefix: 'general-topic',
usageKey: '',
courseware: false,
discussionCount: 1,
questionCount: 1,
}).concat(Factory.buildList('section', 2, null, { topicPrefix: 'courseware' })))
.concat(Factory.buildList('archived-topics', 2, null)));
await executeThunk(fetchCourseTopicsV3(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/${searchByEndPoint}`);
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
},
);
it('should display empty page message for empty learners list', async () => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
learners_tab_enabled: true,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/learners`);
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
});
it('should display post editor form when click on add a post button for posts', async () => {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/my-posts`);
await act(async () => {
fireEvent.click(screen.queryByText('Add a post'));
});
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
});
it('should display post editor form when click on add a post button in legacy topics view', async () => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
enable_in_context: false,
});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/topics`);
expect(screen.queryByText('Nothing here yet')).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.queryByText('Add a post'));
});
await waitFor(() => expect(container.querySelector('.post-form')).toBeInTheDocument());
});
it('should display Add a post button for legacy topics view', async () => {
await renderComponent(`/${courseId}/topics/topic-1`);
expect(screen.queryByText('Add a post')).toBeInTheDocument();
});
it('should display No post selected for legacy topics view', async () => {
await setUpV1TopicsMockResponse();
await renderComponent(`/${courseId}/topics/category-1-topic-1`);
expect(screen.queryByText('No post selected')).toBeInTheDocument();
});
it('should display No topic selected for legacy topics view', async () => {
await setUpV1TopicsMockResponse();
await renderComponent(`/${courseId}/topics`);
expect(screen.queryByText('No topic selected')).toBeInTheDocument();
});
it('should display navigation tabs', async () => {
axiosMock.onGet(`${getCourseMetadataApiUrl(courseId)}`).reply(200, (Factory.build('navigationBar', 1)));
await executeThunk(fetchTab(courseId, 'outline'), store.dispatch, store.getState);
renderComponent(`/${courseId}/topics`);
expect(screen.queryByText('Discussion')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,36 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { PageBanner } from '@edx/paragon';
import { RequestStatus } from '../../data/constants';
import { selectconfigLoadingStatus, selectIsPostingEnabled } from '../data/selectors';
import messages from '../messages';
const DiscussionsRestrictionBanner = () => {
const intl = useIntl();
const isPostingEnabled = useSelector(selectIsPostingEnabled);
const configLoadingStatus = useSelector(selectconfigLoadingStatus);
const [showBanner, setShowBanner] = useState(true);
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return (
<PageBanner
variant="accentB"
show={!isPostingEnabled && showBanner && configLoadingStatus === RequestStatus.SUCCESSFUL}
dismissible
onDismiss={handleDismiss}
>
<div className="font-weight-500">
{intl.formatMessage(messages.blackoutDiscussionInformation)}
</div>
</PageBanner>
);
};
export default DiscussionsRestrictionBanner;

View File

@@ -8,7 +8,7 @@ import { initializeStore } from '../../store';
import { DiscussionContext } from '../common/context';
import { fetchConfigSuccess } from '../data/slices';
import messages from '../messages';
import BlackoutInformationBanner from './BlackoutInformationBanner';
import DiscussionsRestrictionBanner from './DiscussionsRestrictionBanner';
let store;
let container;
@@ -20,13 +20,13 @@ activeEndDate.setDate(activeEndDate.getDate() + 2);
activeStartDate = activeStartDate.toISOString();
activeEndDate = activeEndDate.toISOString();
const getConfigData = (blackouts = []) => ({
const getConfigData = (isPostingEnabled) => ({
id: 'course-v1:edX+DemoX+Demo_Course',
userRoles: ['Admin', 'Student'],
hasModerationPrivileges: false,
isGroupTa: false,
isUserAdmin: false,
blackouts,
isPostingEnabled,
});
function renderComponent() {
@@ -34,7 +34,7 @@ function renderComponent() {
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<BlackoutInformationBanner />
<DiscussionsRestrictionBanner />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
@@ -43,7 +43,7 @@ function renderComponent() {
return container;
}
describe('Blackout Information Banner', () => {
describe('Discussions Restriction Banner', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
@@ -56,13 +56,11 @@ describe('Blackout Information Banner', () => {
});
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 }) => {
{ isPostingEnabled: false, visibility: true },
{ isPostingEnabled: true, visibility: false },
])('Test Discussions Restriction is visible on app load if posting is disabled', async ({ isPostingEnabled, visibility }) => {
store = initializeStore();
await store.dispatch(fetchConfigSuccess(getConfigData(blackouts)));
await store.dispatch(fetchConfigSuccess(getConfigData(isPostingEnabled)));
renderComponent();
if (visibility) {
const element = await screen.findByRole('alert');

View File

@@ -0,0 +1,37 @@
import { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { logError } from '@edx/frontend-platform/logging';
import { RequestStatus } from '../../data/constants';
import {
selectconfigLoadingStatus,
selectIsCourseAdmin,
selectIsCourseStaff,
selectUserIsGroupTa,
selectUserIsStaff,
} from '../data/selectors';
export default function useFeedbackWrapper() {
const isStaff = useSelector(selectUserIsStaff);
const isUserGroupTA = useSelector(selectUserIsGroupTa);
const isCourseAdmin = useSelector(selectIsCourseAdmin);
const isCourseStaff = useSelector(selectIsCourseStaff);
const configStatus = useSelector(selectconfigLoadingStatus);
useEffect(() => {
if (configStatus === RequestStatus.SUCCESSFUL) {
let url = '//w.usabilla.com/9e6036348fa1.js';
if (isStaff || isUserGroupTA || isCourseAdmin || isCourseStaff) {
url = '//w.usabilla.com/767740a06856.js';
}
try {
// eslint-disable-next-line no-undef
window.usabilla_live = lightningjs.require('usabilla_live', url);
} catch (err) {
logError(err);
}
}
}, [configStatus]);
}

View File

@@ -1,16 +1,15 @@
import React, { useState } from 'react';
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } 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 InformationBanner = () => {
const intl = useIntl();
const [showBanner, setShowBanner] = useState(true);
const userRoles = useSelector(selectUserRoles);
const isAdmin = useSelector(selectUserIsStaff);
@@ -20,12 +19,16 @@ function InformationBanner({
const hideLearnMoreButton = ((userRoles.includes('Student') && userRoles.length === 1) || !userRoles.length) && !isAdmin;
const showStaffLink = isAdmin || userRoles.includes('Moderator') || userRoles.includes('Administrator');
const handleDismiss = useCallback(() => {
setShowBanner(false);
}, []);
return (
<PageBanner
variant="light"
show={showBanner}
dismissible
onDismiss={() => setShowBanner(false)}
onDismiss={handleDismiss}
>
<div className="font-weight-500">
{intl.formatMessage(messages.bannerMessage)}
@@ -55,10 +58,6 @@ function InformationBanner({
</div>
</PageBanner>
);
}
InformationBanner.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(InformationBanner);
export default InformationBanner;

View File

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

View File

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

View File

@@ -5,17 +5,17 @@ import classNames from 'classnames';
import { Button } from '@edx/paragon';
import { ReactComponent as EmptyIcon } from '../../assets/empty.svg';
import EmptyIcon from '../../assets/Empty';
function EmptyPage({
const EmptyPage = ({
title,
subTitle = null,
action = null,
actionText = null,
fullWidth = false,
}) {
}) => {
const containerClasses = classNames(
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column pt-5',
'min-content-height justify-content-center align-items-center d-flex w-100 flex-column',
{ 'bg-light-400': !fullWidth },
);
@@ -23,7 +23,7 @@ function EmptyPage({
<div className={containerClasses}>
<div className="d-flex flex-column align-items-center">
<EmptyIcon />
<h3 className="pt-3">{title}</h3>
<h3 className="pt-3 text-gray-500 font-weight-500">{title}</h3>
{subTitle && <p className="pb-2">{subTitle}</p>}
{action && actionText && (
<Button onClick={action} variant="outline-dark">
@@ -33,7 +33,7 @@ function EmptyPage({
</div>
</div>
);
}
};
EmptyPage.propTypes = {
title: propTypes.string.isRequired,
@@ -50,4 +50,4 @@ EmptyPage.defaultProps = {
actionText: null,
};
export default EmptyPage;
export default React.memo(EmptyPage);

View File

@@ -1,26 +1,27 @@
import React from 'react';
import React, { useCallback } from 'react';
import propTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { useIsOnDesktop } from '../data/hooks';
import { selectAreThreadsFiltered, selectPostThreadCount } from '../data/selectors';
import messages from '../messages';
// eslint-disable-next-line import/no-cycle
import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage';
function EmptyPosts({ intl, subTitleMessage }) {
const EmptyPosts = ({ subTitleMessage }) => {
const intl = useIntl();
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isOnDesktop = useIsOnDesktop();
function addPost() {
return dispatch(showPostEditor());
}
const addPost = useCallback(() => (
dispatch(showPostEditor())
), []);
let title = messages.noPostSelected;
let subTitle = null;
@@ -49,11 +50,14 @@ function EmptyPosts({ intl, subTitleMessage }) {
fullWidth={fullWidth}
/>
);
}
EmptyPosts.propTypes = {
subTitleMessage: propTypes.string.isRequired,
intl: intlShape.isRequired,
};
export default injectIntl(EmptyPosts);
EmptyPosts.propTypes = {
subTitleMessage: propTypes.shape({
id: propTypes.string,
defaultMessage: propTypes.string,
description: propTypes.string,
}).isRequired,
};
export default React.memo(EmptyPosts);

View File

@@ -12,7 +12,7 @@ import { AppProvider } from '@edx/frontend-platform/react';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import messages from '../messages';
import { threadsApiUrl } from '../posts/data/api';
import { getThreadsApiUrl } from '../posts/data/api';
import { fetchThreads } from '../posts/data/thunks';
import EmptyPosts from './EmptyPosts';
@@ -20,6 +20,7 @@ import '../posts/data/__factories__';
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const threadsApiUrl = getThreadsApiUrl();
function renderComponent(location = `/${courseId}/`) {
return render(

View File

@@ -1,29 +1,29 @@
import React from 'react';
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../data/constants';
import { useIsOnDesktop, useTotalTopicThreadCount } from '../data/hooks';
import { selectTopicThreadCount } from '../data/selectors';
import messages from '../messages';
// eslint-disable-next-line import/no-cycle
import { messages as postMessages, showPostEditor } from '../posts';
import EmptyPage from './EmptyPage';
function EmptyTopics({ intl }) {
const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES);
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
function addPost() {
return dispatch(showPostEditor());
}
const isOnDesktop = useIsOnDesktop();
const addPost = useCallback(() => (
dispatch(showPostEditor())
), []);
let title = messages.emptyTitle;
let fullWidth = false;
@@ -62,10 +62,6 @@ function EmptyTopics({ intl }) {
fullWidth={fullWidth}
/>
);
}
EmptyTopics.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(EmptyTopics);
export default EmptyTopics;

View File

@@ -9,7 +9,7 @@ import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { API_BASE_URL } from '../../data/constants';
import { getApiBaseUrl } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import messages from '../messages';
@@ -20,7 +20,7 @@ import '../topics/data/__factories__';
let store;
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const topicsApiUrl = `${API_BASE_URL}/api/discussion/v1/course_topics/${courseId}`;
const topicsApiUrl = `${getApiBaseUrl()}/api/discussion/v1/course_topics/${courseId}`;
function renderComponent(location = `/${courseId}/topics/`) {
return render(

View File

@@ -1,4 +1,5 @@
export { default as EmptyLearners } from './EmptyLearners';
export { default as EmptyPage } from './EmptyPage';
// eslint-disable-next-line import/no-cycle
export { default as EmptyPosts } from './EmptyPosts';
export { default as EmptyTopics } from './EmptyTopics';

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