Compare commits

..

21 Commits

Author SHA1 Message Date
ayeshoali
ed3addc021 chore: removed enable_learners_tab_in_discussions_mfe flag dependency 2024-01-05 16:24:06 +05:00
sundasnoreen12
b467298d9a fix: fixed UI issues of discussion for incontext sidebar (#633)
* fix: fixed UI issues of discussion for incontext sidebar

* refactor: added paragon class

* refactor: improved actionBar UI

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2024-01-02 13:35:47 +05:00
renovate[bot]
b5d036a54d fix(deps): update dependency regenerator-runtime to v0.14.1 2023-12-18 15:05:01 +00:00
renovate[bot]
bc997108ef chore(deps): update dependency @edx/frontend-build to v13.0.14 (#628)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-18 18:33:51 +05:00
vladislavkeblysh
6ae5130c14 feat: fixed page styles (#577) 2023-12-18 16:26:38 +05:00
Jenkins
67d79cb3aa chore(i18n): update translations 2023-12-17 15:22:26 -05:00
renovate[bot]
f31a0e71f3 fix(deps): update dependency formik to v2.4.5 (#619)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-14 15:15:42 +05:00
renovate[bot]
9761787c89 fix(deps): update dependency @reduxjs/toolkit to v1.9.7 (#616)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-14 12:30:12 +05:00
renovate[bot]
e5a21f4a75 chore(deps): update dependency @edx/frontend-build to v13.0.12 (#624)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 14:16:54 +05:00
renovate[bot]
1d89e9556a fix(deps): update dependency @edx/frontend-component-footer to v12.6.1 (#625)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 14:11:01 +05:00
Syed Ali Abbas Zaidi
b35632df64 feat: upgrade react router to v6 (#542)
* feat: upgrade react router to v6

* fix: routing issues

* fix: category route should redirect to all posts

* fix: path error on routes
2023-12-07 18:10:48 +05:00
Kshitij Sobti
b36c0266fd fix: null error at useRouteMatch when running on tutor (#613)
tutor sets the PUBLIC_PATH to '/discussions' which causes frontend-platform to
treat all URLs for matching etc to be relative to this path. Since many places
include '/discussions' in the match it causes those matches to break.

This change makes the default PUBLIC_PATH in .env.development to match the one
set by tutor and removes it from the base path of the router letting frontend
platform handle the prefix.

This also allows for deployments to customise this path to be something other
than 'discussions'.
2023-12-06 17:20:28 +05:00
renovate[bot]
0d5df18ab2 fix(deps): update dependency redux to v4.2.1 (#621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 12:21:46 +05:00
renovate[bot]
c61435546d fix(deps): update dependency regenerator-runtime to v0.14.0 (#622)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-05 12:03:29 +05:00
renovate[bot]
df4a3c2a73 chore(deps): update dependency rosie to v2.1.1 (#605)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 16:15:31 +05:00
renovate[bot]
ac635edcb8 chore(deps): update dependency @edx/frontend-build to v13.0.8 (#608)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 13:07:44 +05:00
renovate[bot]
c4f7115732 fix(deps): update dependency @edx/frontend-component-footer to v12.6.0 (#609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 13:01:08 +05:00
renovate[bot]
5cc8ba43fe fix(deps): update dependency @edx/frontend-component-header to v4.10.1 (#610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 12:51:30 +05:00
renovate[bot]
68505821bb fix(deps): update dependency @edx/paragon to v20.46.3 (#611)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-04 11:51:58 +05:00
Ahtisham Shahid
c6d953fe7b feat: removed enable_moderation_reason_codes flag (#615)
* chore: removed deprecated flag

fix: resolved linter error

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

fix: changed workflow

* test: fixed postEditor test case

---------

Co-authored-by: Awais Ansari <awais.ansari63@gmail.com>
2023-12-03 22:49:08 +05:00
Jenkins
a479f5ae5b chore(i18n): update translations 2023-11-19 15:22:18 -05:00
94 changed files with 2155 additions and 2414 deletions

1143
package-lock.json generated
View File

@@ -10,34 +10,34 @@
"license": "AGPL-3.0",
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.5.1",
"@edx/frontend-component-header": "4.9.1",
"@edx/frontend-platform": "4.6.3",
"@edx/paragon": "20.44.0",
"@reduxjs/toolkit": "1.8.0",
"@edx/frontend-component-footer": "12.6.1",
"@edx/frontend-component-header": "4.10.1",
"@edx/frontend-platform": "5.6.1",
"@edx/paragon": "20.46.3",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"core-js": "3.21.1",
"dompurify": "^2.4.3",
"formik": "2.2.9",
"formik": "2.4.5",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"raw-loader": "4.0.2",
"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",
"regenerator-runtime": "0.13.9",
"react-router": "6.18.0",
"react-router-dom": "6.18.0",
"redux": "4.2.1",
"regenerator-runtime": "0.14.1",
"timeago.js": "4.0.2",
"tinymce": "5.10.7",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "13.0.5",
"@edx/frontend-build": "13.0.14",
"@edx/reactifex": "1.1.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
@@ -48,7 +48,7 @@
"glob": "7.2.0",
"husky": "7.0.4",
"jest": "27.5.1",
"rosie": "2.1.0"
"rosie": "2.1.1"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -2057,9 +2057,9 @@
}
},
"node_modules/@edx/frontend-build": {
"version": "13.0.5",
"resolved": "https://registry.npmjs.org/@edx/frontend-build/-/frontend-build-13.0.5.tgz",
"integrity": "sha512-cGCw4deCTjLTt2kVoMKOOo+8HS+CSpRjlZBEln1Qfu/868PEB0IWM1E3c7d0rIlkR9kkt7s7WFpYxcs1fk7Ryw==",
"version": "13.0.14",
"resolved": "https://registry.npmjs.org/@edx/frontend-build/-/frontend-build-13.0.14.tgz",
"integrity": "sha512-AR/2GvIecX4LxJT4QIoeeBbnUVjjpRnT2P6gaqO8zEeoAS9ugYRQmqvCCeKJnt7vGmEEcincKfWJQu5nfUGfdA==",
"dependencies": {
"@babel/cli": "7.22.5",
"@babel/core": "7.22.5",
@@ -2095,26 +2095,26 @@
"eslint-plugin-react-hooks": "4.6.0",
"express": "4.18.2",
"file-loader": "6.2.0",
"html-webpack-plugin": "5.5.3",
"html-webpack-plugin": "5.5.4",
"identity-obj-proxy": "3.0.0",
"image-minimizer-webpack-plugin": "3.8.3",
"jest": "26.6.3",
"mini-css-extract-plugin": "1.6.2",
"postcss": "8.4.31",
"postcss": "8.4.32",
"postcss-custom-media": "10.0.2",
"postcss-loader": "7.3.3",
"postcss-rtlcss": "4.0.8",
"postcss-rtlcss": "4.0.9",
"react-dev-utils": "12.0.1",
"react-refresh": "0.14.0",
"resolve-url-loader": "5.0.0",
"sass": "1.65.1",
"sass": "1.69.5",
"sass-loader": "13.3.2",
"sharp": "0.32.6",
"sharp": "0.33.0",
"source-map-loader": "4.0.1",
"style-loader": "3.3.3",
"url-loader": "4.1.1",
"webpack": "5.89.0",
"webpack-bundle-analyzer": "4.9.1",
"webpack-bundle-analyzer": "4.10.1",
"webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1",
"webpack-merge": "5.9.0"
@@ -3126,15 +3126,15 @@
}
},
"node_modules/@edx/frontend-component-footer": {
"version": "12.5.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.5.1.tgz",
"integrity": "sha512-bLXfSDyyf8z+n4VXkraQ98qhkc+ZXuvRy65kXUE3s560oDv0qdiKU054W8uPY6wtsdu4WQ50C/Mluxzd60UKUg==",
"version": "12.6.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-footer/-/frontend-component-footer-12.6.1.tgz",
"integrity": "sha512-Wjqv1kJ3KaQTFmVVhU2mNuKRSnocNt2j7bDd8RxbZU/UfUM6LxOy1CGbkCAG0xtCnc/n5+1zHJJ/aDX0ak7viA==",
"dependencies": {
"@edx/paragon": "^21.3.1",
"@fortawesome/fontawesome-svg-core": "6.4.2",
"@fortawesome/free-brands-svg-icons": "6.4.2",
"@fortawesome/free-regular-svg-icons": "6.4.2",
"@fortawesome/free-solid-svg-icons": "6.4.2",
"@fortawesome/fontawesome-svg-core": "6.5.1",
"@fortawesome/free-brands-svg-icons": "6.5.1",
"@fortawesome/free-regular-svg-icons": "6.5.1",
"@fortawesome/free-solid-svg-icons": "6.5.1",
"@fortawesome/react-fontawesome": "0.2.0",
"lodash": "^4.17.21"
},
@@ -3200,6 +3200,63 @@
"react": ">=16.x"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-common-types": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz",
"integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==",
"hasInstallScript": true,
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/fontawesome-svg-core": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz",
"integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-brands-svg-icons": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.1.tgz",
"integrity": "sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.1.tgz",
"integrity": "sha512-m6ShXn+wvqEU69wSP84coxLbNl7sGVZb+Ca+XZq6k30SzuP3X4TfPqtycgUh9ASwlNh5OfQCd8pDIWxl+O+LlQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz",
"integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.5.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@edx/frontend-component-footer/node_modules/@fortawesome/react-fontawesome": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz",
@@ -3326,9 +3383,9 @@
}
},
"node_modules/@edx/frontend-component-header": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.9.1.tgz",
"integrity": "sha512-OcCWkdRNihZhT5WkZcKRjHCeD883TACIUi27921g5LG2/7+++9EUG74JGtVDYklk0JCuhr0M9tD87YM4BySlaA==",
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-component-header/-/frontend-component-header-4.10.1.tgz",
"integrity": "sha512-bcQ+ebdy/lM2TfLVB+WhdWNsuc51cFVgt5UQuJcV5nw6ACYUkBVTLbGDT7J8797bsQe6Lywg7Cj9ykB+Dy78Kw==",
"dependencies": {
"@edx/paragon": "21.5.6",
"@fortawesome/fontawesome-svg-core": "6.4.2",
@@ -3529,9 +3586,9 @@
}
},
"node_modules/@edx/frontend-platform": {
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-4.6.3.tgz",
"integrity": "sha512-vvmg2rWfjdOD9BKcHiFlV3n4kVGqMGUYS0UrIk8Dx7BYbb7It03q/twe5b2D3PHQwvNCTei9EgX8+Tn1QhkXBA==",
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-5.6.1.tgz",
"integrity": "sha512-7MOIjGGYplVY7yHrSea90EkQ24UxKxRKU9FaihB41yUSL/Vin1txDuIn3059Xr+60QfIKRsym+LogXe9IZ47Dw==",
"dependencies": {
"@cospired/i18n-iso-languages": "4.1.0",
"@formatjs/intl-pluralrules": "4.3.3",
@@ -3564,7 +3621,7 @@
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"react-redux": "^7.1.1",
"react-router-dom": "^5.0.1",
"react-router-dom": "^6.0.0",
"redux": "^4.0.4"
}
},
@@ -3618,9 +3675,9 @@
}
},
"node_modules/@edx/paragon": {
"version": "20.44.0",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.44.0.tgz",
"integrity": "sha512-C1uC3RaRmlFANtHebFdZzVDM08vgFJRnHE3u97ix07e0ACSQDbVNoZ2H7JgBy8nqHz2JWGHPnvtpvPf5DAZsZQ==",
"version": "20.46.3",
"resolved": "https://registry.npmjs.org/@edx/paragon/-/paragon-20.46.3.tgz",
"integrity": "sha512-cHxoxoOREVFbBqW9IRAtlIAQo1lcF9JJXkLoEw1Vam6oetKSa5Mc0SL5kykbV+1iRPP7kS8A0Csf5nRr0oolLQ==",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.1",
"@fortawesome/react-fontawesome": "^0.1.18",
@@ -3663,9 +3720,9 @@
}
},
"node_modules/@edx/paragon/node_modules/glob": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.0.3.tgz",
"integrity": "sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==",
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
@@ -3681,9 +3738,9 @@
}
},
"node_modules/@edx/paragon/node_modules/minimatch": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.0.tgz",
"integrity": "sha512-9TPBGGak4nHfGZsPBohm9AWg6NoT7QTCehS3BIJABslyZbzxfV78QM2Y6+i741OPZIafFAaiiEMh5OyIrJPgtg==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -3692,9 +3749,13 @@
}
},
"node_modules/@edx/paragon/node_modules/uuid": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.0.tgz",
"integrity": "sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==",
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
@@ -3803,6 +3864,21 @@
"node": ">=12"
}
},
"node_modules/@emnapi/runtime": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.44.0.tgz",
"integrity": "sha512-ZX/etZEZw8DR7zAB1eVQT40lNo0jeqpb6dCgOvctB6FIQ5PoXfMuNY8+ayQfu8tNQbAB8gQWSSJupR8NxeiZXw==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==",
"optional": true
},
"node_modules/@eslint-community/eslint-utils": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz",
@@ -4256,6 +4332,437 @@
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA=="
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.0.tgz",
"integrity": "sha512-070tEheekI1LJWTGPC9WlQEa5UoKTXzzlORBHMX4TbfUxMiL336YHR8vBEUNsjse0RJCX8dZ4ZXwT595aEF1ug==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.0"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.0.tgz",
"integrity": "sha512-pu/nvn152F3qbPeUkr+4e9zVvEhD3jhwzF473veQfMPkOYo9aoWXSfdZH/E6F+nYC3qvFjbxbvdDbUtEbghLqw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.0"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-VzYd6OwnUR81sInf3alj1wiokY50DjsHz5bvfnsFpxs5tqQxESoHtJO6xyksDs3RIkyhMWq2FufXo6GNSU9BMw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"macos": ">=11",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.0.tgz",
"integrity": "sha512-dD9OznTlHD6aovRswaPNEy8dKtSAmNo4++tO7uuR4o5VxbVAOoEQ1uSmN4iFAdQneTHws1lkTZeiXPrcCkh6IA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"macos": ">=10.13",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.0.tgz",
"integrity": "sha512-VwgD2eEikDJUk09Mn9Dzi1OW2OJFRQK+XlBTkUNmAWPrtj8Ly0yq05DFgu1VCMx2/DqCGQVi5A1dM9hTmxf3uw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.0.tgz",
"integrity": "sha512-xTYThiqEZEZc0PRU90yVtM3KE7lw1bKdnDQ9kCTHWbqWyHOe4NpPOtMGy27YnN51q0J5dqRrvicfPbALIOeAZA==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.0.tgz",
"integrity": "sha512-o9E46WWBC6JsBlwU4QyU9578G77HBDT1NInd+aERfxeOPbk0qBZHgoDsQmA2v9TbqJRWzoBPx1aLOhprBMgPjw==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz",
"integrity": "sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.0.tgz",
"integrity": "sha512-OdorplCyvmSAPsoJLldtLh3nLxRrkAAAOHsGWGDYfN0kh730gifK+UZb3dWORRa6EusNqCTjfXV4GxvgJ/nPDQ==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz",
"integrity": "sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.0.tgz",
"integrity": "sha512-4horD3wMFd5a0ddbDY8/dXU9CaOgHjEHALAddXgafoR5oWq5s8X61PDgsSeh4Qupsdo6ycfPPSSNBrfVQnwwrg==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.0"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.0.tgz",
"integrity": "sha512-dcomVSrtgF70SyOr8RCOCQ8XGVThXwe71A1d8MGA+mXEVRJ/J6/TrCbBEJh9ddcEIIsrnrkolaEvYSHqVhswQw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.0"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.0.tgz",
"integrity": "sha512-TiVJbx38J2rNVfA309ffSOB+3/7wOsZYQEOlKqOUdWD/nqkjNGrX+YQGz7nzcf5oy2lC+d37+w183iNXRZNngQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.28",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.0"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.0.tgz",
"integrity": "sha512-PaZM4Zi7/Ek71WgTdvR+KzTZpBqrQOFcPe7/8ZoPRlTYYRe43k6TWsf4GVH6XKRLMYeSp8J89RfAhBrSP4itNA==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"glibc": ">=2.26",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.0"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.0.tgz",
"integrity": "sha512-1QLbbN0zt+32eVrg7bb1lwtvEaZwlhEsY1OrijroMkwAqlHqFj6R33Y47s2XUv7P6Ie1PwCxK/uFnNqMnkd5kg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.0"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.0.tgz",
"integrity": "sha512-CecqgB/CnkvCWFhmfN9ZhPGMLXaEBXl4o7WtA6U3Ztrlh/s7FUKX4vNxpMSYLIrWuuzjiaYdfU3+Tdqh1xaHfw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"musl": ">=1.2.2",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.0"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.0.tgz",
"integrity": "sha512-Hn4js32gUX9qkISlemZBUPuMs0k/xNJebUNl/L6djnU07B/HAA2KaxRVb3HvbU5fL242hLOcp0+tR+M8dvJUFw==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/runtime": "^0.44.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.0.tgz",
"integrity": "sha512-5HfcsCZi3l5nPRF2q3bllMVMDXBqEWI3Q8KQONfzl0TferFE5lnsIG0A1YrntMAGqvkzdW6y1Ci1A2uTvxhfzg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.0.tgz",
"integrity": "sha512-i3DtP/2ce1yKFj4OzOnOYltOEL/+dp4dc4dJXJBv6god1AFTcmkaA99H/7SwOmkCOBQkbVvA3lCGm3/5nDtf9Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0",
"npm": ">=9.6.5",
"pnpm": ">=7.1.0",
"yarn": ">=3.2.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -6008,18 +6515,18 @@
}
},
"node_modules/@reduxjs/toolkit": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.8.0.tgz",
"integrity": "sha512-cdfHWfcvLyhBUDicoFwG1u32JqvwKDxLxDd7zSmSoFw/RhYLOygIRtmaMjPRUUHmVmmAGAvquLLsKKU/677kSQ==",
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.7.tgz",
"integrity": "sha512-t7v8ZPxhhKgOKtU+uyJT13lu4vL7az5aFi4IdoDs/eS548edn2M8Ik9h8fxgvMjGoAUVFSt6ZC1P5cWmQ014QQ==",
"dependencies": {
"immer": "^9.0.7",
"redux": "^4.1.2",
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5"
"immer": "^9.0.21",
"redux": "^4.2.1",
"redux-thunk": "^2.4.2",
"reselect": "^4.1.8"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || 18.0.0-beta",
"react-redux": "^7.2.1 || ^8.0.0-beta"
"react": "^16.9.0 || ^17.0.0 || ^18",
"react-redux": "^7.2.1 || ^8.0.2"
},
"peerDependenciesMeta": {
"react": {
@@ -6030,6 +6537,14 @@
}
}
},
"node_modules/@remix-run/router": {
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.11.0.tgz",
"integrity": "sha512-BHdhcWgeiudl91HvVa2wxqZjSHbheSgIiDvxrF1VjFzBzpTtuDPkOdOi3Iqvc08kXtFkLjhbS+ML9aM8mJS+wQ==",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@restart/context": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@restart/context/-/context-2.1.4.tgz",
@@ -7784,11 +8299,6 @@
"deep-equal": "^2.0.5"
}
},
"node_modules/b4a": {
"version": "1.6.4",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz",
"integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw=="
},
"node_modules/babel-jest": {
"version": "26.6.3",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz",
@@ -8744,11 +9254,6 @@
"fsevents": "~2.3.2"
}
},
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="
},
"node_modules/chrome-trace-event": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
@@ -9587,6 +10092,11 @@
"node": ">=10"
}
},
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
"integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="
},
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -9624,20 +10134,6 @@
"node": ">=0.10"
}
},
"node_modules/decompress-response": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
"dependencies": {
"mimic-response": "^3.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/dedent": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
@@ -9677,14 +10173,6 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
"integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
},
"node_modules/deep-extend": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -11308,14 +11796,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
"engines": {
"node": ">=6"
}
},
"node_modules/expect": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz",
@@ -11600,11 +12080,6 @@
"resolved": "https://registry.npmjs.org/fast-defer/-/fast-defer-1.1.7.tgz",
"integrity": "sha512-tJ01ulDWT2WhqxMKS20nXX6wyX2iInBYpbN3GO7yjKwXMY4qvkdBRxak9IFwBLlFDESox+SwSvqMCZDfe1tqeg=="
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="
},
"node_modules/fast-glob": {
"version": "3.2.12",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz",
@@ -12192,9 +12667,9 @@
}
},
"node_modules/formik": {
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.2.9.tgz",
"integrity": "sha512-LQLcISMmf1r5at4/gyJigGn0gOwFbeEAlji+N9InZF6LIMXnFNkO42sCI8Jt84YZggpD4cPWObAZaxpEFtSzNA==",
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/formik/-/formik-2.4.5.tgz",
"integrity": "sha512-Gxlht0TD3vVdzMDHwkiNZqJ7Mvg77xQNfmBRrNtvzcHZs72TJppSTDKHpImCMJZwcWPBJ8jSQQ95GJzXFf1nAQ==",
"funding": [
{
"type": "individual",
@@ -12202,13 +12677,14 @@
}
],
"dependencies": {
"@types/hoist-non-react-statics": "^3.3.1",
"deepmerge": "^2.1.1",
"hoist-non-react-statics": "^3.3.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"react-fast-compare": "^2.0.1",
"tiny-warning": "^1.0.2",
"tslib": "^1.10.0"
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
@@ -12222,6 +12698,11 @@
"node": ">=0.10.0"
}
},
"node_modules/formik/node_modules/tslib": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -12261,11 +12742,6 @@
"node": ">= 0.6"
}
},
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -12418,11 +12894,6 @@
"node": ">=0.10.0"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="
},
"node_modules/glob": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
@@ -12831,9 +13302,9 @@
}
},
"node_modules/html-webpack-plugin": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.3.tgz",
"integrity": "sha512-6YrDKTuqaP/TquFH7h4srYWsZx+x6k6+FbsTm0ziCwGHDP78Unr1r9F/H4+sGmMbX08GQcJ+K64x55b+7VM/jg==",
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.5.4.tgz",
"integrity": "sha512-3wNSaVVxdxcu0jd4FpQFoICdqgxs4zIQQvj+2yQKFfBOnLETQ6X5CDWdeasuGlSsooFlMkEioWDTqBv1wvw5Iw==",
"dependencies": {
"@types/html-minifier-terser": "^6.0.0",
"html-minifier-terser": "^6.0.2",
@@ -17426,9 +17897,9 @@
}
},
"node_modules/jquery": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz",
"integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==",
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
"peer": true
},
"node_modules/js-tokens": {
@@ -17727,21 +18198,6 @@
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="
},
"node_modules/lodash.escape": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz",
"integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw=="
},
"node_modules/lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g=="
},
"node_modules/lodash.invokemap": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.invokemap/-/lodash.invokemap-4.6.0.tgz",
"integrity": "sha512-CfkycNtMqgUlfjfdh2BhKO/ZXrP8ePOX5lEU/g0R3ItJcnuxWDwokMGKx1hWcfOikmyOVx6X9IwWnDGlgKl61w=="
},
"node_modules/lodash.memoize": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
@@ -17752,11 +18208,6 @@
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="
},
"node_modules/lodash.pullall": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.pullall/-/lodash.pullall-4.2.0.tgz",
"integrity": "sha512-VhqxBKH0ZxPpLhiu68YD1KnHmbhQJQctcipvmFnqIBDYzcIHzf3Zpu0tpeOKtR4x76p9yohc506eGdOjTmyIBg=="
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
@@ -18061,17 +18512,6 @@
"node": ">=6"
}
},
"node_modules/mimic-response": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -18081,19 +18521,6 @@
"node": ">=4"
}
},
"node_modules/mini-create-react-context": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.4.1.tgz",
"integrity": "sha512-YWCYEmd5CQeHGSAKrYvXgmzzkrvssZcuuQDDeqkT+PziKGMgE+0MCCtcKbROzocGBG1meBLl2FotlRwf4gAzbQ==",
"dependencies": {
"@babel/runtime": "^7.12.1",
"tiny-warning": "^1.0.3"
},
"peerDependencies": {
"prop-types": "^15.0.0",
"react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0"
}
},
"node_modules/mini-css-extract-plugin": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-1.6.2.tgz",
@@ -18150,11 +18577,6 @@
"node": ">=0.10.0"
}
},
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="
},
"node_modules/mrmime": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz",
@@ -18186,9 +18608,9 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"node_modules/nanoid": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
"funding": [
{
"type": "github",
@@ -18223,11 +18645,6 @@
"node": ">=0.10.0"
}
},
"node_modules/napi-build-utils": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
"integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg=="
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -18265,52 +18682,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
"integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
},
"node_modules/node-abi": {
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.47.0.tgz",
"integrity": "sha512-2s6B2CWZM//kPgwnuI0KrYwNjfdByE25zvAaEpq9IH4zcNsarH8Ihu/UuX6XMPEogDAxkuUFeZn60pXNHAqn3A==",
"dependencies": {
"semver": "^7.3.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/semver": {
"version": "7.5.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/node-abi/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="
},
"node_modules/node-addon-api": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz",
"integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@@ -19040,14 +19411,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
"node_modules/path-to-regexp": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz",
"integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==",
"dependencies": {
"isarray": "0.0.1"
}
},
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -19253,9 +19616,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"version": "8.4.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
"integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"funding": [
{
"type": "opencollective",
@@ -19271,7 +19634,7 @@
}
],
"dependencies": {
"nanoid": "^3.3.6",
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -19819,14 +20182,14 @@
}
},
"node_modules/postcss-rtlcss": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/postcss-rtlcss/-/postcss-rtlcss-4.0.8.tgz",
"integrity": "sha512-CR2sY889PHnX6K8rjW9FG4Qvm9UJsIekDakMtEYGH3zgFp9XADMeaKcA0hPOmkClNh0jWbkaPBm0jZ6fHmqkJQ==",
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/postcss-rtlcss/-/postcss-rtlcss-4.0.9.tgz",
"integrity": "sha512-dCNKEf+FgTv+EA3XI8ysg2RnpS5s3/iZmU+9qpCNFxHU/BhK+4hz7jyCsCAfo0CLnDrMPtaQENhwb+EGm1wh7Q==",
"dependencies": {
"rtlcss": "4.1.0"
"rtlcss": "4.1.1"
},
"engines": {
"node": ">=12.0.0"
"node": ">=18.0.0"
},
"peerDependencies": {
"postcss": "^8.4.21"
@@ -19878,70 +20241,6 @@
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
},
"node_modules/prebuild-install": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
"integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==",
"dependencies": {
"detect-libc": "^2.0.0",
"expand-template": "^2.0.3",
"github-from-package": "0.0.0",
"minimist": "^1.2.3",
"mkdirp-classic": "^0.5.3",
"napi-build-utils": "^1.0.1",
"node-abi": "^3.3.0",
"pump": "^3.0.0",
"rc": "^1.2.7",
"simple-get": "^4.0.0",
"tar-fs": "^2.0.0",
"tunnel-agent": "^0.6.0"
},
"bin": {
"prebuild-install": "bin.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/prebuild-install/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/prebuild-install/node_modules/tar-fs": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz",
"integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.1.4"
}
},
"node_modules/prebuild-install/node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/prelude-ls": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
@@ -20191,11 +20490,6 @@
}
]
},
"node_modules/queue-tick": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
"integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag=="
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@@ -20245,28 +20539,6 @@
"webpack": "^4.0.0 || ^5.0.0"
}
},
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
"minimist": "^1.2.0",
"strip-json-comments": "~2.0.1"
},
"bin": {
"rc": "cli.js"
}
},
"node_modules/rc/node_modules/strip-json-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
@@ -20782,40 +21054,33 @@
}
},
"node_modules/react-router": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-5.2.1.tgz",
"integrity": "sha512-lIboRiOtDLFdg1VTemMwud9vRVuOCZmUIT/7lUoZiSpPODiiH1UQlfXy+vPLC/7IWdFYnhRwAyNqA/+I7wnvKQ==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.18.0.tgz",
"integrity": "sha512-vk2y7Dsy8wI02eRRaRmOs9g2o+aE72YCx5q9VasT1N9v+lrdB79tIqrjMfByHiY5+6aYkH2rUa5X839nwWGPDg==",
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"hoist-non-react-statics": "^3.1.0",
"loose-envify": "^1.3.1",
"mini-create-react-context": "^0.4.0",
"path-to-regexp": "^1.7.0",
"prop-types": "^15.6.2",
"react-is": "^16.6.0",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
"@remix-run/router": "1.11.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=15"
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.0.tgz",
"integrity": "sha512-ObVBLjUZsphUUMVycibxgMdh5jJ1e3o+KpAZBVeHcNQZ4W+uUGGWsokurzlF4YOldQYRQL4y6yFRWM4m3svmuQ==",
"version": "6.18.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.18.0.tgz",
"integrity": "sha512-Ubrue4+Ercc/BoDkFQfc6og5zRQ4A8YxSO3Knsne+eRbZ+IepAsK249XBH/XaFuOYOYr3L3r13CXTLvYt5JDjw==",
"dependencies": {
"@babel/runtime": "^7.12.13",
"history": "^4.9.0",
"loose-envify": "^1.3.1",
"prop-types": "^15.6.2",
"react-router": "5.2.1",
"tiny-invariant": "^1.0.2",
"tiny-warning": "^1.0.0"
"@remix-run/router": "1.11.0",
"react-router": "6.18.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=15"
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-style-singleton": {
@@ -21032,9 +21297,9 @@
}
},
"node_modules/redux": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.1.2.tgz",
"integrity": "sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw==",
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
"dependencies": {
"@babel/runtime": "^7.9.2"
}
@@ -21064,9 +21329,9 @@
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.9",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
"integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
@@ -21343,9 +21608,9 @@
}
},
"node_modules/rosie": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.0.tgz",
"integrity": "sha512-Dbzdc+prLXZuB/suRptDnBUY29SdGvND3bLg6cll8n7PNqzuyCxSlRfrkn8PqjS9n4QVsiM7RCvxCkKAkTQRjA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/rosie/-/rosie-2.1.1.tgz",
"integrity": "sha512-2AXB7WrIZXtKMZ6Q/PlozqPF5nu/x7NEvRJZOblrJuprrPfm5gL8JVvJPj9aaib9F8IUALnLUFhzXrwEtnI5cQ==",
"dev": true,
"engines": {
"node": ">=10"
@@ -21360,9 +21625,9 @@
}
},
"node_modules/rtlcss": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.0.tgz",
"integrity": "sha512-W+N4hh0nVqVrrn3mRkHakxpB+c9cQ4CRT67O39kgA+1DjyhrdsqyCqIuHXyvWaXn4/835n+oX3fYJCi4+G/06A==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.1.1.tgz",
"integrity": "sha512-/oVHgBtnPNcggP2aVXQjSy6N1mMAfHg4GSag0QtZBlD5bdDgAHwr4pydqJGd+SUCu9260+Pjqbjwtvu7EMH1KQ==",
"dependencies": {
"escalade": "^3.1.1",
"picocolors": "^1.0.0",
@@ -21620,9 +21885,9 @@
}
},
"node_modules/sass": {
"version": "1.65.1",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.65.1.tgz",
"integrity": "sha512-9DINwtHmA41SEd36eVPQ9BJKpn7eKDQmUHmpI0y5Zv2Rcorrh0zS+cFrt050hdNbmmCNKTW3hV5mWfuegNRsEA==",
"version": "1.69.5",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.69.5.tgz",
"integrity": "sha512-qg2+UCJibLr2LCVOt3OlPhr/dqVHWOa9XtZf2OjbLs/T4VPSJ00udtgJxH3neXZm+QqX8B+3cU7RaLqp1iVfcQ==",
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -21925,25 +22190,42 @@
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
},
"node_modules/sharp": {
"version": "0.32.6",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz",
"integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==",
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.0.tgz",
"integrity": "sha512-99DZKudjm/Rmz+M0/26t4DKpXyywAOJaayGS9boEn7FvgtG0RYBi46uPE2c+obcJRtA3AZa0QwJot63gJQ1F0Q==",
"hasInstallScript": true,
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.2",
"node-addon-api": "^6.1.0",
"prebuild-install": "^7.1.1",
"semver": "^7.5.4",
"simple-get": "^4.0.1",
"tar-fs": "^3.0.4",
"tunnel-agent": "^0.6.0"
"semver": "^7.5.4"
},
"engines": {
"node": ">=14.15.0"
"libvips": ">=8.15.0",
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.0",
"@img/sharp-darwin-x64": "0.33.0",
"@img/sharp-libvips-darwin-arm64": "1.0.0",
"@img/sharp-libvips-darwin-x64": "1.0.0",
"@img/sharp-libvips-linux-arm": "1.0.0",
"@img/sharp-libvips-linux-arm64": "1.0.0",
"@img/sharp-libvips-linux-s390x": "1.0.0",
"@img/sharp-libvips-linux-x64": "1.0.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.0",
"@img/sharp-libvips-linuxmusl-x64": "1.0.0",
"@img/sharp-linux-arm": "0.33.0",
"@img/sharp-linux-arm64": "0.33.0",
"@img/sharp-linux-s390x": "0.33.0",
"@img/sharp-linux-x64": "0.33.0",
"@img/sharp-linuxmusl-arm64": "0.33.0",
"@img/sharp-linuxmusl-x64": "0.33.0",
"@img/sharp-wasm32": "0.33.0",
"@img/sharp-win32-ia32": "0.33.0",
"@img/sharp-win32-x64": "0.33.0"
}
},
"node_modules/sharp/node_modules/lru-cache": {
@@ -22027,49 +22309,6 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/simple-get": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"dependencies": {
"decompress-response": "^6.0.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
@@ -22640,15 +22879,6 @@
"node": ">= 0.4"
}
},
"node_modules/streamx": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.15.1.tgz",
"integrity": "sha512-fQMzy2O/Q47rgwErk/eGeLu/roaFWV0jVsogDmrszM9uIw8L5OA+t+V93MgYlufNptfjmYR1tOMWhei/Eh7TQA==",
"dependencies": {
"fast-fifo": "^1.1.0",
"queue-tick": "^1.0.1"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@@ -23039,26 +23269,6 @@
"node": ">=6"
}
},
"node_modules/tar-fs": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.4.tgz",
"integrity": "sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==",
"dependencies": {
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
}
},
"node_modules/tar-stream": {
"version": "3.1.6",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz",
"integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==",
"dependencies": {
"b4a": "^1.6.4",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/terminal-link": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
@@ -23347,17 +23557,6 @@
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
"dependencies": {
"safe-buffer": "^5.0.1"
},
"engines": {
"node": "*"
}
},
"node_modules/type-check": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
@@ -23901,23 +24100,19 @@
}
},
"node_modules/webpack-bundle-analyzer": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.9.1.tgz",
"integrity": "sha512-jnd6EoYrf9yMxCyYDPj8eutJvtjQNp8PHmni/e/ulydHBWhT5J3menXt3HEkScsu9YqMAcG4CfFjs3rj5pVU1w==",
"version": "4.10.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.1.tgz",
"integrity": "sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==",
"dependencies": {
"@discoveryjs/json-ext": "0.5.7",
"acorn": "^8.0.4",
"acorn-walk": "^8.0.0",
"commander": "^7.2.0",
"debounce": "^1.2.1",
"escape-string-regexp": "^4.0.0",
"gzip-size": "^6.0.0",
"html-escaper": "^2.0.2",
"is-plain-object": "^5.0.0",
"lodash.debounce": "^4.0.8",
"lodash.escape": "^4.0.1",
"lodash.flatten": "^4.4.0",
"lodash.invokemap": "^4.6.0",
"lodash.pullall": "^4.2.0",
"lodash.uniqby": "^4.7.0",
"opener": "^1.5.2",
"picocolors": "^1.0.0",
"sirv": "^2.0.3",

View File

@@ -34,34 +34,34 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "12.5.1",
"@edx/frontend-component-header": "4.9.1",
"@edx/frontend-platform": "4.6.3",
"@edx/paragon": "20.44.0",
"@reduxjs/toolkit": "1.8.0",
"@edx/frontend-component-footer": "12.6.1",
"@edx/frontend-component-header": "4.10.1",
"@edx/frontend-platform": "5.6.1",
"@edx/paragon": "20.46.3",
"@reduxjs/toolkit": "1.9.7",
"@tinymce/tinymce-react": "3.13.1",
"babel-polyfill": "6.26.0",
"classnames": "2.3.2",
"core-js": "3.21.1",
"dompurify": "^2.4.3",
"formik": "2.2.9",
"formik": "2.4.5",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"raw-loader": "4.0.2",
"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",
"regenerator-runtime": "0.13.9",
"react-router": "6.18.0",
"react-router-dom": "6.18.0",
"redux": "4.2.1",
"regenerator-runtime": "0.14.1",
"timeago.js": "4.0.2",
"tinymce": "5.10.7",
"yup": "0.31.1"
},
"devDependencies": {
"@edx/browserslist-config": "1.2.0",
"@edx/frontend-build": "13.0.5",
"@edx/frontend-build": "13.0.14",
"@edx/reactifex": "1.1.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "12.1.5",
@@ -72,6 +72,6 @@
"glob": "7.2.0",
"husky": "7.0.4",
"jest": "27.5.1",
"rosie": "2.1.0"
"rosie": "2.1.1"
}
}

View File

@@ -17,7 +17,7 @@ import {
} from '../data/constants';
import { selectCourseCohorts } from '../discussions/cohorts/data/selectors';
import messages from '../discussions/posts/post-filter-bar/messages';
import ActionItem from '../discussions/posts/post-filter-bar/PostFilterBar';
import { ActionItem } from '../discussions/posts/post-filter-bar/PostFilterBar';
const FilterBar = ({
intl,

View File

@@ -1,4 +1,4 @@
import React, { memo, useEffect } from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
@@ -6,30 +6,25 @@ import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import withConditionalInContextRendering from '../../discussions/common/withConditionalInContextRendering';
import { useCourseId } from '../../discussions/data/hooks';
import { fetchTab } from './data/thunks';
import Tabs from './tabs/Tabs';
import messages from './messages';
import './navBar.scss';
const CourseTabsNavigation = ({ activeTab, className, rootSlug }) => {
const CourseTabsNavigation = ({
activeTab, className, courseId, rootSlug,
}) => {
const dispatch = useDispatch();
const intl = useIntl();
const courseId = useCourseId();
const tabs = useSelector(state => state.courseTabs.tabs);
useEffect(() => {
if (courseId) {
dispatch(fetchTab(courseId, rootSlug));
}
dispatch(fetchTab(courseId, rootSlug));
}, [courseId]);
console.log('CourseTabsNavigation');
return (
<div id="courseTabsNavigation" tabIndex="-1" className={classNames('course-tabs-navigation px-4', className)}>
<div id="courseTabsNavigation" className={classNames('course-tabs-navigation px-4', className)}>
{!!tabs.length && (
<Tabs
className="nav-underline-tabs"
@@ -54,12 +49,13 @@ CourseTabsNavigation.propTypes = {
activeTab: PropTypes.string,
className: PropTypes.string,
rootSlug: PropTypes.string,
courseId: PropTypes.string.isRequired,
};
CourseTabsNavigation.defaultProps = {
activeTab: 'discussion',
activeTab: undefined,
className: null,
rootSlug: 'outline',
};
export default memo(withConditionalInContextRendering(CourseTabsNavigation, false));
export default React.memo(CourseTabsNavigation);

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import React, {
useCallback, useEffect, useMemo, useRef, useState,
useCallback, useContext, useEffect, useRef, useState,
} from 'react';
import camelCase from 'lodash/camelCase';
@@ -9,7 +9,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
import { useCurrentPage } from '../discussions/data/hooks';
import { DiscussionContext } from '../discussions/common/context';
import { setUsernameSearch } from '../discussions/learners/data';
import { setSearchQuery } from '../discussions/posts/data';
import postsMessages from '../discussions/posts/post-actions-bar/messages';
@@ -18,7 +18,7 @@ import { setFilter as setTopicFilter } from '../discussions/topics/data/slices';
const Search = () => {
const intl = useIntl();
const dispatch = useDispatch();
const page = useCurrentPage();
const { page } = useContext(DiscussionContext);
const postSearch = useSelector(({ threads }) => threads.filters.search);
const topicSearch = useSelector(({ topics }) => topics.filter);
const learnerSearch = useSelector(({ learners }) => learners.usernameSearch);
@@ -26,15 +26,15 @@ const Search = () => {
const isTopicSearch = 'topics'.includes(page);
const [searchValue, setSearchValue] = useState('');
const previousSearchValueRef = useRef('');
let currentValue = '';
const currentValue = useMemo(() => {
if (isPostSearch) {
return postSearch;
} if (isTopicSearch) {
return topicSearch;
}
return learnerSearch;
}, [postSearch, topicSearch, learnerSearch]);
if (isPostSearch) {
currentValue = postSearch;
} else if (isTopicSearch) {
currentValue = topicSearch;
} else {
currentValue = learnerSearch;
}
const onClear = useCallback(() => {
dispatch(setSearchQuery(''));

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Editor } from '@tinymce/tinymce-react';
import { useLocation, useParams } from 'react-router';
import { useLocation, useParams } from 'react-router-dom';
// TinyMCE so the global var exists
// eslint-disable-next-line no-unused-vars,import/no-extraneous-dependencies
import tinymce from 'tinymce/tinymce';

View File

@@ -1,6 +1,9 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiBaseUrl = () => getConfig().LMS_BASE_URL;
export const getFullUrl = (path) => (
new URL(`${getConfig().PUBLIC_PATH.replace(/\/$/, '')}/${path}`, window.location.origin).href
);
/**
* Enum for thread types.
@@ -137,25 +140,24 @@ export const DiscussionProvider = {
OPEN_EDX: 'openedx',
};
export const BASE_PATH = `${getConfig().PUBLIC_PATH}:courseId`;
const BASE_PATH = '/:courseId';
export const Routes = {
DISCUSSIONS: {
PATH: BASE_PATH,
},
LEARNERS: {
PATH: `${BASE_PATH}/learners`,
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts(/:postId)?`,
PATH: `${BASE_PATH}/learners/:learnerUsername?`,
POSTS: `${BASE_PATH}/learners/:learnerUsername/posts/:postId?`,
POSTS_EDIT: `${BASE_PATH}/learners/:learnerUsername/posts/:postId/edit`,
},
POSTS: {
PATH: `${BASE_PATH}/topics/:topicId`,
MY_POSTS: `${BASE_PATH}/my-posts(/:postId)?`,
ALL_POSTS: `${BASE_PATH}/posts(/:postId)?`,
NEW_POST: [
`${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/topics/:topicId`,
`${BASE_PATH}`,
],
MY_POSTS: `${BASE_PATH}/my-posts/:postId?`,
ALL_POSTS: `${BASE_PATH}/posts/:postId?`,
EDIT_MY_POSTS: `${BASE_PATH}/my-posts/:postId/edit`,
EDIT_ALL_POSTS: `${BASE_PATH}/posts/:postId/edit`,
NEW_POST: `${BASE_PATH}/*`,
EDIT_POST: [
`${BASE_PATH}/category/:category/posts/:postId/edit`,
`${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
@@ -166,19 +168,19 @@ export const Routes = {
},
COMMENTS: {
PATH: [
`${BASE_PATH}/category/:category/posts/:postId`,
`${BASE_PATH}/topics/:topicId/posts/:postId`,
`${BASE_PATH}/category/:category/posts/:postId?`,
`${BASE_PATH}/topics/:topicId/posts/:postId?`,
`${BASE_PATH}/posts/:postId`,
`${BASE_PATH}/my-posts/:postId`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
`${BASE_PATH}/learners/:learnerUsername/posts/:postId?`,
],
PAGE: `${BASE_PATH}/:page`,
PAGE: `${BASE_PATH}/:page/*`,
PAGES: {
category: `${BASE_PATH}/category/:category/posts/:postId`,
topics: `${BASE_PATH}/topics/:topicId/posts/:postId`,
category: `${BASE_PATH}/category/:category/posts/:postId?`,
topics: `${BASE_PATH}/topics/:topicId/posts/:postId?`,
posts: `${BASE_PATH}/posts/:postId`,
'my-posts': `${BASE_PATH}/my-posts/:postId`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId`,
learners: `${BASE_PATH}/learners/:learnerUsername/posts/:postId?`,
},
},
TOPICS: {
@@ -189,9 +191,10 @@ export const Routes = {
],
ALL: `${BASE_PATH}/topics`,
CATEGORY: `${BASE_PATH}/category/:category`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId`,
CATEGORY_POST: `${BASE_PATH}/category/:category/posts/:postId?`,
CATEGORY_POST_EDIT: `${BASE_PATH}/category/:category/posts/:postId/edit`,
TOPIC: `${BASE_PATH}/topics/:topicId`,
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId`,
TOPIC_POST: `${BASE_PATH}/topics/:topicId/posts/:postId?`,
TOPIC_POST_EDIT: `${BASE_PATH}/topics/:topicId/posts/:postId/edit`,
},
};
@@ -205,11 +208,12 @@ export const PostsPages = {
};
export const ALL_ROUTES = []
.concat([Routes.TOPICS.CATEGORY_POST, Routes.TOPICS.CATEGORY])
.concat([Routes.TOPICS.CATEGORY_POST, `${Routes.TOPICS.CATEGORY}?`])
.concat(Routes.COMMENTS.PATH)
.concat(Routes.TOPICS.PATH)
.concat(Routes.POSTS.EDIT_POST)
.concat([Routes.POSTS.ALL_POSTS, Routes.POSTS.MY_POSTS])
.concat([Routes.LEARNERS.POSTS, Routes.LEARNERS.PATH])
.concat([Routes.DISCUSSIONS.PATH]);
.concat([`${Routes.DISCUSSIONS.PATH}/*`]);
export const MAX_UPLOAD_FILE_SIZE = 1024;

View File

@@ -10,7 +10,7 @@ import { Report } from '@edx/paragon/icons';
import { AvatarOutlineAndLabelColors } from '../../data/constants';
import {
selectModerationSettings, selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
selectUserHasModerationPrivileges, selectUserIsGroupTa, selectUserIsStaff,
} from '../data/selectors';
import messages from '../post-comments/messages';
import AlertBar from './AlertBar';
@@ -29,7 +29,6 @@ const AlertBanner = ({
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsGlobalStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeReportedBanner = abuseFlagged;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsGroupTa
@@ -45,7 +44,7 @@ const AlertBanner = ({
{intl.formatMessage(messages.abuseFlaggedMessage)}
</Alert>
)}
{reasonCodesEnabled && canSeeLastEditOrClosedAlert && (
{ canSeeLastEditOrClosedAlert && (
<>
{lastEdit?.reason && (
<AlertBar

View File

@@ -90,7 +90,6 @@ describe.each([
store = initializeStore({
config: {
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);

View File

@@ -2,8 +2,7 @@ import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { generatePath } from 'react-router';
import { Link } from 'react-router-dom';
import { generatePath, Link } from 'react-router-dom';
import * as timeago from 'timeago.js';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -11,7 +10,6 @@ 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 { DiscussionContext } from './context';
import timeLocale from './time-locale';
@@ -46,12 +44,11 @@ const AuthorLabel = ({
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);
const showUserNameAsLink = linkToProfile && author && author !== intl.formatMessage(messages.anonymous);
const authorName = useMemo(() => (
<span
className={classNames('mr-1.5 font-size-14 font-style font-weight-500', {
className={classNames('mr-1.5 font-size-14 font-style font-weight-500 author-name', {
'text-gray-700': isRetiredUser,
'text-primary-500': !authorLabelMessage && !isRetiredUser,
})}

View File

@@ -53,7 +53,6 @@ describe('Author label', () => {
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
learners_tab_enabled: true,
has_moderation_privileges: true,
});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});

View File

@@ -84,7 +84,6 @@ describe.each([
store = initializeStore({
config: {
hasModerationPrivileges: true,
reasonCodesEnabled: true,
},
});
const content = buildTestContent(type, props);

View File

@@ -3,7 +3,7 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -60,15 +60,12 @@ async function mockAxiosReturnPagedCommentsResponses() {
function renderComponent(postId) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider
value={{ courseId, postId }}
value={{ courseId, postId, page: 'posts' }}
>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<DiscussionContent />
<Route
path="*"
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -1,11 +0,0 @@
import { useEnableInContextSidebar } from '../data/hooks';
const withConditionalInContextRendering = (WrappedComponent, condition) => (
function SidebarConditionalRenderer(props) {
const enableInContextSidebar = useEnableInContextSidebar();
return enableInContextSidebar === condition && <WrappedComponent {...props} />;
}
);
export default withConditionalInContextRendering;

View File

@@ -4,16 +4,16 @@ import {
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useRouteMatch } from 'react-router';
import {
matchPath, useLocation, useMatch, useNavigate,
} from 'react-router-dom';
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 {
ALL_ROUTES, BASE_PATH, RequestStatus, 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';
@@ -22,7 +22,7 @@ import { threadsLoadingStatus } from '../posts/data/selectors';
import { selectTopics } from '../topics/data/selectors';
import tourCheckpoints from '../tours/constants';
import { selectTours } from '../tours/data/selectors';
import { fetchDiscussionTours, updateTourShowStatus } from '../tours/data/thunks';
import { updateTourShowStatus } from '../tours/data/thunks';
import messages from '../tours/messages';
import { discussionsPath } from '../utils';
import {
@@ -31,8 +31,6 @@ import {
selectIsCourseAdmin,
selectIsCourseStaff,
selectIsPostingEnabled,
selectLearnersTabEnabled,
selectModerationSettings,
selectPostThreadCount,
selectUserHasModerationPrivileges,
selectUserIsGroupTa,
@@ -55,22 +53,62 @@ export function useTotalTopicThreadCount() {
}
export const useSidebarVisible = () => {
const location = useLocation();
const enableInContext = useSelector(selectEnableInContext);
const isViewingTopics = useRouteMatch(Routes.TOPICS.ALL);
const isViewingLearners = useRouteMatch(Routes.LEARNERS.PATH);
const isViewingTopics = useMatch(Routes.TOPICS.ALL);
const isViewingLearners = useMatch(`${Routes.LEARNERS.PATH}/*`);
const isFiltered = useSelector(selectAreThreadsFiltered);
const totalThreads = useSelector(selectPostThreadCount);
const isThreadsEmpty = Boolean(useSelector(threadsLoadingStatus()) === RequestStatus.SUCCESSFUL && !totalThreads);
const isIncontextTopicsView = Boolean(useRouteMatch(Routes.TOPICS.PATH) && enableInContext);
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics?.isExact || isViewingLearners));
const matchInContextTopicView = Routes.TOPICS.PATH.find((route) => matchPath({ path: `${route}/*` }, location.pathname));
const isInContextTopicsView = Boolean(matchInContextTopicView && enableInContext);
const hideSidebar = Boolean(isThreadsEmpty && !isFiltered && !(isViewingTopics || isViewingLearners));
if (isIncontextTopicsView) {
if (isInContextTopicsView) {
return true;
}
return !hideSidebar;
};
export function useCourseDiscussionData(courseId) {
const dispatch = useDispatch();
const { authenticatedUser } = useContext(AppContext);
useEffect(() => {
async function fetchBaseData() {
await dispatch(fetchCourseConfig(courseId));
await dispatch(fetchCourseBlocks(courseId, authenticatedUser.username));
}
fetchBaseData();
}, [courseId]);
}
export function useRedirectToThread(courseId, enableInContextSidebar) {
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
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[enableInContextSidebar ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
})(location);
navigate({ ...newLocation });
}
}, [redirectToThread]);
}
export function useIsOnDesktop() {
const windowSize = useWindowSize();
return windowSize.width >= breakpoints.medium.minWidth;
@@ -123,18 +161,15 @@ export const useAlertBannerVisible = (
) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const userIsContentAuthor = getAuthenticatedUser().username === author;
const canSeeLastEditOrClosedAlert = (userHasModerationPrivileges || userIsContentAuthor || userIsGroupTa);
const canSeeReportedBanner = abuseFlagged;
return (
(reasonCodesEnabled && canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
(canSeeLastEditOrClosedAlert && (lastEdit?.reason || closed)) || (canSeeReportedBanner)
);
};
export const useShowLearnersTab = () => useSelector(selectLearnersTabEnabled);
/**
* React hook that gets the current topic ID from the current topic or category.
* The topicId in the DiscussionContext only return the direct topicId from the URL.
@@ -224,89 +259,3 @@ export const useDebounce = (value, delay) => {
);
return debouncedValue;
};
export const useEnableInContextSidebar = () => {
const location = useLocation();
return Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
};
export const useCourseId = () => {
const { params: { courseId } } = useRouteMatch(BASE_PATH);
return courseId;
};
export const useCurrentPage = () => {
const { params: { page } } = useRouteMatch(`${Routes.COMMENTS.PAGE}?`);
return page;
};
export const usePostId = () => {
const { params: { postId } } = useRouteMatch(ALL_ROUTES);
return postId;
};
export const useLearnerUsername = () => {
const { params: { learnerUsername } } = useRouteMatch(ALL_ROUTES);
return learnerUsername;
};
export const useTopicId = () => {
const { params: { topicId } } = useRouteMatch(ALL_ROUTES);
return topicId;
};
export const useCategory = () => {
const { params: { category } } = useRouteMatch(ALL_ROUTES);
return category;
};
export function useRedirectToThread() {
const dispatch = useDispatch();
const history = useHistory();
const location = useLocation();
const courseId = useCourseId();
const enableInContextSidebar = useEnableInContextSidebar();
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[enableInContextSidebar ? 'topics' : 'my-posts'], {
courseId,
postId: redirectToThread.threadId,
topicId: redirectToThread.topicId,
})(location);
history.push(newLocation);
}
}, [redirectToThread]);
}
export function useCourseDiscussionData() {
const dispatch = useDispatch();
const courseId = useCourseId();
const { authenticatedUser } = useContext(AppContext);
useEffect(() => {
async function fetchBaseData() {
await Promise.all([
dispatch(fetchCourseConfig(courseId)),
dispatch(fetchCourseBlocks(courseId, authenticatedUser.username)),
dispatch(fetchDiscussionTours()),
]);
}
fetchBaseData();
}, [courseId]);
}

View File

@@ -14,8 +14,6 @@ export const selectUserIsGroupTa = state => state.config.isGroupTa;
export const selectConfigLoadingStatus = state => state.config.status;
export const selectLearnersTabEnabled = state => state.config.learnersTabEnabled;
export const selectUserRoles = state => state.config.userRoles;
export const selectDivisionSettings = state => state.config.settings;
@@ -33,7 +31,6 @@ export const selectIsPostingEnabled = state => state.config.isPostingEnabled;
export const selectModerationSettings = state => ({
postCloseReasons: state.config.postCloseReasons,
editReasons: state.config.editReasons,
reasonCodesEnabled: state.config.reasonCodesEnabled,
});
export const selectDiscussionProvider = state => state.config.provider;

View File

@@ -16,7 +16,6 @@ const configSlice = createSlice({
isCourseAdmin: false,
isCourseStaff: false,
isUserAdmin: false,
learnersTabEnabled: false,
isPostingEnabled: false,
settings: {
divisionScheme: 'none',
@@ -24,7 +23,6 @@ const configSlice = createSlice({
dividedInlineDiscussions: [],
dividedCourseWideDiscussions: [],
},
reasonCodesEnabled: false,
editReasons: [],
postCloseReasons: [],
enableInContext: false,

View File

@@ -1,20 +0,0 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { LearningHeader } from '@edx/frontend-component-header';
import selectCourseTabs from '../../components/NavigationBar/data/selectors';
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
const CourseHeader = () => {
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
console.log('CourseHeader', courseNumber, courseTitle, org);
return (courseNumber || courseTitle || org) && (
<LearningHeader courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
);
};
export default memo(withConditionalInContextRendering(CourseHeader, false));

View File

@@ -1,24 +0,0 @@
import React, { memo } from 'react';
import classNames from 'classnames';
import { useEnableInContextSidebar } from '../data/hooks';
import NavigationBar from '../navigation/navigation-bar/NavigationBar';
import PostActionsBar from '../posts/post-actions-bar/PostActionsBar';
const DiscussionActionBar = () => {
const enableInContextSidebar = useEnableInContextSidebar();
return (
<div
className={classNames('d-flex flex-row justify-content-between navbar fixed-top', {
'pl-4 pr-3 py-0': enableInContextSidebar,
})}
>
<NavigationBar />
<PostActionsBar />
</div>
);
};
export default memo(DiscussionActionBar);

View File

@@ -1,10 +1,10 @@
import React, { lazy, Suspense } from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { Route, Routes } from 'react-router-dom';
import Spinner from '../../components/Spinner';
import { Routes } from '../../data/constants';
import { Routes as ROUTES } from '../../data/constants';
const PostEditor = lazy(() => import('../posts/post-editor/PostEditor'));
const PostCommentsView = lazy(() => import('../post-comments/PostCommentsView'));
@@ -16,20 +16,20 @@ const DiscussionContent = () => {
<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">
<Suspense fallback={(<Spinner />)}>
{postEditorVisible ? (
<Route path={Routes.POSTS.NEW_POST}>
<PostEditor />
</Route>
) : (
<Switch>
<Route path={Routes.POSTS.EDIT_POST}>
<PostEditor editExisting />
</Route>
<Route path={Routes.COMMENTS.PATH}>
<PostCommentsView />
</Route>
</Switch>
)}
<Routes>
{postEditorVisible ? (
<Route path={ROUTES.POSTS.NEW_POST} element={<PostEditor />} />
) : (
<>
{ROUTES.POSTS.EDIT_POST.map(route => (
<Route key={route} path={route} element={<PostEditor editExisting />} />
))}
{ROUTES.COMMENTS.PATH.map(route => (
<Route key={route} path={route} element={<PostCommentsView />} />
))}
</>
)}
</Routes>
</Suspense>
</div>
</div>

View File

@@ -1,9 +0,0 @@
import React, { memo } from 'react';
import Footer from '@edx/frontend-component-footer';
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
const DiscussionFooter = () => <Footer />;
export default memo(withConditionalInContextRendering(DiscussionFooter, false));

View File

@@ -1,17 +0,0 @@
import React, { memo } from 'react';
import CourseTabsNavigation from '../../components/NavigationBar/CourseTabsNavigation';
import CourseHeader from './CourseHeader';
const DiscussionHeader = () => {
console.log('DiscussionHeader');
return (
<>
<CourseHeader />
<CourseTabsNavigation />
</>
);
};
export default memo(DiscussionHeader);

View File

@@ -1,53 +0,0 @@
import React, { lazy, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useEnableInContextSidebar } from '../data/hooks';
import DiscussionsProductTour from '../tours/DiscussionsProductTour';
import DiscussionActionBar from './DiscussionActionBar';
import DiscussionFooter from './DiscussionFooter';
import DiscussionHeader from './DiscussionHeader';
import DiscussionSidebar from './DiscussionSidebar';
import InfoPage from './InfoPage';
import LayoutSwitcher from './LayoutSwitcher';
import LegacyBreadcrumb from './LegacyBreadcrumb';
const DiscussionsRestrictionBanner = lazy(() => import('./DiscussionsRestrictionBanner'));
const DiscussionsLayout = ({ children }) => {
const postActionBarRef = useRef(null);
const enableInContextSidebar = useEnableInContextSidebar();
return (
<>
<DiscussionHeader />
<main className="container-fluid d-flex flex-column p-0 w-100" id="main" tabIndex="-1">
<div
ref={postActionBarRef}
className={classNames('header-action-bar', {
'shadow-none border-light-300 border-bottom': enableInContextSidebar,
})}
>
<DiscussionActionBar />
<DiscussionsRestrictionBanner />
</div>
<LegacyBreadcrumb />
<LayoutSwitcher
sidebar={<DiscussionSidebar postActionBarRef={postActionBarRef} />}
infoPage={<InfoPage />}
>
{children}
</LayoutSwitcher>
<DiscussionsProductTour />
</main>
<DiscussionFooter />
</>
);
};
DiscussionsLayout.propTypes = {
children: PropTypes.node.isRequired,
};
export default React.memo(DiscussionsLayout);

View File

@@ -1,16 +1,21 @@
import React, { lazy, Suspense } from 'react';
import React, {
lazy, Suspense, useContext, useEffect, useRef,
} from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
Redirect, Route, Switch, useLocation,
} from 'react-router';
Navigate, Route, Routes,
} from 'react-router-dom';
import { useWindowSize } from '@edx/paragon';
import Spinner from '../../components/Spinner';
import { RequestStatus, Routes } from '../../data/constants';
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
import { RequestStatus, Routes as ROUTES } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import { useContainerSize, useIsOnDesktop, useIsOnXLDesktop } from '../data/hooks';
import { selectConfigLoadingStatus, selectEnableInContext } from '../data/selectors';
import ResizableSidebar from './ResizableSidebar';
const TopicPostsView = lazy(() => import('../in-context-topics/TopicPostsView'));
const InContextTopicsView = lazy(() => import('../in-context-topics/TopicsView'));
@@ -19,71 +24,108 @@ const LearnersView = lazy(() => import('../learners/LearnersView'));
const PostsView = lazy(() => import('../posts/PostsView'));
const LegacyTopicsView = lazy(() => import('../topics/TopicsView'));
const DiscussionSidebar = ({ postActionBarRef }) => {
const location = useLocation();
const enableInContextSidebar = useEnableInContextSidebar();
const DiscussionSidebar = ({ displaySidebar, postActionBarRef }) => {
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 memoizedRedirection = React.useMemo(() => (
<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 && (
<Route path={Routes.LEARNERS.PATH} component={LearnersView} />
)}
{configStatus === RequestStatus.SUCCESSFUL && (
<Redirect
from={Routes.DISCUSSIONS.PATH}
to={{
...location,
pathname: Routes.POSTS.ALL_POSTS,
}}
/>
)}
</Switch>
</Suspense>
), [enableInContext, enableInContextSidebar, configStatus, location, redirectToLearnersTab]);
useEffect(() => {
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, enableInContextSidebar]);
return (
<ResizableSidebar postActionBarRef={postActionBarRef}>
{memoizedRedirection}
</ResizableSidebar>
<div
ref={sidebarRef}
className={classNames('flex-column position-sticky', {
'd-none': !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': !enableInContextSidebar,
})}
data-testid="sidebar"
>
<Suspense fallback={(<Spinner />)}>
<Routes>
{enableInContext && !enableInContextSidebar && (
<Route
path={ROUTES.TOPICS.ALL}
element={<InContextTopicsView />}
/>
)}
{enableInContext && !enableInContextSidebar && (
[
ROUTES.TOPICS.TOPIC,
ROUTES.TOPICS.CATEGORY,
ROUTES.TOPICS.TOPIC_POST,
ROUTES.TOPICS.TOPIC_POST_EDIT,
].map((route) => (
<Route
key={route}
path={route}
element={<TopicPostsView />}
/>
))
)}
{[
ROUTES.POSTS.ALL_POSTS,
ROUTES.POSTS.EDIT_ALL_POSTS,
ROUTES.POSTS.MY_POSTS,
ROUTES.POSTS.EDIT_MY_POSTS,
ROUTES.TOPICS.CATEGORY,
ROUTES.TOPICS.CATEGORY_POST,
ROUTES.TOPICS.CATEGORY_POST_EDIT,
ROUTES.TOPICS.TOPIC,
ROUTES.TOPICS.TOPIC_POST,
ROUTES.TOPICS.TOPIC_POST_EDIT,
].map((route) => (
<Route
key={route}
path={route}
element={<PostsView />}
/>
))}
{ROUTES.TOPICS.PATH.map(path => (
<Route key={path} path={path} element={<LegacyTopicsView />} />
))}
{
[ROUTES.LEARNERS.POSTS, ROUTES.LEARNERS.POSTS_EDIT].map((route) => (
<Route key={route} path={route} element={<LearnerPostsView />} />
))
}
<Route path={ROUTES.LEARNERS.PATH} element={<LearnersView />} />
{configStatus === RequestStatus.SUCCESSFUL && (
<Route path={`${ROUTES.DISCUSSIONS.PATH}/*`} element={<Navigate to="posts" />} />
)}
</Routes>
</Suspense>
</div>
);
};
DiscussionSidebar.propTypes = {
displaySidebar: PropTypes.bool,
postActionBarRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
]),
};
DiscussionSidebar.defaultProps = {
displaySidebar: false,
postActionBarRef: null,
};
export default React.memo(DiscussionSidebar);

View File

@@ -3,7 +3,7 @@ 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 { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -28,8 +28,8 @@ function renderComponent(displaySidebar = true, location = `/${courseId}/`) {
const wrapper = render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{ courseId, page: 'posts' }}>
<MemoryRouter initialEntries={[location]}>
<DiscussionSidebar displaySidebar={displaySidebar} postActionBarRef={null} />
</MemoryRouter>

View File

@@ -1,47 +1,161 @@
import React, { lazy, Suspense, useMemo } from 'react';
/* eslint-disable react/jsx-no-constructed-context-values */
import React, { lazy, Suspense, useRef } from 'react';
import { useRouteMatch } from 'react-router';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import {
matchPath, Route, Routes, useLocation, useMatch,
} from 'react-router-dom';
import { LearningHeader as Header } from '@edx/frontend-component-header';
import { Spinner } from '../../components';
import { ALL_ROUTES } from '../../data/constants';
import { selectCourseTabs } from '../../components/NavigationBar/data/selectors';
import { ALL_ROUTES, DiscussionProvider, Routes as ROUTES } from '../../data/constants';
import { DiscussionContext } from '../common/context';
import {
useCourseDiscussionData, useCurrentPage, useEnableInContextSidebar, useRedirectToThread,
useCourseDiscussionData, useIsOnDesktop, useRedirectToThread, useSidebarVisible,
} from '../data/hooks';
import DiscussionLayout from './DiscussionLayout';
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 { selectPostEditorVisible } from '../posts/data/selectors';
import useFeedbackWrapper from './FeedbackWrapper';
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 DiscussionsHome = () => {
useCourseDiscussionData();
useRedirectToThread();
useFeedbackWrapper();
const page = useCurrentPage();
const enableInContextSidebar = useEnableInContextSidebar();
const location = useLocation();
const postActionBarRef = useRef(null);
const postEditorVisible = useSelector(selectPostEditorVisible);
const provider = useSelector(selectDiscussionProvider);
const enableInContext = useSelector(selectEnableInContext);
const { courseNumber, courseTitle, org } = useSelector(selectCourseTabs);
const pageParams = useMatch(ROUTES.COMMENTS.PAGE)?.params;
const page = pageParams?.page || null;
const matchPattern = ALL_ROUTES.find((route) => matchPath({ path: route }, location.pathname));
const { params } = useMatch(matchPattern);
const isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const enableInContextSidebar = Boolean(new URLSearchParams(location.search).get('inContextSidebar') !== null);
const {
params: {
courseId, postId, topicId, category, learnerUsername,
},
} = useRouteMatch(ALL_ROUTES);
courseId, postId, topicId, category, learnerUsername,
} = params;
const contextValues = useMemo(() => ({
page,
courseId,
postId,
topicId,
enableInContextSidebar,
category,
learnerUsername,
}), [page, courseId, postId, topicId, enableInContextSidebar, category, learnerUsername]);
useCourseDiscussionData(courseId);
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, only show the sidebar if the content area isn't displayed. */
const displayContentArea = (postId || postEditorVisible || (learnerUsername && postId));
if (displayContentArea) { displaySidebar = isOnDesktop; }
return (
<Suspense fallback={(<Spinner />)}>
<DiscussionLayout>
<DiscussionContext.Provider value={contextValues}>
<DiscussionContent />
</DiscussionContext.Provider>
</DiscussionLayout>
<DiscussionContext.Provider value={{
page,
courseId,
postId,
topicId,
enableInContextSidebar,
category,
learnerUsername,
}}
>
{!enableInContextSidebar && (
<Header courseOrg={org} courseNumber={courseNumber} courseTitle={courseTitle} />
)}
<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-2 py-0': enableInContextSidebar,
})}
>
{!enableInContextSidebar && (
<NavigationBar />
)}
<PostActionsBar />
</div>
<DiscussionsRestrictionBanner />
</div>
{provider === DiscussionProvider.LEGACY && (
<Suspense fallback={(<Spinner />)}>
<Routes>
{[
ROUTES.TOPICS.CATEGORY,
ROUTES.TOPICS.CATEGORY_POST,
ROUTES.TOPICS.CATEGORY_POST_EDIT,
ROUTES.TOPICS.TOPIC,
ROUTES.TOPICS.TOPIC_POST,
ROUTES.TOPICS.TOPIC_POST_EDIT,
].map((route) => (
<Route
key={route}
path={route}
element={<LegacyBreadcrumbMenu />}
/>
))}
</Routes>
</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 && (
<Routes>
<>
{ROUTES.TOPICS.PATH.map(route => (
<Route
key={route}
path={`${route}/*`}
element={(enableInContext || enableInContextSidebar) ? <InContextEmptyTopics /> : <EmptyTopics />}
/>
))}
<Route
path={ROUTES.POSTS.MY_POSTS}
element={<EmptyPosts subTitleMessage={messages.emptyMyPosts} />}
/>
{[`${ROUTES.POSTS.PATH}/*`, ROUTES.POSTS.ALL_POSTS, ROUTES.LEARNERS.POSTS].map((route) => (
<Route
key={route}
path={route}
element={<EmptyPosts subTitleMessage={messages.emptyAllPosts} />}
/>
))}
<Route path={ROUTES.LEARNERS.PATH} element={<EmptyLearners />} />
</>
</Routes>
)}
</div>
{!enableInContextSidebar && (
<DiscussionsProductTour />
)}
</main>
{!enableInContextSidebar && <Footer />}
</DiscussionContext.Provider>
</Suspense>
);
};

View File

@@ -5,7 +5,7 @@ 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 { MemoryRouter } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -42,7 +42,7 @@ function renderComponent(location = `/${courseId}/`) {
const wrapper = render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<DiscussionsHome />
</MemoryRouter>
@@ -198,9 +198,7 @@ describe('DiscussionsHome', () => {
);
it('should display empty page message for empty learners list', async () => {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
learners_tab_enabled: true,
});
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {});
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
await renderComponent(`/${courseId}/learners`);

View File

@@ -1,37 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Route, Switch } from 'react-router';
import { Routes } from '../../data/constants';
import { useEnableInContextSidebar, useShowLearnersTab } from '../data/hooks';
import { selectEnableInContext } from '../data/selectors';
import { EmptyLearners, EmptyPosts, EmptyTopics } from '../empty-posts';
import { EmptyTopic as InContextEmptyTopics } from '../in-context-topics/components';
import messages from '../messages';
const InfoPage = () => {
const enableInContext = useSelector(selectEnableInContext);
const isRedirectToLearners = useShowLearnersTab();
const enableInContextSidebar = useEnableInContextSidebar();
return (
<Switch>
<Route
path={Routes.TOPICS.PATH}
component={(enableInContext || enableInContextSidebar) ? InContextEmptyTopics : EmptyTopics}
/>
<Route
path={Routes.POSTS.MY_POSTS}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyMyPosts} />}
/>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.LEARNERS.POSTS]}
render={routeProps => <EmptyPosts {...routeProps} subTitleMessage={messages.emptyAllPosts} />}
/>
{isRedirectToLearners && <Route path={Routes.LEARNERS.PATH} component={EmptyLearners} />}
</Switch>
);
};
export default React.memo(InfoPage);

View File

@@ -1,40 +0,0 @@
import React, { useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import {
useIsOnDesktop, useLearnerUsername, usePostId, useSidebarVisible,
} from '../data/hooks';
import { selectPostEditorVisible } from '../posts/data/selectors';
const LayoutSwitcher = ({ children, sidebar, infoPage }) => {
const postId = usePostId();
const learnerUsername = useLearnerUsername();
const isOnDesktop = useIsOnDesktop();
let displaySidebar = useSidebarVisible();
const postEditorVisible = useSelector(selectPostEditorVisible);
const displayContentArea = useMemo(() => {
const isContentVisible = postId || postEditorVisible || (learnerUsername && postId);
if (isContentVisible) {
displaySidebar = isOnDesktop;
}
return isContentVisible;
}, [postId, postEditorVisible, learnerUsername, isOnDesktop]);
return (
<div className="d-flex flex-row position-relative">
{displaySidebar && sidebar }
{displayContentArea ? children : infoPage }
</div>
);
};
LayoutSwitcher.propTypes = {
children: PropTypes.node.isRequired,
sidebar: PropTypes.node.isRequired,
infoPage: PropTypes.node.isRequired,
};
export default React.memo(LayoutSwitcher);

View File

@@ -1,23 +0,0 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { Route } from 'react-router-dom';
import { DiscussionProvider, Routes } from '../../data/constants';
import { selectDiscussionProvider } from '../data/selectors';
import LegacyBreadcrumbMenu from '../navigation/breadcrumb-menu/LegacyBreadcrumbMenu';
const LegacyBreadcrumb = () => {
const provider = useSelector(selectDiscussionProvider);
return (
provider === DiscussionProvider.LEGACY && (
<Route
path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}
component={LegacyBreadcrumbMenu}
/>
)
);
};
export default memo(LegacyBreadcrumb);

View File

@@ -1,54 +0,0 @@
import React, { useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useWindowSize } from '@edx/paragon';
import {
useContainerSize, useEnableInContextSidebar, useIsOnDesktop, useIsOnXLDesktop,
} from '../data/hooks';
const ResizableSidebar = ({ children, postActionBarRef }) => {
const sidebarRef = useRef(null);
const isOnDesktop = useIsOnDesktop();
const isOnXLDesktop = useIsOnXLDesktop();
const enableInContextSidebar = useEnableInContextSidebar();
const postActionBarHeight = useContainerSize(postActionBarRef);
const { height: windowHeight } = useWindowSize();
useEffect(() => {
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, enableInContextSidebar]);
return (
<div
data-testid="sidebar"
ref={sidebarRef}
className={classNames('flex-column position-sticky d-flex overflow-auto box-shadow-centered-1', {
'w-100': !isOnDesktop,
'sidebar-desktop-width': isOnDesktop && !isOnXLDesktop,
'w-25 sidebar-XL-width': isOnXLDesktop,
'min-content-height': !enableInContextSidebar,
})}
>
{children}
</div>
);
};
ResizableSidebar.propTypes = {
children: PropTypes.node.isRequired,
postActionBarRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
};
export default React.memo(ResizableSidebar);

View File

@@ -26,7 +26,7 @@ function renderComponent(location = `/${courseId}/`) {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<EmptyPosts subTitleMessage={messages.emptyMyPosts} />
</MemoryRouter>

View File

@@ -1,11 +1,10 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { useParams } from 'react-router-dom';
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';
@@ -15,11 +14,11 @@ import EmptyPage from './EmptyPage';
const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES);
const { topicId } = useParams();
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const hasGlobalThreads = useTotalTopicThreadCount() > 0;
const topicThreadCount = useSelector(selectTopicThreadCount(match.params.topicId));
const topicThreadCount = useSelector(selectTopicThreadCount(topicId));
const addPost = useCallback(() => (
dispatch(showPostEditor())
@@ -35,7 +34,7 @@ const EmptyTopics = () => {
return null;
}
if (match.params.topicId) {
if (topicId) {
if (topicThreadCount > 0) {
title = messages.noPostSelected;
} else {

View File

@@ -2,14 +2,14 @@ import { render, screen } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { Context as ResponsiveContext } from 'react-responsive';
import { MemoryRouter } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
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 { getApiBaseUrl } from '../../data/constants';
import { getApiBaseUrl, Routes as ROUTES } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import messages from '../messages';
@@ -26,9 +26,12 @@ function renderComponent(location = `/${courseId}/topics/`) {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<EmptyTopics />
<Routes>
<Route path={ROUTES.TOPICS.ALL} element={<EmptyTopics />} />
<Route path={ROUTES.TOPICS.TOPIC} element={<EmptyTopics />} />
</Routes>
</MemoryRouter>
</AppProvider>
</ResponsiveContext.Provider>

View File

@@ -4,7 +4,9 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { generatePath, MemoryRouter, Route } from 'react-router';
import {
generatePath, MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -12,7 +14,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { AppProvider } from '@edx/frontend-platform/react';
import { PostActionsBar } from '../../components';
import { Routes } from '../../data/constants';
import { Routes as ROUTES } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { DiscussionContext } from '../common/context';
@@ -35,16 +37,21 @@ let axiosMock;
let lastLocation;
let container;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
async function renderComponent({ topicId, category } = { }) {
let path = `/${courseId}/topics`;
if (topicId) {
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
path = generatePath(ROUTES.POSTS.PATH, { courseId, topicId });
} else if (category) {
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
path = generatePath(ROUTES.TOPICS.CATEGORY, { courseId, category });
}
const wrapper = await render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{
courseId,
topicId,
@@ -53,19 +60,35 @@ async function renderComponent({ topicId, category } = { }) {
}}
>
<MemoryRouter initialEntries={[path]}>
<Route exact path={[Routes.POSTS.PATH, Routes.TOPICS.CATEGORY]}>
<TopicPostsView />
</Route>
<Route exact path={[Routes.TOPICS.ALL]}>
<PostActionsBar />
<TopicsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
{
[
ROUTES.POSTS.PATH,
ROUTES.TOPICS.CATEGORY,
].map((route) => (
<Route
key={route}
path={route}
element={(
<>
<TopicPostsView />
<LocationComponent />
</>
)}
/>
))
}
<Route
path={ROUTES.TOPICS.ALL}
element={(
<>
<PostActionsBar />
<TopicsView />
<LocationComponent />
</>
)}
/>
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -5,7 +5,9 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -32,24 +34,21 @@ let axiosMock;
let lastLocation;
let container;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{ courseId, category }}>
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
<Route path="/:courseId/topics/">
<TopicsView />
</Route>
<Route path="/:courseId/category/:category">
<TopicPostsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
<Route path="/:courseId/topics/*" element={<><TopicsView /><LocationComponent /></>} />
<Route path="/:courseId/category/:category" element={<><TopicPostsView /><LocationComponent /></>} />
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -1,7 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Icon, IconButton, Spinner } from '@edx/paragon';
@@ -12,7 +12,7 @@ import messages from '../messages';
const BackButton = ({
intl, path, title, loading,
}) => {
const history = useHistory();
const navigate = useNavigate();
return (
<>
@@ -22,7 +22,7 @@ const BackButton = ({
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(path)}
onClick={() => navigate(path)}
alt={intl.formatMessage(messages.backAlt)}
/>
<div className="d-flex flex-fill justify-content-center align-items-center mr-4.5">

View File

@@ -1,11 +1,10 @@
import React, { useCallback, useContext } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { ALL_ROUTES } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useIsOnDesktop } from '../../data/hooks';
import { selectPostThreadCount } from '../../data/selectors';
@@ -16,11 +15,11 @@ import { selectCourseWareThreadsCount, selectTotalTopicsThreadsCount } from '../
const EmptyTopics = () => {
const intl = useIntl();
const match = useRouteMatch(ALL_ROUTES);
const { category, topicId } = useParams();
const dispatch = useDispatch();
const isOnDesktop = useIsOnDesktop();
const { enableInContextSidebar } = useContext(DiscussionContext);
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(match.params.category));
const courseWareThreadsCount = useSelector(selectCourseWareThreadsCount(category));
const topicThreadsCount = useSelector(selectPostThreadCount);
// hasGlobalThreads is used to determine if there are any post available in courseware and non-courseware topics
const hasGlobalThreads = useSelector(selectTotalTopicsThreadsCount) > 0;
@@ -39,7 +38,7 @@ const EmptyTopics = () => {
return null;
}
if (match.params.topicId) {
if (topicId) {
if (topicThreadsCount > 0) {
title = messages.noPostSelected;
} else {
@@ -48,7 +47,7 @@ const EmptyTopics = () => {
subTitle = messages.emptyTopic;
fullWidth = true;
}
} else if (match.params.category) {
} else if (category) {
if (enableInContextSidebar && topicThreadsCount > 0) {
title = messages.noPostSelected;
} else if (courseWareThreadsCount > 0) {

View File

@@ -1,4 +1,4 @@
import React, { memo, useCallback, useEffect } from 'react';
import React, { useCallback, useContext, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
@@ -6,15 +6,14 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, SearchField } from '@edx/paragon';
import { Search as SearchIcon } from '@edx/paragon/icons';
import { useCurrentPage } from '../../data/hooks';
import { DiscussionContext } from '../../common/context';
import postsMessages from '../../posts/post-actions-bar/messages';
import { setFilter as setTopicFilter } from '../data/slices';
const TopicSearchBar = () => {
const intl = useIntl();
const dispatch = useDispatch();
const page = useCurrentPage();
const { page } = useContext(DiscussionContext);
const topicSearch = useSelector(({ inContextTopics }) => inContextTopics.filter);
let searchValue = '';
@@ -58,4 +57,4 @@ const TopicSearchBar = () => {
);
};
export default memo(TopicSearchBar);
export default TopicSearchBar;

View File

@@ -2,8 +2,7 @@ import React, { useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -41,7 +40,7 @@ const SectionBaseGroup = ({
role="option"
data-subsection-id={subsection.id}
data-testid="subsection-group"
to={sectionUrl(subsection.id)}
to={sectionUrl(subsection.id)()}
onClick={() => isSelected(subsection.id)}
aria-current={isSelected(section.id) ? 'page' : undefined}
tabIndex={(isSelected(subsection.id) || index === 0) ? 0 : -1}

View File

@@ -5,8 +5,7 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
@@ -42,7 +41,7 @@ const Topic = ({
'border-light-400 border-bottom': showDivider,
})}
data-topic-id={topic.id}
to={topicUrl}
to={topicUrl()}
onClick={() => isSelected(topic.id)}
aria-current={isSelected(topic.id) ? 'page' : undefined}
role="option"

View File

@@ -4,7 +4,7 @@ import React, {
import capitalize from 'lodash/capitalize';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
@@ -35,7 +35,7 @@ import messages from './messages';
const LearnerPostsView = () => {
const intl = useIntl();
const location = useLocation();
const history = useHistory();
const navigate = useNavigate();
const dispatch = useDispatch();
const postsIds = useSelector(selectAllThreadsIds);
@@ -83,7 +83,7 @@ const LearnerPostsView = () => {
iconAs={Icon}
style={{ padding: '18px' }}
size="inline"
onClick={() => history.push(discussionsPath(Routes.LEARNERS.PATH, { courseId })(location))}
onClick={() => navigate({ ...discussionsPath(Routes.LEARNERS.PATH, { courseId })(location) })}
alt={intl.formatMessage(messages.back)}
/>
<div className="text-primary-500 font-style font-weight-bold py-2.5">

View File

@@ -6,7 +6,9 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -33,26 +35,26 @@ const username = 'abc123';
let container;
let lastLocation;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
async function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
page: 'learners',
}}
>
<MemoryRouter initialEntries={[`/${courseId}/learners/${username}/posts`]}>
<Route path="/:courseId/learners/:learnerUsername/posts">
<LearnerPostsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
<Route path="/:courseId/learners/:learnerUsername?/posts?" element={<><LearnerPostsView /><LocationComponent /></>} />
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -1,16 +1,14 @@
import React, { useCallback, useEffect, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Redirect, useLocation, useParams,
} from 'react-router';
import { useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Spinner } from '@edx/paragon';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus, Routes } from '../../data/constants';
import { selectConfigLoadingStatus, selectLearnersTabEnabled } from '../data/selectors';
import { RequestStatus } from '../../data/constants';
import { selectConfigLoadingStatus } from '../data/selectors';
import NoResults from '../posts/NoResults';
import {
learnersLoadingStatus,
@@ -27,25 +25,21 @@ import messages from './messages';
const LearnersView = () => {
const intl = useIntl();
const { courseId } = useParams();
const location = useLocation();
const dispatch = useDispatch();
const orderBy = useSelector(selectLearnerSorting());
const nextPage = useSelector(selectLearnerNextPage());
const loadingStatus = useSelector(learnersLoadingStatus());
const usernameSearch = useSelector(selectUsernameSearch());
const courseConfigLoadingStatus = useSelector(selectConfigLoadingStatus);
const learnersTabEnabled = useSelector(selectLearnersTabEnabled);
const learners = useSelector(selectAllLearners);
useEffect(() => {
if (learnersTabEnabled) {
if (usernameSearch) {
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
} else {
dispatch(fetchLearners(courseId, { orderBy }));
}
if (usernameSearch) {
dispatch(fetchLearners(courseId, { orderBy, usernameSearch }));
} else {
dispatch(fetchLearners(courseId, { orderBy }));
}
}, [courseId, orderBy, learnersTabEnabled, usernameSearch]);
}, [courseId, orderBy, usernameSearch]);
const loadPage = useCallback(async () => {
if (nextPage) {
@@ -63,12 +57,12 @@ const LearnersView = () => {
const renderLearnersList = useMemo(() => (
(
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learnersTabEnabled && learners.map((learner) => (
courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && learners.map((learner) => (
<LearnerCard learner={learner} key={learner.username} />
))
// eslint-disable-next-line react/jsx-no-useless-fragment
) || <></>
), [courseConfigLoadingStatus, learnersTabEnabled, learners]);
), [courseConfigLoadingStatus, learners]);
return (
<div className="d-flex flex-column border-right border-light-400">
@@ -83,14 +77,6 @@ const LearnersView = () => {
/>
)}
<div className="list-group list-group-flush learner" role="list">
{courseConfigLoadingStatus === RequestStatus.SUCCESSFUL && !learnersTabEnabled && (
<Redirect
to={{
...location,
pathname: Routes.DISCUSSIONS.PATH,
}}
/>
)}
{renderLearnersList}
{loadingStatus === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">

View File

@@ -7,7 +7,7 @@ import {
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -34,7 +34,7 @@ let container;
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{
page: 'learners',
learnerUsername: 'learner-1',
@@ -42,10 +42,17 @@ function renderComponent() {
}}
>
<MemoryRouter initialEntries={[`/${courseId}/`]}>
<Route path="/:courseId/">
<PostActionsBar />
<LearnersView />
</Route>
<Routes>
<Route
path="/:courseId/"
element={(
<>
<PostActionsBar />
<LearnersView />
</>
)}
/>
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -62,7 +69,6 @@ describe('LearnersView', () => {
username: 'test_user',
administrator: true,
roles: [],
learnersTabEnabled: false,
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
@@ -99,7 +105,6 @@ describe('LearnersView', () => {
async function assignPrivilages(hasModerationPrivileges = false) {
axiosMock.onGet(getDiscussionsConfigUrl(courseId)).reply(200, {
learners_tab_enabled: true,
user_is_privileged: true,
hasModerationPrivileges,
});
@@ -107,13 +112,6 @@ describe('LearnersView', () => {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
test('Learners tab is disabled by default', async () => {
await setUpLearnerMockResponse();
await renderComponent();
expect(screen.queryByText('learner-1')).not.toBeInTheDocument();
});
test('Learners tab is enabled', async () => {
await setUpLearnerMockResponse();
await assignPrivilages();

View File

@@ -5,12 +5,10 @@ import {
waitFor,
} from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import { generatePath, MemoryRouter, Route } from 'react-router';
import { initializeMockApp } from '@edx/frontend-platform';
import { AppProvider } from '@edx/frontend-platform/react';
import { Routes } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { DiscussionContext } from '../../common/context';
import LearnerPostFilterBar from './LearnerPostFilterBar';
@@ -18,32 +16,21 @@ import LearnerPostFilterBar from './LearnerPostFilterBar';
let store;
const username = 'abc123';
const courseId = 'course-v1:edX+DemoX+Demo_Course';
const path = generatePath(
Routes.LEARNERS.POSTS,
{ courseId, learnerUsername: username },
);
function renderComponent() {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<MemoryRouter initialEntries={[path]}>
<Route
path={Routes.LEARNERS.POSTS}
component={LearnerPostFilterBar}
/>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
}
const renderComponent = () => render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider
value={{
learnerUsername: username,
courseId,
}}
>
<LearnerPostFilterBar />
</DiscussionContext.Provider>
</AppProvider>
</IntlProvider>,
);
describe('LearnerPostFilterBar', () => {
beforeEach(async () => {

View File

@@ -18,7 +18,7 @@ const LearnerCard = ({ learner }) => {
0: enableInContextSidebar ? 'in-context' : undefined,
learnerUsername: learner.username,
courseId,
});
})();
return (
<Link

View File

@@ -29,7 +29,7 @@ const BreadcrumbDropdown = ({
key="null"
active={!currentItem}
as={Link}
to={showAllPath}
to={showAllPath()}
>
{showAllMsg}
</Dropdown.Item>
@@ -39,7 +39,7 @@ const BreadcrumbDropdown = ({
key={itemLabelFunc(item)}
active={itemActiveFunc(item)}
as={Link}
to={itemPathFunc(item)}
to={itemPathFunc(item)()}
>
{itemLabelFunc(item)}
</Dropdown.Item>

View File

@@ -1,7 +1,7 @@
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { useRouteMatch } from 'react-router';
import { useParams } from 'react-router-dom';
import { Routes } from '../../../data/constants';
import {
@@ -15,12 +15,10 @@ import BreadcrumbDropdown from './BreadcrumbDropdown';
const LegacyBreadcrumbMenu = () => {
const {
params: {
courseId,
category,
topicId: currentTopicId,
},
} = useRouteMatch([Routes.TOPICS.CATEGORY, Routes.TOPICS.TOPIC]);
courseId,
category,
topicId: currentTopicId,
} = useParams();
const currentTopic = useSelector(selectTopic(currentTopicId));
const currentCategory = category || currentTopic?.categoryId;
const decodedCurrentCategory = String(currentCategory).replace('%23', '#');

View File

@@ -5,14 +5,14 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
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 { getApiBaseUrl, Routes } from '../../../data/constants';
import { getApiBaseUrl, Routes as ROUTES } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { fetchCourseTopics } from '../../topics/data/thunks';
@@ -28,15 +28,22 @@ let axiosMock;
function renderComponent(path) {
render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[path]}>
<Route
path={[
Routes.POSTS.PATH,
Routes.TOPICS.CATEGORY,
]}
component={LegacyBreadcrumbMenu}
/>
<Routes>
{
[
ROUTES.POSTS.PATH,
ROUTES.TOPICS.CATEGORY,
].map((route) => (
<Route
key={route}
path={route}
element={<LegacyBreadcrumbMenu />}
/>
))
}
</Routes>
</MemoryRouter>
</AppProvider>
</IntlProvider>,

View File

@@ -1,21 +1,20 @@
import React, { memo, useMemo } from 'react';
import React, { useContext, useMemo } from 'react';
import { matchPath } from 'react-router';
import { NavLink } from 'react-router-dom';
import { matchPath, NavLink, useLocation } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Nav } from '@edx/paragon';
import { Routes } from '../../../data/constants';
import withConditionalInContextRendering from '../../common/withConditionalInContextRendering';
import { useCourseId, useShowLearnersTab } from '../../data/hooks';
import { DiscussionContext } from '../../common/context';
import { discussionsPath } from '../../utils';
import messages from './messages';
const NavigationBar = () => {
const intl = useIntl();
const courseId = useCourseId();
const showLearnersTab = useShowLearnersTab();
const { courseId } = useContext(DiscussionContext);
const location = useLocation();
const isTopicsNavActive = Boolean(matchPath({ path: `${Routes.TOPICS.CATEGORY}/*` }, location.pathname));
const navLinks = useMemo(() => ([
{
@@ -28,41 +27,31 @@ const NavigationBar = () => {
},
{
route: Routes.TOPICS.ALL,
isActive: (match, location) => Boolean(matchPath(location.pathname, { path: Routes.TOPICS.PATH })),
labelMessage: messages.allTopics,
},
{
route: Routes.LEARNERS.PATH,
labelMessage: messages.learners,
},
]), []);
useMemo(() => {
if (showLearnersTab) {
navLinks.push({
route: Routes.LEARNERS.PATH,
labelMessage: messages.learners,
});
}
}, [showLearnersTab]);
console.log('NavigationBar');
const navLinksList = useMemo(() => (
navLinks.map(link => (
<Nav.Item key={link.route}>
<Nav.Link
key={link.route}
as={NavLink}
to={discussionsPath(link.route, { courseId })}
isActive={link.isActive}
>
{intl.formatMessage(link.labelMessage)}
</Nav.Link>
</Nav.Item>
))
), [navLinks]);
return (
<Nav variant="pills" className="py-2 nav-button-group">
{navLinksList}
{navLinks.map(link => (
<Nav.Item key={link.route}>
<Nav.Link
key={link.route}
as={NavLink}
to={discussionsPath(link.route, { courseId })()}
className={isTopicsNavActive && link.route === Routes.TOPICS.ALL && 'active'}
>
{intl.formatMessage(link.labelMessage)}
</Nav.Link>
</Nav.Item>
))}
</Nav>
);
};
export default memo(withConditionalInContextRendering(NavigationBar, false));
export default React.memo(NavigationBar);

View File

@@ -2,14 +2,16 @@ import React, {
Suspense, useCallback, useContext, useEffect, useState,
} from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Icon, IconButton } from '@edx/paragon';
import { ArrowBack } from '@edx/paragon/icons';
import Spinner from '../../components/Spinner';
import { EndorsementStatus, PostsPages, ThreadType } from '../../data/constants';
import {
EndorsementStatus, PostsPages, ThreadType,
} from '../../data/constants';
import { useDispatchWithState } from '../../data/hooks';
import { DiscussionContext } from '../common/context';
import { useIsOnDesktop } from '../data/hooks';
@@ -27,7 +29,7 @@ const CommentsView = React.lazy(() => import('./comments/CommentsView'));
const PostCommentsView = () => {
const intl = useIntl();
const history = useHistory();
const navigate = useNavigate();
const location = useLocation();
const isOnDesktop = useIsOnDesktop();
const [addingResponse, setAddingResponse] = useState(false);
@@ -37,6 +39,9 @@ const PostCommentsView = () => {
} = useContext(DiscussionContext);
const commentsCount = useCommentsCount(postId);
const { closed, id: threadId, type } = usePost(postId);
const redirectUrl = discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location);
useEffect(() => {
if (!threadId) {
@@ -89,9 +94,7 @@ const PostCommentsView = () => {
variant="plain"
className="px-0 line-height-24 py-0 my-1.5 border-0 font-weight-normal font-style text-primary-500"
iconBefore={ArrowBack}
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
onClick={() => navigate({ ...redirectUrl })}
size="sm"
>
{intl.formatMessage(messages.backAlt)}
@@ -106,9 +109,7 @@ const PostCommentsView = () => {
style={{ padding: '18px' }}
size="inline"
className="ml-4 mt-4"
onClick={() => history.push(discussionsPath(PostsPages[page], {
courseId, learnerUsername, category, topicId,
})(location))}
onClick={() => navigate({ ...redirectUrl })}
alt={intl.formatMessage(messages.backAlt)}
/>
)

View File

@@ -3,7 +3,9 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { camelCaseObject, initializeMockApp } from '@edx/frontend-platform';
@@ -89,11 +91,10 @@ async function getThreadAPIResponse(attr = null) {
await executeThunk(fetchThread(discussionPostId), store.dispatch, store.getState);
}
async function setupCourseConfig(reasonCodesEnabled = true) {
async function setupCourseConfig() {
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
has_moderation_privileges: true,
isPostingEnabled: true,
reason_codes_enabled: reasonCodesEnabled,
editReasons: [
{ code: 'reason-1', label: 'reason 1' },
{ code: 'reason-2', label: 'reason 2' },
@@ -107,22 +108,28 @@ async function setupCourseConfig(reasonCodesEnabled = true) {
await executeThunk(fetchCourseConfig(courseId), store.dispatch, store.getState);
}
function renderComponent(postId, isClosed = false) {
const LocationComponent = () => {
testLocation = useLocation();
return null;
};
function renderComponent(postId, isClosed = false, page = 'posts', path = `/${courseId}/posts/${postId}`) {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider
value={{ courseId, postId, isClosed }}
value={{
courseId, postId, page, isClosed, topicId: 'topic-id',
}}
>
<MemoryRouter initialEntries={[`/${courseId}/posts/${postId}`]}>
<MemoryRouter initialEntries={[path]}>
<DiscussionContent />
<Route
path="*"
render={({ location }) => {
testLocation = location;
return null;
}}
/>
<Routes>
<Route
path="*"
element={<LocationComponent />}
/>
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -392,12 +399,12 @@ describe('ThreadView', () => {
assertLastUpdateData({ edit_reason_code: 'reason-1' });
});
it('should close the post directly if reason codes are not enabled', async () => {
await setupCourseConfig(false);
await waitFor(() => renderComponent(discussionPostId));
it('should reopen the post', async () => {
await setupCourseConfig();
renderComponent(closedPostId);
const post = await screen.findByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
const post = screen.getByTestId('post-thread-2');
const hoverCard = within(post).getByTestId('hover-card-thread-2');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
@@ -405,34 +412,12 @@ describe('ThreadView', () => {
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /close/i }));
fireEvent.click(screen.getByRole('button', { name: /reopen/i }));
});
expect(screen.queryByRole('dialog', { name: /close post/i })).not.toBeInTheDocument();
assertLastUpdateData({ closed: true });
assertLastUpdateData({ closed: false });
});
it.each([true, false])(
'should reopen the post directly when reason codes enabled=%s',
async (reasonCodesEnabled) => {
await setupCourseConfig(reasonCodesEnabled);
renderComponent(closedPostId);
const post = screen.getByTestId('post-thread-2');
const hoverCard = within(post).getByTestId('hover-card-thread-2');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
);
});
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 () => {
await setupCourseConfig(false);
await waitFor(() => renderComponent(discussionPostId));
@@ -450,6 +435,28 @@ describe('ThreadView', () => {
expect(testLocation.pathname).toBe(`/${courseId}/posts/${discussionPostId}/edit`);
});
it('should show the editor if the post is edited on topics page', async () => {
await setupCourseConfig(false);
await waitFor(() => renderComponent(
discussionPostId,
false,
'topics',
`/${courseId}/topics/topic-id/posts/${discussionPostId}`,
));
const post = await screen.findByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
await act(async () => {
fireEvent.click(
within(hoverCard).getByRole('button', { name: /actions menu/i }),
);
});
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
});
expect(testLocation.pathname).toBe(`/${courseId}/topics/topic-id/posts/${discussionPostId}/edit`);
});
it('should allow pinning the post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await screen.findByTestId('post-thread-1');
@@ -465,6 +472,20 @@ describe('ThreadView', () => {
assertLastUpdateData({ pinned: false });
});
it('should allow copying a link to the post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await screen.findByTestId('post-thread-1');
const hoverCard = within(post).getByTestId('hover-card-thread-1');
Object.assign(navigator, { clipboard: { writeText: jest.fn() } });
await act(async () => {
fireEvent.click(within(hoverCard).getByRole('button', { name: /actions menu/i }));
});
await act(async () => {
fireEvent.click(within(hoverCard).getByRole('button', { name: /copy link/i }));
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(`http://localhost/${courseId}/posts/${discussionPostId}`);
});
it('should allow reporting the post', async () => {
await waitFor(() => renderComponent(discussionPostId));
const post = await screen.findByTestId('post-thread-1');

View File

@@ -36,7 +36,7 @@ const CommentsView = ({ endorsed }) => {
const handleDefinition = useCallback((message, commentsLength) => (
<div
className="mx-4 my-14px text-gray-700 font-style"
className="comment-line mx-4 my-14px text-gray-700 font-style"
role="heading"
aria-level="2"
>

View File

@@ -40,10 +40,10 @@ const CommentEditor = ({
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const userIsStaff = useSelector(selectUserIsStaff);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const { editReasons } = useSelector(selectModerationSettings);
const [submitting, dispatch] = useDispatchWithState();
const canDisplayEditReason = (reasonCodesEnabled && edit
const canDisplayEditReason = (edit
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& author !== authenticatedUser.username
);

View File

@@ -1,14 +0,0 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { selectAllThreadsIds } from './data/selectors';
import PostsList from './PostsList';
const AllPostsList = () => {
const postsIds = useSelector(selectAllThreadsIds);
return <PostsList postsIds={postsIds} topicsIds={null} />;
};
export default memo(AllPostsList);

View File

@@ -1,22 +0,0 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
import { useCategory, useEnableInContextSidebar } from '../data/hooks';
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
import PostsList from './PostsList';
const PostsView = () => {
const category = useCategory();
const enableInContextSidebar = useEnableInContextSidebar();
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
};
export default memo(PostsView);

View File

@@ -1,90 +0,0 @@
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { Button, Spinner } from '@edx/paragon';
import { RequestStatus } from '../../data/constants';
import { useCourseId, useCurrentPage } from '../data/hooks';
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { fetchUserPosts } from '../learners/data/thunks';
import messages from '../messages';
import { usePostList } from './data/hooks';
import {
selectThreadFilters, selectThreadNextPage, selectThreadSorting, threadsLoadingStatus,
} from './data/selectors';
import { fetchThreads } from './data/thunks';
import NoResults from './NoResults';
import { PostLink } from './post';
const PostsList = ({
postsIds, topicsIds, isTopicTab, parentIsLoading,
}) => {
const intl = useIntl();
const dispatch = useDispatch();
const { authenticatedUser } = useContext(AppContext);
const page = useCurrentPage();
const courseId = useCourseId();
const loadingStatus = useSelector(threadsLoadingStatus());
const orderBy = useSelector(selectThreadSorting());
const filters = useSelector(selectThreadFilters());
const nextPage = useSelector(selectThreadNextPage());
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsStaff = useSelector(selectUserIsStaff);
const configStatus = useSelector(selectConfigLoadingStatus);
const sortedPostsIds = usePostList(postsIds);
const showOwnPosts = page === 'my-posts';
const loadThreads = useCallback((topicIds, pageNum = undefined, isFilterChanged = false) => {
const params = {
orderBy,
filters,
page: pageNum,
author: showOwnPosts ? authenticatedUser.username : null,
countFlagged: (userHasModerationPrivileges || userIsStaff) || undefined,
topicIds,
isFilterChanged,
};
if (showOwnPosts && filters.search === '') {
dispatch(fetchUserPosts(courseId, params));
} else {
dispatch(fetchThreads(courseId, params));
}
}, [courseId, orderBy, filters, showOwnPosts, authenticatedUser.username, userHasModerationPrivileges, userIsStaff]);
return (
loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading ? (
<div className="d-flex justify-content-center p-4 mx-auto my-auto">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
nextPage && loadingStatus === RequestStatus.SUCCESSFUL && (
<Button onClick={() => loadThreads(topicsIds, nextPage)} variant="primary" size="md">
{intl.formatMessage(messages.loadMorePosts)}
</Button>
)
)
);
};
PostsList.propTypes = {
postsIds: PropTypes.arrayOf(PropTypes.string),
topicsIds: PropTypes.arrayOf(PropTypes.string),
isTopicTab: PropTypes.bool,
parentIsLoading: PropTypes.bool,
};
PostsList.defaultProps = {
postsIds: [],
topicsIds: undefined,
isTopicTab: false,
parentIsLoading: undefined,
};
export default React.memo(PostsList);

View File

@@ -21,7 +21,7 @@ function renderComponent(location = `/${courseId}/`) {
return render(
<IntlProvider locale="en">
<ResponsiveContext.Provider value={{ width: 1280 }}>
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[location]}>
<NoResults />
</MemoryRouter>

View File

@@ -10,7 +10,7 @@ import { AppContext } from '@edx/frontend-platform/react';
import { Button, Spinner } from '@edx/paragon';
import { RequestStatus } from '../../data/constants';
import { useCourseId, useCurrentPage } from '../data/hooks';
import { DiscussionContext } from '../common/context';
import { selectConfigLoadingStatus, selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors';
import { fetchUserPosts } from '../learners/data/thunks';
import messages from '../messages';
@@ -28,8 +28,7 @@ const PostsList = ({
const intl = useIntl();
const dispatch = useDispatch();
const { authenticatedUser } = useContext(AppContext);
const page = useCurrentPage();
const courseId = useCourseId();
const { courseId, page } = useContext(DiscussionContext);
const loadingStatus = useSelector(threadsLoadingStatus());
const orderBy = useSelector(selectThreadSorting());
const filters = useSelector(selectThreadFilters());
@@ -81,8 +80,6 @@ const PostsList = ({
))
), [sortedPostsIds]);
console.log('sortedPostsIds', sortedPostsIds, loadingStatus === RequestStatus.IN_PROGRESS || parentIsLoading);
return (
<>
{!parentIsLoading && postInstances}

View File

@@ -1,32 +0,0 @@
import React, { memo, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import SearchInfo from '../../components/SearchInfo';
import { setSearchQuery } from './data/slices';
const PostsSearchInfo = () => {
const dispatch = useDispatch();
const searchString = useSelector(({ threads }) => threads.filters.search);
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
const loadingStatus = useSelector(({ threads }) => threads.status);
const handleOnClear = useCallback(() => {
dispatch(setSearchQuery(''));
}, []);
return (
searchString && (
<SearchInfo
count={resultsFound}
text={searchString}
loadingStatus={loadingStatus}
onClear={handleOnClear}
textSearchRewrite={textSearchRewrite}
/>
)
);
};
export default memo(PostsSearchInfo);

View File

@@ -1,30 +1,103 @@
import React, { memo, useMemo } from 'react';
import React, {
useCallback, useContext, useEffect, useMemo,
} from 'react';
import PropTypes from 'prop-types';
import { useCategory, useTopicId } from '../data/hooks';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import SearchInfo from '../../components/SearchInfo';
import { selectCurrentCategoryGrouping, selectTopicsUnderCategory } from '../../data/selectors';
import { DiscussionContext } from '../common/context';
import { selectEnableInContext } from '../data/selectors';
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { handleKeyDown } from '../utils';
import { selectAllThreadsIds, selectTopicThreadsIds } from './data/selectors';
import { setSearchQuery } from './data/slices';
import PostFilterBar from './post-filter-bar/PostFilterBar';
import AllPostsList from './AllPostsList';
import CategoryPostsList from './CategoryPostsList';
import PostsSearchInfo from './PostsSearchInfo';
import TopicPostsList from './TopicPostsList';
import PostsList from './PostsList';
const AllPostsList = () => {
const postsIds = useSelector(selectAllThreadsIds);
return <PostsList postsIds={postsIds} topicsIds={null} />;
};
const TopicPostsList = React.memo(({ topicId }) => {
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
});
TopicPostsList.propTypes = {
topicId: PropTypes.string.isRequired,
};
const CategoryPostsList = React.memo(({ category }) => {
const { enableInContextSidebar } = useContext(DiscussionContext);
const groupedCategory = useSelector(selectCurrentCategoryGrouping)(category);
// If grouping at subsection is enabled, only apply it when browsing discussions in context in the learning MFE.
const topicIds = useSelector(selectTopicsUnderCategory)(enableInContextSidebar ? groupedCategory : category);
const postsIds = useSelector(enableInContextSidebar ? selectAllThreadsIds : selectTopicThreadsIds(topicIds));
return <PostsList postsIds={postsIds} topicsIds={topicIds} />;
});
CategoryPostsList.propTypes = {
category: PropTypes.string.isRequired,
};
const PostsView = () => {
const topicId = useTopicId();
const category = useCategory();
const {
topicId,
category,
courseId,
enableInContextSidebar,
} = useContext(DiscussionContext);
const dispatch = useDispatch();
const enableInContext = useSelector(selectEnableInContext);
const searchString = useSelector(({ threads }) => threads.filters.search);
const resultsFound = useSelector(({ threads }) => threads.totalThreads);
const textSearchRewrite = useSelector(({ threads }) => threads.textSearchRewrite);
const loadingStatus = useSelector(({ threads }) => threads.status);
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
useEffect(() => {
if (isEmpty(topics)) {
dispatch((enableInContext || enableInContextSidebar)
? fetchCourseTopicsV3(courseId)
: fetchCourseTopics(courseId));
}
}, [topics]);
const handleOnClear = useCallback(() => {
dispatch(setSearchQuery(''));
}, []);
const postsListComponent = useMemo(() => {
if (topicId) {
return <TopicPostsList />;
return <TopicPostsList topicId={topicId} />;
}
if (category) {
return <CategoryPostsList />;
return <CategoryPostsList category={category} />;
}
return <AllPostsList />;
}, [topicId, category]);
return (
<div className="discussion-posts d-flex flex-column h-100">
<PostsSearchInfo />
{searchString && (
<SearchInfo
count={resultsFound}
text={searchString}
loadingStatus={loadingStatus}
onClear={handleOnClear}
textSearchRewrite={textSearchRewrite}
/>
)}
<PostFilterBar />
<div className="border-bottom border-light-400" />
<div className="list-group list-group-flush flex-fill" role="list" onKeyDown={e => handleKeyDown(e)}>
@@ -34,4 +107,4 @@ const PostsView = () => {
);
};
export default memo(PostsView);
export default PostsView;

View File

@@ -5,15 +5,15 @@ import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import {
generatePath, MemoryRouter, Route, Switch,
} from 'react-router';
generatePath, MemoryRouter, Route, Routes,
} from 'react-router-dom';
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 { getApiBaseUrl, Routes, ThreadType } from '../../data/constants';
import { getApiBaseUrl, Routes as ROUTES, ThreadType } from '../../data/constants';
import { initializeStore } from '../../store';
import { executeThunk } from '../../test-utils';
import { getCohortsApiUrl } from '../cohorts/data/api';
@@ -39,24 +39,24 @@ const username = 'abc123';
async function renderComponent({
postId, topicId, category, myPosts, enableInContextSidebar = false,
} = { myPosts: false }) {
let path = generatePath(Routes.POSTS.ALL_POSTS, { courseId });
let page;
let path = generatePath(ROUTES.POSTS.ALL_POSTS, { courseId });
let page = 'posts';
if (postId) {
path = generatePath(Routes.POSTS.ALL_POSTS, { courseId, postId });
path = generatePath(ROUTES.POSTS.ALL_POSTS, { courseId, postId });
page = 'posts';
} else if (topicId) {
path = generatePath(Routes.POSTS.PATH, { courseId, topicId });
page = 'posts';
path = generatePath(ROUTES.POSTS.PATH, { courseId, topicId });
page = 'topics';
} else if (category) {
path = generatePath(Routes.TOPICS.CATEGORY, { courseId, category });
path = generatePath(ROUTES.TOPICS.CATEGORY, { courseId, category });
page = 'category';
} else if (myPosts) {
path = generatePath(Routes.POSTS.MY_POSTS, { courseId });
path = generatePath(ROUTES.POSTS.MY_POSTS, { courseId });
page = 'my-posts';
}
await render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<MemoryRouter initialEntries={[path]}>
<DiscussionContext.Provider value={{
courseId,
@@ -67,15 +67,18 @@ async function renderComponent({
enableInContextSidebar,
}}
>
<Switch>
<Route path={Routes.POSTS.MY_POSTS}>
<PostsView />
</Route>
<Route
path={[Routes.POSTS.PATH, Routes.POSTS.ALL_POSTS, Routes.TOPICS.CATEGORY]}
component={PostsView}
/>
</Switch>
<Routes>
{
[
ROUTES.POSTS.PATH,
ROUTES.POSTS.MY_POSTS,
ROUTES.POSTS.ALL_POSTS,
ROUTES.TOPICS.CATEGORY,
].map((route) => (
<Route key={route} path={route} element={<PostsView />} />
))
}
</Routes>
</DiscussionContext.Provider>
</MemoryRouter>
</AppProvider>

View File

@@ -1,35 +0,0 @@
import React, { memo, useEffect } from 'react';
import isEmpty from 'lodash/isEmpty';
import { useDispatch, useSelector } from 'react-redux';
import { useCourseId, useEnableInContextSidebar, useTopicId } from '../data/hooks';
import { selectEnableInContext } from '../data/selectors';
import { selectTopics as selectInContextTopics } from '../in-context-topics/data/selectors';
import { fetchCourseTopicsV3 } from '../in-context-topics/data/thunks';
import { selectTopics } from '../topics/data/selectors';
import { fetchCourseTopics } from '../topics/data/thunks';
import { selectTopicThreadsIds } from './data/selectors';
import PostsList from './PostsList';
const TopicPostsList = () => {
const dispatch = useDispatch();
const topicId = useTopicId();
const courseId = useCourseId();
const enableInContextSidebar = useEnableInContextSidebar();
const enableInContext = useSelector(selectEnableInContext);
const postsIds = useSelector(selectTopicThreadsIds([topicId]));
const topics = useSelector(enableInContext ? selectInContextTopics : selectTopics);
useEffect(() => {
if (isEmpty(topics)) {
dispatch((enableInContext || enableInContextSidebar)
? fetchCourseTopicsV3(courseId)
: fetchCourseTopics(courseId));
}
}, [courseId, topics]);
return <PostsList postsIds={postsIds} topicsIds={[topicId]} isTopicTab />;
};
export default memo(TopicPostsList);

View File

@@ -26,6 +26,7 @@ Factory.define('thread')
'type',
'voted',
'pinned',
'copy_link',
],
author: 'test_user',
author_label: 'Staff',

View File

@@ -7,10 +7,20 @@ import { selectThreadsByIds } from './selectors';
export const usePostList = (ids) => {
const posts = useSelector(selectThreadsByIds(ids));
const pinnedPostsIds = [];
const unpinnedPostsIds = [];
const sortedIds = useMemo(() => (
[...posts].sort((a, b) => (b.pinned - a.pinned)).map((post) => post.id)
), [posts]);
const sortedIds = useMemo(() => {
posts.forEach((post) => {
if (post.pinned) {
pinnedPostsIds.push(post.id);
} else {
unpinnedPostsIds.push(post.id);
}
});
return [...pinnedPostsIds, ...unpinnedPostsIds];
}, [posts]);
return sortedIds;
};

View File

@@ -1,46 +0,0 @@
import React, { useCallback } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
import { RequestStatus } from '../../../data/constants';
import { useEnableInContextSidebar, useUserPostingEnabled } from '../../data/hooks';
import { selectConfigLoadingStatus } from '../../data/selectors';
import { showPostEditor } from '../data';
import messages from './messages';
const AddPostButton = () => {
const intl = useIntl();
const dispatch = useDispatch();
const loadingStatus = useSelector(selectConfigLoadingStatus);
const enableInContextSidebar = useEnableInContextSidebar();
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
const handleAddPost = useCallback(() => {
dispatch(showPostEditor());
}, []);
return (
loadingStatus === RequestStatus.SUCCESSFUL && isUserPrivilegedInPostingRestriction && (
<>
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
<Button
variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames(
'my-0 font-style border-0 line-height-24',
{ 'px-3 py-10px border-0': enableInContextSidebar },
)}
onClick={handleAddPost}
size={enableInContextSidebar ? 'md' : 'sm'}
>
{intl.formatMessage(messages.addAPost)}
</Button>
</>
)
);
};
export default AddPostButton;

View File

@@ -1,47 +1,80 @@
import React, { memo, useCallback } from 'react';
import React, { useCallback, useContext } from 'react';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, IconButton } from '@edx/paragon';
import {
Button, Icon, IconButton,
} from '@edx/paragon';
import { Close } from '@edx/paragon/icons';
import { useEnableInContextSidebar } from '../../data/hooks';
import Search from '../../../components/Search';
import { RequestStatus } from '../../../data/constants';
import { DiscussionContext } from '../../common/context';
import { useUserPostingEnabled } from '../../data/hooks';
import { selectConfigLoadingStatus, selectEnableInContext } from '../../data/selectors';
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
import { postMessageToParent } from '../../utils';
import AddPostButton from './AddPostButton';
import { showPostEditor } from '../data';
import messages from './messages';
import SearchField from './SearchField';
import './actionBar.scss';
const PostActionsBar = () => {
const intl = useIntl();
const enableInContextSidebar = useEnableInContextSidebar();
const dispatch = useDispatch();
const loadingStatus = useSelector(selectConfigLoadingStatus);
const enableInContext = useSelector(selectEnableInContext);
const isUserPrivilegedInPostingRestriction = useUserPostingEnabled();
const { enableInContextSidebar, page } = useContext(DiscussionContext);
const handleCloseInContext = useCallback(() => {
postMessageToParent('learning.events.sidebar.close');
}, []);
const handleAddPost = useCallback(() => {
dispatch(showPostEditor());
}, []);
return (
<div className={classNames('d-flex justify-content-end flex-grow-1', { 'py-1': !enableInContextSidebar })}>
<SearchField />
{!enableInContextSidebar && (
(enableInContext && ['topics', 'category'].includes(page))
? <IncontextSearch />
: <Search />
)}
{enableInContextSidebar && (
<h4 className="d-flex flex-grow-1 font-weight-bold font-style my-0 py-10px align-self-center">
{intl.formatMessage(messages.title)}
</h4>
)}
<AddPostButton />
{loadingStatus === RequestStatus.SUCCESSFUL && isUserPrivilegedInPostingRestriction && (
<>
{!enableInContextSidebar && <div className="border-right border-light-400 mx-3" />}
<Button
variant={enableInContextSidebar ? 'plain' : 'brand'}
className={classNames(
'my-0 font-style border-0 line-height-24',
{ 'px-3 py-10px border-0': enableInContextSidebar },
)}
onClick={handleAddPost}
size={enableInContextSidebar ? 'md' : 'sm'}
>
{intl.formatMessage(messages.addAPost)}
</Button>
</>
)}
{enableInContextSidebar && (
<>
<div className="border-right border-light-300 mr-3 ml-1.5 my-10px" />
<div className="justify-content-center mt-2.5 mx-3px">
<div className="border-right border-light-300 mr-2 my-10px" />
<div className="d-flex align-items-center justify-content-center">
<IconButton
src={Close}
size="sm"
iconAs={Icon}
onClick={handleCloseInContext}
alt={intl.formatMessage(messages.close)}
iconClassNames="spinner-dimensions"
className="spinner-dimensions"
/>
</div>
</>
@@ -50,4 +83,4 @@ const PostActionsBar = () => {
);
};
export default memo(PostActionsBar);
export default PostActionsBar;

View File

@@ -1,20 +0,0 @@
import React, { memo } from 'react';
import { useSelector } from 'react-redux';
import Search from '../../../components/Search';
import withConditionalInContextRendering from '../../common/withConditionalInContextRendering';
import { useCurrentPage } from '../../data/hooks';
import { selectEnableInContext } from '../../data/selectors';
import { TopicSearchBar as IncontextSearch } from '../../in-context-topics/topic-search';
const SearchField = () => {
const enableInContext = useSelector(selectEnableInContext);
const page = useCurrentPage();
return (
enableInContext && ['topics', 'category'].includes(page) ? <IncontextSearch /> : <Search />
);
};
export default memo(withConditionalInContextRendering(SearchField, false));

View File

@@ -1,3 +1,4 @@
.small-font {
font-size: .875rem !important;
}

View File

@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import { Formik } from 'formik';
import { isEmpty } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import * as Yup from 'yup';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -55,7 +55,7 @@ const PostEditor = ({
editExisting,
}) => {
const intl = useIntl();
const history = useHistory();
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const editorRef = useRef(null);
@@ -75,12 +75,12 @@ const PostEditor = ({
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const settings = useSelector(selectDivisionSettings);
const { allowAnonymous, allowAnonymousToPeers } = useSelector(selectAnonymousPostingConfig);
const { reasonCodesEnabled, editReasons } = useSelector(selectModerationSettings);
const { editReasons } = useSelector(selectModerationSettings);
const userIsStaff = useSelector(selectUserIsStaff);
const archivedTopics = useSelector(selectArchivedTopics);
const postEditorId = `post-editor-${editExisting ? postId : 'new'}`;
const canDisplayEditReason = (reasonCodesEnabled && editExisting
const canDisplayEditReason = (editExisting
&& (userHasModerationPrivileges || userIsGroupTa || userIsStaff)
&& post?.author !== authenticatedUser.username
);
@@ -126,7 +126,7 @@ const PostEditor = ({
learnerUsername: post?.author,
category,
})(location);
history.push(newLocation);
navigate({ ...newLocation });
}
dispatch(hidePostEditor());
}, [postId, topicId, post?.author, category, editExisting, commentsPagePath, location]);

View File

@@ -7,14 +7,14 @@ import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter';
import { act } from 'react-dom/test-utils';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
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 { getApiBaseUrl, Routes } from '../../../data/constants';
import { getApiBaseUrl, Routes as ROUTES } from '../../../data/constants';
import { initializeStore } from '../../../store';
import { executeThunk } from '../../../test-utils';
import { getCohortsApiUrl } from '../../cohorts/data/api';
@@ -37,17 +37,19 @@ let axiosMock;
let container;
async function renderComponent(editExisting = false, location = `/${courseId}/posts/`) {
const path = editExisting ? Routes.POSTS.EDIT_POST : Routes.POSTS.NEW_POSTS;
const paths = editExisting ? ROUTES.POSTS.EDIT_POST : [ROUTES.POSTS.NEW_POST];
const wrapper = await render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider
value={{ courseId, category: null }}
>
<MemoryRouter initialEntries={[location]}>
<Route path={path}>
<PostEditor editExisting={editExisting} />
</Route>
<Routes>
{paths.map((path) => (
<Route path={path} element={<PostEditor editExisting={editExisting} />} />
))}
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>
@@ -266,7 +268,18 @@ describe('PostEditor', () => {
test('cancel posting of existing post', async () => {
const threadId = 'thread-1';
await setupData();
await setupData({
editReasons: [
{
code: 'reason-1',
label: 'Reason 1',
},
{
code: 'reason-2',
label: 'Reason 2',
},
],
});
await act(async () => {
axiosMock.onGet(`${threadsApiUrl}${threadId}/`).reply(200, Factory.build('thread'));
await executeThunk(fetchThread(threadId), store.dispatch, store.getState);
@@ -292,7 +305,6 @@ describe('PostEditor', () => {
config: {
provider: 'legacy',
hasModerationPrivileges: true,
reasonCodesEnabled: true,
editReasons: [
{
code: 'reason-1',

View File

@@ -1,37 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { Form, Icon } from '@edx/paragon';
import { Check } from '@edx/paragon/icons';
const ActionItem = ({
id, label, value, selected,
}) => (
<label
htmlFor={id}
style={{ cursor: 'pointer' }}
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
data-testid={value === selected ? 'selected' : null}
aria-checked={value === selected}
tabIndex={value === selected ? '0' : '-1'}
>
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
{label}
</Form.Radio>
<span aria-hidden className="text-truncate">
{label}
</span>
</label>
);
ActionItem.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
selected: PropTypes.string.isRequired,
};
export default React.memo(ActionItem);

View File

@@ -1,81 +0,0 @@
import React, { useEffect, useMemo } from 'react';
import PropTypes from 'prop-types';
import { capitalize, isEmpty, toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { Form, Spinner } from '@edx/paragon';
import { RequestStatus } from '../../../data/constants';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
import { useCourseId } from '../../data/hooks';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import { selectThreadFilters } from '../data/selectors';
import ActionItem from './ActionItem';
import withFilterHandleChange from './withFilterHandleChange';
const CohortFilters = ({ handleSortFilterChange }) => {
const dispatch = useDispatch();
const courseId = useCourseId();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const currentFilters = useSelector(selectThreadFilters());
const { status } = useSelector(state => state.cohorts);
const cohorts = useSelector(selectCourseCohorts);
useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) {
dispatch(fetchCourseCohorts(courseId));
}
}, [userHasModerationPrivileges]);
const cohortsMenu = useMemo(() => (
<>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={currentFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={cohort.id}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={currentFilters.cohort}
/>
))}
</>
), [cohorts, currentFilters.cohort]);
return (
userHasModerationPrivileges && (
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={currentFilters.cohort}
onChange={handleSortFilterChange}
>
{cohortsMenu}
</Form.RadioSet>
</div>
)}
</>
)
);
};
CohortFilters.propTypes = {
handleSortFilterChange: PropTypes.func.isRequired,
};
export default React.memo(withFilterHandleChange(CohortFilters));

View File

@@ -1,69 +0,0 @@
import React, { useCallback, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { capitalize, toString } from 'lodash';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon } from '@edx/paragon';
import { Tune } from '@edx/paragon/icons';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages';
const CollapsibleFilter = ({ children }) => {
const intl = useIntl();
const currentSorting = useSelector(selectThreadSorting());
const currentFilters = useSelector(selectThreadFilters());
const cohorts = useSelector(selectCourseCohorts);
const [isOpen, setOpen] = useState(false);
const selectedCohort = useMemo(() => (
cohorts.find(cohort => (
toString(cohort.id) === currentFilters.cohort
))
), [cohorts, currentFilters.cohort]);
const handleToggle = useCallback(() => {
setOpen(!isOpen);
}, [isOpen]);
return (
<Collapsible.Advanced
open={isOpen}
onToggle={handleToggle}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-500 pr-4 font-style">
{intl.formatMessage(messages.sortFilterStatus, {
own: false,
type: currentFilters.postType,
sort: currentSorting,
status: currentFilters.status,
cohortType: selectedCohort?.name ? 'group' : 'all',
cohort: capitalize(selectedCohort?.name),
})}
</span>
<span id="icon-tune">
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</span>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
{children}
</Collapsible.Body>
</Collapsible.Advanced>
);
};
CollapsibleFilter.propTypes = {
children: PropTypes.node.isRequired,
};
export default React.memo(CollapsibleFilter);

View File

@@ -1,24 +1,317 @@
import React from 'react';
import React, {
useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import PropTypes from 'prop-types';
import { Form } from '@edx/paragon';
import classNames from 'classnames';
import { capitalize, isEmpty, toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import CohortFilters from './CohortFilters';
import CollapsibleFilter from './CollapsibleFilter';
import PostSortFilters from './PostSortFilters';
import PostStatusFilters from './PostStatusFilters';
import PostTypeFilters from './PostTypeFilters';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Collapsible, Form, Icon, Spinner,
} from '@edx/paragon';
import { Check, Tune } from '@edx/paragon/icons';
const PostFilterBar = () => (
<CollapsibleFilter>
<Form>
<div className="d-flex flex-row py-2 justify-content-between">
<PostTypeFilters />
<PostStatusFilters />
<PostSortFilters />
</div>
<CohortFilters />
</Form>
</CollapsibleFilter>
);
import {
PostsStatusFilter, RequestStatus,
ThreadOrdering, ThreadType,
} from '../../../data/constants';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import { fetchCourseCohorts } from '../../cohorts/data/thunks';
import { DiscussionContext } from '../../common/context';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import {
setCohortFilter, setPostsTypeFilter, setSortedBy, setStatusFilter,
} from '../data';
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
import messages from './messages';
export const ActionItem = React.memo(({
id,
label,
value,
selected,
}) => (
<label
htmlFor={id}
className="focus border-bottom-0 d-flex align-items-center w-100 py-2 m-0 font-weight-500 filter-menu"
data-testid={value === selected ? 'selected' : null}
style={{ cursor: 'pointer' }}
aria-checked={value === selected}
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
tabIndex={value === selected ? '0' : '-1'}
>
<Icon src={Check} className={classNames('text-success mr-2', { invisible: value !== selected })} />
<Form.Radio id={id} className="sr-only sr-only-focusable" value={value} tabIndex="0">
{label}
</Form.Radio>
<span aria-hidden className="text-truncate">
{label}
</span>
</label>
));
ActionItem.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
selected: PropTypes.string.isRequired,
};
const PostFilterBar = () => {
const intl = useIntl();
const dispatch = useDispatch();
const { courseId } = useParams();
const { page } = useContext(DiscussionContext);
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const currentSorting = useSelector(selectThreadSorting());
const currentFilters = useSelector(selectThreadFilters());
const { status } = useSelector(state => state.cohorts);
const cohorts = useSelector(selectCourseCohorts);
const [isOpen, setOpen] = useState(false);
const selectedCohort = useMemo(() => (
cohorts.find(cohort => (
toString(cohort.id) === currentFilters.cohort
))
), [cohorts, currentFilters.cohort]);
const handleSortFilterChange = useCallback((event) => {
const currentType = currentFilters.postType;
const currentStatus = currentFilters.status;
const {
name,
value,
} = event.currentTarget;
const filterContentEventProperties = {
statusFilter: currentStatus,
threadTypeFilter: currentType,
sortFilter: currentSorting,
cohortFilter: selectedCohort,
triggeredBy: name,
};
if (name === 'type') {
dispatch(setPostsTypeFilter(value));
if (
value === ThreadType.DISCUSSION && currentStatus === PostsStatusFilter.UNANSWERED
) {
// You can't filter discussions by unanswered
dispatch(setStatusFilter(PostsStatusFilter.ALL));
}
filterContentEventProperties.threadTypeFilter = value;
}
if (name === 'status') {
dispatch(setStatusFilter(value));
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
// You can't filter discussions by unanswered so switch type to questions
dispatch(setPostsTypeFilter(ThreadType.QUESTION));
}
if (value === PostsStatusFilter.UNRESPONDED && currentType !== ThreadType.DISCUSSION) {
// You can't filter questions by not responded so switch type to discussion
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
}
filterContentEventProperties.statusFilter = value;
}
if (name === 'sort') {
dispatch(setSortedBy(value));
filterContentEventProperties.sortFilter = value;
}
if (name === 'cohort') {
dispatch(setCohortFilter(value));
filterContentEventProperties.cohortFilter = value;
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
}, [currentFilters, currentSorting, dispatch, selectedCohort]);
const handleToggle = useCallback(() => {
setOpen(!isOpen);
}, [isOpen]);
useEffect(() => {
if (userHasModerationPrivileges && isEmpty(cohorts)) {
dispatch(fetchCourseCohorts(courseId));
}
}, [courseId, userHasModerationPrivileges]);
const renderCohortFilter = useMemo(() => (
userHasModerationPrivileges && (
<>
<div className="border-bottom my-2" />
{status === RequestStatus.IN_PROGRESS ? (
<div className="d-flex justify-content-center p-4">
<Spinner animation="border" variant="primary" size="lg" />
</div>
) : (
<div className="d-flex flex-row pt-2">
<Form.RadioSet
name="cohort"
className="d-flex flex-column list-group list-group-flush w-100"
value={currentFilters.cohort}
onChange={handleSortFilterChange}
>
<ActionItem
id="all-groups"
label="All groups"
value=""
selected={currentFilters.cohort}
/>
{cohorts.map(cohort => (
<ActionItem
key={cohort.id}
id={toString(cohort.id)}
label={capitalize(cohort.name)}
value={toString(cohort.id)}
selected={currentFilters.cohort}
/>
))}
</Form.RadioSet>
</div>
)}
</>
)
), [cohorts, currentFilters.cohort, handleSortFilterChange, status, userHasModerationPrivileges]);
return (
<Collapsible.Advanced
open={isOpen}
onToggle={handleToggle}
className="filter-bar collapsible-card-lg border-0"
>
<Collapsible.Trigger className="collapsible-trigger border-0">
<span className="text-primary-500 pr-4 font-style">
{intl.formatMessage(messages.sortFilterStatus, {
own: false,
type: currentFilters.postType,
sort: currentSorting,
status: currentFilters.status,
cohortType: selectedCohort?.name ? 'group' : 'all',
cohort: capitalize(selectedCohort?.name),
})}
</span>
<span id="icon-tune">
<Collapsible.Visible whenClosed>
<Icon src={Tune} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={Tune} />
</Collapsible.Visible>
</span>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body px-4 pb-3 pt-0">
<Form>
<div className="d-flex flex-row py-2 justify-content-between">
<Form.RadioSet
name="type"
className="d-flex flex-column list-group list-group-flush"
value={currentFilters.postType}
onChange={handleSortFilterChange}
>
<ActionItem
id="type-all"
label={intl.formatMessage(messages.allPosts)}
value={ThreadType.ALL}
selected={currentFilters.postType}
/>
<ActionItem
id="type-discussions"
label={intl.formatMessage(messages.filterDiscussions)}
value={ThreadType.DISCUSSION}
selected={currentFilters.postType}
/>
<ActionItem
id="type-questions"
label={intl.formatMessage(messages.filterQuestions)}
value={ThreadType.QUESTION}
selected={currentFilters.postType}
/>
</Form.RadioSet>
<Form.RadioSet
name="status"
className="d-flex flex-column list-group list-group-flush"
value={currentFilters.status}
onChange={handleSortFilterChange}
>
<ActionItem
id="status-any"
label={intl.formatMessage(messages.filterAnyStatus)}
value={PostsStatusFilter.ALL}
selected={currentFilters.status}
/>
<ActionItem
id="status-unread"
label={intl.formatMessage(messages.filterUnread)}
value={PostsStatusFilter.UNREAD}
selected={currentFilters.status}
/>
{page !== 'my-posts' && (
<ActionItem
id="status-following"
label={intl.formatMessage(messages.filterFollowing)}
value={PostsStatusFilter.FOLLOWING}
selected={currentFilters.status}
/>
)}
{(userHasModerationPrivileges || userIsGroupTa) && (
<ActionItem
id="status-reported"
label={intl.formatMessage(messages.filterReported)}
value={PostsStatusFilter.REPORTED}
selected={currentFilters.status}
/>
)}
<ActionItem
id="status-unanswered"
label={intl.formatMessage(messages.filterUnanswered)}
value={PostsStatusFilter.UNANSWERED}
selected={currentFilters.status}
/>
<ActionItem
id="status-unresponded"
label={intl.formatMessage(messages.filterUnresponded)}
value={PostsStatusFilter.UNRESPONDED}
selected={currentFilters.status}
/>
</Form.RadioSet>
<Form.RadioSet
name="sort"
className="d-flex flex-column list-group list-group-flush"
value={currentSorting}
onChange={handleSortFilterChange}
>
<ActionItem
id="sort-activity"
label={intl.formatMessage(messages.lastActivityAt)}
value={ThreadOrdering.BY_LAST_ACTIVITY}
selected={currentSorting}
/>
<ActionItem
id="sort-comments"
label={intl.formatMessage(messages.commentCount)}
value={ThreadOrdering.BY_COMMENT_COUNT}
selected={currentSorting}
/>
<ActionItem
id="sort-votes"
label={intl.formatMessage(messages.voteCount)}
value={ThreadOrdering.BY_VOTE_COUNT}
selected={currentSorting}
/>
</Form.RadioSet>
</div>
{renderCohortFilter}
</Form>
</Collapsible.Body>
</Collapsible.Advanced>
);
};
export default React.memo(PostFilterBar);

View File

@@ -1,52 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { ThreadOrdering } from '../../../data/constants';
import { selectThreadSorting } from '../data/selectors';
import ActionItem from './ActionItem';
import messages from './messages';
import withFilterHandleChange from './withFilterHandleChange';
const PostSortFilters = ({ handleSortFilterChange }) => {
const intl = useIntl();
const currentSorting = useSelector(selectThreadSorting());
return (
<Form.RadioSet
name="sort"
className="d-flex flex-column list-group list-group-flush"
value={currentSorting}
onChange={handleSortFilterChange}
>
<ActionItem
id="sort-activity"
label={intl.formatMessage(messages.lastActivityAt)}
value={ThreadOrdering.BY_LAST_ACTIVITY}
selected={currentSorting}
/>
<ActionItem
id="sort-comments"
label={intl.formatMessage(messages.commentCount)}
value={ThreadOrdering.BY_COMMENT_COUNT}
selected={currentSorting}
/>
<ActionItem
id="sort-votes"
label={intl.formatMessage(messages.voteCount)}
value={ThreadOrdering.BY_VOTE_COUNT}
selected={currentSorting}
/>
</Form.RadioSet>
);
};
PostSortFilters.propTypes = {
handleSortFilterChange: PropTypes.func.isRequired,
};
export default React.memo(withFilterHandleChange(PostSortFilters));

View File

@@ -1,79 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { PostsStatusFilter } from '../../../data/constants';
import { useCurrentPage } from '../../data/hooks';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../data/selectors';
import { selectThreadFilters } from '../data/selectors';
import ActionItem from './ActionItem';
import messages from './messages';
import withFilterHandleChange from './withFilterHandleChange';
const PostStatusFilters = ({ handleSortFilterChange }) => {
const intl = useIntl();
const page = useCurrentPage();
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const { status } = useSelector(selectThreadFilters());
return (
<Form.RadioSet
name="status"
className="d-flex flex-column list-group list-group-flush"
value={status}
onChange={handleSortFilterChange}
>
<ActionItem
id="status-any"
label={intl.formatMessage(messages.filterAnyStatus)}
value={PostsStatusFilter.ALL}
selected={status}
/>
<ActionItem
id="status-unread"
label={intl.formatMessage(messages.filterUnread)}
value={PostsStatusFilter.UNREAD}
selected={status}
/>
{page !== 'my-posts' && (
<ActionItem
id="status-following"
label={intl.formatMessage(messages.filterFollowing)}
value={PostsStatusFilter.FOLLOWING}
selected={status}
/>
)}
{(userHasModerationPrivileges || userIsGroupTa) && (
<ActionItem
id="status-reported"
label={intl.formatMessage(messages.filterReported)}
value={PostsStatusFilter.REPORTED}
selected={status}
/>
)}
<ActionItem
id="status-unanswered"
label={intl.formatMessage(messages.filterUnanswered)}
value={PostsStatusFilter.UNANSWERED}
selected={status}
/>
<ActionItem
id="status-unresponded"
label={intl.formatMessage(messages.filterUnresponded)}
value={PostsStatusFilter.UNRESPONDED}
selected={status}
/>
</Form.RadioSet>
);
};
PostStatusFilters.propTypes = {
handleSortFilterChange: PropTypes.func.isRequired,
};
export default React.memo(withFilterHandleChange(PostStatusFilters));

View File

@@ -1,52 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Form } from '@edx/paragon';
import { ThreadType } from '../../../data/constants';
import { selectThreadFilters } from '../data/selectors';
import ActionItem from './ActionItem';
import messages from './messages';
import withFilterHandleChange from './withFilterHandleChange';
const PostTypeFilters = ({ handleSortFilterChange }) => {
const intl = useIntl();
const currentFilters = useSelector(selectThreadFilters());
return (
<Form.RadioSet
name="type"
className="d-flex flex-column list-group list-group-flush"
value={currentFilters.postType}
onChange={handleSortFilterChange}
>
<ActionItem
id="type-all"
label={intl.formatMessage(messages.allPosts)}
value={ThreadType.ALL}
selected={currentFilters.postType}
/>
<ActionItem
id="type-discussions"
label={intl.formatMessage(messages.filterDiscussions)}
value={ThreadType.DISCUSSION}
selected={currentFilters.postType}
/>
<ActionItem
id="type-questions"
label={intl.formatMessage(messages.filterQuestions)}
value={ThreadType.QUESTION}
selected={currentFilters.postType}
/>
</Form.RadioSet>
);
};
PostTypeFilters.propTypes = {
handleSortFilterChange: PropTypes.func.isRequired,
};
export default React.memo(withFilterHandleChange(PostTypeFilters));

View File

@@ -1,84 +0,0 @@
import React, { useCallback, useMemo } from 'react';
import { toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
import { PostsStatusFilter, ThreadType } from '../../../data/constants';
import { selectCourseCohorts } from '../../cohorts/data/selectors';
import {
setCohortFilter, setPostsTypeFilter, setSortedBy, setStatusFilter,
} from '../data';
import { selectThreadFilters, selectThreadSorting } from '../data/selectors';
const withFilterHandleChange = WrappedComponent => (
function FilterHandleChange(props) {
const dispatch = useDispatch();
const currentSorting = useSelector(selectThreadSorting());
const currentFilters = useSelector(selectThreadFilters());
const cohorts = useSelector(selectCourseCohorts);
const selectedCohort = useMemo(() => (
cohorts?.find(cohort => (
toString(cohort.id) === currentFilters.cohort
))
), [cohorts, currentFilters.cohort]);
const handleSortFilterChange = useCallback((event) => {
const currentType = currentFilters.postType;
const currentStatus = currentFilters.status;
const {
name,
value,
} = event.currentTarget;
const filterContentEventProperties = {
statusFilter: currentStatus,
threadTypeFilter: currentType,
sortFilter: currentSorting,
cohortFilter: selectedCohort,
triggeredBy: name,
};
if (name === 'type') {
dispatch(setPostsTypeFilter(value));
if (
value === ThreadType.DISCUSSION && currentStatus === PostsStatusFilter.UNANSWERED
) {
// You can't filter discussions by unanswered
dispatch(setStatusFilter(PostsStatusFilter.ALL));
}
filterContentEventProperties.threadTypeFilter = value;
}
if (name === 'status') {
dispatch(setStatusFilter(value));
if (value === PostsStatusFilter.UNANSWERED && currentType !== ThreadType.QUESTION) {
// You can't filter discussions by unanswered so switch type to questions
dispatch(setPostsTypeFilter(ThreadType.QUESTION));
}
if (value === PostsStatusFilter.UNRESPONDED && currentType !== ThreadType.DISCUSSION) {
// You can't filter questions by not responded so switch type to discussion
dispatch(setPostsTypeFilter(ThreadType.DISCUSSION));
}
filterContentEventProperties.statusFilter = value;
}
if (name === 'sort') {
dispatch(setSortedBy(value));
filterContentEventProperties.sortFilter = value;
}
if (name === 'cohort') {
dispatch(setCohortFilter(value));
filterContentEventProperties.cohortFilter = value;
}
sendTrackEvent('edx.forum.filter.content', filterContentEventProperties);
}, [currentFilters, currentSorting, selectedCohort]);
return <WrappedComponent {...props} handleSortFilterChange={handleSortFilterChange} />;
}
);
export default withFilterHandleChange;

View File

@@ -4,21 +4,22 @@ import PropTypes from 'prop-types';
import classNames from 'classnames';
import { toString } from 'lodash';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Hyperlink, useToggle } from '@edx/paragon';
import HTMLLoader from '../../../components/HTMLLoader';
import { ContentActions } from '../../../data/constants';
import { ContentActions, getFullUrl } from '../../../data/constants';
import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors';
import { AlertBanner, Confirmation } from '../../common';
import { DiscussionContext } from '../../common/context';
import HoverCard from '../../common/HoverCard';
import { ContentTypes } from '../../data/constants';
import { selectModerationSettings, selectUserHasModerationPrivileges } from '../../data/selectors';
import { selectUserHasModerationPrivileges } from '../../data/selectors';
import { selectTopic } from '../../topics/data/selectors';
import { truncatePath } from '../../utils';
import { selectThread } from '../data/selectors';
import { removeThread, updateExistingThread } from '../data/thunks';
import ClosePostReasonModal from './ClosePostReasonModal';
@@ -35,13 +36,12 @@ const Post = ({ handleAddResponseButton }) => {
} = useSelector(selectThread(postId));
const intl = useIntl();
const location = useLocation();
const history = useHistory();
const navigate = useNavigate();
const dispatch = useDispatch();
const courseId = useSelector((state) => state.config.id);
const { courseId } = useContext(DiscussionContext);
const topic = useSelector(selectTopic(topicId));
const getTopicSubsection = useSelector(selectorForUnitSubsection);
const topicContext = useSelector(selectTopicContext(topicId));
const { reasonCodesEnabled } = useSelector(selectModerationSettings);
const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false);
const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false);
const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false);
@@ -49,9 +49,11 @@ const Post = ({ handleAddResponseButton }) => {
const displayPostFooter = following || voteCount || closed || (groupId && userHasModerationPrivileges);
const handleDeleteConfirmation = useCallback(async () => {
const basePath = truncatePath(location.pathname);
await dispatch(removeThread(postId));
history.push({
pathname: '.',
navigate({
pathname: basePath,
search: enableInContextSidebar && '?inContextSidebar',
});
hideDeleteConfirmation();
@@ -62,7 +64,7 @@ const Post = ({ handleAddResponseButton }) => {
hideReportConfirmation();
}, [abuseFlagged, postId, hideReportConfirmation]);
const handlePostContentEdit = useCallback(() => history.push({
const handlePostContentEdit = useCallback(() => navigate({
...location,
pathname: `${location.pathname}/edit`,
}), [location.pathname]);
@@ -70,16 +72,13 @@ const Post = ({ handleAddResponseButton }) => {
const handlePostClose = useCallback(() => {
if (closed) {
dispatch(updateExistingThread(postId, { closed: false }));
} else if (reasonCodesEnabled) {
showClosePostModal();
} else {
dispatch(updateExistingThread(postId, { closed: true }));
showClosePostModal();
}
}, [closed, postId, reasonCodesEnabled, showClosePostModal]);
}, [closed, postId, showClosePostModal]);
const handlePostCopyLink = useCallback(() => {
const postURL = new URL(`${getConfig().PUBLIC_PATH}${courseId}/posts/${postId}`, window.location.origin);
navigator.clipboard.writeText(postURL.href);
navigator.clipboard.writeText(getFullUrl(`${courseId}/posts/${postId}`));
}, [window.location.origin, postId, courseId]);
const handlePostPin = useCallback(() => dispatch(

View File

@@ -1,9 +1,9 @@
import React, { useMemo } from 'react';
import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Badge, Icon } from '@edx/paragon';
@@ -12,9 +12,7 @@ import { CheckCircle } from '@edx/paragon/icons';
import { PushPin } from '../../../components/icons';
import { AvatarOutlineAndLabelColors, Routes, ThreadType } from '../../../data/constants';
import AuthorLabel from '../../common/AuthorLabel';
import {
useCategory, useCourseId, useCurrentPage, useEnableInContextSidebar, useLearnerUsername, usePostId,
} from '../../data/hooks';
import { DiscussionContext } from '../../common/context';
import { discussionsPath, isPostPreviewAvailable } from '../../utils';
import { selectThread } from '../data/selectors';
import messages from './messages';
@@ -27,27 +25,27 @@ const PostLink = ({
showDivider,
}) => {
const intl = useIntl();
const courseId = useCourseId();
const selectedPostId = usePostId();
const page = useCurrentPage();
const enableInContextSidebar = useEnableInContextSidebar();
const category = useCategory();
const learnerUsername = useLearnerUsername();
const { search } = useLocation();
const {
courseId,
postId: selectedPostId,
page,
enableInContextSidebar,
category,
learnerUsername,
} = useContext(DiscussionContext);
const {
topicId, hasEndorsed, type, author, authorLabel, abuseFlagged, abuseFlaggedCount, read, commentCount,
unreadCommentCount, id, pinned, previewBody, title, voted, voteCount, following, groupId, groupName, createdAt,
} = useSelector(selectThread(postId));
const linkUrl = discussionsPath(Routes.COMMENTS.PAGES[page], {
const { pathname } = discussionsPath(Routes.COMMENTS.PAGES[page], {
0: enableInContextSidebar ? 'in-context' : undefined,
courseId,
topicId,
postId,
category,
learnerUsername,
});
})();
const showAnsweredBadge = hasEndorsed && type === ThreadType.QUESTION;
const authorLabelColor = AvatarOutlineAndLabelColors[authorLabel];
const canSeeReportedBadge = abuseFlagged || abuseFlaggedCount;
@@ -66,7 +64,7 @@ const PostLink = ({
'border-bottom border-light-400': showDivider,
})
}
to={linkUrl}
to={`${pathname}${enableInContextSidebar ? search : ''}`}
aria-current={checkIsSelected ? 'page' : undefined}
role="option"
tabIndex={(checkIsSelected || idx === 0) ? 0 : -1}

View File

@@ -34,7 +34,6 @@ const mockThread = async (id, abuseFlagged) => {
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/settings`).reply(200, {});
axiosMock.onGet(`${courseConfigApiUrl}${courseId}/`).reply(200, {
learners_tab_enabled: true,
has_moderation_privileges: true,
});
axiosMock.onGet(`${threadsApiUrl}${id}/`).reply(200, Factory.build('thread', {
@@ -49,7 +48,7 @@ function renderComponent(id) {
return render(
<IntlProvider locale="en">
<AppProvider store={store}>
<DiscussionContext.Provider value={{ courseId }}>
<DiscussionContext.Provider value={{ courseId, page: 'posts' }}>
<PostLink
key={id}
postId={id}

View File

@@ -3,7 +3,7 @@ import React, {
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { useParams } from 'react-router-dom';
import SearchInfo from '../../components/SearchInfo';
import { RequestStatus } from '../../data/constants';

View File

@@ -3,7 +3,9 @@ import {
} from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import { IntlProvider } from 'react-intl';
import { MemoryRouter, Route } from 'react-router';
import {
MemoryRouter, Route, Routes, useLocation,
} from 'react-router-dom';
import { Factory } from 'rosie';
import { initializeMockApp } from '@edx/frontend-platform';
@@ -28,24 +30,21 @@ let axiosMock;
let lastLocation;
let container;
const LocationComponent = () => {
lastLocation = useLocation();
return null;
};
function renderComponent() {
const wrapper = render(
<IntlProvider locale="en">
<AppProvider store={store}>
<AppProvider store={store} wrapWithRouter={false}>
<DiscussionContext.Provider value={{ courseId }}>
<MemoryRouter initialEntries={[`/${courseId}/topics/`]}>
<Route path="/:courseId/topics/">
<TopicsView />
</Route>
<Route path="/:courseId/category/:category">
<TopicsView />
</Route>
<Route
render={({ location }) => {
lastLocation = location;
return null;
}}
/>
<Routes>
<Route path="/:courseId/topics/*" element={<><TopicsView /><LocationComponent /></>} />
<Route path="/:courseId/category/:category" element={<><TopicsView /><LocationComponent /></>} />
</Routes>
</MemoryRouter>
</DiscussionContext.Provider>
</AppProvider>

View File

@@ -2,7 +2,7 @@ import React, { useContext, useMemo } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { Link } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
@@ -20,10 +20,15 @@ const TopicGroupBase = ({
topicsIds,
}) => {
const intl = useIntl();
const { courseId } = useContext(DiscussionContext);
const { search } = useLocation();
const { courseId, enableInContextSidebar } = useContext(DiscussionContext);
const filter = useSelector(selectTopicFilter);
const topics = useSelector(selectTopicsById(topicsIds));
const hasTopics = topics.length > 0;
const { pathname } = discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: groupId,
})();
const matchesFilter = useMemo(() => (
filter ? groupTitle?.toLowerCase().includes(filter) : true
@@ -69,10 +74,7 @@ const TopicGroupBase = ({
{linkToGroup && groupId ? (
<Link
className="text-decoration-none text-primary-500"
to={discussionsPath(Routes.TOPICS.CATEGORY, {
courseId,
category: groupId,
})}
to={`${pathname}${enableInContextSidebar ? search : ''}`}
>
{groupTitle}
</Link>

View File

@@ -1,18 +1,18 @@
/* eslint-disable react/prop-types */
/* eslint-disable no-unused-vars, react/forbid-prop-types */
import React, { useCallback } from 'react';
import React, { useCallback, useContext } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router';
import { Link } from 'react-router-dom';
import { Link, useLocation, useParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, OverlayTrigger, Tooltip } from '@edx/paragon';
import { HelpOutline, PostOutline, Report } from '@edx/paragon/icons';
import { Routes } from '../../../../data/constants';
import { DiscussionContext } from '../../../common/context';
import { selectUserHasModerationPrivileges, selectUserIsGroupTa } from '../../../data/selectors';
import { discussionsPath } from '../../../utils';
import { selectTopic } from '../../data/selectors';
@@ -20,6 +20,8 @@ import messages from '../../messages';
const Topic = ({ topicId, showDivider, index }) => {
const intl = useIntl();
const { search } = useLocation();
const { enableInContextSidebar } = useContext(DiscussionContext);
const { courseId } = useParams();
const topic = useSelector(selectTopic(topicId));
const {
@@ -28,7 +30,7 @@ const Topic = ({ topicId, showDivider, index }) => {
const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges);
const userIsGroupTa = useSelector(selectUserIsGroupTa);
const canSeeReportedStats = (activeFlags || inactiveFlags) && (userHasModerationPrivileges || userIsGroupTa);
const topicUrl = discussionsPath(Routes.TOPICS.TOPIC, { courseId, topicId });
const { pathname } = discussionsPath(Routes.TOPICS.TOPIC, { courseId, topicId })();
const isSelected = useCallback((selectedId) => (
window.location.pathname.includes(selectedId)
@@ -42,7 +44,7 @@ const Topic = ({ topicId, showDivider, index }) => {
})
}
data-topic-id={id}
to={topicUrl}
to={`${pathname}${enableInContextSidebar ? search : ''}`}
onClick={() => isSelected(id)}
aria-current={isSelected(id) ? 'page' : undefined}
role="option"

View File

@@ -1,23 +1,31 @@
import React, { memo } from 'react';
import React, { useEffect } from 'react';
import isEmpty from 'lodash/isEmpty';
import { useDispatch } from 'react-redux';
import { ProductTour } from '@edx/paragon';
import withConditionalInContextRendering from '../common/withConditionalInContextRendering';
import { useTourConfiguration } from '../data/hooks';
import { fetchDiscussionTours } from './data/thunks';
const DiscussionsProductTour = () => {
const dispatch = useDispatch();
const config = useTourConfiguration();
console.log('DiscussionsProductTour');
useEffect(() => {
dispatch(fetchDiscussionTours());
}, []);
return (
!isEmpty(config) && (
<ProductTour
tours={config}
/>
)
// eslint-disable-next-line react/jsx-no-useless-fragment
<>
{!isEmpty(config) && (
<ProductTour
tours={config}
/>
)}
</>
);
};
export default memo(withConditionalInContextRendering(DiscussionsProductTour, false));
export default DiscussionsProductTour;

View File

@@ -3,7 +3,9 @@ import { useCallback, useContext, useMemo } from 'react';
import { getIn } from 'formik';
import { uniqBy } from 'lodash';
import { useSelector } from 'react-redux';
import { generatePath, useRouteMatch } from 'react-router';
import {
generatePath, matchPath, useLocation,
} from 'react-router-dom';
import { getConfig } from '@edx/frontend-platform';
import {
@@ -11,7 +13,9 @@ import {
} from '@edx/paragon/icons';
import { InsertLink } from '../components/icons';
import { ContentActions, Routes, ThreadType } from '../data/constants';
import {
ContentActions, Routes, ThreadType,
} from '../data/constants';
import { ContentSelectors } from './data/constants';
import { PostCommentsContext } from './post-comments/postCommentsContext';
import messages from './messages';
@@ -42,8 +46,9 @@ export function isFormikFieldInvalid(field, {
* @returns {string}
*/
export function useCommentsPagePath() {
const { params } = useRouteMatch(Routes.COMMENTS.PAGE);
return Routes.COMMENTS.PAGES[params.page];
const location = useLocation();
const { params: { page } } = matchPath({ path: Routes.COMMENTS.PAGE }, location.pathname);
return Routes.COMMENTS.PAGES[page];
}
/**
@@ -284,3 +289,7 @@ export function handleKeyDown(event) {
export function isLastElementOfList(list, element) {
return list[list.length - 1] === element;
}
export function truncatePath(path) {
return path.substring(0, path.lastIndexOf('/'));
}

View File

@@ -1,211 +1,211 @@
{
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.edit": "Edit",
"discussions.actions.pin": "Pin",
"discussions.actions.unpin": "Unpin",
"discussions.actions.delete": "Delete",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
"discussions.actions.unreport": "Unreport",
"discussions.actions.endorse": "Endorse",
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
"discussions.empty.topic": "All discussion activity for this topic will show up here.",
"discussions.empty.title": "Nothing here yet",
"discussions.empty.noPostSelected": "No post selected",
"discussions.empty.noTopicSelected": "No topic selected",
"discussions.sidebar.noResultsFound": "No results found",
"discussions.sidebar.differentKeywords": "Try searching different keywords",
"discussions.sidebar.removeKeywords": "Try searching different keywords or removing some filters",
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
"discussions.sidebar.removeFilters": "Try removing some filters",
"discussions.empty.iconAlt": "Empty",
"discussions.authors.label.staff": "Staff",
"discussions.authors.label.ta": "TA",
"discussions.learner.loadMostPosts": "Load more posts",
"discussions.post.anonymous.author": "anonymous",
"discussion.blackoutBanner.information": "Posting in discussions is disabled by the course team",
"discussions.editor.image.warning.message": "Images having width or height greater than 999px will not be visible when the post, response or comment is viewed using in-line course discussions",
"discussions.editor.image.warning.title": "Warning!",
"discussions.editor.image.warning.dismiss": "Ok",
"navigation.course.tabs.label": "Course Material",
"discussions.topics.backAlt": "Back to topics list",
"discussions.actions.button.alt": "एक्शन मेन्यू",
"discussions.actions.copylink": "कॉपी लिंक",
"discussions.actions.edit": "संपादित करें",
"discussions.actions.pin": "पिन",
"discussions.actions.unpin": "अनपिन",
"discussions.actions.delete": "नष्ट करें",
"discussions.confirmation.button.confirm": "पुष्टि करें",
"discussions.actions.close": "बंद करे",
"discussions.actions.reopen": "फिर से खोलें",
"discussions.actions.report": "रिपोर्ट",
"discussions.actions.unreport": "अनरिपोर्ट",
"discussions.actions.endorse": "समर्थन",
"discussions.actions.unendorse": "समर्थन न करें",
"discussions.actions.markAnswered": "उत्तर के रूप में चिह्नित करें",
"discussions.actions.unMarkAnswered": "उत्तर के रूप में अचिह्नित करें",
"discussions.modal.confirmation.button.cancel": "रद्द करना",
"discussions.empty.allTopics": "इन विषयों के लिए सभी चर्चा गतिविधियाँ यहाँ दिखाई देंगी।",
"discussions.empty.allPosts": "आपके पाठ्यक्रम की सभी चर्चा गतिविधियाँ यहाँ दिखाई देंगी।",
"discussions.empty.myPosts": "जिन पोस्टों के साथ आपने इंटरैक्ट किया है वे यहां दिखाई देंगी।",
"discussions.empty.topic": "इस विषय की सभी चर्चा गतिविधियाँ यहाँ दिखाई देंगी।",
"discussions.empty.title": "अभी तक यहां कुछ भी नहीं है",
"discussions.empty.noPostSelected": "कोई पोस्ट चयनित नहीं",
"discussions.empty.noTopicSelected": "कोई विषय चयनित नहीं",
"discussions.sidebar.noResultsFound": "कोई परिणाम नहीं मिला",
"discussions.sidebar.differentKeywords": "अलग-अलग कीवर्ड खोजने का प्रयास करें",
"discussions.sidebar.removeKeywords": "अलग-अलग कीवर्ड खोजने या कुछ फ़िल्टर हटाने का प्रयास करें",
"discussions.sidebar.removeKeywordsOnly": "अलग-अलग कीवर्ड खोजने का प्रयास करें",
"discussions.sidebar.removeFilters": "कुछ फ़िल्टर हटाने का प्रयास करें",
"discussions.empty.iconAlt": "खाली",
"discussions.authors.label.staff": "कर्मचारी",
"discussions.authors.label.ta": "टीए",
"discussions.learner.loadMostPosts": "और पोस्ट लोड करें",
"discussions.post.anonymous.author": "गुमनाम",
"discussion.blackoutBanner.information": "चर्चाओं में पोस्ट करना पाठ्यक्रम टीम द्वारा अक्षम कर दिया गया है",
"discussions.editor.image.warning.message": "जब पोस्ट, प्रतिक्रिया या टिप्पणी को इन-लाइन पाठ्यक्रम चर्चाओं का उपयोग करके देखा जाता है तो 999px से अधिक चौड़ाई या ऊंचाई वाली छवियां दिखाई नहीं देंगी",
"discussions.editor.image.warning.title": "चेतावनी!",
"discussions.editor.image.warning.dismiss": "ठीक",
"navigation.course.tabs.label": "पाठ्यक्रम सामग्री",
"discussions.topics.backAlt": "विषय सूची पर वापस जाएं",
"discussions.topics.discussions": "{count, plural, =0 {Discussion} one {# Discussion} other {# Discussions} }",
"discussions.topics.questions": "{count, plural, =0 {Question} one {# Question} other {# Questions} }",
"discussions.topics.reported": "{reported} reported",
"discussions.topics.previouslyReported": "{previouslyReported} previously reported",
"discussions.topics.find.label": "Search topics",
"discussions.topics.unnamed.section.label": "Unnamed Section",
"discussions.topics.unnamed.subsection.label": "Unnamed Subsection",
"discussions.subtopics.unnamed.topic.label": "Unnamed Topic",
"discussions.topics.title": "No topic exists",
"discussions.topics.createTopic": "Please contact you admin to create a topic",
"discussions.topics.nothing": "Nothing here yet",
"discussions.topics.archived.label": "Archived",
"discussions.learner.reported": "{reported} reported",
"discussions.learner.previouslyReported": "{previouslyReported} previously reported",
"discussions.learner.lastLogin": "Last active {lastActiveTime}",
"discussions.learner.loadMostLearners": "Load more",
"discussions.learner.back": "Back",
"discussions.learner.activityForLearner": "Activity for {username}",
"discussions.learner.mostActivity": "Most activity",
"discussions.learner.reportedActivity": "Reported activity",
"discussions.learner.recentActivity": "Recent activity",
"discussions.learner.sortFilterStatus": "All learners sorted by {sort, select, flagged {reported activity} activity {most activity} other {{sort}} }",
"discussion.learner.allActivity": "All activity",
"discussion.learner.posts": "Posts",
"discussions.comments.comment.addComment": "Add comment",
"discussions.comments.comment.addResponse": "Add a response",
"discussions.comments.comment.abuseFlaggedMessage": "Content reported for staff to review",
"discussions.actions.back.alt": "Back to list",
"discussions.topics.reported": "{reported} रिपोर्ट किया गया",
"discussions.topics.previouslyReported": "{previouslyReported} ने पहले रिपोर्ट किया था",
"discussions.topics.find.label": "विषय खोजें",
"discussions.topics.unnamed.section.label": "अनाम अनुभाग",
"discussions.topics.unnamed.subsection.label": "अनाम उपखंड",
"discussions.subtopics.unnamed.topic.label": "अनाम विषय",
"discussions.topics.title": "कोई विषय मौजूद नहीं है",
"discussions.topics.createTopic": "विषय बनाने के लिए कृपया अपने व्यवस्थापक से संपर्क करें",
"discussions.topics.nothing": "अभी तक यहां कुछ भी नहीं है",
"discussions.topics.archived.label": "संग्रहीत",
"discussions.learner.reported": "{reported} रिपोर्ट किया गया",
"discussions.learner.previouslyReported": "{previouslyReported} ने पहले रिपोर्ट किया था",
"discussions.learner.lastLogin": "अंतिम सक्रिय {lastActiveTime}",
"discussions.learner.loadMostLearners": "और लोड करें",
"discussions.learner.back": "वापस",
"discussions.learner.activityForLearner": "{username} के लिए गतिविधि",
"discussions.learner.mostActivity": "सर्वाधिक गतिविधि",
"discussions.learner.reportedActivity": "रिपोर्ट की गई गतिविधि",
"discussions.learner.recentActivity": "हाल की गतिविधि",
"discussions.learner.sortFilterStatus": "सभी शिक्षार्थियों को {sort, select, flagged {reported activity} activity {most activity} other {{sort}} }",
"discussion.learner.allActivity": "सारी गतिविधि",
"discussion.learner.posts": "पोस्ट",
"discussions.comments.comment.addComment": "टिप्पणी जोड़ें",
"discussions.comments.comment.addResponse": "एक प्रतिक्रिया जोड़ें",
"discussions.comments.comment.abuseFlaggedMessage": "कर्मचारियों की समीक्षा के लिए सामग्री रिपोर्ट की गई",
"discussions.actions.back.alt": "सूची पर वापस जाएं",
"discussions.comments.comment.responseCount": "{num, plural, =0 {No responses} one {Showing # response} other {Showing # responses} }",
"discussions.comments.comment.endorsedResponseCount": "{num, plural, =0 {No endorsed responses} one {Showing # endorsed response} other {Showing # endorsed responses} }",
"discussions.comments.comment.loadMoreComments": "Load more comments",
"discussions.comments.comment.loadMoreResponses": "Load more responses",
"discussions.comments.comment.visibility": "This post is visible to {group, select, null {Everyone} other {{group}} }.",
"discussions.comments.comment.loadMoreComments": "और टिप्पणियों को लोड करें",
"discussions.comments.comment.loadMoreResponses": "अधिक प्रतिक्रियाएं लोड करें",
"discussions.comments.comment.visibility": "यह पोस्ट {group, select, null {Everyone} other {{group}} } को दृश्यमान है।",
"discussions.comments.comment.postedTime": "{postType, select, discussion {Discussion} question {Question} other {{postType}} } posted {relativeTime} by",
"discussions.comments.comment.commentTime": "Posted {relativeTime}",
"discussions.comments.comment.answer": "Answer",
"discussions.comments.comment.answeredlabel": "Marked as answered by",
"discussions.comments.comment.endorsed": "Endorsed",
"discussions.comments.comment.endorsedlabel": "Endorsed by",
"discussions.actions.label": "Actions menu",
"discussions.editor.submit": "Submit",
"discussions.editor.submitting": "Submitting",
"discussions.editor.cancel": "Cancel",
"discussions.editor.error.empty": "Post content cannot be empty.",
"discussions.editor.delete.response.title": "Delete response",
"discussions.editor.delete.response.description": "Are you sure you want to permanently delete this response?",
"discussions.editor.delete.comment.title": "Delete comment",
"discussions.editor.delete.comment.description": "Are you sure you want to permanently delete this comment?",
"discussions.delete.confirmation.button.delete": "Delete",
"discussions.editor.response.response.title": "Report inappropriate content?",
"discussions.editor.response.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.report.comment.title": "Report inappropriate content?",
"discussions.editor.report.comment.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.editor.comments.editReasonCode": "Reason for editing",
"discussions.editor.posts.editReasonCode.error": "Select reason for editing",
"discussions.comment.comments.editedBy": "Edited by",
"discussions.comments.comment.commentTime": "{relativeTime} पोस्ट किया गया",
"discussions.comments.comment.answer": "उत्तर",
"discussions.comments.comment.answeredlabel": "उत्तर देने के रूप में चिह्नित किया गया",
"discussions.comments.comment.endorsed": "समर्थन किया",
"discussions.comments.comment.endorsedlabel": "द्वारा समर्थन",
"discussions.actions.label": "एक्शन मेन्यू",
"discussions.editor.submit": "प्रस्तुत",
"discussions.editor.submitting": "भेजने से",
"discussions.editor.cancel": "रद्द करना",
"discussions.editor.error.empty": "पोस्ट सामग्री खाली नहीं हो सकती।",
"discussions.editor.delete.response.title": "प्रतिक्रिया हटाएँ",
"discussions.editor.delete.response.description": "क्या आप वाकई इस प्रतिक्रिया को स्थायी रूप से हटाना चाहते हैं?",
"discussions.editor.delete.comment.title": "टिप्पणी हटाएँ",
"discussions.editor.delete.comment.description": "क्या आप वाकई इस टिप्पणी को स्थायी रूप से हटाना चाहते हैं?",
"discussions.delete.confirmation.button.delete": "नष्ट करें",
"discussions.editor.response.response.title": "अनुचित सामग्री की रिपोर्ट करें?",
"discussions.editor.response.description": "चर्चा मॉडरेशन टीम इस सामग्री की समीक्षा करेगी और उचित कार्रवाई करेगी।",
"discussions.editor.report.comment.title": "अनुचित सामग्री की रिपोर्ट करें?",
"discussions.editor.report.comment.description": "चर्चा मॉडरेशन टीम इस सामग्री की समीक्षा करेगी और उचित कार्रवाई करेगी।",
"discussions.editor.comments.editReasonCode": "संपादन का कारण",
"discussions.editor.posts.editReasonCode.error": "संपादन का कारण चुनें",
"discussions.comment.comments.editedBy": "द्वारा संपादित",
"discussions.comment.comments.fullStop": "•",
"discussions.comment.comments.reason": "Reason",
"discussions.post.closedBy": "Post closed by",
"discussion.comment.time": "{time} ago",
"discussion.thread.notFound": "Thread not found",
"discussions.comment.comments.reason": "कारण",
"discussions.post.closedBy": "पोस्ट बंद कर दी गई",
"discussion.comment.time": "{time} पहले",
"discussion.thread.notFound": "थ्रेड नहीं मिला",
"discussions.comment.sortFilterStatus": "{sort, select, false {Oldest first} true {Newest first} other {{sort}} }",
"discussions.topics.sort.message": "Sorted by {sortBy}",
"discussions.topics.sort.lastActivity": "Recent activity",
"discussions.topics.sort.commentCount": "Most activity",
"discussions.topics.sort.courseStructure": "Course Structure",
"discussions.topics.unnamed.label": "Unnamed category",
"discussions.subtopics.unnamed.label": "Unnamed subcategory",
"tour.action.advance": "Next",
"tour.action.dismiss": "Dismiss",
"tour.action.end": "Okay",
"tour.body.notRespondedFilter": "Now you can filter discussions to find posts with no response.",
"tour.title.notRespondedFilter": "New filtering option!",
"tour.body.responseSortTour": "Responses and comments are now sorted by newest first. Please use this option to change the sort order",
"tour.title.responseSortTour": "Sort Responses!",
"learn.course.tabs.navigation.overflow.menu": "More...",
"discussions.navigation.breadcrumbMenu.allTopics": "Topics",
"discussions.navigation.breadcrumbMenu.showAll": "Show all",
"discussions.navigation.navigationBar.allPosts": "All posts",
"discussions.navigation.navigationBar.allTopics": "Topics",
"discussions.navigation.navigationBar.myPosts": "My posts",
"discussions.navigation.navigationBar.learners": "Learners",
"discussions.post.author.anonymous": "anonymous",
"discussions.post.addResponse": "Add response",
"discussions.post.lastResponse": "Last response {time}",
"discussions.post.postedOn": "Posted {time} by {author} {authorLabel}",
"discussions.post.contentReported": "Reported",
"discussions.post.following": "Following",
"discussions.post.follow": "Follow",
"discussions.post.followed": "Followed",
"discussions.post.notFollowed": "Not Followed",
"discussions.post.answered": "Answered",
"discussions.post.unFollow": "Unfollow",
"discussions.post.like": "Like",
"discussions.post.removeLike": "Unlike",
"discussions.post.liked": "liked",
"discussions.post.likes": "likes",
"discussions.post.viewActivity": "View activity",
"discussions.post.activity": "Activity",
"discussions.post.closed": "Post closed for responses and comments",
"discussions.post.relatedTo": "Related to",
"discussions.editor.delete.post.title": "Delete post",
"discussions.editor.delete.post.description": "Are you sure you want to permanently delete this post?",
"discussions.post.delete.confirmation.button.delete": "Delete",
"discussions.editor.report.post.title": "Report inappropriate content?",
"discussions.editor.report.post.description": "The discussion moderation team will review this content and take appropriate action.",
"discussions.post.closePostModal.title": "Close post",
"discussions.post.closePostModal.text": "Enter a reason for closing this post. This will only be displayed to other moderators.",
"discussions.post.closePostModal.reasonCodeInput": "Reason",
"discussions.post.closePostModal.cancel": "Cancel",
"discussions.post.closePostModal.confirm": "Close post",
"discussions.post.label.new": "{count} New",
"discussions.post.editedBy": "Edited by",
"discussions.post.editReason": "Reason",
"discussions.post.postWithoutPreview": "No preview available",
"discussions.post.follow.description": "you are following this post",
"discussions.post.unfollow.description": "you are not following this post",
"discussions.app.title": "Discussions",
"discussions.posts.actionBar.searchAllPosts": "Search all posts",
"discussions.topics.sort.message": "{sortBy} द्वारा क्रमबद्ध",
"discussions.topics.sort.lastActivity": "हाल की गतिविधि",
"discussions.topics.sort.commentCount": "सर्वाधिक गतिविधि",
"discussions.topics.sort.courseStructure": "पाठ्यक्रम संरचना",
"discussions.topics.unnamed.label": "अनाम श्रेणी",
"discussions.subtopics.unnamed.label": "अनाम उपश्रेणी",
"tour.action.advance": "अगला",
"tour.action.dismiss": "ख़ारिज करें",
"tour.action.end": "ठीक है",
"tour.body.notRespondedFilter": "अब आप बिना प्रतिक्रिया वाली पोस्ट ढूंढने के लिए चर्चाओं को फ़िल्टर कर सकते हैं।",
"tour.title.notRespondedFilter": "नया फ़िल्टरिंग विकल्प!",
"tour.body.responseSortTour": "प्रतिक्रियाएँ और टिप्पणियाँ अब सबसे पहले नवीनतम के अनुसार क्रमबद्ध की जाती हैं। कृपया क्रम बदलने के लिए इस विकल्प का उपयोग करें",
"tour.title.responseSortTour": "प्रतिक्रियाएँ क्रमबद्ध करें!",
"learn.course.tabs.navigation.overflow.menu": "ज़्यादा...",
"discussions.navigation.breadcrumbMenu.allTopics": "विषय",
"discussions.navigation.breadcrumbMenu.showAll": "सभी दिखाएं",
"discussions.navigation.navigationBar.allPosts": "सभी पोस्ट",
"discussions.navigation.navigationBar.allTopics": "विषय",
"discussions.navigation.navigationBar.myPosts": "मेरी पोस्ट",
"discussions.navigation.navigationBar.learners": "छात्र",
"discussions.post.author.anonymous": "गुमनाम",
"discussions.post.addResponse": "जवाब जोड़ें",
"discussions.post.lastResponse": "अंतिम प्रतिक्रिया {time}",
"discussions.post.postedOn": "{time} {author} {authorLabel} द्वारा पोस्ट",
"discussions.post.contentReported": "सूचित किया जा चूका है",
"discussions.post.following": "फ़ॉलोइंग",
"discussions.post.follow": "फ़ॉलो",
"discussions.post.followed": "अनुसरित",
"discussions.post.notFollowed": "नहीं अनुसरित",
"discussions.post.answered": "उत्‍तरित",
"discussions.post.unFollow": "करें",
"discussions.post.like": "जैसे",
"discussions.post.removeLike": "अलग",
"discussions.post.liked": "पसंद की",
"discussions.post.likes": "पसंद",
"discussions.post.viewActivity": "गतिविधि देखें",
"discussions.post.activity": "पुनः प्रयास करें",
"discussions.post.closed": "प्रतिक्रियाओं और टिप्पणियों के लिए पोस्ट बंद है",
"discussions.post.relatedTo": "संबंधित",
"discussions.editor.delete.post.title": "पोस्ट हटाएं",
"discussions.editor.delete.post.description": "क्या आप वाकई इस पोस्ट को स्थायी रूप से हटाना चाहते हैं?",
"discussions.post.delete.confirmation.button.delete": "नष्ट करें",
"discussions.editor.report.post.title": "अनुचित सामग्री की रिपोर्ट करें?",
"discussions.editor.report.post.description": "चर्चा मॉडरेशन टीम इस सामग्री की समीक्षा करेगी और उचित कार्रवाई करेगी।",
"discussions.post.closePostModal.title": "पोस्ट बंद करें",
"discussions.post.closePostModal.text": "इस पोस्ट को बंद करने का कारण दर्ज करें। यह केवल अन्य मॉडरेटर्स को प्रदर्शित होगा।",
"discussions.post.closePostModal.reasonCodeInput": "कारण",
"discussions.post.closePostModal.cancel": "रद्द करना",
"discussions.post.closePostModal.confirm": "पोस्ट बंद करें",
"discussions.post.label.new": "{count} नया",
"discussions.post.editedBy": "द्वारा संपादित",
"discussions.post.editReason": "कारण",
"discussions.post.postWithoutPreview": "कोई पूर्वावलोकन उपलब्ध नहीं है",
"discussions.post.follow.description": "आप इस पोस्ट का पालन कर रहे हैं",
"discussions.post.unfollow.description": "आप इस पोस्ट का पालन नहीं कर रहे हैं",
"discussions.app.title": "चर्चाएँ",
"discussions.posts.actionBar.searchAllPosts": "सभी पोस्‍ट खोजें",
"discussions.posts.actionBar.search": "{page, select, topics {Search topics} posts {Search all posts} learners {Search learners} myPosts {Search all posts} other {{page}} }",
"discussions.actionBar.searchInfo": "Showing {count} results for \"{text}\"",
"discussions.actionBar.searchRewriteInfo": "No results found for \"{searchString}\". Showing {count} results for \"{textSearchRewrite}\".",
"discussions.actionBar.searchInfoSearching": "Searching...",
"discussions.actionBar.clearSearch": "Clear results",
"discussion.posts.actionBar.add": "Add a post",
"discussion.posts.actionBar.close": "Close",
"discussions.post.editor.type": "Post type",
"discussions.post.editor.addPostHeading": "Add a post",
"discussions.post.editor.editPostHeading": "Edit post",
"discussions.post.editor.typeDescription": "Questions raise issues that need answers. Discussions share ideas and start conversations.",
"discussions.post.editor.required": "Required",
"discussions.post.editor.questionType": "Question",
"discussions.post.editor.questionDescription": "Raise issues that need answers",
"discussions.post.editor.discussionType": "Discussion",
"discussions.post.editor.discussionDescription": "Share ideas and start conversations",
"discussions.post.editor.topicArea": "Topic area",
"discussions.post.editor.topicAreaDescription": "Add your post to a relevant topic to help others find it.",
"discussions.post.editor.cohortVisibility": "Cohort visibility",
"discussions.post.editor.cohortVisibilityAllLearners": "All learners",
"discussions.post.editor.title": "Post title",
"discussions.post.editor.titleDescription": "Add a clear and descriptive title to encourage participation.",
"discussions.post.editor.title.error": "Post title cannot be empty.",
"discussions.post.editor.content.error": "Post content cannot be empty.",
"discussions.post.editor.questionText": "Your question or idea (required)",
"discussions.post.editor.preview": "Preview",
"discussions.post.editor.followPost": "Follow this post",
"discussions.post.editor.anonymousPost": "Post anonymously",
"discussions.post.editor.anonymousToPeersPost": "Post anonymously to peers",
"discussions.editor.posts.editReasonCode": "Reason for editing",
"discussions.editor.posts.showPreview.button": "Show preview",
"discussions.topic.noName.label": "Unnamed category",
"discussions.subtopic.noName.label": "Unnamed subcategory",
"discussions.posts.filter.showALl": "Show all",
"discussions.posts.filter.discussions": "Discussions",
"discussions.posts.filter.questions": "Questions",
"discussions.posts.filter.message": "Status: {filterBy}",
"discussions.posts.status.filter.anyStatus": "Any status",
"discussions.posts.status.filter.unread": "Unread",
"discussions.posts.status.filter.following": "Following",
"discussions.posts.status.filter.reported": "Reported",
"discussions.posts.status.filter.unanswered": "Unanswered",
"discussions.posts.status.filter.unresponded": "Not responded",
"discussions.posts.filter.myPosts": "My posts",
"discussions.posts.filter.myDiscussions": "My discussions",
"discussions.posts.filter.myQuestions": "My questions",
"discussions.posts.sort.message": "Sorted by {sortBy}",
"discussions.posts.sort.lastActivity": "Recent activity",
"discussions.posts.sort.commentCount": "Most activity",
"discussions.posts.sort.voteCount": "Most likes",
"discussions.actionBar.searchInfo": "\"{text}\" के लिए {count} परिणाम दिखा रहा है",
"discussions.actionBar.searchRewriteInfo": "\"{searchString}\" के लिए कोई परिणाम नहीं मिले। \"{textSearchRewrite}\" के लिए {count} परिणाम दिखा रहे हैं।",
"discussions.actionBar.searchInfoSearching": "खोज रहा है...",
"discussions.actionBar.clearSearch": "स्पष्ट परिणाम",
"discussion.posts.actionBar.add": "एक पोस्ट जोड़ें",
"discussion.posts.actionBar.close": "बंद करे",
"discussions.post.editor.type": "पद प्रकार",
"discussions.post.editor.addPostHeading": "एक पोस्ट जोड़ें",
"discussions.post.editor.editPostHeading": "संपादित पोस्ट",
"discussions.post.editor.typeDescription": "प्रश्न ऐसे मुद्दे उठाते हैं जिनके उत्तर की आवश्यकता होती है। चर्चाएँ विचार साझा करती हैं और बातचीत शुरू करती हैं।",
"discussions.post.editor.required": "आवश्यक",
"discussions.post.editor.questionType": "सवाल",
"discussions.post.editor.questionDescription": "जवाब दें",
"discussions.post.editor.discussionType": "चर्चा",
"discussions.post.editor.discussionDescription": "विचारों को साझा करें और बातचीत शुरू करें",
"discussions.post.editor.topicArea": "विषय क्षेत्र",
"discussions.post.editor.topicAreaDescription": "दूसरों को इसे ढूंढने में मदद करने के लिए अपनी पोस्ट को किसी प्रासंगिक विषय पर जोड़ें।",
"discussions.post.editor.cohortVisibility": "समूह दृश्यता",
"discussions.post.editor.cohortVisibilityAllLearners": "सभी शिक्षार्थी",
"discussions.post.editor.title": "पोस्ट शीर्षक",
"discussions.post.editor.titleDescription": "भागीदारी को प्रोत्साहित करने के लिए एक स्पष्ट और वर्णनात्मक शीर्षक जोड़ें।",
"discussions.post.editor.title.error": "पोस्ट शीर्षक खाली नहीं हो सकता है।",
"discussions.post.editor.content.error": "पोस्ट सामग्री खाली नहीं हो सकती।",
"discussions.post.editor.questionText": "आपका प्रश्न या विचार (आवश्यक)",
"discussions.post.editor.preview": "पूर्वावलोकन",
"discussions.post.editor.followPost": "इस पोस्ट को फ़ॉलो करें",
"discussions.post.editor.anonymousPost": "गुमनाम रूप से पोस्ट करें",
"discussions.post.editor.anonymousToPeersPost": "साथियों को गुमनाम रूप से पोस्ट करें",
"discussions.editor.posts.editReasonCode": "संपादन का कारण",
"discussions.editor.posts.showPreview.button": "पूर्वावलोकन दिखाएं",
"discussions.topic.noName.label": "अनाम श्रेणी",
"discussions.subtopic.noName.label": "अनाम उपश्रेणी",
"discussions.posts.filter.showALl": "सभी दिखाएं",
"discussions.posts.filter.discussions": "चर्चाएँ",
"discussions.posts.filter.questions": "प्रश्न",
"discussions.posts.filter.message": "स्थिति: {filterBy}",
"discussions.posts.status.filter.anyStatus": "कोई स्थिति",
"discussions.posts.status.filter.unread": "अपठित",
"discussions.posts.status.filter.following": "फ़ॉलोइंग",
"discussions.posts.status.filter.reported": "सूचित किया जा चूका है",
"discussions.posts.status.filter.unanswered": "अनुत्तरित",
"discussions.posts.status.filter.unresponded": "जवाब नहीं दिया",
"discussions.posts.filter.myPosts": "मेरे पोस्ट",
"discussions.posts.filter.myDiscussions": "मेरी चर्चाएँ",
"discussions.posts.filter.myQuestions": "मेरे सवाल",
"discussions.posts.sort.message": "{sortBy} द्वारा क्रमबद्ध",
"discussions.posts.sort.lastActivity": "हाल की गतिविधि",
"discussions.posts.sort.commentCount": "सर्वाधिक गतिविधि",
"discussions.posts.sort.voteCount": "सबसे अधिक पसंद",
"discussions.posts.sort-filter.sortFilterStatus": "{own, select, false {All} true {Own} other {{own}} } {status, select, statusAll {} statusUnread {unread} statusFollowing {followed} statusReported {reported} statusUnanswered {unanswered} statusUnresponded {unresponded} other {{status}} } {type, select, discussion {discussions} question {questions} all {posts} other {{type}} } {cohortType, select, all {} group {in {cohort}} other {{cohortType}} } sorted by {sort, select, lastActivityAt {recent activity} commentCount {most activity} voteCount {most likes} other {{sort}} }"
}

View File

@@ -1,29 +1,29 @@
{
"discussions.actions.button.alt": "Actions menu",
"discussions.actions.copylink": "Copy link",
"discussions.actions.edit": "Edit",
"discussions.actions.pin": "Pin",
"discussions.actions.unpin": "Unpin",
"discussions.actions.delete": "Delete",
"discussions.confirmation.button.confirm": "Confirm",
"discussions.actions.close": "Close",
"discussions.actions.reopen": "Reopen",
"discussions.actions.report": "Report",
"discussions.actions.unreport": "Unreport",
"discussions.actions.endorse": "Endorse",
"discussions.actions.unendorse": "Unendorse",
"discussions.actions.markAnswered": "Mark as answered",
"discussions.actions.unMarkAnswered": "Unmark as answered",
"discussions.modal.confirmation.button.cancel": "Cancel",
"discussions.empty.allTopics": "All discussion activity for these topics will show up here.",
"discussions.empty.allPosts": "All discussion activity for your course will show up here.",
"discussions.empty.myPosts": "Posts you've interacted with will show up here.",
"discussions.empty.topic": "All discussion activity for this topic will show up here.",
"discussions.empty.title": "Nothing here yet",
"discussions.empty.noPostSelected": "No post selected",
"discussions.empty.noTopicSelected": "No topic selected",
"discussions.sidebar.noResultsFound": "No results found",
"discussions.sidebar.differentKeywords": "Try searching different keywords",
"discussions.actions.button.alt": "操作菜单",
"discussions.actions.copylink": "复制链接",
"discussions.actions.edit": "编辑",
"discussions.actions.pin": "处理",
"discussions.actions.unpin": "不做处理",
"discussions.actions.delete": "删除",
"discussions.confirmation.button.confirm": "确认",
"discussions.actions.close": "关闭",
"discussions.actions.reopen": "重开",
"discussions.actions.report": "报告",
"discussions.actions.unreport": "取消报告",
"discussions.actions.endorse": "支持",
"discussions.actions.unendorse": "取消支持",
"discussions.actions.markAnswered": "标记为已回答",
"discussions.actions.unMarkAnswered": "取消标记为已回答",
"discussions.modal.confirmation.button.cancel": "取消",
"discussions.empty.allTopics": "这些主题的所有讨论活动都将显示在这里。",
"discussions.empty.allPosts": "您课程的所有讨论活动都将显示在这里。",
"discussions.empty.myPosts": "您互动过的帖子会显示在这里。",
"discussions.empty.topic": "该主题的所有讨论活动都将显示在这里。",
"discussions.empty.title": "什么都没有",
"discussions.empty.noPostSelected": "未选择帖子",
"discussions.empty.noTopicSelected": "未选择主题",
"discussions.sidebar.noResultsFound": "未找到结果",
"discussions.sidebar.differentKeywords": "尝试搜索不同的关键字",
"discussions.sidebar.removeKeywords": "Try searching different keywords or removing some filters",
"discussions.sidebar.removeKeywordsOnly": "Try searching different keywords",
"discussions.sidebar.removeFilters": "Try removing some filters",

View File

@@ -140,11 +140,6 @@ $fa-font-path: "~font-awesome/fonts";
margin-left: 2px;
}
.mx-3px {
margin-left: 3px;
margin-right: 3px;
}
.mt-14px {
margin-top: 14px;
}
@@ -340,6 +335,10 @@ header {
.nav-item:not(:last-child){
.nav-link {
border-right: 0;
@media screen and (max-width: 567px) {
border-right: solid 1px #e9e6e4;
}
}
}
}
@@ -488,6 +487,11 @@ header {
z-index: 1;
}
.comment-line {
width: calc(100% - 180px);
line-height: 1;
}
.post-preview,
.discussion-comments {
blockquote {
@@ -529,3 +533,8 @@ header {
left: 50%;
transform: translate(-50%, -50%);
}
.author-name {
line-height: 1;
word-break: break-all;
}