From 5d28b259e4fa5a1e5850c4095e491a60f5b2c059 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Fri, 19 May 2023 17:39:24 +0800 Subject: [PATCH 01/76] chore(react): add react-refresh for Fast Refresh --- client/package.json | 2 ++ client/webpack.common.js | 11 ------- client/webpack.dev.js | 18 +++++++++++ client/webpack.prod.js | 9 ++++++ client/yarn.lock | 68 +++++++++++++++++++++++++++++++++++----- 5 files changed, 90 insertions(+), 18 deletions(-) diff --git a/client/package.json b/client/package.json index 6114b79a806..2c44fcc64be 100644 --- a/client/package.json +++ b/client/package.json @@ -117,6 +117,7 @@ "@babel/preset-react": "^7.22.5", "@babel/preset-typescript": "^7.22.5", "@formatjs/cli": "^6.1.3", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", "@svgr/webpack": "^8.0.1", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^14.0.0", @@ -174,6 +175,7 @@ "postcss-loader": "^7.3.3", "prettier": "^2.8.8", "prettier-plugin-tailwindcss": "^0.3.0", + "react-refresh": "^0.14.0", "redux-logger": "^3.0.6", "sass-loader": "^13.3.2", "style-loader": "^3.3.3", diff --git a/client/webpack.common.js b/client/webpack.common.js index 2585379c7f4..d577ee62ca8 100644 --- a/client/webpack.common.js +++ b/client/webpack.common.js @@ -81,17 +81,6 @@ module.exports = { ], module: { rules: [ - { - test: /\.(js|jsx|ts|tsx)$/, - use: { - loader: 'babel-loader', - options: { - cacheCompression: false, - cacheDirectory: true, - }, - }, - exclude: /node_modules/, - }, { test: /\.css$/, use: ['style-loader', 'css-loader'], diff --git a/client/webpack.dev.js b/client/webpack.dev.js index 4c85a759eb3..a3c2d804df0 100644 --- a/client/webpack.dev.js +++ b/client/webpack.dev.js @@ -1,4 +1,5 @@ const { merge } = require('webpack-merge'); +const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); const common = require('./webpack.common'); @@ -33,4 +34,21 @@ module.exports = merge(common, { removeAvailableModules: false, removeEmptyChunks: false, }, + plugins: [new ReactRefreshWebpackPlugin()], + module: { + rules: [ + { + test: /\.(js|jsx|ts|tsx)$/, + use: { + loader: 'babel-loader', + options: { + cacheCompression: false, + cacheDirectory: true, + plugins: ['react-refresh/babel'], + }, + }, + exclude: /node_modules/, + }, + ], + }, }); diff --git a/client/webpack.prod.js b/client/webpack.prod.js index eac4b3b7261..90349c2fee6 100644 --- a/client/webpack.prod.js +++ b/client/webpack.prod.js @@ -12,4 +12,13 @@ module.exports = merge(common, { optimization: { usedExports: true, }, + module: { + rules: [ + { + test: /\.(js|jsx|ts|tsx)$/, + use: ['babel-loader'], + exclude: /node_modules/, + }, + ], + }, }); diff --git a/client/yarn.lock b/client/yarn.lock index 23942bb8443..6333ea3a25e 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -1998,6 +1998,21 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== +"@pmmmwh/react-refresh-webpack-plugin@^0.5.10": + version "0.5.10" + resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz#2eba163b8e7dbabb4ce3609ab5e32ab63dda3ef8" + integrity sha512-j0Ya0hCFZPd4x40qLzbhGsh9TMtdb+CJQiso+WxLOPNasohq9cc5SNUcwsZaRH6++Xh91Xkm/xHCkuIiIu0LUA== + dependencies: + ansi-html-community "^0.0.8" + common-path-prefix "^3.0.0" + core-js-pure "^3.23.3" + error-stack-parser "^2.0.6" + find-up "^5.0.0" + html-entities "^2.1.0" + loader-utils "^2.0.4" + schema-utils "^3.0.0" + source-map "^0.7.3" + "@popperjs/core@^2.11.8": version "2.11.8" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" @@ -3431,6 +3446,11 @@ batch@0.6.1: resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" @@ -3806,6 +3826,11 @@ commander@^7.2.0: resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== +common-path-prefix@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" + integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -3897,6 +3922,11 @@ core-js-compat@^3.30.1, core-js-compat@^3.30.2: dependencies: browserslist "^4.21.5" +core-js-pure@^3.23.3: + version "3.30.2" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.30.2.tgz#005a82551f4af3250dcfb46ed360fad32ced114e" + integrity sha512-p/npFUJXXBkCCTIlEGBdghofn00jWG6ZOtdoIXSJmAu2QBvN0IqpZXWweOytcwE6cfx8ZvVUy1vw8zxhe4Y2vg== + core-js-pure@^3.25.1: version "3.25.2" resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.25.2.tgz#44a4fd873bdd4fecf6ca11512bcefedbe87e744a" @@ -4464,6 +4494,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" + integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" @@ -4553,7 +4588,7 @@ error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" -error-stack-parser@^2.0.4: +error-stack-parser@^2.0.4, error-stack-parser@^2.0.6: version "2.1.4" resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.1.4.tgz#229cb01cdbfa84440bfa91876285b94680188286" integrity sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ== @@ -5581,7 +5616,7 @@ html-encoding-sniffer@^3.0.0: dependencies: whatwg-encoding "^2.0.0" -html-entities@^2.3.2: +html-entities@^2.1.0, html-entities@^2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== @@ -6651,7 +6686,7 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.2.2, json5@^2.2.3: +json5@^2.1.2, json5@^2.2.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -6749,6 +6784,15 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== +loader-utils@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" + integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== + dependencies: + big.js "^5.2.2" + emojis-list "^3.0.0" + json5 "^2.1.2" + locate-path@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" @@ -8324,6 +8368,11 @@ react-redux@^8.0.4, react-redux@^8.1.1: react-is "^18.0.0" use-sync-external-store "^1.0.0" +react-refresh@^0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" + integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== + react-resizable@^3.0.5: version "3.0.5" resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-3.0.5.tgz#362721f2efbd094976f1780ae13f1ad7739786c1" @@ -8816,10 +8865,10 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -schema-utils@^3.1.1, schema-utils@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" - integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== +schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.2.tgz#36c10abca6f7577aeae136c804b0c741edeadc99" + integrity sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg== dependencies: "@types/json-schema" "^7.0.8" ajv "^6.12.5" @@ -9058,6 +9107,11 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +source-map@^0.7.3: + version "0.7.4" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" + integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== + spdy-transport@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" From 64d7202d40d2bdb818d2b694d8df6810603bfb1c Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Fri, 19 May 2023 18:17:26 +0800 Subject: [PATCH 02/76] refactor(announcements): correctly type `AnnouncementListData` --- app/controllers/announcements_controller.rb | 4 ++++ .../course/announcements_controller.rb | 4 ++-- .../system/admin/announcements_controller.rb | 4 ++-- .../instance/announcements_controller.rb | 4 ++-- .../_announcement_data.json.jbuilder | 21 +++++++++++++++++++ .../_announcement_list_data.json.jbuilder | 19 ----------------- app/views/announcements/index.json.jbuilder | 2 +- .../course/announcements/index.json.jbuilder | 2 +- .../course/courses/_course_data.json.jbuilder | 2 +- .../admin/announcements/index.json.jbuilder | 2 +- .../announcements/index.json.jbuilder | 2 +- client/app/api/Announcements.ts | 7 +++++-- client/app/api/course/Announcements.ts | 19 +++++++---------- client/app/api/system/Admin.ts | 4 ++-- client/app/api/system/InstanceAdmin.ts | 4 ++-- client/app/bundles/announcements/store.ts | 4 ++-- client/app/bundles/announcements/types.ts | 8 +++---- .../components/misc/AnnouncementCard.tsx | 4 ++-- .../components/misc/AnnouncementsDisplay.tsx | 10 ++++----- .../app/bundles/course/announcements/store.ts | 3 +-- .../app/bundles/course/announcements/types.ts | 7 +++---- .../components/misc/CourseAnnouncements.tsx | 4 ++-- .../app/bundles/system/admin/admin/store.ts | 3 +-- .../app/bundles/system/admin/admin/types.ts | 7 +++---- .../system/admin/instance/instance/store.ts | 3 +-- .../system/admin/instance/instance/types.ts | 7 +++---- client/app/types/course/announcements.ts | 18 ++++++++++++---- client/app/types/course/courses.ts | 6 +++--- 28 files changed, 96 insertions(+), 88 deletions(-) create mode 100644 app/views/announcements/_announcement_data.json.jbuilder diff --git a/app/controllers/announcements_controller.rb b/app/controllers/announcements_controller.rb index 1e35cbd2dd6..096102f6988 100644 --- a/app/controllers/announcements_controller.rb +++ b/app/controllers/announcements_controller.rb @@ -20,4 +20,8 @@ def mark_as_read @announcement.mark_as_read! for: current_user head :ok end + + def unread + @unread_announcements = unread_global_announcements.sort { |a, b| b.start_at <=> a.start_at } + end end diff --git a/app/controllers/course/announcements_controller.rb b/app/controllers/course/announcements_controller.rb index 5bf548ed6c1..9434643491d 100644 --- a/app/controllers/course/announcements_controller.rb +++ b/app/controllers/course/announcements_controller.rb @@ -19,7 +19,7 @@ def index def create if @announcement.save - render partial: 'announcements/announcement_list_data', + render partial: 'announcements/announcement_data', locals: { announcement: @announcement } else render json: { errors: @announcement.errors }, status: :bad_request @@ -28,7 +28,7 @@ def create def update if @announcement.update(announcement_params) - render partial: 'announcements/announcement_list_data', + render partial: 'announcements/announcement_data', locals: { announcement: @announcement }, status: :ok else diff --git a/app/controllers/system/admin/announcements_controller.rb b/app/controllers/system/admin/announcements_controller.rb index 5225a156a88..c04c18ce92d 100644 --- a/app/controllers/system/admin/announcements_controller.rb +++ b/app/controllers/system/admin/announcements_controller.rb @@ -14,7 +14,7 @@ def index def create if @announcement.save - render partial: 'announcements/announcement_list_data', + render partial: 'announcements/announcement_data', locals: { announcement: @announcement }, status: :ok else @@ -24,7 +24,7 @@ def create def update if @announcement.update(announcement_params) - render partial: 'announcements/announcement_list_data', + render partial: 'announcements/announcement_data', locals: { announcement: @announcement.reload }, status: :ok else diff --git a/app/controllers/system/admin/instance/announcements_controller.rb b/app/controllers/system/admin/instance/announcements_controller.rb index 16319f1766f..6e782779b73 100644 --- a/app/controllers/system/admin/instance/announcements_controller.rb +++ b/app/controllers/system/admin/instance/announcements_controller.rb @@ -15,7 +15,7 @@ def index def create if @announcement.save - render partial: 'announcements/announcement_list_data', + render partial: 'announcements/announcement_data', locals: { announcement: @announcement }, status: :ok else @@ -25,7 +25,7 @@ def create def update if @announcement.update(announcement_params) - render partial: 'announcements/announcement_list_data', + render partial: 'announcements/announcement_data', locals: { announcement: @announcement.reload }, status: :ok else diff --git a/app/views/announcements/_announcement_data.json.jbuilder b/app/views/announcements/_announcement_data.json.jbuilder new file mode 100644 index 00000000000..ced0ff14fe2 --- /dev/null +++ b/app/views/announcements/_announcement_data.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true +json.partial! 'announcements/announcement_list_data', announcement: announcement + +json.endTime announcement.end_at + +user_or_course_user = local_assigns[:course_user] || announcement.creator + +json.creator do + json.id user_or_course_user.id + json.name user_or_course_user.name + json.userUrl url_to_user_or_course_user(@course, user_or_course_user) +end + +json.isUnread announcement.unread?(current_user) +json.isSticky announcement.sticky? +json.isCurrentlyActive announcement.currently_active? + +json.permissions do + json.canEdit can?(:edit, announcement) + json.canDelete can?(:destroy, announcement) +end diff --git a/app/views/announcements/_announcement_list_data.json.jbuilder b/app/views/announcements/_announcement_list_data.json.jbuilder index c9d51010c52..2cad9958556 100644 --- a/app/views/announcements/_announcement_list_data.json.jbuilder +++ b/app/views/announcements/_announcement_list_data.json.jbuilder @@ -1,24 +1,5 @@ # frozen_string_literal: true - json.id announcement.id json.title announcement.title json.content format_ckeditor_rich_text(announcement.content) json.startTime announcement.start_at -json.endTime announcement.end_at - -user_or_course_user = local_assigns[:course_user] || announcement.creator - -json.creator do - json.id user_or_course_user.id - json.name user_or_course_user.name - json.userUrl url_to_user_or_course_user(@course, user_or_course_user) -end - -json.isUnread announcement.unread?(current_user) -json.isSticky announcement.sticky? -json.isCurrentlyActive announcement.currently_active? - -json.permissions do - json.canEdit can?(:edit, announcement) - json.canDelete can?(:destroy, announcement) -end diff --git a/app/views/announcements/index.json.jbuilder b/app/views/announcements/index.json.jbuilder index 8d026835448..98e8c837a18 100644 --- a/app/views/announcements/index.json.jbuilder +++ b/app/views/announcements/index.json.jbuilder @@ -1,5 +1,5 @@ # frozen_string_literal: true json.announcements @announcements do |announcement| - json.partial! 'announcements/announcement_list_data', announcement: announcement + json.partial! 'announcements/announcement_data', announcement: announcement end diff --git a/app/views/course/announcements/index.json.jbuilder b/app/views/course/announcements/index.json.jbuilder index 0cb679597ae..fd92396add7 100644 --- a/app/views/course/announcements/index.json.jbuilder +++ b/app/views/course/announcements/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.announcements @announcements do |announcement| - json.partial! 'announcements/announcement_list_data', + json.partial! 'announcements/announcement_data', announcement: announcement, course_user: @course_users_hash[announcement.creator_id] end diff --git a/app/views/course/courses/_course_data.json.jbuilder b/app/views/course/courses/_course_data.json.jbuilder index 3056778d368..5083d840089 100644 --- a/app/views/course/courses/_course_data.json.jbuilder +++ b/app/views/course/courses/_course_data.json.jbuilder @@ -19,7 +19,7 @@ if can?(:manage, current_course) || current_course.user?(current_user) # Announcements if @currently_active_announcements && !@currently_active_announcements.empty? json.currentlyActiveAnnouncements @currently_active_announcements do |announcement| - json.partial! 'announcements/announcement_list_data', announcement: announcement + json.partial! 'announcements/announcement_data', announcement: announcement end else json.currentlyActiveAnnouncements nil diff --git a/app/views/system/admin/announcements/index.json.jbuilder b/app/views/system/admin/announcements/index.json.jbuilder index 3f0fc92f0a8..e946b6edf9b 100644 --- a/app/views/system/admin/announcements/index.json.jbuilder +++ b/app/views/system/admin/announcements/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.announcements @announcements do |announcement| - json.partial! 'announcements/announcement_list_data', announcement: announcement + json.partial! 'announcements/announcement_data', announcement: announcement end json.permissions do diff --git a/app/views/system/admin/instance/announcements/index.json.jbuilder b/app/views/system/admin/instance/announcements/index.json.jbuilder index 08ce29813c7..f6d2298a867 100644 --- a/app/views/system/admin/instance/announcements/index.json.jbuilder +++ b/app/views/system/admin/instance/announcements/index.json.jbuilder @@ -1,6 +1,6 @@ # frozen_string_literal: true json.announcements @announcements do |announcement| - json.partial! 'announcements/announcement_list_data', announcement: announcement + json.partial! 'announcements/announcement_data', announcement: announcement end json.permissions do diff --git a/client/app/api/Announcements.ts b/client/app/api/Announcements.ts index 02b26b5e66b..c390b537dea 100644 --- a/client/app/api/Announcements.ts +++ b/client/app/api/Announcements.ts @@ -1,4 +1,7 @@ -import { AnnouncementListData } from 'types/course/announcements'; +import { + AnnouncementData, + AnnouncementListData, +} from 'types/course/announcements'; import BaseAPI from './Base'; import { APIResponse } from './types'; @@ -13,7 +16,7 @@ export default class AnnouncementsAPI extends BaseAPI { * Fetches all the announcements (admin and instance announcements) */ index(): APIResponse<{ - announcements: AnnouncementListData[]; + announcements: AnnouncementData[]; }> { return this.client.get(this.#urlPrefix); } diff --git a/client/app/api/course/Announcements.ts b/client/app/api/course/Announcements.ts index 74f2210647f..61c6d14c961 100644 --- a/client/app/api/course/Announcements.ts +++ b/client/app/api/course/Announcements.ts @@ -1,10 +1,10 @@ -import { AxiosResponse } from 'axios'; import { AnnouncementData, - AnnouncementListData, - AnnouncementPermissions, + FetchAnnouncementsData, } from 'types/course/announcements'; +import { APIResponse } from 'api/types'; + import BaseCourseAPI from './Base'; export default class AnnouncementsAPI extends BaseCourseAPI { @@ -15,19 +15,14 @@ export default class AnnouncementsAPI extends BaseCourseAPI { /** * Fetches all the announcements */ - index(): Promise< - AxiosResponse<{ - announcements: AnnouncementListData[]; - permissions: AnnouncementPermissions; - }> - > { + index(): APIResponse { return this.client.get(this.#urlPrefix); } /** * Creates a new announcement */ - create(params: FormData): Promise> { + create(params: FormData): APIResponse { return this.client.post(this.#urlPrefix, params); } @@ -37,7 +32,7 @@ export default class AnnouncementsAPI extends BaseCourseAPI { update( announcementId: number, params: FormData | object, - ): Promise> { + ): APIResponse { return this.client.patch(`${this.#urlPrefix}/${announcementId}`, params); } @@ -49,7 +44,7 @@ export default class AnnouncementsAPI extends BaseCourseAPI { * success response: {} * error response: {} */ - delete(announcementId: number): Promise { + delete(announcementId: number): APIResponse { return this.client.delete(`${this.#urlPrefix}/${announcementId}`); } } diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 67095c0a7eb..76731a45d71 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -1,6 +1,6 @@ import { AxiosResponse } from 'axios'; import { - AnnouncementListData, + AnnouncementData, AnnouncementPermissions, } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; @@ -27,7 +27,7 @@ export default class AdminAPI extends BaseSystemAPI { */ indexAnnouncements(): Promise< AxiosResponse<{ - announcements: AnnouncementListData[]; + announcements: AnnouncementData[]; permissions: AnnouncementPermissions; }> > { diff --git a/client/app/api/system/InstanceAdmin.ts b/client/app/api/system/InstanceAdmin.ts index 2e6875d7315..e7695c43e8c 100644 --- a/client/app/api/system/InstanceAdmin.ts +++ b/client/app/api/system/InstanceAdmin.ts @@ -1,6 +1,6 @@ import { AxiosResponse } from 'axios'; import { - AnnouncementListData, + AnnouncementData, AnnouncementPermissions, } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; @@ -36,7 +36,7 @@ export default class InstanceAdminAPI extends BaseSystemAPI { */ indexAnnouncements(): Promise< AxiosResponse<{ - announcements: AnnouncementListData[]; + announcements: AnnouncementData[]; permissions: AnnouncementPermissions; }> > { diff --git a/client/app/bundles/announcements/store.ts b/client/app/bundles/announcements/store.ts index ea0f6436022..1849d624ed3 100644 --- a/client/app/bundles/announcements/store.ts +++ b/client/app/bundles/announcements/store.ts @@ -1,5 +1,5 @@ import produce from 'immer'; -import { AnnouncementListData } from 'types/course/announcements'; +import { AnnouncementData } from 'types/course/announcements'; import { createEntityStore, removeAllFromStore, @@ -35,7 +35,7 @@ const reducer = produce( ); export function saveAnnouncementsList( - announcements: AnnouncementListData[], + announcements: AnnouncementData[], ): SaveAnnouncementListAction { return { type: SAVE_ANNOUNCEMENT_LIST, diff --git a/client/app/bundles/announcements/types.ts b/client/app/bundles/announcements/types.ts index f08a6afe5f5..5162cd38271 100644 --- a/client/app/bundles/announcements/types.ts +++ b/client/app/bundles/announcements/types.ts @@ -1,6 +1,6 @@ import { - AnnouncementListData, - AnnouncementMiniEntity, + AnnouncementData, + AnnouncementEntity, } from 'types/course/announcements'; import { EntityStore } from 'types/store'; @@ -10,12 +10,12 @@ export const SAVE_ANNOUNCEMENT_LIST = 'system/admin/SAVE_ANNOUNCEMENTS_LIST'; // Action Types export interface SaveAnnouncementListAction { type: typeof SAVE_ANNOUNCEMENT_LIST; - announcements: AnnouncementListData[]; + announcements: AnnouncementData[]; } export type GlobalActionType = SaveAnnouncementListAction; // State Types export interface GlobalAnnouncementState { - announcements: EntityStore; + announcements: EntityStore; } diff --git a/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx b/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx index 4d8bae09307..6c7c26c0d40 100644 --- a/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx +++ b/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx @@ -7,8 +7,8 @@ import { grey } from '@mui/material/colors'; import equal from 'fast-deep-equal'; import { Operation } from 'store'; import { + AnnouncementEntity, AnnouncementFormData, - AnnouncementMiniEntity, } from 'types/course/announcements'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; @@ -20,7 +20,7 @@ import { formatFullDateTime } from 'lib/moment'; import AnnouncementEdit from '../../pages/AnnouncementEdit'; interface Props extends WrappedComponentProps { - announcement: AnnouncementMiniEntity; + announcement: AnnouncementEntity; showEditOptions?: boolean; updateOperation?: ( announcementId: number, diff --git a/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx b/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx index 06d6c9fee05..c015da7cbad 100644 --- a/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx +++ b/client/app/bundles/course/announcements/components/misc/AnnouncementsDisplay.tsx @@ -4,8 +4,8 @@ import { Grid, Stack } from '@mui/material'; import equal from 'fast-deep-equal'; import { Operation } from 'store'; import { + AnnouncementEntity, AnnouncementFormData, - AnnouncementMiniEntity, AnnouncementPermissions, } from 'types/course/announcements'; @@ -16,7 +16,7 @@ import useItems from 'lib/hooks/items/useItems'; import AnnouncementCard from './AnnouncementCard'; interface Props extends WrappedComponentProps { - announcements: AnnouncementMiniEntity[]; + announcements: AnnouncementEntity[]; announcementPermissions: AnnouncementPermissions; updateOperation?: ( announcementId: number, @@ -35,11 +35,11 @@ const translations = defineMessages({ const itemsPerPage = 12; -const searchKeys: (keyof AnnouncementMiniEntity)[] = ['title', 'content']; +const searchKeys: (keyof AnnouncementEntity)[] = ['title', 'content']; export const sortFunc = ( - announcements: AnnouncementMiniEntity[], -): AnnouncementMiniEntity[] => { + announcements: AnnouncementEntity[], +): AnnouncementEntity[] => { const sortedAnnouncements = [...announcements]; sortedAnnouncements .sort((a, b) => Date.parse(b.startTime) - Date.parse(a.startTime)) diff --git a/client/app/bundles/course/announcements/store.ts b/client/app/bundles/course/announcements/store.ts index f97cd6d6138..f6eb1cb651e 100644 --- a/client/app/bundles/course/announcements/store.ts +++ b/client/app/bundles/course/announcements/store.ts @@ -1,7 +1,6 @@ import produce from 'immer'; import { AnnouncementData, - AnnouncementListData, AnnouncementPermissions, } from 'types/course/announcements'; import { @@ -63,7 +62,7 @@ const reducer = produce( export const actions = { saveAnnouncementList: ( - announcementList: AnnouncementListData[], + announcementList: AnnouncementData[], announcementPermissions: AnnouncementPermissions, ): SaveAnnouncementListAction => { return { diff --git a/client/app/bundles/course/announcements/types.ts b/client/app/bundles/course/announcements/types.ts index f3b38d38a7d..b3460e703cf 100644 --- a/client/app/bundles/course/announcements/types.ts +++ b/client/app/bundles/course/announcements/types.ts @@ -1,7 +1,6 @@ import { AnnouncementData, - AnnouncementListData, - AnnouncementMiniEntity, + AnnouncementEntity, AnnouncementPermissions, } from 'types/course/announcements'; import { EntityStore } from 'types/store'; @@ -15,7 +14,7 @@ export const DELETE_ANNOUNCEMENT = 'course/announcement/DELETE_ANNOUNCEMENT'; // Action Types export interface SaveAnnouncementListAction { type: typeof SAVE_ANNOUNCEMENT_LIST; - announcementList: AnnouncementListData[]; + announcementList: AnnouncementData[]; announcementPermissions: AnnouncementPermissions; } @@ -35,6 +34,6 @@ export type AnnouncementsActionType = // State Types export interface AnnouncementsState { - announcements: EntityStore; + announcements: EntityStore; permissions: AnnouncementPermissions; } diff --git a/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx b/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx index 6c323eea3ba..3d083a81471 100644 --- a/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx +++ b/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx @@ -1,12 +1,12 @@ import { FC } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; import { Stack } from '@mui/material'; -import { AnnouncementMiniEntity } from 'types/course/announcements'; +import { AnnouncementEntity } from 'types/course/announcements'; import AnnouncementCard from '../../../announcements/components/misc/AnnouncementCard'; interface Props extends WrappedComponentProps { - announcements: AnnouncementMiniEntity[]; + announcements: AnnouncementEntity[]; } const translations = defineMessages({ diff --git a/client/app/bundles/system/admin/admin/store.ts b/client/app/bundles/system/admin/admin/store.ts index 1db4362da19..c6eae0cccb3 100644 --- a/client/app/bundles/system/admin/admin/store.ts +++ b/client/app/bundles/system/admin/admin/store.ts @@ -1,7 +1,6 @@ import { produce } from 'immer'; import { AnnouncementData, - AnnouncementListData, AnnouncementPermissions, } from 'types/course/announcements'; import { CourseListData, CourseStats } from 'types/system/courses'; @@ -170,7 +169,7 @@ const reducer = produce((draft: AdminState, action: AdminActionType) => { export const actions = { saveAnnouncementList: ( - announcementList: AnnouncementListData[], + announcementList: AnnouncementData[], announcementPermissions: AnnouncementPermissions, ): SaveAnnouncementListAction => { return { diff --git a/client/app/bundles/system/admin/admin/types.ts b/client/app/bundles/system/admin/admin/types.ts index ecf14fe12f3..a7b748e8611 100644 --- a/client/app/bundles/system/admin/admin/types.ts +++ b/client/app/bundles/system/admin/admin/types.ts @@ -1,7 +1,6 @@ import { AnnouncementData, - AnnouncementListData, - AnnouncementMiniEntity, + AnnouncementEntity, AnnouncementPermissions, } from 'types/course/announcements'; import { EntityStore } from 'types/store'; @@ -33,7 +32,7 @@ export const DELETE_INSTANCE = 'system/admin/DELETE_INSTANCE'; // Action Types export interface SaveAnnouncementListAction { type: typeof SAVE_ANNOUNCEMENT_LIST; - announcementList: AnnouncementListData[]; + announcementList: AnnouncementData[]; announcementPermissions: AnnouncementPermissions; } @@ -104,7 +103,7 @@ export type AdminActionType = // State Types export interface AdminState { - announcements: EntityStore; + announcements: EntityStore; courses: EntityStore; instances: EntityStore; users: EntityStore; diff --git a/client/app/bundles/system/admin/instance/instance/store.ts b/client/app/bundles/system/admin/instance/instance/store.ts index 63f953b2cbf..a2860ba3d6c 100644 --- a/client/app/bundles/system/admin/instance/instance/store.ts +++ b/client/app/bundles/system/admin/instance/instance/store.ts @@ -1,7 +1,6 @@ import { produce } from 'immer'; import { AnnouncementData, - AnnouncementListData, AnnouncementPermissions, } from 'types/course/announcements'; import { CourseListData, CourseStats } from 'types/system/courses'; @@ -194,7 +193,7 @@ const reducer = produce( export const actions = { saveAnnouncementList: ( - announcementList: AnnouncementListData[], + announcementList: AnnouncementData[], announcementPermissions: AnnouncementPermissions, ): SaveAnnouncementListAction => { return { diff --git a/client/app/bundles/system/admin/instance/instance/types.ts b/client/app/bundles/system/admin/instance/instance/types.ts index e1438d2a282..6305654a83d 100644 --- a/client/app/bundles/system/admin/instance/instance/types.ts +++ b/client/app/bundles/system/admin/instance/instance/types.ts @@ -1,7 +1,6 @@ import { AnnouncementData, - AnnouncementListData, - AnnouncementMiniEntity, + AnnouncementEntity, AnnouncementPermissions, } from 'types/course/announcements'; import { EntityStore } from 'types/store'; @@ -43,7 +42,7 @@ export const DELETE_INVITATION = 'system/instance/DELETE_INVITATION'; // Action Types export interface SaveAnnouncementListAction { type: typeof SAVE_ANNOUNCEMENT_LIST; - announcementList: AnnouncementListData[]; + announcementList: AnnouncementData[]; announcementPermissions: AnnouncementPermissions; } @@ -124,7 +123,7 @@ export type InstanceAdminActionType = // State Types export interface InstanceAdminState { - announcements: EntityStore; + announcements: EntityStore; courses: EntityStore; roleRequests: EntityStore; users: EntityStore; diff --git a/client/app/types/course/announcements.ts b/client/app/types/course/announcements.ts index 0e1153ff59d..de02e0276a2 100644 --- a/client/app/types/course/announcements.ts +++ b/client/app/types/course/announcements.ts @@ -14,8 +14,12 @@ export type AnnouncementListDataPermissions = Permissions< export interface AnnouncementListData { id: number; title: string; - content: string; // HTML String + content: string; startTime: string; + markAsReadUrl: string; +} + +export interface AnnouncementData extends AnnouncementListData { endTime: string; creator: CourseUserBasicListData; isUnread: boolean; @@ -24,13 +28,20 @@ export interface AnnouncementListData { permissions: AnnouncementListDataPermissions; } -export interface AnnouncementData extends AnnouncementListData {} +export interface FetchAnnouncementsData { + announcements: AnnouncementData[]; + permissions: AnnouncementPermissions; +} export interface AnnouncementMiniEntity { id: number; title: string; - content: string; // HTML String + content: string; startTime: string; + markAsReadUrl: string; +} + +export interface AnnouncementEntity extends AnnouncementMiniEntity { endTime: string; creator: CourseUserBasicMiniEntity; isUnread: boolean; @@ -39,7 +50,6 @@ export interface AnnouncementMiniEntity { permissions: AnnouncementListDataPermissions; } -export interface AnnouncementEntity extends AnnouncementMiniEntity {} export interface AnnouncementFormData { id?: number; title: string; diff --git a/client/app/types/course/courses.ts b/client/app/types/course/courses.ts index 2c38b8e5f73..162f96beb16 100644 --- a/client/app/types/course/courses.ts +++ b/client/app/types/course/courses.ts @@ -1,7 +1,7 @@ import { Permissions } from 'types'; import { TodoData } from './lesson-plan/todos'; -import { AnnouncementListData, AnnouncementMiniEntity } from './announcements'; +import { AnnouncementData, AnnouncementEntity } from './announcements'; import { CourseUserListData } from './courseUsers'; import { NotificationData } from './notifications'; @@ -30,7 +30,7 @@ export interface CourseData extends CourseListData { instructors?: CourseUserListData[]; // --- // Or this exists - currentlyActiveAnnouncements?: AnnouncementListData[]; + currentlyActiveAnnouncements?: AnnouncementData[]; assessmentTodos?: TodoData[]; videoTodos?: TodoData[]; surveyTodos?: TodoData[]; @@ -58,7 +58,7 @@ export interface CourseEntity extends CourseMiniEntity { instructors?: CourseUserListData[]; // --- // Or this exists - currentlyActiveAnnouncements: AnnouncementMiniEntity[]; + currentlyActiveAnnouncements: AnnouncementEntity[]; assessmentTodos: TodoData[]; videoTodos: TodoData[]; surveyTodos: TodoData[]; From c4322d9aa742b5265bf6bc6ad731fc09b9b931cd Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:32:06 +0800 Subject: [PATCH 03/76] feat(announcements): add `unread` global announcements --- app/controllers/announcements_controller.rb | 15 ++++++++++++--- .../_announcement_data.json.jbuilder | 2 +- .../_announcement_list_data.json.jbuilder | 1 + client/app/api/Announcements.ts | 13 +++++++------ config/routes.rb | 1 + 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app/controllers/announcements_controller.rb b/app/controllers/announcements_controller.rb index 096102f6988..70286c9e069 100644 --- a/app/controllers/announcements_controller.rb +++ b/app/controllers/announcements_controller.rb @@ -9,7 +9,8 @@ def index respond_to do |format| format.html format.json do - @announcements = global_announcements.includes(:creator) + announcements = requesting_unread? ? unread_global_announcements : global_announcements + @announcements = announcements.includes(:creator) end end end @@ -21,7 +22,15 @@ def mark_as_read head :ok end - def unread - @unread_announcements = unread_global_announcements.sort { |a, b| b.start_at <=> a.start_at } + protected + + def publicly_accessible? + requesting_unread? + end + + private + + def requesting_unread? + params[:unread] == 'true' end end diff --git a/app/views/announcements/_announcement_data.json.jbuilder b/app/views/announcements/_announcement_data.json.jbuilder index ced0ff14fe2..c66b45c08dd 100644 --- a/app/views/announcements/_announcement_data.json.jbuilder +++ b/app/views/announcements/_announcement_data.json.jbuilder @@ -11,7 +11,7 @@ json.creator do json.userUrl url_to_user_or_course_user(@course, user_or_course_user) end -json.isUnread announcement.unread?(current_user) +json.isUnread !user_signed_in? || announcement.unread?(current_user) json.isSticky announcement.sticky? json.isCurrentlyActive announcement.currently_active? diff --git a/app/views/announcements/_announcement_list_data.json.jbuilder b/app/views/announcements/_announcement_list_data.json.jbuilder index 2cad9958556..71a3a373656 100644 --- a/app/views/announcements/_announcement_list_data.json.jbuilder +++ b/app/views/announcements/_announcement_list_data.json.jbuilder @@ -3,3 +3,4 @@ json.id announcement.id json.title announcement.title json.content format_ckeditor_rich_text(announcement.content) json.startTime announcement.start_at +json.markAsReadUrl announcement_mark_as_read_path(announcement) diff --git a/client/app/api/Announcements.ts b/client/app/api/Announcements.ts index c390b537dea..931929d575b 100644 --- a/client/app/api/Announcements.ts +++ b/client/app/api/Announcements.ts @@ -1,7 +1,4 @@ -import { - AnnouncementData, - AnnouncementListData, -} from 'types/course/announcements'; +import { AnnouncementData } from 'types/course/announcements'; import BaseAPI from './Base'; import { APIResponse } from './types'; @@ -15,9 +12,13 @@ export default class AnnouncementsAPI extends BaseAPI { /** * Fetches all the announcements (admin and instance announcements) */ - index(): APIResponse<{ + index(unread = false): APIResponse<{ announcements: AnnouncementData[]; }> { - return this.client.get(this.#urlPrefix); + return this.client.get(this.#urlPrefix, { params: { unread } }); + } + + markAsRead(url: string): APIResponse { + return this.client.post(url); } } diff --git a/config/routes.rb b/config/routes.rb index 5c4edb36093..df5a3ae2ce0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,6 +77,7 @@ resources :announcements, only: [:index] do post 'mark_as_read' end + resources :jobs, only: [:show] resources :instance_user_role_requests, path: 'role_requests' do From cd0c4a842eef012069c987013fb60bea5953fb4d Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 13:50:06 +0800 Subject: [PATCH 04/76] refactor(constants): add icons for all course components --- .../SidebarSettings/SidebarSettingsForm.tsx | 13 +-- .../learning-map/components/Node/index.jsx | 6 +- .../components/tables/PersonalTimesTable.tsx | 10 +- client/app/lib/constants/icons.ts | 106 ++++++++++++++++++ client/app/lib/constants/sharedConstants.ts | 15 --- client/app/types/course/admin/sidebar.ts | 4 +- 6 files changed, 125 insertions(+), 29 deletions(-) create mode 100644 client/app/lib/constants/icons.ts diff --git a/client/app/bundles/course/admin/pages/SidebarSettings/SidebarSettingsForm.tsx b/client/app/bundles/course/admin/pages/SidebarSettings/SidebarSettingsForm.tsx index 93527508561..6b9aa0c8c2f 100644 --- a/client/app/bundles/course/admin/pages/SidebarSettings/SidebarSettingsForm.tsx +++ b/client/app/bundles/course/admin/pages/SidebarSettings/SidebarSettingsForm.tsx @@ -19,6 +19,7 @@ import produce from 'immer'; import { SidebarItem, SidebarItems } from 'types/course/admin/sidebar'; import Section from 'lib/components/core/layouts/Section'; +import { defensivelyGetIcon } from 'lib/constants/icons'; import useTranslation from 'lib/hooks/useTranslation'; import translations from './translations'; @@ -54,7 +55,7 @@ const SidebarSettingsForm = (props: SidebarSettingsFormProps): JSX.Element => { id: item.id, title: item.title, weight: index + 1, - iconClassName: item.iconClassName, + icon: item.icon, })); props.onSubmit(newSidebarItems, setSettings, () => @@ -100,6 +101,8 @@ const SidebarSettingsForm = (props: SidebarSettingsFormProps): JSX.Element => { transform, }; + const Icon = defensivelyGetIcon(item.icon, 'outlined'); + return ( { /> - -
+ + diff --git a/client/app/bundles/course/learning-map/components/Node/index.jsx b/client/app/bundles/course/learning-map/components/Node/index.jsx index 535167dfbbb..96286026f33 100644 --- a/client/app/bundles/course/learning-map/components/Node/index.jsx +++ b/client/app/bundles/course/learning-map/components/Node/index.jsx @@ -6,7 +6,7 @@ import { Card, CardContent } from '@mui/material'; import { createTheme, ThemeProvider } from '@mui/material/styles'; import PropTypes from 'prop-types'; -import { COURSE_COMPONENT_ICONS } from 'lib/constants/sharedConstants'; +import { defensivelyGetIcon } from 'lib/constants/icons'; import { nodeShape } from '../../propTypes'; import translations from '../../translations'; @@ -85,7 +85,7 @@ const Node = (props) => { setIsNodeMenuDisplayed(true); }; - const IconComponent = COURSE_COMPONENT_ICONS[node.courseMaterialType]; + const Icon = defensivelyGetIcon(node.courseMaterialType); return (
@@ -107,7 +107,7 @@ const Node = (props) => { />
)} - + {!canModify && !node.unlocked && }
diff --git a/client/app/bundles/course/users/components/tables/PersonalTimesTable.tsx b/client/app/bundles/course/users/components/tables/PersonalTimesTable.tsx index d05e9ffd405..04065288c73 100644 --- a/client/app/bundles/course/users/components/tables/PersonalTimesTable.tsx +++ b/client/app/bundles/course/users/components/tables/PersonalTimesTable.tsx @@ -11,7 +11,10 @@ import { } from '@mui/material'; import { PersonalTimeMiniEntity } from 'types/course/personalTimes'; -import { COURSE_COMPONENT_ICONS } from 'lib/constants/sharedConstants'; +import { + CourseComponentIconName, + defensivelyGetIcon, +} from 'lib/constants/icons'; import { getAssessmentURL, getVideoURL } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import tableTranslations from 'lib/translations/table'; @@ -64,9 +67,10 @@ const getIcon = (item: PersonalTimeMiniEntity): JSX.Element => { } else if (item.type === ITEM_ACTABLE_TYPES.assessment.name) { materialType = 'assessment'; } - const IconComponent = COURSE_COMPONENT_ICONS[materialType]; - return ; + const Icon = defensivelyGetIcon(materialType as CourseComponentIconName); + + return ; }; const PersonalTimesTable: FC = (props) => { diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts new file mode 100644 index 00000000000..4308173074c --- /dev/null +++ b/client/app/lib/constants/icons.ts @@ -0,0 +1,106 @@ +import { + AddCircle, + AddCircleOutlineOutlined, + Book, + BookOutlined, + Campaign, + CampaignOutlined, + ChatBubble, + ChatBubbleOutlineOutlined, + Circle, + CircleOutlined, + EmojiEvents, + EmojiEventsOutlined, + FileCopy, + FileCopyOutlined, + Folder, + FolderOutlined, + Forum, + ForumOutlined, + Groups, + GroupsOutlined, + Home, + HomeOutlined, + InsertChart, + InsertChartOutlined, + Leaderboard, + LeaderboardOutlined, + ManageAccounts, + ManageAccountsOutlined, + Map as MapIcon, + MapOutlined, + OfflineBolt, + OfflineBoltOutlined, + People, + PeopleOutlined, + PieChart, + PieChartOutlined, + Send, + SendOutlined, + Settings, + SettingsOutlined, + Stairs, + StairsOutlined, + SvgIconComponent, + Upload, + UploadOutlined, + Videocam, + VideocamOutlined, + ViewTimeline, + ViewTimelineOutlined, +} from '@mui/icons-material'; + +interface IconTuple { + outlined: SvgIconComponent; + filled: SvgIconComponent; +} + +export const COURSE_COMPONENT_ICONS = { + achievement: { outlined: EmojiEventsOutlined, filled: EmojiEvents }, + assessment: { outlined: SendOutlined, filled: Send }, + material: { outlined: FolderOutlined, filled: Folder }, + survey: { outlined: PieChartOutlined, filled: PieChart }, + video: { outlined: VideocamOutlined, filled: Videocam }, + announcement: { outlined: CampaignOutlined, filled: Campaign }, + submission: { outlined: UploadOutlined, filled: Upload }, + comments: { outlined: ChatBubbleOutlineOutlined, filled: ChatBubble }, + leaderboard: { outlined: LeaderboardOutlined, filled: Leaderboard }, + users: { outlined: PeopleOutlined, filled: People }, + lessonPlan: { outlined: BookOutlined, filled: Book }, + forum: { outlined: ForumOutlined, filled: Forum }, + manageUsers: { outlined: ManageAccountsOutlined, filled: ManageAccounts }, + statistics: { outlined: InsertChartOutlined, filled: InsertChart }, + disbursement: { outlined: AddCircleOutlineOutlined, filled: AddCircle }, + duplication: { outlined: FileCopyOutlined, filled: FileCopy }, + levels: { outlined: StairsOutlined, filled: Stairs }, + groups: { outlined: GroupsOutlined, filled: Groups }, + skills: { outlined: OfflineBoltOutlined, filled: OfflineBolt }, + timelines: { outlined: ViewTimelineOutlined, filled: ViewTimeline }, + settings: { outlined: SettingsOutlined, filled: Settings }, + home: { outlined: HomeOutlined, filled: Home }, + map: { outlined: MapOutlined, filled: MapIcon }, +} satisfies Record; + +export type CourseComponentIconName = keyof typeof COURSE_COMPONENT_ICONS; + +const DEFAULT_ICON_TUPLE: IconTuple = { + outlined: CircleOutlined, + filled: Circle, +}; + +/** + * Returns the `name` component's icon based on the `kind` variant. If the `name` + * is unresolved during runtime, the default icon is returned. + * + * For some reasons, it's possible that `name` is unresolved. This bug was spotted + * in CircleCI test runs. Accessing the `kind` by simply doing something like + * `COURSE_COMPONENT_ICONS[name][kind]` is therefore unsafe and may result in fatal + * error. This function is a defensive wrapper around the unsafe subscription. + */ +export const defensivelyGetIcon = ( + name: CourseComponentIconName, + kind: keyof IconTuple = 'filled', +): SvgIconComponent => { + const iconTuple = COURSE_COMPONENT_ICONS[name] ?? DEFAULT_ICON_TUPLE; + return iconTuple[kind]; +}; diff --git a/client/app/lib/constants/sharedConstants.ts b/client/app/lib/constants/sharedConstants.ts index b62b6ddbfb7..83a9da2244d 100644 --- a/client/app/lib/constants/sharedConstants.ts +++ b/client/app/lib/constants/sharedConstants.ts @@ -1,10 +1,3 @@ -import { - EmojiEvents, - Flight, - Folder, - PieChart, - Videocam, -} from '@mui/icons-material'; import type { CourseUserRoles, StaffRoles } from 'types/course/courseUsers'; import { InstanceUserRoles, @@ -59,14 +52,6 @@ export const AVAILABLE_LOCALES: { [key in Locale]: string } = { zh: '中文', }; -export const COURSE_COMPONENT_ICONS = { - achievement: EmojiEvents, - assessment: Flight, - material: Folder, - survey: PieChart, - video: Videocam, -}; - export default { TIMELINE_ALGORITHMS, USER_ROLES, diff --git a/client/app/types/course/admin/sidebar.ts b/client/app/types/course/admin/sidebar.ts index 6dacc6db761..e39479d4ecc 100644 --- a/client/app/types/course/admin/sidebar.ts +++ b/client/app/types/course/admin/sidebar.ts @@ -1,8 +1,10 @@ +import { CourseComponentIconName } from 'lib/constants/icons'; + export interface SidebarItem { id: string; title: string; weight: number; - iconClassName: string; + icon: CourseComponentIconName; } export type SidebarItems = SidebarItem[]; From c708165c747104fe9bb10c28f67fc337a7c0fd80 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 13:50:27 +0800 Subject: [PATCH 05/76] style(sidebar): use standard icons, fix missing item keys --- .../components/course/achievements_component.rb | 2 +- .../components/course/announcements_component.rb | 2 +- .../components/course/assessments_component.rb | 8 ++++---- .../components/course/discussion/topics_component.rb | 2 +- .../components/course/duplication_component.rb | 2 +- .../components/course/experience_points_component.rb | 3 ++- app/controllers/components/course/forums_component.rb | 2 +- app/controllers/components/course/groups_component.rb | 2 +- .../components/course/leaderboard_component.rb | 2 +- .../components/course/learning_map_component.rb | 2 +- .../components/course/lesson_plan_component.rb | 2 +- app/controllers/components/course/levels_component.rb | 2 +- app/controllers/components/course/materials_component.rb | 2 +- .../course/multiple_reference_timelines_component.rb | 2 +- app/controllers/components/course/settings_component.rb | 3 ++- app/controllers/components/course/statistics_component.rb | 2 +- app/controllers/components/course/survey_component.rb | 2 +- app/controllers/components/course/users_component.rb | 5 +++-- app/controllers/components/course/videos_component.rb | 2 +- .../course/admin/sidebar_settings/edit.json.jbuilder | 2 +- 20 files changed, 27 insertions(+), 24 deletions(-) diff --git a/app/controllers/components/course/achievements_component.rb b/app/controllers/components/course/achievements_component.rb index 17fcc73b9c9..25644990f9d 100644 --- a/app/controllers/components/course/achievements_component.rb +++ b/app/controllers/components/course/achievements_component.rb @@ -14,7 +14,7 @@ def sidebar_items [ { key: :achievements, - icon: 'trophy', + icon: :achievement, title: I18n.t('course.achievement.achievements.sidebar_title'), weight: 4, path: course_achievements_path(current_course), diff --git a/app/controllers/components/course/announcements_component.rb b/app/controllers/components/course/announcements_component.rb index b4e0ec81ef7..a82836bc70a 100644 --- a/app/controllers/components/course/announcements_component.rb +++ b/app/controllers/components/course/announcements_component.rb @@ -16,7 +16,7 @@ def main_sidebar_items [ { key: :announcements, - icon: 'bullhorn', + icon: :announcement, title: settings.title || t('course.announcements.sidebar_title'), weight: 1, path: course_announcements_path(current_course), diff --git a/app/controllers/components/course/assessments_component.rb b/app/controllers/components/course/assessments_component.rb index 71521af429f..6b242b3bcca 100644 --- a/app/controllers/components/course/assessments_component.rb +++ b/app/controllers/components/course/assessments_component.rb @@ -25,10 +25,10 @@ def assessment_categories current_course.assessment_categories.select(&:persisted?).map do |category| { key: "assessments_#{category.id}", - icon: 'plane', # TODO: category.icon in db that user can select and set + icon: :assessment, # TODO: category.icon in db that user can select and set title: category.title, weight: 2, - path: course_assessments_path(current_course, category: category, tab: category.tabs.first), + path: course_assessments_path(current_course, category: category), unread: 0 } end @@ -38,7 +38,7 @@ def assessment_submissions [ { key: :assessments_submissions, - icon: 'upload', + icon: :submission, title: t('course.assessment.submissions.sidebar_title'), weight: 3, path: course_submissions_path(current_course), @@ -53,7 +53,7 @@ def admin_sidebar_items [ { key: :assessments_skills, - icon: 'bolt', + icon: :skills, title: t('course.assessment.skills.sidebar_title'), type: :admin, weight: 8, diff --git a/app/controllers/components/course/discussion/topics_component.rb b/app/controllers/components/course/discussion/topics_component.rb index f083e76fffe..5fdadc72759 100644 --- a/app/controllers/components/course/discussion/topics_component.rb +++ b/app/controllers/components/course/discussion/topics_component.rb @@ -17,7 +17,7 @@ def main_sidebar_items [ { key: :discussion_topics, - icon: 'comments', + icon: :comments, title: settings.title || t('course.discussion.topics.sidebar_title'), weight: 5, path: course_topics_path(current_course), diff --git a/app/controllers/components/course/duplication_component.rb b/app/controllers/components/course/duplication_component.rb index 8300b1ac571..c9d1c7b3c81 100644 --- a/app/controllers/components/course/duplication_component.rb +++ b/app/controllers/components/course/duplication_component.rb @@ -12,7 +12,7 @@ def sidebar_items [ { key: :duplication, - icon: 'clone', + icon: :duplication, title: t('layouts.duplication.title'), type: :admin, weight: 5, diff --git a/app/controllers/components/course/experience_points_component.rb b/app/controllers/components/course/experience_points_component.rb index c37e9a55671..9f110192800 100644 --- a/app/controllers/components/course/experience_points_component.rb +++ b/app/controllers/components/course/experience_points_component.rb @@ -15,7 +15,8 @@ def sidebar_items [ { - icon: 'magic', + key: :disburse_experience_points, + icon: :disbursement, title: t('course.experience_points.disbursement.sidebar_title'), type: :admin, weight: 4, diff --git a/app/controllers/components/course/forums_component.rb b/app/controllers/components/course/forums_component.rb index 46195959001..93684637214 100644 --- a/app/controllers/components/course/forums_component.rb +++ b/app/controllers/components/course/forums_component.rb @@ -16,7 +16,7 @@ def main_sidebar_items [ { key: :forums, - icon: 'list-ul', + icon: :forum, title: settings.title || t('course.forum.forums.sidebar_title'), weight: 10, path: course_forums_path(current_course), diff --git a/app/controllers/components/course/groups_component.rb b/app/controllers/components/course/groups_component.rb index dd51ddb2075..ec5c42b0360 100644 --- a/app/controllers/components/course/groups_component.rb +++ b/app/controllers/components/course/groups_component.rb @@ -13,7 +13,7 @@ def sidebar_items [ { key: :groups, - icon: 'share-alt', + icon: :groups, title: I18n.t('course.groups.sidebar_title'), type: :admin, weight: 7, diff --git a/app/controllers/components/course/leaderboard_component.rb b/app/controllers/components/course/leaderboard_component.rb index 40100cfdf66..ba618f1ee06 100644 --- a/app/controllers/components/course/leaderboard_component.rb +++ b/app/controllers/components/course/leaderboard_component.rb @@ -20,7 +20,7 @@ def main_sidebar_items [ { key: :leaderboard, - icon: 'star', + icon: :leaderboard, title: settings.title || t('course.leaderboards.sidebar_title'), weight: 6, path: course_leaderboard_path(current_course) diff --git a/app/controllers/components/course/learning_map_component.rb b/app/controllers/components/course/learning_map_component.rb index 046d6ed1004..d22e7661645 100644 --- a/app/controllers/components/course/learning_map_component.rb +++ b/app/controllers/components/course/learning_map_component.rb @@ -14,7 +14,7 @@ def sidebar_items [ { key: :learning_map, - icon: 'map', + icon: :map, title: t('layouts.learning_map.title'), weight: 5, path: course_learning_map_path(current_course) diff --git a/app/controllers/components/course/lesson_plan_component.rb b/app/controllers/components/course/lesson_plan_component.rb index 67b52be0694..043e44e5979 100644 --- a/app/controllers/components/course/lesson_plan_component.rb +++ b/app/controllers/components/course/lesson_plan_component.rb @@ -20,7 +20,7 @@ def main_sidebar_items [ { key: :lesson_plan, - icon: 'book', + icon: :lessonPlan, title: I18n.t('course.lesson_plan.items.sidebar_title'), weight: 8, path: course_lesson_plan_path(current_course) diff --git a/app/controllers/components/course/levels_component.rb b/app/controllers/components/course/levels_component.rb index 631df2351b3..d2d75d04100 100644 --- a/app/controllers/components/course/levels_component.rb +++ b/app/controllers/components/course/levels_component.rb @@ -16,7 +16,7 @@ def sidebar_items [ { key: :levels, - icon: 'star-half-o', + icon: :levels, title: I18n.t('course.levels.sidebar_title'), type: :admin, weight: 6, diff --git a/app/controllers/components/course/materials_component.rb b/app/controllers/components/course/materials_component.rb index 5eed3565d97..5a0d05fbc84 100644 --- a/app/controllers/components/course/materials_component.rb +++ b/app/controllers/components/course/materials_component.rb @@ -16,7 +16,7 @@ def main_sidebar_items [ { key: :materials, - icon: 'folder-open', + icon: :material, title: settings.title || t('course.material.sidebar_title'), weight: 9, path: course_material_folder_path(current_course, current_course.root_folder), diff --git a/app/controllers/components/course/multiple_reference_timelines_component.rb b/app/controllers/components/course/multiple_reference_timelines_component.rb index 30ebc1bdd4b..7f76a32b3bb 100644 --- a/app/controllers/components/course/multiple_reference_timelines_component.rb +++ b/app/controllers/components/course/multiple_reference_timelines_component.rb @@ -16,7 +16,7 @@ def sidebar_items [ { key: :reference_timelines, - icon: 'random', + icon: :timelines, type: :admin, weight: 8, title: t('layouts.multiple_reference_timelines.timeline_designer'), diff --git a/app/controllers/components/course/settings_component.rb b/app/controllers/components/course/settings_component.rb index 49b96a0d8bc..8ce746b4758 100644 --- a/app/controllers/components/course/settings_component.rb +++ b/app/controllers/components/course/settings_component.rb @@ -22,7 +22,8 @@ def admin_sidebar_items [ { - icon: 'gear', + key: :admin, + icon: :settings, title: t('layouts.course_admin.title'), type: :admin, weight: 9, diff --git a/app/controllers/components/course/statistics_component.rb b/app/controllers/components/course/statistics_component.rb index bf36448a19b..101d0a01541 100644 --- a/app/controllers/components/course/statistics_component.rb +++ b/app/controllers/components/course/statistics_component.rb @@ -12,7 +12,7 @@ def sidebar_items [ { key: :statistics, - icon: 'bar-chart', + icon: :statistics, title: t('course.statistics.header'), type: :admin, weight: 2, diff --git a/app/controllers/components/course/survey_component.rb b/app/controllers/components/course/survey_component.rb index 082ef50a78c..1adcf2e71c0 100644 --- a/app/controllers/components/course/survey_component.rb +++ b/app/controllers/components/course/survey_component.rb @@ -14,7 +14,7 @@ def sidebar_items [ { key: :surveys, - icon: 'pie-chart', + icon: :survey, title: I18n.t('course.surveys.sidebar_title'), weight: 11, path: course_surveys_path(current_course) diff --git a/app/controllers/components/course/users_component.rb b/app/controllers/components/course/users_component.rb index efdeb3b2a65..c383ffaa22b 100644 --- a/app/controllers/components/course/users_component.rb +++ b/app/controllers/components/course/users_component.rb @@ -20,7 +20,7 @@ def main_sidebar_items [ { key: :users, - icon: 'group', + icon: :users, title: t('course.users.sidebar_title'), weight: 7, path: course_users_path(current_course) @@ -39,7 +39,8 @@ def admin_sidebar_items [ { - icon: 'user-plus', + key: :manage_users, + icon: :manageUsers, title: t('layouts.course_users.title'), type: :admin, weight: 1, diff --git a/app/controllers/components/course/videos_component.rb b/app/controllers/components/course/videos_component.rb index a97adfcb642..1abe751474a 100644 --- a/app/controllers/components/course/videos_component.rb +++ b/app/controllers/components/course/videos_component.rb @@ -24,7 +24,7 @@ def main_sidebar_items [ { key: :videos, - icon: 'video-camera', + icon: :video, title: settings.title || t('course.video.videos.sidebar_title'), weight: 12, path: course_videos_path(current_course, tab: current_course.default_video_tab), diff --git a/app/views/course/admin/sidebar_settings/edit.json.jbuilder b/app/views/course/admin/sidebar_settings/edit.json.jbuilder index 8721df2fcd0..bfdcd9cd1f3 100644 --- a/app/views/course/admin/sidebar_settings/edit.json.jbuilder +++ b/app/views/course/admin/sidebar_settings/edit.json.jbuilder @@ -5,5 +5,5 @@ json.array! sorted_sidebar_items do |item| json.id item.id json.title item.title json.weight item.weight - json.iconClassName "fa fa-#{item.icon}" + json.icon item.icon end From 9afc7ebd13d44d53e3211510438c695b42de22f8 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 13:58:59 +0800 Subject: [PATCH 06/76] chore(eslint): disable `import/prefer-default-export` --- client/.eslintrc.js | 2 +- client/app/bundles/announcements/operations.ts | 1 - client/app/bundles/announcements/selectors.ts | 1 - .../course/assessment/components/AssessmentForm/operations.ts | 1 - .../course/assessment/pages/AssessmentStatistics/utils.js | 1 - client/app/bundles/course/assessment/sessions/operations.ts | 1 - .../app/bundles/course/statistics/utils/parseStaffResponse.js | 1 - .../bundles/course/statistics/utils/parseStudentsResponse.js | 1 - client/app/bundles/course/video-submissions/operations.ts | 1 - client/app/bundles/users/operations.ts | 1 - client/app/lib/helpers/react-hook-form-helper.js | 2 -- client/app/theme/mui-style.ts | 1 - 12 files changed, 1 insertion(+), 13 deletions(-) diff --git a/client/.eslintrc.js b/client/.eslintrc.js index cb256ea0b3e..f86ad82293f 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -110,7 +110,7 @@ module.exports = { }, ], 'import/no-extraneous-dependencies': ['warn', { devDependencies: true }], - 'import/prefer-default-export': 'warn', + 'import/prefer-default-export': 'off', 'jsx-a11y/anchor-is-valid': 'off', 'jsx-a11y/click-events-have-key-events': 'off', 'jsx-a11y/label-has-for': 'off', diff --git a/client/app/bundles/announcements/operations.ts b/client/app/bundles/announcements/operations.ts index 0a253b143eb..abeb75319c8 100644 --- a/client/app/bundles/announcements/operations.ts +++ b/client/app/bundles/announcements/operations.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { Operation } from 'store'; import GlobalAPI from 'api'; diff --git a/client/app/bundles/announcements/selectors.ts b/client/app/bundles/announcements/selectors.ts index 724684a2343..acc49690923 100644 --- a/client/app/bundles/announcements/selectors.ts +++ b/client/app/bundles/announcements/selectors.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { AppState } from 'store'; import { selectMiniEntities } from 'utilities/store'; diff --git a/client/app/bundles/course/assessment/components/AssessmentForm/operations.ts b/client/app/bundles/course/assessment/components/AssessmentForm/operations.ts index e928754eca2..2306eddfe54 100644 --- a/client/app/bundles/course/assessment/components/AssessmentForm/operations.ts +++ b/client/app/bundles/course/assessment/components/AssessmentForm/operations.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { Operation } from 'store'; import CourseAPI from 'api/course'; diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js index 988c26a05b1..fe6c388bb16 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/utils.js @@ -8,7 +8,6 @@ function processDayDifference(dayDifference) { return `D+${dayDifference}`; } -// eslint-disable-next-line import/prefer-default-export export function processSubmissionsIntoChartData(submissions) { const submittedSubmissions = submissions.filter((s) => s.submittedAt != null); const mappedSubmissions = submittedSubmissions diff --git a/client/app/bundles/course/assessment/sessions/operations.ts b/client/app/bundles/course/assessment/sessions/operations.ts index cb8ee5b7683..5df77f6e86a 100644 --- a/client/app/bundles/course/assessment/sessions/operations.ts +++ b/client/app/bundles/course/assessment/sessions/operations.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { AxiosError } from 'axios'; import { SessionFormData, diff --git a/client/app/bundles/course/statistics/utils/parseStaffResponse.js b/client/app/bundles/course/statistics/utils/parseStaffResponse.js index d4be541890f..e9db9a93961 100644 --- a/client/app/bundles/course/statistics/utils/parseStaffResponse.js +++ b/client/app/bundles/course/statistics/utils/parseStaffResponse.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const processStaff = (staff) => ({ ...staff, numGraded: parseInt(staff.numGraded ?? 0, 10), diff --git a/client/app/bundles/course/statistics/utils/parseStudentsResponse.js b/client/app/bundles/course/statistics/utils/parseStudentsResponse.js index 42ab2e194b4..556d94f08cd 100644 --- a/client/app/bundles/course/statistics/utils/parseStudentsResponse.js +++ b/client/app/bundles/course/statistics/utils/parseStudentsResponse.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const processStudent = (student) => ({ ...student, level: parseInt(student.level ?? 0, 10), diff --git a/client/app/bundles/course/video-submissions/operations.ts b/client/app/bundles/course/video-submissions/operations.ts index 15293d76f3b..0a42ff9e65a 100644 --- a/client/app/bundles/course/video-submissions/operations.ts +++ b/client/app/bundles/course/video-submissions/operations.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ import { VideoSubmissionListData } from 'types/course/videoSubmissions'; import CourseAPI from 'api/course'; diff --git a/client/app/bundles/users/operations.ts b/client/app/bundles/users/operations.ts index 0968eb163a8..f4925dc9798 100644 --- a/client/app/bundles/users/operations.ts +++ b/client/app/bundles/users/operations.ts @@ -4,7 +4,6 @@ import GlobalAPI from 'api'; import { actions } from './store'; -// eslint-disable-next-line import/prefer-default-export export function fetchUser(userId: number): Operation { return async (dispatch) => GlobalAPI.users.fetch(userId).then((response) => { diff --git a/client/app/lib/helpers/react-hook-form-helper.js b/client/app/lib/helpers/react-hook-form-helper.js index e70c9b7520f..8a5d480deeb 100644 --- a/client/app/lib/helpers/react-hook-form-helper.js +++ b/client/app/lib/helpers/react-hook-form-helper.js @@ -1,5 +1,3 @@ -/* eslint-disable import/prefer-default-export */ - const toCamel = (str) => str.replace(/([-_][a-z])/gi, ($1) => $1.toUpperCase().replace('-', '').replace('_', ''), diff --git a/client/app/theme/mui-style.ts b/client/app/theme/mui-style.ts index 84a9fc7001d..e90934bc3cd 100644 --- a/client/app/theme/mui-style.ts +++ b/client/app/theme/mui-style.ts @@ -1,4 +1,3 @@ -/* eslint-disable import/prefer-default-export */ export const tabsStyle = { // to show tab indicator on firefox '& .MuiTabs-indicator': { From 7e3750a3d33bf40445446f0e816179bf935be8a2 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:00:28 +0800 Subject: [PATCH 07/76] style(fonts): replace Roboto with Inter Inter has better distinctions between font weights. It also looks modern; distinguishing our app from generic Material Design apps. --- client/app/lib/initializers/webfont.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/lib/initializers/webfont.js b/client/app/lib/initializers/webfont.js index 568ac7b606a..7f9221b4f68 100644 --- a/client/app/lib/initializers/webfont.js +++ b/client/app/lib/initializers/webfont.js @@ -6,7 +6,7 @@ import WebFont from 'webfontloader'; WebFont.load({ google: { - families: ['Roboto:400,700,i4', 'Varela Round'], + families: ['Inter:400,500,600,700,i4', 'Varela Round'], }, timeout: 1500, }); From 50e7320e66ff940907016805d62f0fd504be3a2b Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:02:41 +0800 Subject: [PATCH 08/76] style(tailwind): move colour slots to its plugin definition --- client/tailwind.config.ts | 40 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index 026b2ce5860..d42e33b7696 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -35,12 +35,6 @@ export default { '100%': { backgroundColor: 'transparent' }, }, }, - colors: { - 'slot-1': `var(${SLOTTED_COLOR_VAR}-1)`, - 'slot-2': `var(${SLOTTED_COLOR_VAR}-2)`, - 'slot-3': `var(${SLOTTED_COLOR_VAR}-3)`, - 'slot-4': `var(${SLOTTED_COLOR_VAR}-4)`, - }, }, }, corePlugins: { @@ -83,17 +77,31 @@ export default { { values: theme('spacing'), type: 'number' }, ); }), - plugin(({ matchUtilities, theme }) => { - matchUtilities( - { - 'slot-1': (value) => ({ [`${SLOTTED_COLOR_VAR}-1`]: value }), - 'slot-2': (value) => ({ [`${SLOTTED_COLOR_VAR}-2`]: value }), - 'slot-3': (value) => ({ [`${SLOTTED_COLOR_VAR}-3`]: value }), - 'slot-4': (value) => ({ [`${SLOTTED_COLOR_VAR}-4`]: value }), + plugin( + ({ matchUtilities, theme }) => { + matchUtilities( + { + 'slot-1': (value) => ({ [`${SLOTTED_COLOR_VAR}-1`]: value }), + 'slot-2': (value) => ({ [`${SLOTTED_COLOR_VAR}-2`]: value }), + 'slot-3': (value) => ({ [`${SLOTTED_COLOR_VAR}-3`]: value }), + 'slot-4': (value) => ({ [`${SLOTTED_COLOR_VAR}-4`]: value }), + }, + { values: flattenColorPalette(theme('colors')), type: 'color' }, + ); + }, + { + theme: { + extend: { + colors: { + 'slot-1': `var(${SLOTTED_COLOR_VAR}-1)`, + 'slot-2': `var(${SLOTTED_COLOR_VAR}-2)`, + 'slot-3': `var(${SLOTTED_COLOR_VAR}-3)`, + 'slot-4': `var(${SLOTTED_COLOR_VAR}-4)`, + }, + }, }, - { values: flattenColorPalette(theme('colors')), type: 'color' }, - ); - }), + }, + ), ], important: '#root', } satisfies Config; From d04e29e712c1ddc47452a47a2777b50c4da52c55 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:03:38 +0800 Subject: [PATCH 09/76] feat(tailwind): add `bg-fade-to-r` --- client/tailwind.config.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index d42e33b7696..a5b43b4dc13 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -65,7 +65,10 @@ export default { matchUtilities( { 'bg-fade-to-l': (value) => ({ - background: `linear-gradient(90deg, transparent 0%, ${value} 20%)`, + background: `linear-gradient(90deg, transparent 0%, ${value} 25%)`, + }), + 'bg-fade-to-r': (value) => ({ + background: `linear-gradient(-90deg, transparent 0%, ${value} 25%)`, }), }, { values: flattenColorPalette(theme('colors')), type: 'color' }, From f80d665b70a0bbb7783e3923b7abc5cd688b8f93 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:04:08 +0800 Subject: [PATCH 10/76] feat(tailwind): add instant borders plugin --- client/tailwind.config.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index a5b43b4dc13..e3b39543767 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -74,6 +74,25 @@ export default { { values: flattenColorPalette(theme('colors')), type: 'color' }, ); }), + plugin(({ matchUtilities, theme }) => { + matchUtilities( + { + 'border-only-t': (value) => ({ borderTop: `1px solid ${value}` }), + 'border-only-b': (value) => ({ borderBottom: `1px solid ${value}` }), + 'border-only-l': (value) => ({ borderLeft: `1px solid ${value}` }), + 'border-only-r': (value) => ({ borderRight: `1px solid ${value}` }), + 'border-only-x': (value) => ({ + borderLeft: `1px solid ${value}`, + borderRight: `1px solid ${value}`, + }), + 'border-only-y': (value) => ({ + borderTop: `1px solid ${value}`, + borderBottom: `1px solid ${value}`, + }), + }, + { values: flattenColorPalette(theme('colors')), type: 'color' }, + ); + }), plugin(({ matchUtilities, theme }) => { matchUtilities( { wh: (value) => ({ width: value, height: value }) }, From 2e46e546986bf4ef8e7117c02f8e34c6d2127f88 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:05:01 +0800 Subject: [PATCH 11/76] feat(tailwind): add primary colour --- client/tailwind.config.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/tailwind.config.ts b/client/tailwind.config.ts index e3b39543767..535f4c65cae 100644 --- a/client/tailwind.config.ts +++ b/client/tailwind.config.ts @@ -4,6 +4,8 @@ import type { Config } from 'tailwindcss'; import flattenColorPalette from 'tailwindcss/lib/util/flattenColorPalette'; import plugin from 'tailwindcss/plugin'; +import palette from './app/theme/palette'; + const SLOTTED_COLOR_VAR = '--tw-slotted-color'; export default { @@ -35,6 +37,9 @@ export default { '100%': { backgroundColor: 'transparent' }, }, }, + colors: { + primary: palette.primary.main, + }, }, }, corePlugins: { From 58e61dd23ef3ad6e4db346b586c2c0a15f728871 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:09:44 +0800 Subject: [PATCH 12/76] style: remove persistent custom scrollbar --- .../skills/components/tables/SkillsTable.scss | 15 ------------- .../skills/components/tables/SkillsTable.tsx | 2 -- .../components/misc/CourseInfoBox.scss | 22 ------------------- .../courses/components/misc/CourseInfoBox.tsx | 4 +--- 4 files changed, 1 insertion(+), 42 deletions(-) delete mode 100644 client/app/bundles/course/assessment/skills/components/tables/SkillsTable.scss delete mode 100644 client/app/bundles/course/courses/components/misc/CourseInfoBox.scss diff --git a/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.scss b/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.scss deleted file mode 100644 index 9ed1e52e722..00000000000 --- a/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.scss +++ /dev/null @@ -1,15 +0,0 @@ -// scss-lint:disable ColorVariable -::-webkit-scrollbar-track { - background-color: #f5f5f5; - border-radius: 10px; -} - -::-webkit-scrollbar { - background-color: #f5f5f5; - width: 12px; -} - -::-webkit-scrollbar-thumb { - background-color: rgb(182, 182, 182); - border-radius: 10px; -} diff --git a/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.tsx b/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.tsx index e2f6bdb503b..54f00c81d2a 100644 --- a/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.tsx +++ b/client/app/bundles/course/assessment/skills/components/tables/SkillsTable.tsx @@ -29,8 +29,6 @@ import Note from 'lib/components/core/Note'; import { TableEnum } from '../../types'; import SkillManagementButtons from '../buttons/SkillManagementButtons'; -import './SkillsTable.scss'; - interface Props extends WrappedComponentProps { data: SkillBranchMiniEntity[]; tableType: TableEnum; diff --git a/client/app/bundles/course/courses/components/misc/CourseInfoBox.scss b/client/app/bundles/course/courses/components/misc/CourseInfoBox.scss deleted file mode 100644 index 793cabd97a9..00000000000 --- a/client/app/bundles/course/courses/components/misc/CourseInfoBox.scss +++ /dev/null @@ -1,22 +0,0 @@ -// scss-lint:disable ColorVariable -::-webkit-scrollbar-track { - background-color: #f5f5f5; - border-radius: 10px; -} - -::-webkit-scrollbar { - background-color: #f5f5f5; - width: 12px; -} - -::-webkit-scrollbar-thumb { - background-color: rgb(182, 182, 182); - border-radius: 10px; -} - -.coursePicture { - img { - height: 100px; - width: 100px; - } -} diff --git a/client/app/bundles/course/courses/components/misc/CourseInfoBox.tsx b/client/app/bundles/course/courses/components/misc/CourseInfoBox.tsx index 431ea3db501..cd1ea77988c 100644 --- a/client/app/bundles/course/courses/components/misc/CourseInfoBox.tsx +++ b/client/app/bundles/course/courses/components/misc/CourseInfoBox.tsx @@ -6,8 +6,6 @@ import { CourseMiniEntity } from 'types/course/courses'; import { getCourseURL } from 'lib/helpers/url-builders'; -import styles from './CourseInfoBox.scss'; - interface Props extends WrappedComponentProps { course: CourseMiniEntity; } @@ -40,7 +38,7 @@ const CourseInfoBox: FC = (props) => { }} >

Date: Mon, 29 May 2023 14:12:33 +0800 Subject: [PATCH 13/76] refactor(AssessmentsTable): make `StackedBadges` reusable --- .../_achievement_badges.json.jbuilder | 6 ++ .../assessments/index.json.jbuilder | 6 +- .../AssessmentsIndex/AssessmentsTable.tsx | 5 +- .../pages/AssessmentsIndex/StackedBadges.tsx | 74 ------------------- .../components/extensions/StackedBadges.tsx | 64 ++++++++++++++++ .../types/course/assessment/assessments.ts | 12 +-- 6 files changed, 82 insertions(+), 85 deletions(-) create mode 100644 app/views/course/assessment/assessments/_achievement_badges.json.jbuilder delete mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/StackedBadges.tsx create mode 100644 client/app/lib/components/extensions/StackedBadges.tsx diff --git a/app/views/course/assessment/assessments/_achievement_badges.json.jbuilder b/app/views/course/assessment/assessments/_achievement_badges.json.jbuilder new file mode 100644 index 00000000000..d564a6609c0 --- /dev/null +++ b/app/views/course/assessment/assessments/_achievement_badges.json.jbuilder @@ -0,0 +1,6 @@ +# frozen_string_literal: true +json.array! achievements do |achievement| + json.url course_achievement_path(course, achievement) + json.badgeUrl achievement_badge_path(achievement) + json.title achievement.title +end diff --git a/app/views/course/assessment/assessments/index.json.jbuilder b/app/views/course/assessment/assessments/index.json.jbuilder index fe55c1e24c1..cf5375327ab 100644 --- a/app/views/course/assessment/assessments/index.json.jbuilder +++ b/app/views/course/assessment/assessments/index.json.jbuilder @@ -48,10 +48,8 @@ json.assessments @assessments do |assessment| achievement_conditionals = @conditional_service.achievement_conditional_for(assessment) top_conditionals = achievement_conditionals.first(3) - json.topConditionals top_conditionals do |achievement| - json.url course_achievement_path(current_course, achievement) - json.badgeUrl achievement_badge_path(achievement) - json.title achievement.title + json.topConditionals do + json.partial! 'achievement_badges', achievements: top_conditionals, course: current_course end conditionals_count = achievement_conditionals.size diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx index 2d44db24918..a7c701d2538 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx @@ -6,13 +6,13 @@ import { import Note from 'lib/components/core/Note'; import PersonalStartEndTime from 'lib/components/extensions/PersonalStartEndTime'; +import StackedBadges from 'lib/components/extensions/StackedBadges'; import Table, { ColumnTemplate } from 'lib/components/table'; import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; import ActionButtons from './ActionButtons'; -import StackedBadges from './StackedBadges'; import StatusBadges from './StatusBadges'; interface AssessmentsTableProps { @@ -62,9 +62,10 @@ const AssessmentsTable = (props: AssessmentsTableProps): JSX.Element => { title: t(translations.neededFor), cell: (assessment) => ( ), unless: !display.isAchievementsEnabled, diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/StackedBadges.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/StackedBadges.tsx deleted file mode 100644 index 304b2585841..00000000000 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/StackedBadges.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { ReactNode } from 'react'; -import { Avatar, Tooltip, Typography } from '@mui/material'; -import { AssessmentListData } from 'types/course/assessment/assessments'; - -import Link from 'lib/components/core/Link'; -import useTranslation from 'lib/hooks/useTranslation'; - -import translations from '../../translations'; - -interface StackableBadgeProps { - title: string; - src?: string; - children?: ReactNode; - className?: string; -} - -interface StackedBadgesProps { - badges?: AssessmentListData['topConditionals']; - remainingCount?: number; - assessmentUrl?: string; -} - -const StackableBadge = (props: StackableBadgeProps): JSX.Element => ( - - - {props.children} - - -); - -const StackedBadges = (props: StackedBadgesProps): JSX.Element => { - const { t } = useTranslation(); - - return ( -

-
- {props.badges?.map((badge) => ( - - - - ))} - - {props.remainingCount && ( - - - - +{props.remainingCount} - - - - )} -
-
- ); -}; - -export default StackedBadges; diff --git a/client/app/lib/components/extensions/StackedBadges.tsx b/client/app/lib/components/extensions/StackedBadges.tsx new file mode 100644 index 00000000000..f91a7d6e9c4 --- /dev/null +++ b/client/app/lib/components/extensions/StackedBadges.tsx @@ -0,0 +1,64 @@ +import { ReactNode } from 'react'; +import { Avatar, Tooltip, Typography } from '@mui/material'; +import { AchievementBadgeData } from 'types/course/assessment/assessments'; + +import Link from 'lib/components/core/Link'; + +interface StackableBadgeProps { + title: string; + src?: string; + children?: ReactNode; +} + +interface StackedBadgesProps { + badges?: AchievementBadgeData[]; + remainingCount?: number; + seeRemainingUrl?: string; + seeRemainingTooltip?: string; +} + +const StackableBadge = (props: StackableBadgeProps): JSX.Element => ( + + + {props.children} + + +); + +const StackedBadges = (props: StackedBadgesProps): JSX.Element => ( +
+
+ {props.badges?.map((badge) => ( + + + + ))} + + {props.remainingCount && ( + + + + +{props.remainingCount} + + + + )} +
+
+); + +export default StackedBadges; diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index dfa71a38e51..1e42a4472a5 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -16,6 +16,12 @@ interface AssessmentActionsData { deleteUrl?: string; } +export interface AchievementBadgeData { + url: string; + badgeUrl: string; + title: string; +} + export interface AssessmentListData extends AssessmentActionsData { id: number; title: string; @@ -37,11 +43,7 @@ export interface AssessmentListData extends AssessmentActionsData { isBonusEnded?: boolean; isEndTimePassed?: boolean; remainingConditionalsCount?: number; - topConditionals?: { - url: string; - badgeUrl: string; - title: string; - }[]; + topConditionals?: AchievementBadgeData[]; } export interface AssessmentsListData { From 8111b958404d4219a54acab39c121e19132c9adc Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:13:44 +0800 Subject: [PATCH 14/76] feat(AssessmentsTable): disable flying action buttons --- .../pages/AssessmentsIndex/ActionButtons.tsx | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx index 0c40b70bc53..8ff0f1317c0 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ActionButtons.tsx @@ -56,35 +56,22 @@ const ActionButtons = (props: ActionButtonsProps): JSX.Element => { .catch(() => setAttempting(false)); }; - const actionButton = actionButtonUrl && ( - - - - ); - return (
- {student ? ( - actionButton - ) : ( -
- {actionButton} -
+ {actionButtonUrl && ( + + + )} {assessment.editUrl && ( From bfed8af273611315eeb227adf21737e5f4c9395c Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:16:58 +0800 Subject: [PATCH 15/76] feat(SearchField): make autofocus work --- client/app/lib/components/core/fields/SearchField.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/client/app/lib/components/core/fields/SearchField.tsx b/client/app/lib/components/core/fields/SearchField.tsx index 843bd5bae07..c785da1848e 100644 --- a/client/app/lib/components/core/fields/SearchField.tsx +++ b/client/app/lib/components/core/fields/SearchField.tsx @@ -16,6 +16,7 @@ const SearchField = (props: SearchFieldProps): JSX.Element => { const [keyword, setKeyword] = useState(''); const [isPending, startTransition] = useTransition(); + const ref = useRef(null); const changeKeyword = (newKeyword: string): void => { setKeyword(newKeyword); @@ -27,9 +28,15 @@ const SearchField = (props: SearchFieldProps): JSX.Element => { onChangeKeyword?.(''); }; + useEffect(() => { + if (!props.autoFocus) return; + + ref.current?.focus(); + }, []); + return ( Date: Mon, 29 May 2023 14:17:13 +0800 Subject: [PATCH 16/76] feat(SearchField): add `noIcon` prop --- .../components/core/fields/SearchField.tsx | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/app/lib/components/core/fields/SearchField.tsx b/client/app/lib/components/core/fields/SearchField.tsx index c785da1848e..ac67041121e 100644 --- a/client/app/lib/components/core/fields/SearchField.tsx +++ b/client/app/lib/components/core/fields/SearchField.tsx @@ -1,4 +1,10 @@ -import { ComponentProps, useState, useTransition } from 'react'; +import { + ComponentProps, + useEffect, + useRef, + useState, + useTransition, +} from 'react'; import { Clear, Search } from '@mui/icons-material'; import { IconButton, InputAdornment } from '@mui/material'; @@ -9,10 +15,11 @@ type SearchFieldProps = ComponentProps & { onChangeKeyword?: (keyword: string) => void; placeholder?: string; className?: string; + noIcon?: boolean; }; const SearchField = (props: SearchFieldProps): JSX.Element => { - const { onChangeKeyword, ...textFieldProps } = props; + const { onChangeKeyword, noIcon, ...otherProps } = props; const [keyword, setKeyword] = useState(''); const [isPending, startTransition] = useTransition(); @@ -40,7 +47,7 @@ const SearchField = (props: SearchFieldProps): JSX.Element => { fullWidth hiddenLabel InputProps={{ - startAdornment: ( + startAdornment: !noIcon && ( @@ -57,13 +64,12 @@ const SearchField = (props: SearchFieldProps): JSX.Element => { ), }} - onChange={(e): void => changeKeyword(e.target.value)} - placeholder={props.placeholder} size="small" trims - value={keyword} variant="filled" - {...textFieldProps} + {...otherProps} + onChange={(e): void => changeKeyword(e.target.value)} + value={keyword} /> ); }; From 872c08b85833768bcac286ad5be1e9401e78ec79 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:20:21 +0800 Subject: [PATCH 17/76] refactor: ProviderWrapper -> Providers --- client/app/App.tsx | 6 +- .../TestCaseView/__test__/index.test.js | 38 ++-- .../__test__/submissionsTable.test.jsx | 6 +- .../pages/VideoShow/VideoPlayerWithStore.jsx | 6 +- .../SubmissionEditWithStore.jsx | 6 +- .../StatisticsWithStore.jsx | 6 +- .../lib/components/wrappers/I18nProvider.tsx | 30 +++ .../components/wrappers/ProviderWrapper.jsx | 210 ------------------ .../app/lib/components/wrappers/Providers.tsx | 28 +++ .../components/wrappers/RollbarWrapper.tsx | 7 +- .../lib/components/wrappers/StoreProvider.tsx | 31 +++ .../lib/components/wrappers/ThemeProvider.tsx | 139 ++++++++++++ .../lib/components/wrappers/ToastProvider.tsx | 21 ++ client/app/utilities/TestApp.tsx | 6 +- 14 files changed, 289 insertions(+), 251 deletions(-) create mode 100644 client/app/lib/components/wrappers/I18nProvider.tsx delete mode 100644 client/app/lib/components/wrappers/ProviderWrapper.jsx create mode 100644 client/app/lib/components/wrappers/Providers.tsx create mode 100644 client/app/lib/components/wrappers/StoreProvider.tsx create mode 100644 client/app/lib/components/wrappers/ThemeProvider.tsx create mode 100644 client/app/lib/components/wrappers/ToastProvider.tsx diff --git a/client/app/App.tsx b/client/app/App.tsx index 606f8de8bc2..aae8aadd2e1 100644 --- a/client/app/App.tsx +++ b/client/app/App.tsx @@ -1,19 +1,19 @@ import { BrowserRouter } from 'react-router-dom'; -import ProviderWrapper from 'lib/components/wrappers/ProviderWrapper'; +import Providers from 'lib/components/wrappers/Providers'; import NotificationPopup from 'lib/containers/NotificationPopup'; import RoutedApp from './RoutedApp'; import { store } from './store'; const App = (): JSX.Element => ( - + - + ); export default App; diff --git a/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js b/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js index 576a59257fb..a6a3c8bf384 100644 --- a/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js +++ b/client/app/bundles/course/assessment/submission/containers/TestCaseView/__test__/index.test.js @@ -1,7 +1,7 @@ import { mount } from 'enzyme'; import { VisibleTestCaseView } from 'course/assessment/submission/containers/TestCaseView'; -import ProviderWrapper from 'lib/components/wrappers/ProviderWrapper'; +import Providers from 'lib/components/wrappers/Providers'; import { workflowStates } from '../../../constants'; @@ -65,9 +65,9 @@ describe('TestCaseView', () => { describe('when viewing as staff', () => { it('renders all test cases and standard streams', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(publicTestCases).exists()).toBe(true); @@ -79,9 +79,9 @@ describe('TestCaseView', () => { it('renders staff-only warnings', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(privateWarningIcon).exists()).toBe(true); @@ -93,14 +93,14 @@ describe('TestCaseView', () => { describe('when showEvaluation & showPrivate are true', () => { it('renders staff-only warnings when assessment is not yet published', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(privateWarningIcon).exists()).toBe(true); @@ -109,13 +109,13 @@ describe('TestCaseView', () => { it('does not render staff-only warnings when assessment is published', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(privateWarningIcon).exists()).toBe(false); @@ -126,12 +126,12 @@ describe('TestCaseView', () => { describe('when students can see standard streams', () => { it('does not render staff-only warnings', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(standardOutputWarningIcon).exists()).toBe( @@ -147,14 +147,14 @@ describe('TestCaseView', () => { describe('when viewing as student', () => { it('does not show any staff-only warnings', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(privateWarningIcon).exists()).toBe(false); @@ -165,12 +165,12 @@ describe('TestCaseView', () => { it('shows standard streams when the flag is enabled', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(standardOutput).exists()).toBe(true); @@ -180,13 +180,13 @@ describe('TestCaseView', () => { describe('when showEvaluation & showPrivate flags are enabled', () => { it('shows private and evaluation tests after assessment is published', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(privateTestCases).exists()).toBe(true); @@ -195,14 +195,14 @@ describe('TestCaseView', () => { it('does not show private and evaluation tests before assessment is published', () => { const testCaseView = mount( - + - , + , ); expect(testCaseView.find(privateTestCases).exists()).toBe(false); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx index aba6a2ce9b4..52a55b0aa41 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/__test__/submissionsTable.test.jsx @@ -1,6 +1,6 @@ import { mount } from 'enzyme'; -import ProviderWrapper from 'lib/components/wrappers/ProviderWrapper'; +import Providers from 'lib/components/wrappers/Providers'; import SubmissionsTable from '../SubmissionsTable'; @@ -49,9 +49,9 @@ const defaultProps = { const setupTest = (propsOverrides) => { const props = { ...defaultProps, ...propsOverrides }; const submissionsTable = mount( - + - , + , ); return { diff --git a/client/app/bundles/course/video/pages/VideoShow/VideoPlayerWithStore.jsx b/client/app/bundles/course/video/pages/VideoShow/VideoPlayerWithStore.jsx index 6752dfccc99..5431180bd3c 100644 --- a/client/app/bundles/course/video/pages/VideoShow/VideoPlayerWithStore.jsx +++ b/client/app/bundles/course/video/pages/VideoShow/VideoPlayerWithStore.jsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import equal from 'fast-deep-equal'; import PropTypes from 'prop-types'; -import { StoreProviderWrapper } from 'lib/components/wrappers/ProviderWrapper'; +import StoreProvider from 'lib/components/wrappers/StoreProvider'; import HeatMap from '../../submission/containers/Charts/HeatMap'; import VideoPlayer from '../../submission/containers/VideoPlayer'; @@ -11,7 +11,7 @@ import storeCreator from '../../submission/store'; import styles from '../../submission/containers/Statistics.scss'; const VideoPlayerWithStore = ({ video, statistics }) => ( - + <>
@@ -21,7 +21,7 @@ const VideoPlayerWithStore = ({ video, statistics }) => (
- + ); VideoPlayerWithStore.propTypes = { video: PropTypes.object.isRequired, diff --git a/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/SubmissionEditWithStore.jsx b/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/SubmissionEditWithStore.jsx index d367b8eda61..ba46f864eae 100644 --- a/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/SubmissionEditWithStore.jsx +++ b/client/app/bundles/course/video/submission/pages/VideoSubmissionEdit/SubmissionEditWithStore.jsx @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import CourseAPI from 'api/course'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import { StoreProviderWrapper } from 'lib/components/wrappers/ProviderWrapper'; +import StoreProvider from 'lib/components/wrappers/StoreProvider'; import Submission from '../../containers/Submission'; import storeCreator from '../../store'; @@ -37,13 +37,13 @@ const SubmissionEditWithStore = ({ data }) => { return (
- + - +
); }; diff --git a/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/StatisticsWithStore.jsx b/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/StatisticsWithStore.jsx index 962efa96f1c..c45a5f58245 100644 --- a/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/StatisticsWithStore.jsx +++ b/client/app/bundles/course/video/submission/pages/VideoSubmissionShow/StatisticsWithStore.jsx @@ -2,7 +2,7 @@ import { memo } from 'react'; import equal from 'fast-deep-equal'; import PropTypes from 'prop-types'; -import { StoreProviderWrapper } from 'lib/components/wrappers/ProviderWrapper'; +import StoreProvider from 'lib/components/wrappers/StoreProvider'; import Statistics from '../../containers/Statistics'; import VideoPlayer from '../../containers/VideoPlayer'; @@ -11,7 +11,7 @@ import storeCreator from '../../store'; import styles from '../../containers/Statistics.scss'; const StatisticsWithStore = ({ video, statistics }) => ( - +
@@ -19,7 +19,7 @@ const StatisticsWithStore = ({ video, statistics }) => (
- + ); StatisticsWithStore.propTypes = { video: PropTypes.object.isRequired, diff --git a/client/app/lib/components/wrappers/I18nProvider.tsx b/client/app/lib/components/wrappers/I18nProvider.tsx new file mode 100644 index 00000000000..4c8f2f0d134 --- /dev/null +++ b/client/app/lib/components/wrappers/I18nProvider.tsx @@ -0,0 +1,30 @@ +import { ReactNode } from 'react'; +import { IntlProvider } from 'react-intl'; + +import { i18nLocale } from 'lib/helpers/server-context'; + +import translations from '../../../../build/locales/locales.json'; + +interface I18nProviderProps { + children: ReactNode; +} + +const I18nProvider = (props: I18nProviderProps): JSX.Element => { + if (!i18nLocale) throw new Error(`Illegal i18nLocale: ${i18nLocale}`); + + const localeWithoutRegionCode = i18nLocale.toLowerCase().split(/[_-]+/)[0]; + + let messages; + if (localeWithoutRegionCode !== 'en') { + messages = + translations[localeWithoutRegionCode] || translations[i18nLocale]; + } + + return ( + + {props.children} + + ); +}; + +export default I18nProvider; diff --git a/client/app/lib/components/wrappers/ProviderWrapper.jsx b/client/app/lib/components/wrappers/ProviderWrapper.jsx deleted file mode 100644 index c9b69b682ad..00000000000 --- a/client/app/lib/components/wrappers/ProviderWrapper.jsx +++ /dev/null @@ -1,210 +0,0 @@ -import { IntlProvider } from 'react-intl'; -import { Provider as ReduxProvider } from 'react-redux'; -import { ToastContainer } from 'react-toastify'; -import { injectStyle } from 'react-toastify/dist/inject-style'; -import { - createTheme, - StyledEngineProvider, - ThemeProvider, -} from '@mui/material/styles'; -import PropTypes from 'prop-types'; -import { PersistGate } from 'redux-persist/lib/integration/react'; -import { lime, red } from 'tailwindcss/colors'; -import resolveConfig from 'tailwindcss/resolveConfig'; -import { grey } from 'theme/colors'; -import palette from 'theme/palette'; - -import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import { i18nLocale } from 'lib/helpers/server-context'; - -import translations from '../../../../build/locales/locales.json'; -import tailwindUserConfig from '../../../../tailwind.config'; - -import ErrorBoundary from './ErrorBoundary'; -import RollBarWrapper from './RollbarWrapper'; -import 'react-tooltip/dist/react-tooltip.css'; - -injectStyle(); - -const propTypes = { - store: PropTypes.shape({ - subscribe: PropTypes.func.isRequired, - dispatch: PropTypes.func.isRequired, - getState: PropTypes.func.isRequired, - }), - persistor: PropTypes.object, - children: PropTypes.node.isRequired, -}; - -const tailwindConfig = resolveConfig(tailwindUserConfig); - -const pxInInt = (pixels) => parseInt(pixels.replace('px', ''), 10); - -const ProviderWrapper = ({ store, persistor, children }) => { - const localeWithoutRegionCode = i18nLocale.toLowerCase().split(/[_-]+/)[0]; - - // TODO: Replace with React's createRoot once true SPA is ready - const rootElement = document.getElementById('root'); - - const theme = createTheme({ - palette, - // https://material-ui.com/customization/themes/#typography---html-font-size - // https://material-ui.com/style/typography/#migration-to-typography-v2 - typography: { - htmlFontSize: 10, - }, - breakpoints: { - values: { - xs: 0, - sm: pxInInt(tailwindConfig.theme.screens.sm), - md: pxInInt(tailwindConfig.theme.screens.md), - lg: pxInInt(tailwindConfig.theme.screens.lg), - xl: pxInInt(tailwindConfig.theme.screens.xl), - }, - }, - components: { - MuiAlert: { - styleOverrides: { - standardError: { backgroundColor: red[100] }, - standardSuccess: { backgroundColor: lime[50] }, - }, - }, - MuiDialog: { - defaultProps: { container: rootElement }, - }, - MuiPopover: { - // TODO: *Must* remove once SPA is ready - // Popover elements, e.g., Menu, MenuItem, attaches an `overflow: hidden` style to this `container` to - // prevent scrolling and having the Popover floating senselessly in the `container`. Usually, this `container` - // defaults to `body`. This time, we set it to `rootElement` which is NOT `body` nor the viewport. This causes - // the senseless floating issue while the page scrolls. - defaultProps: { container: rootElement }, - }, - MuiPopper: { - defaultProps: { container: rootElement }, - }, - MuiCard: { styleOverrides: { root: { overflow: 'visible' } } }, - MuiMenuItem: { styleOverrides: { root: { height: '48px' } } }, - MuiAppBar: { - styleOverrides: { - // When there is a MUI Dialog component, somehow - // the color of appbar is changed... smh - root: { - background: `${palette.primary.main} !important`, - color: 'white !important', - }, - }, - }, - MuiDialogContent: { - styleOverrides: { - root: { - color: 'black', - fontSize: '16px', - fontFamily: `'Roboto', 'sans-serif'`, - }, - }, - }, - MuiAccordionSummary: { - styleOverrides: { - root: { width: '100%' }, - content: { - margin: 0, - paddingLeft: '16px', - '&$expanded': { margin: 0 }, - }, - }, - }, - MuiStepLabel: { - styleOverrides: { - iconContainer: { - paddingLeft: '2px', - paddingRight: '2px', - }, - }, - }, - MuiTableCell: { - styleOverrides: { - root: { - padding: '8px 14px', - height: '48px', - }, - head: { - color: grey[500], - padding: '16px 16px', - }, - sizeSmall: { - padding: '6px 12px', - height: '40px', - }, - }, - }, - MuiTableRow: { - styleOverrides: { - root: { '&:last-child td, &:last-child th': { border: 0 } }, - }, - }, - }, - }); - - let messages; - if (localeWithoutRegionCode !== 'en') { - messages = - translations[localeWithoutRegionCode] || translations[i18nLocale]; - } - - let providers = ( - <> - {children} - - - ); - - if (store && persistor) { - providers = ( - } persistor={persistor}> - {providers} - - ); - } - - providers = ( - - - {providers} - - - ); - - if (store) { - providers = {providers}; - } - - return ( - - {providers} - - ); -}; - -ProviderWrapper.propTypes = propTypes; - -export const StoreProviderWrapper = ({ store, persistor, children }) => { - let providers = children; - - if (store && persistor) { - providers = ( - } persistor={persistor}> - {providers} - - ); - } - - if (store) - providers = {providers}; - - return providers; -}; - -StoreProviderWrapper.propTypes = propTypes; - -export default ProviderWrapper; diff --git a/client/app/lib/components/wrappers/Providers.tsx b/client/app/lib/components/wrappers/Providers.tsx new file mode 100644 index 00000000000..6d61626e395 --- /dev/null +++ b/client/app/lib/components/wrappers/Providers.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react'; + +import ErrorBoundary from './ErrorBoundary'; +import I18nProvider from './I18nProvider'; +import RollbarProvider from './RollbarWrapper'; +import StoreProvider, { StoreProviderProps } from './StoreProvider'; +import ThemeProvider from './ThemeProvider'; +import ToastProvider from './ToastProvider'; + +interface ProvidersProps extends StoreProviderProps { + children: ReactNode; +} + +const Providers = (props: ProvidersProps): JSX.Element => ( + + + + + + {props.children} + + + + + +); + +export default Providers; diff --git a/client/app/lib/components/wrappers/RollbarWrapper.tsx b/client/app/lib/components/wrappers/RollbarWrapper.tsx index 495d98b6f3f..145b6ebef30 100644 --- a/client/app/lib/components/wrappers/RollbarWrapper.tsx +++ b/client/app/lib/components/wrappers/RollbarWrapper.tsx @@ -1,11 +1,10 @@ -import { FC } from 'react'; import { Provider } from '@rollbar/react'; -interface Props { +interface RollbarProviderProps { children: JSX.Element; } -const RollBarWrapper: FC = (props) => { +const RollbarProvider = (props: RollbarProviderProps): JSX.Element => { // TODO: To report user id as well after we move to SPA. const rollbarConfig = process.env.NODE_ENV === 'development' @@ -46,4 +45,4 @@ const RollBarWrapper: FC = (props) => { return {props.children}; }; -export default RollBarWrapper; +export default RollbarProvider; diff --git a/client/app/lib/components/wrappers/StoreProvider.tsx b/client/app/lib/components/wrappers/StoreProvider.tsx new file mode 100644 index 00000000000..d2c7cf559d8 --- /dev/null +++ b/client/app/lib/components/wrappers/StoreProvider.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import { Persistor } from 'redux-persist'; +import { PersistGate } from 'redux-persist/integration/react'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; + +export interface StoreProviderProps { + store?: Store; + children: ReactNode; + persistor?: Persistor; +} + +const StoreProvider = (props: StoreProviderProps): JSX.Element => { + if (!props.store) return props.children as JSX.Element; + + return ( + + {props.persistor ? ( + } persistor={props.persistor}> + {props.children} + + ) : ( + props.children + )} + + ); +}; + +export default StoreProvider; diff --git a/client/app/lib/components/wrappers/ThemeProvider.tsx b/client/app/lib/components/wrappers/ThemeProvider.tsx new file mode 100644 index 00000000000..e80b857720a --- /dev/null +++ b/client/app/lib/components/wrappers/ThemeProvider.tsx @@ -0,0 +1,139 @@ +import { ReactNode } from 'react'; +import { + createTheme, + StyledEngineProvider, + ThemeProvider as MuiThemeProvider, +} from '@mui/material/styles'; +// eslint-disable-next-line import/no-extraneous-dependencies +import resolveConfig from 'tailwindcss/resolveConfig'; +import { grey } from 'theme/colors'; +import palette from 'theme/palette'; + +import tailwindUserConfig from '../../../../tailwind.config'; + +interface ThemeProviderProps { + children: ReactNode; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const tailwindConfig = resolveConfig(tailwindUserConfig) as any; + +const pxToNumber = (pixels: string): number => + parseInt(pixels.replace('px', ''), 10); + +const ThemeProvider = (props: ThemeProviderProps): JSX.Element => { + // TODO: Replace with React's createRoot once true SPA is ready + const rootElement = document.getElementById('root'); + + const theme = createTheme({ + palette, + // https://material-ui.com/customization/themes/#typography---html-font-size + // https://material-ui.com/style/typography/#migration-to-typography-v2 + typography: { + htmlFontSize: 10, + fontFamily: `'Inter', 'sans-serif'`, + }, + breakpoints: { + values: { + xs: 0, + sm: pxToNumber(tailwindConfig.theme.screens.sm), + md: pxToNumber(tailwindConfig.theme.screens.md), + lg: pxToNumber(tailwindConfig.theme.screens.lg), + xl: pxToNumber(tailwindConfig.theme.screens.xl), + }, + }, + components: { + MuiTab: { + styleOverrides: { + root: { textTransform: 'none' }, + }, + }, + MuiButton: { + defaultProps: { + disableElevation: true, + classes: { root: 'rounded-full px-4 py-2' }, + }, + styleOverrides: { + root: { textTransform: 'none' }, + }, + }, + MuiDialog: { + defaultProps: { + container: rootElement, + PaperProps: { + className: 'rounded-2xl shadow-2xl', + }, + }, + }, + MuiPopover: { + // TODO: *Must* remove once SPA is ready + // Popover elements, e.g., Menu, MenuItem, attaches an `overflow: hidden` style to this `container` to + // prevent scrolling and having the Popover floating senselessly in the `container`. Usually, this `container` + // defaults to `body`. This time, we set it to `rootElement` which is NOT `body` nor the viewport. This causes + // the senseless floating issue while the page scrolls. + defaultProps: { container: rootElement }, + }, + MuiPopper: { + defaultProps: { container: rootElement }, + }, + MuiCard: { styleOverrides: { root: { overflow: 'visible' } } }, + MuiMenuItem: { styleOverrides: { root: { height: '48px' } } }, + MuiDialogContent: { + styleOverrides: { + root: { + color: 'black', + fontSize: '16px', + fontFamily: `'Roboto', 'sans-serif'`, + }, + }, + }, + MuiAccordionSummary: { + styleOverrides: { + root: { width: '100%' }, + content: { + margin: 0, + paddingLeft: '16px', + '&$expanded': { margin: 0 }, + }, + }, + }, + MuiStepLabel: { + styleOverrides: { + iconContainer: { + paddingLeft: '2px', + paddingRight: '2px', + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + padding: '8px 14px', + height: '48px', + }, + head: { + color: grey[500], + padding: '16px 16px', + }, + sizeSmall: { + padding: '6px 12px', + height: '40px', + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { '&:last-child td, &:last-child th': { border: 0 } }, + }, + }, + }, + }); + + return ( + + {props.children} + + ); +}; + +export default ThemeProvider; diff --git a/client/app/lib/components/wrappers/ToastProvider.tsx b/client/app/lib/components/wrappers/ToastProvider.tsx new file mode 100644 index 00000000000..ff7d8aea3ce --- /dev/null +++ b/client/app/lib/components/wrappers/ToastProvider.tsx @@ -0,0 +1,21 @@ +import { ReactNode } from 'react'; +import { ToastContainer } from 'react-toastify'; +import { injectStyle } from 'react-toastify/dist/inject-style'; + +injectStyle(); + +interface ToastProviderProps { + children: ReactNode; +} + +const ToastProvider = (props: ToastProviderProps): JSX.Element => { + return ( + <> + {props.children} + + + + ); +}; + +export default ToastProvider; diff --git a/client/app/utilities/TestApp.tsx b/client/app/utilities/TestApp.tsx index f564400e2f3..5e283a77e25 100644 --- a/client/app/utilities/TestApp.tsx +++ b/client/app/utilities/TestApp.tsx @@ -2,7 +2,7 @@ import { ReactNode } from 'react'; import { MemoryRouter } from 'react-router-dom'; import { AppState, setUpStoreWithState, store as appStore } from 'store'; -import ProviderWrapper from 'lib/components/wrappers/ProviderWrapper'; +import Providers from 'lib/components/wrappers/Providers'; import NotificationPopup from 'lib/containers/NotificationPopup'; export interface CustomRenderOptions { @@ -20,11 +20,11 @@ const TestApp = (props: TestAppProps): JSX.Element => { const store = props.state ? setUpStoreWithState(props.state) : appStore; return ( - + {props.children} - + ); }; From e83379e2aae0db1c72cfee443df9583230ce478d Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:29:05 +0800 Subject: [PATCH 18/76] chore(deps-dev): bump react-router-dom from 6.3.0 to 6.11.1 in /client --- client/package.json | 2 +- client/yarn.lock | 38 ++++++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/client/package.json b/client/package.json index 2c44fcc64be..1d1da918cb3 100644 --- a/client/package.json +++ b/client/package.json @@ -89,7 +89,7 @@ "react-player": "^2.12.0", "react-redux": "^8.1.1", "react-resizable": "^3.0.5", - "react-router-dom": "^6.3.0", + "react-router-dom": "^6.11.1", "react-scroll": "^1.8.9", "react-toastify": "^9.1.3", "react-tooltip": "^5.15.0", diff --git a/client/yarn.lock b/client/yarn.lock index 6333ea3a25e..08eafc3994c 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2048,6 +2048,11 @@ redux-thunk "^2.4.2" reselect "^4.1.8" +"@remix-run/router@1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.6.2.tgz#bbe75f8c59e0b7077584920ce2cc76f8f354934d" + integrity sha512-LzqpSrMK/3JBAVBI9u3NWtOhWNw5AMQfrUFYB0+bDHTSw17z++WJLsPsxAuK+oSddsxk4d7F/JcdDPM1M5YAhA== + "@rollbar/react@^0.11.2": version "0.11.2" resolved "https://registry.yarnpkg.com/@rollbar/react/-/react-0.11.2.tgz#f5d71f21e9207f183080fa8a05a618ad78cf6d17" @@ -8381,20 +8386,20 @@ react-resizable@^3.0.5: prop-types "15.x" react-draggable "^4.0.3" -react-router-dom@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.3.0.tgz#a0216da813454e521905b5fa55e0e5176123f43d" - integrity sha512-uaJj7LKytRxZNQV8+RbzJWnJ8K2nPsOOEuX7aQstlMZKQT0164C+X2w6bnkqU3sjtLvpd5ojrezAyfZ1+0sStw== +react-router-dom@^6.11.1: + version "6.11.2" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.11.2.tgz#324d55750ffe2ecd54ca4ec6b7bc7ab01741f170" + integrity sha512-JNbKtAeh1VSJQnH6RvBDNhxNwemRj7KxCzc5jb7zvDSKRnPWIFj9pO+eXqjM69gQJ0r46hSz1x4l9y0651DKWw== dependencies: - history "^5.2.0" - react-router "6.3.0" + "@remix-run/router" "1.6.2" + react-router "6.11.2" -react-router@6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.3.0.tgz#3970cc64b4cb4eae0c1ea5203a80334fdd175557" - integrity sha512-7Wh1DzVQ+tlFjkeo+ujvjSqSJmkt1+8JO+T5xklPlgrh70y7ogx75ODRW0ThWhY7S+6yEDks8TYrtQe/aoboBQ== +react-router@6.11.2: + version "6.11.2" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.11.2.tgz#006301c4da1a173d7ad76b7ecd2da01b9dd3837a" + integrity sha512-74z9xUSaSX07t3LM+pS6Un0T55ibUE/79CzfZpy5wsPDZaea1F8QkrsiyRnA2YQ7LwE/umaydzXZV80iDCPkMg== dependencies: - history "^5.2.0" + "@remix-run/router" "1.6.2" react-scroll@^1.8.9: version "1.8.9" @@ -8865,7 +8870,7 @@ scheduler@^0.23.0: dependencies: loose-envify "^1.1.0" -schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.1.2: +schema-utils@^3.0.0, schema-utils@^3.1.1: version "3.1.2" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.2.tgz#36c10abca6f7577aeae136c804b0c741edeadc99" integrity sha512-pvjEHOgWc9OWA/f/DE3ohBWTD6EleVLf7iFUkoSwAxttdBhB9QUebQgxER2kWueOvRJXPHNnyrvvh9eZINB8Eg== @@ -8874,6 +8879,15 @@ schema-utils@^3.0.0, schema-utils@^3.1.1, schema-utils@^3.1.2: ajv "^6.12.5" ajv-keywords "^3.5.2" +schema-utils@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== + dependencies: + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + schema-utils@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.0.0.tgz#60331e9e3ae78ec5d16353c467c34b3a0a1d3df7" From 761d4fecacb2327fff42450a92c93f50b874eb85 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:32:13 +0800 Subject: [PATCH 19/76] feat(courses): add `/sidebar` endpoint --- app/controllers/course/courses_controller.rb | 5 +- .../_course_user_progress.json.jbuilder | 27 +++++++++++ .../courses/_sidebar_items.json.jbuilder | 8 ++++ .../course/courses/sidebar.json.jbuilder | 34 ++++++++++++++ client/app/api/course/Courses.ts | 46 +++++++++---------- client/app/types/course/courses.ts | 37 ++++++++++++++- config/routes.rb | 2 + 7 files changed, 134 insertions(+), 25 deletions(-) create mode 100644 app/views/course/courses/_course_user_progress.json.jbuilder create mode 100644 app/views/course/courses/_sidebar_items.json.jbuilder create mode 100644 app/views/course/courses/sidebar.json.jbuilder diff --git a/app/controllers/course/courses_controller.rb b/app/controllers/course/courses_controller.rb index ebc1b102a05..f6bea291e2e 100644 --- a/app/controllers/course/courses_controller.rb +++ b/app/controllers/course/courses_controller.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class Course::CoursesController < Course::Controller include Course::ActivityFeedsConcern - skip_authorize_resource :course, only: [:show, :index] + skip_authorize_resource :course, only: [:show, :index, :sidebar] def index @courses = Course.publicly_accessible @@ -33,6 +33,9 @@ def create def destroy end + def sidebar + end + protected def publicly_accessible? diff --git a/app/views/course/courses/_course_user_progress.json.jbuilder b/app/views/course/courses/_course_user_progress.json.jbuilder new file mode 100644 index 00000000000..a0731445411 --- /dev/null +++ b/app/views/course/courses/_course_user_progress.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true +levels_enabled = !current_component_host[:course_levels_component].nil? +achievements_enabled = !current_component_host[:course_achievements_component].nil? + +if levels_enabled + json.level course_user.level_number + json.nextLevelPercentage course_user.level_progress_percentage + + experience_points = course_user.experience_points + json.exp experience_points + + next_threshold = course_user.next_level_threshold + difference = next_threshold - experience_points + json.nextLevelExpDelta difference > 0 ? difference : 'max' +end + +if achievements_enabled + recent_achievements = course_user.achievements.recently_obtained(5) + + json.recentAchievements do + json.partial! 'course/assessment/assessments/achievement_badges', + achievements: recent_achievements, + course: course_user.course + end + + json.remainingAchievementsCount course_user.achievement_count - recent_achievements.size +end diff --git a/app/views/course/courses/_sidebar_items.json.jbuilder b/app/views/course/courses/_sidebar_items.json.jbuilder new file mode 100644 index 00000000000..61530207369 --- /dev/null +++ b/app/views/course/courses/_sidebar_items.json.jbuilder @@ -0,0 +1,8 @@ +# frozen_string_literal: true +json.array! items do |item| + json.key item[:key] + json.label item[:title] + json.path item[:path] + json.icon item[:icon] + json.unread item[:unread] if item[:unread]&.nonzero? +end diff --git a/app/views/course/courses/sidebar.json.jbuilder b/app/views/course/courses/sidebar.json.jbuilder new file mode 100644 index 00000000000..c20100fc7ff --- /dev/null +++ b/app/views/course/courses/sidebar.json.jbuilder @@ -0,0 +1,34 @@ +# frozen_string_literal: true +json.courseTitle current_course.title +json.courseUrl course_path(current_course) +json.courseLogoUrl url_to_course_logo(current_course) +json.courseUserUrl url_to_user_or_course_user(current_course, current_course_user) +json.userName current_user.name + +if current_course_user.present? && can?(:read, current_course) + json.courseUserName current_course_user.name + json.courseUserRole current_course_user.role + json.userAvatarUrl user_image(current_course_user.user) + + if can?(:manage, Course::UserEmailUnsubscription.new(course_user: current_course_user)) + json.manageEmailSubscriptionUrl course_user_manage_email_subscription_path(current_course, current_course_user) + end + + if current_course_user.student? && current_course.gamified? + json.progress do + json.partial! 'course_user_progress', course_user: current_course_user + end + end +end + +if can?(:read, current_course) + json.sidebar do + json.partial! 'sidebar_items', items: controller.sidebar_items(type: :normal) + end + + unless (admin_sidebar_items = controller.sidebar_items(type: :admin)).empty? + json.adminSidebar do + json.partial! 'sidebar_items', items: admin_sidebar_items + end + end +end diff --git a/client/app/api/course/Courses.ts b/client/app/api/course/Courses.ts index 5c029f1e841..f70e7128567 100644 --- a/client/app/api/course/Courses.ts +++ b/client/app/api/course/Courses.ts @@ -1,11 +1,13 @@ -import { AxiosResponse } from 'axios'; import { CourseData, + CourseLayoutData, CourseListData, CoursePermissions, } from 'types/course/courses'; import { RoleRequestBasicListData } from 'types/system/instance/roleRequests'; +import { APIResponse } from 'api/types'; + import BaseCourseAPI from './Base'; export default class CoursesAPI extends BaseCourseAPI { @@ -14,27 +16,27 @@ export default class CoursesAPI extends BaseCourseAPI { /** * Fetches all of the courses */ - index(): Promise< - AxiosResponse<{ - courses: CourseListData[]; - instanceUserRoleRequest?: RoleRequestBasicListData; - permissions: CoursePermissions; - }> - > { + index(): APIResponse<{ + courses: CourseListData[]; + instanceUserRoleRequest?: RoleRequestBasicListData; + permissions: CoursePermissions; + }> { return this.client.get(this.#baseUrlPrefix); } /** * Fetches one course */ - fetch(courseId: number): Promise< - AxiosResponse<{ - course: CourseData; - }> - > { + fetch(courseId: number): APIResponse<{ + course: CourseData; + }> { return this.client.get(`${this.#baseUrlPrefix}/${courseId}`); } + fetchLayout(courseId: number): APIResponse { + return this.client.get(`${this.#baseUrlPrefix}/${courseId}/sidebar`); + } + /** * Creates a course. * @@ -47,19 +49,17 @@ export default class CoursesAPI extends BaseCourseAPI { * error response: { errors: [] } - An array of errors will be returned upon validation error. */ - create(params: FormData): Promise< - AxiosResponse<{ - id: number; - title: string; - }> - > { + create(params: FormData): APIResponse<{ + id: number; + title: string; + }> { return this.client.post(this.#baseUrlPrefix, params); } /** * Removes a todo */ - removeTodo(ignoreLink: string): Promise { + removeTodo(ignoreLink: string): APIResponse { return this.client.post(ignoreLink); } @@ -69,7 +69,7 @@ export default class CoursesAPI extends BaseCourseAPI { sendNewRegistrationCode( registrationLink: string, myData: FormData, - ): Promise> { + ): APIResponse { return this.client.postForm(registrationLink, myData); } @@ -77,14 +77,14 @@ export default class CoursesAPI extends BaseCourseAPI { * Submits an enrol request */ - submitEnrolRequest(link: string): Promise> { + submitEnrolRequest(link: string): APIResponse<{ id: number }> { return this.client.postForm(link); } /** * Cancels a pending enrol request */ - cancelEnrolRequest(link: string): Promise> { + cancelEnrolRequest(link: string): APIResponse { return this.client.delete(link); } } diff --git a/client/app/types/course/courses.ts b/client/app/types/course/courses.ts index 162f96beb16..7a979ccfc2d 100644 --- a/client/app/types/course/courses.ts +++ b/client/app/types/course/courses.ts @@ -1,8 +1,11 @@ import { Permissions } from 'types'; +import { CourseComponentIconName } from 'lib/constants/icons'; + +import { AchievementBadgeData } from './assessment/assessments'; import { TodoData } from './lesson-plan/todos'; import { AnnouncementData, AnnouncementEntity } from './announcements'; -import { CourseUserListData } from './courseUsers'; +import { CourseUserListData, CourseUserRoles } from './courseUsers'; import { NotificationData } from './notifications'; export type CoursePermissions = Permissions<'canCreate' | 'isCurrentUser'>; @@ -71,3 +74,35 @@ export interface NewCourseFormData { title: string; description: string; } + +export interface SidebarItemData { + key: string; + label: string; + path: string; + icon: CourseComponentIconName; + unread?: number; +} + +export interface CourseUserProgressData { + level?: number; + exp?: number; + nextLevelPercentage?: number; + nextLevelExpDelta?: number | 'max'; + recentAchievements?: AchievementBadgeData[]; + remainingAchievementsCount?: number; +} + +export interface CourseLayoutData { + courseTitle: string; + courseUrl: string; + courseLogoUrl: string; + courseUserUrl: string; + userName: string; + courseUserName?: string; + courseUserRole?: CourseUserRoles; + userAvatarUrl?: string; + sidebar?: SidebarItemData[]; + adminSidebar?: SidebarItemData[]; + manageEmailSubscriptionUrl?: string; + progress?: CourseUserProgressData; +} diff --git a/config/routes.rb b/config/routes.rb index df5a3ae2ce0..163fe94e3d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -127,6 +127,8 @@ scope module: 'course' do resources :courses, except: [:new, :edit, :update] do + get 'sidebar', on: :member + namespace :admin do get '/' => 'admin#index' patch '/' => 'admin#update' From 4529fe485a859cea9459788de298d7a2283f2428 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:36:11 +0800 Subject: [PATCH 20/76] feat: add endpoint to fetch home data --- app/views/pages/home.json.jbuilder | 25 +++++++++++++++++++++++++ client/app/api/Home.ts | 15 +++++++++++++++ client/app/api/index.ts | 2 ++ client/app/types/home.ts | 18 ++++++++++++++++++ 4 files changed, 60 insertions(+) create mode 100644 app/views/pages/home.json.jbuilder create mode 100644 client/app/api/Home.ts create mode 100644 client/app/types/home.ts diff --git a/app/views/pages/home.json.jbuilder b/app/views/pages/home.json.jbuilder new file mode 100644 index 00000000000..48ba8fcf553 --- /dev/null +++ b/app/views/pages/home.json.jbuilder @@ -0,0 +1,25 @@ +# frozen_string_literal: true +if user_signed_in? + my_courses = Course.containing_user(current_user).ordered_by_start_at + if my_courses.present? + json.courses my_courses do |course| + json.title course.title + json.url course_path(course) + end + end + + json.user do + json.name current_user.name + json.url user_path(current_user) + json.avatarUrl user_image(current_user) + json.role current_user.role + json.instanceRole controller.current_instance_user.role + end + + json.signOutUrl destroy_user_session_path + + if user_masquerade? + json.masqueradeUserName current_user.name + json.stopMasqueradingUrl back_masquerade_path(current_user) + end +end diff --git a/client/app/api/Home.ts b/client/app/api/Home.ts new file mode 100644 index 00000000000..68a687491c6 --- /dev/null +++ b/client/app/api/Home.ts @@ -0,0 +1,15 @@ +import { HomeLayoutData } from 'types/home'; + +import BaseAPI from './Base'; +import { APIResponse } from './types'; + +export default class HomeAPI extends BaseAPI { + // eslint-disable-next-line class-methods-use-this + get #urlPrefix(): string { + return '/'; + } + + fetch(): APIResponse { + return this.client.get(this.#urlPrefix); + } +} diff --git a/client/app/api/index.ts b/client/app/api/index.ts index 427f44a93dd..8e67a2de0a0 100644 --- a/client/app/api/index.ts +++ b/client/app/api/index.ts @@ -1,4 +1,5 @@ import AnnouncementsAPI from './Announcements'; +import HomeAPI from './Home'; import JobsAPI from './Jobs'; import UsersAPI from './Users'; @@ -6,6 +7,7 @@ const GlobalAPI = { announcements: new AnnouncementsAPI(), jobs: new JobsAPI(), users: new UsersAPI(), + home: new HomeAPI(), }; Object.freeze(GlobalAPI); diff --git a/client/app/types/home.ts b/client/app/types/home.ts new file mode 100644 index 00000000000..6b807b45974 --- /dev/null +++ b/client/app/types/home.ts @@ -0,0 +1,18 @@ +import { InstanceUserRoles } from './system/instance/users'; +import { UserRoles } from './users'; + +interface HomeLayoutUserData { + name: string; + url: string; + avatarUrl: string; + role: UserRoles; + instanceRole: InstanceUserRoles; +} + +export interface HomeLayoutData { + courses?: { title: string; url: string }[]; + user?: HomeLayoutUserData; + signOutUrl?: string; + masqueradeUserName?: string; + stopMasqueradingUrl?: string; +} From a026a8b3209a6c9f126c1a6e1d9512e885d8acaf Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 15:38:34 +0800 Subject: [PATCH 21/76] style(Section): sticks to top 0px of page --- client/app/lib/components/core/layouts/Section.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/app/lib/components/core/layouts/Section.tsx b/client/app/lib/components/core/layouts/Section.tsx index 08ffe7b5569..859a173526f 100644 --- a/client/app/lib/components/core/layouts/Section.tsx +++ b/client/app/lib/components/core/layouts/Section.tsx @@ -27,8 +27,8 @@ const Section = (props: SectionProps): JSX.Element => ( > Date: Mon, 29 May 2023 15:38:19 +0800 Subject: [PATCH 22/76] style(DataTable): remove elevation --- client/app/lib/components/core/layouts/DataTable.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/app/lib/components/core/layouts/DataTable.jsx b/client/app/lib/components/core/layouts/DataTable.jsx index 73abc476bb0..bca7521c346 100644 --- a/client/app/lib/components/core/layouts/DataTable.jsx +++ b/client/app/lib/components/core/layouts/DataTable.jsx @@ -10,6 +10,7 @@ const options = { filterType: 'dropdown', responsive: 'standard', fixedSelectColumn: false, + elevation: 0, }; const processTheme = (theme, newHeight, grid, alignCenter, newPadding) => @@ -135,7 +136,6 @@ const DataTable = (props) => {
From b8e564ffe4fd1a43547d89ae4615f6336a601ee5 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 14:29:37 +0800 Subject: [PATCH 23/76] feat(router): add Dynamic Nest API --- .../lib/hooks/router/dynamicNest/builder.ts | 60 ++++++++++++++++ .../lib/hooks/router/dynamicNest/crumbs.ts | 55 +++++++++++++++ .../lib/hooks/router/dynamicNest/handles.ts | 33 +++++++++ .../app/lib/hooks/router/dynamicNest/index.ts | 3 + .../router/dynamicNest/useDynamicNest.ts | 69 +++++++++++++++++++ .../app/lib/hooks/router/dynamicNest/utils.ts | 12 ++++ 6 files changed, 232 insertions(+) create mode 100644 client/app/lib/hooks/router/dynamicNest/builder.ts create mode 100644 client/app/lib/hooks/router/dynamicNest/crumbs.ts create mode 100644 client/app/lib/hooks/router/dynamicNest/handles.ts create mode 100644 client/app/lib/hooks/router/dynamicNest/index.ts create mode 100644 client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts create mode 100644 client/app/lib/hooks/router/dynamicNest/utils.ts diff --git a/client/app/lib/hooks/router/dynamicNest/builder.ts b/client/app/lib/hooks/router/dynamicNest/builder.ts new file mode 100644 index 00000000000..0ed5465acf0 --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest/builder.ts @@ -0,0 +1,60 @@ +import { Location } from 'react-router-dom'; + +import { buildCrumbData, CrumbData, CrumbState } from './crumbs'; +import { + getHandleData, + getHandleRequestData, + getShouldRevalidateCrumb, +} from './handles'; +import { isPromise, Match, Promisable } from './utils'; + +interface CrumbsDataBuilderResult { + data: Promisable; + hasPending: boolean; + invalidCrumbs: Set; +} + +export const buildCrumbsData = ( + matches: Match[], + location: Location, + state: CrumbState, +): CrumbsDataBuilderResult => { + const invalidCrumbs = new Set(Object.keys(state)); + const oldCrumbsCount = invalidCrumbs.size; + + let hasPromise = false; + let newCrumbsCount = 0; + + const delta = matches.reduce[]>((crumbs, match) => { + const handleData = getHandleData(match, location); + if (!handleData) return crumbs; + + newCrumbsCount += 1; + + const crumbExists = invalidCrumbs.has(match.pathname); + + if (getShouldRevalidateCrumb(handleData) || crumbExists) + invalidCrumbs.delete(match.pathname); + + if (getShouldRevalidateCrumb(handleData) || !crumbExists) { + const content = getHandleRequestData(handleData); + + if (isPromise(content)) { + hasPromise = true; + crumbs.push(content.then((data) => buildCrumbData(match, data))); + } else { + crumbs.push(buildCrumbData(match, content)); + } + } + + return crumbs; + }, []); + + const validCrumbsCount = oldCrumbsCount - invalidCrumbs.size; + + return { + data: hasPromise ? Promise.all(delta) : (delta as CrumbData[]), + hasPending: newCrumbsCount > validCrumbsCount, + invalidCrumbs, + }; +}; diff --git a/client/app/lib/hooks/router/dynamicNest/crumbs.ts b/client/app/lib/hooks/router/dynamicNest/crumbs.ts new file mode 100644 index 00000000000..a34b5f61aff --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest/crumbs.ts @@ -0,0 +1,55 @@ +import produce from 'immer'; + +import { Descriptor } from 'lib/hooks/useTranslation'; + +import { isRecord, Match } from './utils'; + +export type CrumbTitle = string | Descriptor | null | undefined; + +export interface CrumbContent { + url?: string; + title?: CrumbTitle; +} + +export interface CrumbData { + id: string; + pathname: string; + activePath?: string | null; + content?: CrumbContent | CrumbContent[]; +} + +export interface CrumbPath { + pathname?: string; + activePath?: string | null; + content?: CrumbContent | CrumbContent[]; +} + +export type CrumbState = Record; + +export const isCrumbPath = (value: unknown): value is CrumbPath => + isRecord(value) && 'content' in value; + +export const buildCrumbData = ( + match: Match, + data: CrumbTitle | CrumbPath, +): CrumbData => ({ + id: match.id, + pathname: match.pathname, + ...(isCrumbPath(data) + ? data + : { + content: { + title: data, + url: match.pathname, + }, + }), +}); + +export const combineCrumbs = ( + delta: CrumbData[], +): ((state: CrumbState) => CrumbState) => + produce((draft) => { + delta.forEach((crumb) => { + draft[crumb.pathname] = crumb; + }); + }); diff --git a/client/app/lib/hooks/router/dynamicNest/handles.ts b/client/app/lib/hooks/router/dynamicNest/handles.ts new file mode 100644 index 00000000000..9b258f5d2eb --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest/handles.ts @@ -0,0 +1,33 @@ +import { Location } from 'react-router-dom'; + +import { CrumbPath, CrumbTitle } from './crumbs'; +import { isRecord, Match, Promisable } from './utils'; + +interface HandleRequest { + shouldRevalidate?: boolean; + getData: () => Promisable; +} + +export type HandleData = CrumbTitle | HandleRequest | null; + +export type DataHandle = (match: Match, location: Location) => HandleData; + +export type Handle = CrumbTitle | DataHandle; + +export const isHandleRequest = (value: unknown): value is HandleRequest => + isRecord(value) && 'getData' in value && typeof value.getData === 'function'; + +export const getHandleData = (match: Match, location: Location): HandleData => { + const handle = match.handle as Handle | undefined; + if (!handle) return null; + + return typeof handle === 'function' ? handle(match, location) : handle; +}; + +export const getShouldRevalidateCrumb = (data: HandleData): boolean => + isHandleRequest(data) && (data.shouldRevalidate ?? false); + +export const getHandleRequestData = ( + data: HandleData, +): Promisable => + isHandleRequest(data) ? data.getData() : data; diff --git a/client/app/lib/hooks/router/dynamicNest/index.ts b/client/app/lib/hooks/router/dynamicNest/index.ts new file mode 100644 index 00000000000..1cfad6a63e7 --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest/index.ts @@ -0,0 +1,3 @@ +export type { CrumbContent, CrumbData, CrumbPath, CrumbTitle } from './crumbs'; +export type { DataHandle } from './handles'; +export { default as useDynamicNest } from './useDynamicNest'; diff --git a/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts b/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts new file mode 100644 index 00000000000..1f43dd20360 --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest/useDynamicNest.ts @@ -0,0 +1,69 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useLocation, useMatches } from 'react-router-dom'; +import produce from 'immer'; + +import { buildCrumbsData } from './builder'; +import { combineCrumbs, CrumbData, CrumbState } from './crumbs'; +import { isPromise } from './utils'; + +interface UseDynamicNestHook { + crumbs: CrumbData[]; + loading: boolean; + activePath?: string; +} + +const useDynamicNest = (): UseDynamicNestHook => { + const matches = useMatches(); + const location = useLocation(); + + const [state, setState] = useState({}); + const [loading, setLoading] = useState(false); + + useEffect(() => { + let ignore = false; + + const result = buildCrumbsData(matches, location, state); + + if (isPromise(result.data)) { + setLoading(result.hasPending); + + result.data + .then((crumbs) => !ignore && setState(combineCrumbs(crumbs))) + .finally(() => setLoading(false)); + } else { + setState(combineCrumbs(result.data)); + } + + setState( + produce((draft) => { + result.invalidCrumbs.forEach((pathname) => delete draft[pathname]); + }), + ); + + return () => { + ignore = true; + }; + }, [matches]); + + const crumbs = useMemo( + () => Object.values(state).sort((a, b) => a.id.localeCompare(b.id)), + [state], + ); + + const activePath = useMemo(() => { + for (let index = crumbs.length - 1; index >= 0; index--) { + const crumb = crumbs[index]; + + // Allow the distinction between `undefined` and `null`. If `activePath` + // is `null`, it means that explicitly there shouldn't be any active paths + // for the current nest. + if (crumb.activePath !== undefined) return crumb.activePath ?? ''; + } + + return undefined; + }, [crumbs]); + + return { crumbs, loading, activePath }; +}; + +export default useDynamicNest; diff --git a/client/app/lib/hooks/router/dynamicNest/utils.ts b/client/app/lib/hooks/router/dynamicNest/utils.ts new file mode 100644 index 00000000000..2eddfe7e95b --- /dev/null +++ b/client/app/lib/hooks/router/dynamicNest/utils.ts @@ -0,0 +1,12 @@ +import { useMatches } from 'react-router-dom'; + +export type Promisable = T | Promise; + +export type Match = ReturnType[number]; + +export const isRecord = ( + value: unknown, +): value is Record => typeof value === 'object' && value !== null; + +export const isPromise = (value: unknown): value is Promise => + isRecord(value) && 'then' in value && typeof value.then === 'function'; From 0db886c9c1fc18437f32be6342019f7bb41ced18 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 15:24:55 +0800 Subject: [PATCH 24/76] feat(app): add `AppContainer` --- .../default/views/layouts/default.html.slim | 1 + .../containers/AppContainer/AppContainer.tsx | 36 ++++ .../lib/containers/AppContainer/AppLoader.ts | 20 +++ .../AppContainer/GlobalAnnouncements.tsx | 93 ++++++++++ .../AppContainer/IfRailsSaysSafeToRender.tsx | 162 ++++++++++++++++++ .../AppContainer/MasqueradeBanner.tsx | 55 ++++++ .../app/lib/containers/AppContainer/index.ts | 2 + .../lib/containers/CourselessContainer.tsx | 51 ++++++ 8 files changed, 420 insertions(+) create mode 100644 client/app/lib/containers/AppContainer/AppContainer.tsx create mode 100644 client/app/lib/containers/AppContainer/AppLoader.ts create mode 100644 client/app/lib/containers/AppContainer/GlobalAnnouncements.tsx create mode 100644 client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx create mode 100644 client/app/lib/containers/AppContainer/MasqueradeBanner.tsx create mode 100644 client/app/lib/containers/AppContainer/index.ts create mode 100644 client/app/lib/containers/CourselessContainer.tsx diff --git a/app/themes/default/views/layouts/default.html.slim b/app/themes/default/views/layouts/default.html.slim index 93651dbb6b2..996905d3de3 100644 --- a/app/themes/default/views/layouts/default.html.slim +++ b/app/themes/default/views/layouts/default.html.slim @@ -4,6 +4,7 @@ html title = page_title meta http-equiv="X-UA-Compatible" content="IE=edge" + meta name="status" content=response.status = server_context_meta_tag = viewport_meta_tag = application_resources diff --git a/client/app/lib/containers/AppContainer/AppContainer.tsx b/client/app/lib/containers/AppContainer/AppContainer.tsx new file mode 100644 index 00000000000..896073ccbc6 --- /dev/null +++ b/client/app/lib/containers/AppContainer/AppContainer.tsx @@ -0,0 +1,36 @@ +import { Outlet } from 'react-router-dom'; + +import NotificationPopup from 'lib/containers/NotificationPopup'; + +import { loader, useAppLoader } from './AppLoader'; +import GlobalAnnouncements from './GlobalAnnouncements'; +import IfRailsSaysSafeToRender from './IfRailsSaysSafeToRender'; +import MasqueradeBanner from './MasqueradeBanner'; + +const AppContainer = (): JSX.Element => { + const data = useAppLoader(); + const homeData = data.home; + + return ( +
+ {homeData.stopMasqueradingUrl && homeData.masqueradeUserName && ( + + )} + + {Boolean(data.announcements?.length) && ( + + )} + + + + + + +
+ ); +}; + +export default Object.assign(AppContainer, { loader }); diff --git a/client/app/lib/containers/AppContainer/AppLoader.ts b/client/app/lib/containers/AppContainer/AppLoader.ts new file mode 100644 index 00000000000..db439035ac2 --- /dev/null +++ b/client/app/lib/containers/AppContainer/AppLoader.ts @@ -0,0 +1,20 @@ +import { useLoaderData, useOutletContext } from 'react-router-dom'; +import { AnnouncementMiniEntity } from 'types/course/announcements'; +import { HomeLayoutData } from 'types/home'; + +import GlobalAPI from 'api'; + +interface AppLoaderData { + home: HomeLayoutData; + announcements: AnnouncementMiniEntity[]; +} + +export const loader = async (): Promise => ({ + home: (await GlobalAPI.home.fetch()).data, + announcements: (await GlobalAPI.announcements.index(true)).data.announcements, +}); + +export const useAppLoader = (): AppLoaderData => + useLoaderData() as AppLoaderData; + +export const useAppContext = (): HomeLayoutData => useOutletContext(); diff --git a/client/app/lib/containers/AppContainer/GlobalAnnouncements.tsx b/client/app/lib/containers/AppContainer/GlobalAnnouncements.tsx new file mode 100644 index 00000000000..aa378f01568 --- /dev/null +++ b/client/app/lib/containers/AppContainer/GlobalAnnouncements.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { CampaignOutlined, Close } from '@mui/icons-material'; +import { IconButton, Typography } from '@mui/material'; +import produce from 'immer'; +import { AnnouncementMiniEntity } from 'types/course/announcements'; + +import GlobalAPI from 'api'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatFullDateTime } from 'lib/moment'; + +const translations = defineMessages({ + nUnreadAnnouncements: { + id: 'course.announcements.GlobalAnnouncements.nUnreadAnnouncements', + defaultMessage: + '{n} more unread {n, plural, one {announcement} other {announcements}}', + }, +}); + +interface GlobalAnnouncementsProps { + in: AnnouncementMiniEntity[]; +} + +const GlobalAnnouncements = ( + props: GlobalAnnouncementsProps, +): JSX.Element | null => { + const [announcements, setAnnouncements] = useState(props.in); + + const { t } = useTranslation(); + + const latestAnnouncement = announcements[0]; + if (!latestAnnouncement) return null; + + const markAsRead = async (index: number): Promise => { + const announcement = announcements[index]; + + try { + await GlobalAPI.announcements.markAsRead(announcement.markAsReadUrl); + } catch { + /* empty */ + } finally { + setAnnouncements( + produce((draft) => { + draft.splice(index, 1); + }), + ); + } + }; + + return ( + <> +
+ + +
+ + {latestAnnouncement.title} + + + + {formatFullDateTime(latestAnnouncement.startTime)} + + + +
+ + => markAsRead(0)} size="small"> + + +
+ + {announcements.length > 1 && ( +
+ + + + {t(translations.nUnreadAnnouncements, { + n: announcements.length - 1, + })} + +
+ )} + + ); +}; + +export default GlobalAnnouncements; diff --git a/client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx b/client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx new file mode 100644 index 00000000000..70c2a1e0d3e --- /dev/null +++ b/client/app/lib/containers/AppContainer/IfRailsSaysSafeToRender.tsx @@ -0,0 +1,162 @@ +import { ComponentType, ReactNode, useEffect } from 'react'; +import { defineMessages } from 'react-intl'; +import { + ArrowBack, + ConfirmationNumberOutlined, + DoNotDisturbOnOutlined, + SvgIconComponent, +} from '@mui/icons-material'; +import { Typography } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + accessDenied: { + id: 'app.containers.AppContainer.AppErrorPage.accessDenied', + defaultMessage: 'Oops, access denied!', + }, + accessDeniedDescription: { + id: 'app.containers.AppContainer.AppErrorPage.accessDeniedDescription', + defaultMessage: 'You are not authorised to access this page.', + }, + goBack: { + id: 'app.containers.AppContainer.AppErrorPage.goBack', + defaultMessage: 'Go back', + }, + sessionExpired: { + id: 'app.containers.AppContainer.AppErrorPage.sessionExpired', + defaultMessage: "You'll need to sign in again.", + }, + sessionExpiredDescription: { + id: 'app.containers.AppContainer.AppErrorPage.sessionExpiredDescription', + defaultMessage: + "Your previous session is expired. We're redirecting you to the sign in page.", + }, + goToSignInPage: { + id: 'app.containers.AppContainer.AppErrorPage.goToSignInPage', + defaultMessage: 'Go to the sign in page now', + }, +}); + +interface MessageBodyProps { + Icon: SvgIconComponent; + iconClassName?: string; + title: string; + description: string; + children?: ReactNode; +} + +const MessageBody = ({ Icon, ...props }: MessageBodyProps): JSX.Element => ( +
+ + +
+ {props.title} + + {props.description} +
+ + {props.children} +
+); + +const ForbiddenMessageBody = (): JSX.Element => { + const { t } = useTranslation(); + + return ( + + { + window.history.back(); + }} + underline="hover" + > + + {t(translations.goBack)} + + + ); +}; + +const SessionExpiredMessageBody = (): JSX.Element => { + const { t } = useTranslation(); + + useEffect(() => { + const redirectTimeout = setTimeout(() => { + window.location.href = '/users/sign_in'; + }, 2000); + + return () => clearTimeout(redirectTimeout); + }, []); + + return ( + + + {t(translations.goToSignInPage)} + + + ); +}; + +const messageBodies: Record = { + 403: ForbiddenMessageBody, + 401: SessionExpiredMessageBody, +}; + +/** + * Returns the HTTP status code from the Rails' router, if found. + * + * This works in unison with `default.slim.html` defining a `meta` tag with the response + * HTTP status code as integer. + */ +const getRailsResponseStatusCode = (): number | undefined => { + const statusMeta = document.querySelector('meta[name="status"]'); + const content = statusMeta?.getAttribute('content'); + if (!content) return undefined; + + return parseInt(content, 10); +}; + +/** + * Renders the `children` if Rails' router says that the page is safe to render. + * + * Since Rails' router still renders parts of the page when we get a 403, the mounted React + * app will still load the React page and make a HTTP request (if needed), and would probably + * fail. This component is a TEMPORARY workaround to prevent the (rest of the) React app from + * rendering if Rails' deem our request NOT to be safe. + * + * "Safe" here means that Rails' HTML render response has an HTTP status code listed in + * `keyof messageBodies`. + */ +const IfRailsSaysSafeToRender = (props: { + children: JSX.Element; +}): JSX.Element => { + const status = getRailsResponseStatusCode(); + + const StatusMessageBody = status && messageBodies[status]; + if (!StatusMessageBody) return props.children; + + return ( +
+ +
+ ); +}; + +export default IfRailsSaysSafeToRender; diff --git a/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx b/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx new file mode 100644 index 00000000000..9dd01bc390b --- /dev/null +++ b/client/app/lib/containers/AppContainer/MasqueradeBanner.tsx @@ -0,0 +1,55 @@ +import { defineMessages } from 'react-intl'; +import { TheaterComedy } from '@mui/icons-material'; +import { Typography } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + masquerading: { + id: 'lib.components.core.banners.MasqueradeBanner.masquerading', + defaultMessage: 'Masquerading as {as}.', + }, + stopMasquerading: { + id: 'lib.components.core.banners.MasqueradeBanner.stopMasquerading', + defaultMessage: 'Stop masquerading', + }, +}); + +interface MasqueradeBannerProps { + as: string; + stopMasqueradingUrl: string; +} + +const MasqueradeBanner = (props: MasqueradeBannerProps): JSX.Element => { + const { as: userName, stopMasqueradingUrl } = props; + + const { t } = useTranslation(); + + return ( +
+
+ + + + {t(translations.masquerading, { + as: userName, + strong: (chunk) => ( + {chunk} + ), + })} + +
+ + + {t(translations.stopMasquerading)} + +
+ ); +}; + +export default MasqueradeBanner; diff --git a/client/app/lib/containers/AppContainer/index.ts b/client/app/lib/containers/AppContainer/index.ts new file mode 100644 index 00000000000..344db757f84 --- /dev/null +++ b/client/app/lib/containers/AppContainer/index.ts @@ -0,0 +1,2 @@ +export { default } from './AppContainer'; +export { useAppContext } from './AppLoader'; diff --git a/client/app/lib/containers/CourselessContainer.tsx b/client/app/lib/containers/CourselessContainer.tsx new file mode 100644 index 00000000000..79a42d3ab4d --- /dev/null +++ b/client/app/lib/containers/CourselessContainer.tsx @@ -0,0 +1,51 @@ +import { Outlet } from 'react-router-dom'; + +import Footer from 'lib/components/core/layouts/Footer'; +import { + CrumbData, + CrumbTitle, + useDynamicNest, +} from 'lib/hooks/router/dynamicNest'; +import useTranslation, { translatable } from 'lib/hooks/useTranslation'; + +import BrandingHead from '../components/navigation/BrandingHead'; + +const getLastCrumbTitle = (crumbs: CrumbData[]): CrumbTitle | null => { + const content = crumbs.at(-1)?.content; + if (!content) return null; + + const actualContent = Array.isArray(content) ? content.at(-1) : content; + if (!actualContent) return null; + + return actualContent.title; +}; + +/** + * Container for non-course pages. Pending name and design. + */ +const CourselessContainer = (): JSX.Element => { + const { t } = useTranslation(); + + const { crumbs } = useDynamicNest(); + + const crumbTitle = getLastCrumbTitle(crumbs); + const title = translatable(crumbTitle) ? t(crumbTitle) : crumbTitle; + + return ( +
+
+ +
+ +
+
+ +
+ +
+
+
+ ); +}; + +export default CourselessContainer; From 71461122d5198c962fcb0cecfe491171d23595ca Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 15:22:20 +0800 Subject: [PATCH 25/76] feat(courses): add `CourseContainer` --- client/app/api/Users.ts | 4 + .../container/Breadcrumbs/Breadcrumbs.tsx | 84 ++++++++ .../course/container/Breadcrumbs/Crumb.tsx | 34 +++ .../course/container/Breadcrumbs/index.ts | 1 + .../course/container/Breadcrumbs/sliders.tsx | 88 ++++++++ .../course/container/CourseContainer.tsx | 71 +++++++ .../course/container/Sidebar/CourseItem.tsx | 142 +++++++++++++ .../container/Sidebar/CourseUserItem.tsx | 196 ++++++++++++++++++ .../container/Sidebar/CourseUserProgress.tsx | 108 ++++++++++ .../course/container/Sidebar/LevelRing.tsx | 54 +++++ .../container/Sidebar/PinSidebarButton.tsx | 46 ++++ .../course/container/Sidebar/Sidebar.tsx | 71 +++++++ .../container/Sidebar/SidebarAccordion.tsx | 50 +++++ .../container/Sidebar/SidebarContainer.tsx | 196 ++++++++++++++++++ .../course/container/Sidebar/SidebarItem.tsx | 91 ++++++++ .../bundles/course/container/Sidebar/index.ts | 1 + client/app/bundles/course/container/index.ts | 1 + .../lib/components/core/LoadingEllipsis.tsx | 13 ++ client/app/lib/components/core/PopupMenu.tsx | 136 ++++++++++++ .../lib/components/core/layouts/Footer.tsx | 67 ++++++ .../lib/components/core/layouts/layout.scss | 34 --- .../components/navigation/BrandingHead.tsx | 161 ++++++++++++++ .../navigation/UserPopupMenuList.tsx | 49 +++++ client/app/lib/hooks/useMedia.ts | 17 ++ client/app/lib/hooks/useTranslation.ts | 6 + client/app/theme/index.css | 56 ++++- 26 files changed, 1742 insertions(+), 35 deletions(-) create mode 100644 client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx create mode 100644 client/app/bundles/course/container/Breadcrumbs/Crumb.tsx create mode 100644 client/app/bundles/course/container/Breadcrumbs/index.ts create mode 100644 client/app/bundles/course/container/Breadcrumbs/sliders.tsx create mode 100644 client/app/bundles/course/container/CourseContainer.tsx create mode 100644 client/app/bundles/course/container/Sidebar/CourseItem.tsx create mode 100644 client/app/bundles/course/container/Sidebar/CourseUserItem.tsx create mode 100644 client/app/bundles/course/container/Sidebar/CourseUserProgress.tsx create mode 100644 client/app/bundles/course/container/Sidebar/LevelRing.tsx create mode 100644 client/app/bundles/course/container/Sidebar/PinSidebarButton.tsx create mode 100644 client/app/bundles/course/container/Sidebar/Sidebar.tsx create mode 100644 client/app/bundles/course/container/Sidebar/SidebarAccordion.tsx create mode 100644 client/app/bundles/course/container/Sidebar/SidebarContainer.tsx create mode 100644 client/app/bundles/course/container/Sidebar/SidebarItem.tsx create mode 100644 client/app/bundles/course/container/Sidebar/index.ts create mode 100644 client/app/bundles/course/container/index.ts create mode 100644 client/app/lib/components/core/LoadingEllipsis.tsx create mode 100644 client/app/lib/components/core/PopupMenu.tsx create mode 100644 client/app/lib/components/core/layouts/Footer.tsx create mode 100644 client/app/lib/components/navigation/BrandingHead.tsx create mode 100644 client/app/lib/components/navigation/UserPopupMenuList.tsx create mode 100644 client/app/lib/hooks/useMedia.ts diff --git a/client/app/api/Users.ts b/client/app/api/Users.ts index 05c5fbc9814..c12ee2c36a8 100644 --- a/client/app/api/Users.ts +++ b/client/app/api/Users.ts @@ -77,4 +77,8 @@ export default class UsersAPI extends BaseAPI { ): APIResponse { return this.client.post(url); } + + signOut(url: string): APIResponse { + return this.client.delete(url); + } } diff --git a/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx new file mode 100644 index 00000000000..866c17ccbd5 --- /dev/null +++ b/client/app/bundles/course/container/Breadcrumbs/Breadcrumbs.tsx @@ -0,0 +1,84 @@ +import { useCallback } from 'react'; +import { Breadcrumbs as MuiBreadcrumbs } from '@mui/material'; + +import LoadingEllipsis from 'lib/components/core/LoadingEllipsis'; +import { CrumbData } from 'lib/hooks/router/dynamicNest'; +import { CrumbContent } from 'lib/hooks/router/dynamicNest/crumbs'; +import useTranslation, { translatable } from 'lib/hooks/useTranslation'; + +import Crumb from './Crumb'; +import { Slider, useSliders } from './sliders'; + +interface BreadcrumbProps { + in: CrumbData[]; + className?: string; + loading?: boolean; +} + +const Breadcrumbs = (props: BreadcrumbProps): JSX.Element => { + const { in: crumbs } = props; + + const { t } = useTranslation(); + + const sliders = useSliders(); + + const getCrumbElement = useCallback( + (content: CrumbContent, disabled: boolean, key: string): JSX.Element => ( + + {translatable(content.title) ? t(content.title) : content.title} + + ), + [], + ); + + const validCrumbs = crumbs.reduce((elements, crumb, index) => { + const content = crumb.content; + if (!content) return elements; + + const isLastCrumb = index >= crumbs.length - 1; + + if (Array.isArray(content)) { + content.forEach((item, itemIndex) => { + const isLastItem = isLastCrumb && itemIndex >= content.length - 1; + const key = `${crumb.pathname}-${itemIndex}`; + + elements.push(getCrumbElement(item, isLastItem, key)); + }); + } else { + elements.push(getCrumbElement(content, isLastCrumb, crumb.pathname)); + } + + return elements; + }, []); + + return ( +
+ + + + +
+ ); +}; + +export default Breadcrumbs; diff --git a/client/app/bundles/course/container/Breadcrumbs/Crumb.tsx b/client/app/bundles/course/container/Breadcrumbs/Crumb.tsx new file mode 100644 index 00000000000..37e65a8b90c --- /dev/null +++ b/client/app/bundles/course/container/Breadcrumbs/Crumb.tsx @@ -0,0 +1,34 @@ +import { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Grow, Typography } from '@mui/material'; + +interface CrumbProps { + children: ReactNode; + to?: string | false; +} + +const Crumb = (props: CrumbProps): JSX.Element => { + const { to: url, children: title } = props; + + const crumbText = ( + + {title} + + ); + + return ( + + {url ? {crumbText} : crumbText} + + ); +}; + +const CrumbSeparator = (): JSX.Element => ( + + + / + + +); + +export default Object.assign(Crumb, { Separator: CrumbSeparator }); diff --git a/client/app/bundles/course/container/Breadcrumbs/index.ts b/client/app/bundles/course/container/Breadcrumbs/index.ts new file mode 100644 index 00000000000..3ff68ca5894 --- /dev/null +++ b/client/app/bundles/course/container/Breadcrumbs/index.ts @@ -0,0 +1 @@ +export { default } from './Breadcrumbs'; diff --git a/client/app/bundles/course/container/Breadcrumbs/sliders.tsx b/client/app/bundles/course/container/Breadcrumbs/sliders.tsx new file mode 100644 index 00000000000..f1d72f8a809 --- /dev/null +++ b/client/app/bundles/course/container/Breadcrumbs/sliders.tsx @@ -0,0 +1,88 @@ +import { + RefObject, + UIEventHandler, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { IconButton, Slide } from '@mui/material'; + +interface UseSlidersHook { + ref: RefObject; + showStart: boolean; + showEnd: boolean; + handleScroll: UIEventHandler; + onClickStart: () => void; + onClickEnd: () => void; +} + +export const useSliders = (): UseSlidersHook => { + const ref = useRef(null); + const [showSliders, setShowSliders] = useState([false, false]); + + const resetShowSliders = (element: Element): void => { + const start = element.scrollLeft; + const end = element.clientWidth + start; + + const isStartOfScroll = start === 0; + const isEndOfScroll = end >= element.scrollWidth; + + setShowSliders([!isStartOfScroll, !isEndOfScroll]); + }; + + useLayoutEffect(() => { + if (!ref.current) return undefined; + + const observer = new ResizeObserver((entries) => { + const target = entries[0]?.target; + if (!target) return; + + ref.current?.scrollTo({ left: target.scrollWidth }); + resetShowSliders(target); + }); + + observer.observe(ref.current); + + return () => observer.disconnect(); + }, []); + + return { + ref, + showStart: showSliders[0], + showEnd: showSliders[1], + handleScroll: (e) => resetShowSliders(e.currentTarget), + onClickStart: () => + ref.current?.scrollBy({ + left: -((ref.current?.scrollWidth || 100) / 2), + behavior: 'smooth', + }), + onClickEnd: (): void => + ref.current?.scrollBy({ + left: (ref.current?.scrollWidth || 100) / 2, + behavior: 'smooth', + }), + }; +}; + +interface SliderProps { + in?: boolean; + start?: boolean; + onClick?: () => void; +} + +export const Slider = (props: SliderProps): JSX.Element => ( + +
+ + {props.start ? : } + +
+
+); diff --git a/client/app/bundles/course/container/CourseContainer.tsx b/client/app/bundles/course/container/CourseContainer.tsx new file mode 100644 index 00000000000..65ebad7827c --- /dev/null +++ b/client/app/bundles/course/container/CourseContainer.tsx @@ -0,0 +1,71 @@ +import { ComponentRef, useRef, useState } from 'react'; +import { LoaderFunction, Outlet, useLoaderData } from 'react-router-dom'; +import { MenuOutlined } from '@mui/icons-material'; +import { IconButton } from '@mui/material'; +import { CourseLayoutData } from 'types/course/courses'; + +import CourseAPI from 'api/course'; +import PopupNotifier from 'course/user-notification/PopupNotifier'; +import Footer from 'lib/components/core/layouts/Footer'; +import { DataHandle, useDynamicNest } from 'lib/hooks/router/dynamicNest'; + +import Breadcrumbs from './Breadcrumbs'; +import Sidebar from './Sidebar'; + +const CourseContainer = (): JSX.Element => { + const data = useLoaderData() as CourseLayoutData; + + const sidebarRef = useRef>(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const { crumbs, loading, activePath } = useDynamicNest(); + + return ( +
+ + +
+
+ {!sidebarOpen && ( + sidebarRef.current?.show()}> + + + )} + + +
+ +
+ +
+ +
+
+ + +
+ ); +}; + +const loader: LoaderFunction = async ({ params }) => { + const id = parseInt(params?.courseId ?? '', 10) || undefined; + if (!id) throw new Error(`CourseContainer was loaded with ID: ${id}.`); + + const response = await CourseAPI.courses.fetchLayout(id); + return response.data; +}; + +const handle: DataHandle = (match) => { + return (match.data as CourseLayoutData).courseTitle; +}; + +export default Object.assign(CourseContainer, { loader, handle }); diff --git a/client/app/bundles/course/container/Sidebar/CourseItem.tsx b/client/app/bundles/course/container/Sidebar/CourseItem.tsx new file mode 100644 index 00000000000..a829010341a --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/CourseItem.tsx @@ -0,0 +1,142 @@ +import { useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Avatar, Typography } from '@mui/material'; +import { CourseLayoutData } from 'types/course/courses'; + +import SearchField from 'lib/components/core/fields/SearchField'; +import PopupMenu from 'lib/components/core/PopupMenu'; +import { useAppContext } from 'lib/containers/AppContainer'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + thisCourse: { + id: 'course.courses.CourseItem.thisCourse', + defaultMessage: 'This course', + }, + jumpToOtherCourses: { + id: 'course.courses.CourseItem.jumpToOtherCourses', + defaultMessage: 'Jump to your other courses', + }, + searchCourses: { + id: 'course.courses.CourseItem.searchCourses', + defaultMessage: 'Search courses', + }, + noCoursesMatch: { + id: 'course.courses.CourseItem.noCoursesMatch', + defaultMessage: "Oops, no courses matched '{keyword}'.", + }, + seeAllCourses: { + id: 'course.courses.CourseItem.seeAllCourses', + defaultMessage: 'See all courses', + }, + createNewCourse: { + id: 'course.courses.CourseItem.createNewCourse', + defaultMessage: 'Create a new course', + }, +}); + +interface CourseItemProps { + in: CourseLayoutData; +} + +const CourseItem = (props: CourseItemProps): JSX.Element => { + const { in: data } = props; + + const { t } = useTranslation(); + + const { courses } = useAppContext(); + + const [anchorElement, setAnchorElement] = useState(); + const [filterCourseKeyword, setFilterCourseKeyword] = useState(''); + + const filteredCourses = useMemo(() => { + if (!filterCourseKeyword) return courses; + + return courses?.filter((course) => + course.title.toLowerCase().includes(filterCourseKeyword.toLowerCase()), + ); + }, [filterCourseKeyword]); + + return ( + <> +
setAnchorElement(e.currentTarget)} + role="button" + tabIndex={0} + > + + + + {data.courseTitle} + +
+ + setAnchorElement(undefined)} + > + + {data.courseTitle} + + + + + {Boolean(courses?.length) && ( + <> + + + + + + + + {filteredCourses?.map((course) => ( + + {course.title} + + ))} + + {!filteredCourses?.length && ( + + {t(translations.noCoursesMatch, { + keyword: filterCourseKeyword, + })} + + )} + + + + + )} + + + + {t(translations.seeAllCourses)} + + + + {t(translations.createNewCourse)} + + + + + ); +}; + +export default CourseItem; diff --git a/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx b/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx new file mode 100644 index 00000000000..b3510956b9e --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/CourseUserItem.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Avatar, Typography } from '@mui/material'; +import { CourseLayoutData } from 'types/course/courses'; + +import PopupMenu from 'lib/components/core/PopupMenu'; +import UserPopupMenuList from 'lib/components/navigation/UserPopupMenuList'; +import { COURSE_USER_ROLES } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +import CourseUserProgress from './CourseUserProgress'; +import LevelRing from './LevelRing'; + +const translations = defineMessages({ + differentCourseNameHint: { + id: 'course.courses.CourseUserItem.differentCourseNameHint', + defaultMessage: + "You're seeing a name different from your account name because this course's manager invited " + + 'you with this name.', + }, + goToYourProfile: { + id: 'course.courses.CourseUserItem.goToYourProfile', + defaultMessage: 'Go to your profile', + }, + manageEmailSubscriptions: { + id: 'course.courses.CourseUserItem.manageEmailSubscriptions', + defaultMessage: 'Manage email subscriptions', + }, + inThisCourse: { + id: 'course.courses.CourseUserItem.inThisCourse', + defaultMessage: 'In this course', + }, + inCoursemology: { + id: 'course.courses.CourseUserItem.inCoursemology', + defaultMessage: 'In Coursemology', + }, + notInThisCourse: { + id: 'course.courses.CourseUserItem.notInThisCourse', + defaultMessage: 'Not in this course', + }, + notInThisCourseHint: { + id: 'course.courses.CourseUserItem.notInThisCourseHint', + defaultMessage: + 'You are not a user in this course. So, you can only view publicly available information about this course. ' + + "You may contact this course's instructor to be invited to this course.", + }, +}); + +interface CourseUserItemProps { + from: CourseLayoutData; +} + +interface CourseUserNameAndRoleProps { + from: CourseLayoutData; +} + +const CourseUserNameAndRole = ( + props: CourseUserNameAndRoleProps, +): JSX.Element => { + const { t } = useTranslation(); + + return ( + <> + + {props.from.courseUserName ?? props.from.userName} + + + + {COURSE_USER_ROLES[props.from.courseUserRole!] ?? + t(translations.notInThisCourse)} + + + ); +}; + +const SimpleCourseUserItemContent = ( + props: CourseUserItemProps, +): JSX.Element => ( +
+ + +
+ +
+
+); + +const MaxLevelCrown = (): JSX.Element => ( + + 👑 + +); + +const CourseUserItemContent = (props: CourseUserItemProps): JSX.Element => { + const { from: data } = props; + + if (!data.progress) return ; + + return ( + <> +
+ + + + +
+ {data.progress.nextLevelExpDelta === 'max' && } + +
+
+ + + + ); +}; + +const CourseUserItem = (props: CourseUserItemProps): JSX.Element => { + const { from: data } = props; + + const { t } = useTranslation(); + + const [anchorElement, setAnchorElement] = useState(); + + return ( + <> +
setAnchorElement(e.currentTarget)} + role="button" + tabIndex={0} + > + +
+ + setAnchorElement(undefined)} + > + {data.courseUserName ? ( + <> + {data.userName !== data.courseUserName && ( + <> + + {t(translations.differentCourseNameHint)} + + + + + )} + + + + {t(translations.goToYourProfile)} + + + {data.manageEmailSubscriptionUrl && ( + + {t(translations.manageEmailSubscriptions)} + + )} + + + ) : ( + + {t(translations.notInThisCourseHint)} + + )} + + + + + + + ); +}; + +export default CourseUserItem; diff --git a/client/app/bundles/course/container/Sidebar/CourseUserProgress.tsx b/client/app/bundles/course/container/Sidebar/CourseUserProgress.tsx new file mode 100644 index 00000000000..b2a2328a01f --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/CourseUserProgress.tsx @@ -0,0 +1,108 @@ +import { defineMessages } from 'react-intl'; +import { Typography } from '@mui/material'; +import { CourseUserProgressData } from 'types/course/courses'; + +import StackedBadges from 'lib/components/extensions/StackedBadges'; +import { getCourseId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; + +const standardFormatter = new Intl.NumberFormat('en'); + +const compactFormatter = new Intl.NumberFormat('en', { + notation: 'compact', + maximumFractionDigits: 2, +}); + +const translations = defineMessages({ + max: { + id: 'course.courses.CourseUserProgress.max', + defaultMessage: 'Max', + }, + expCounter: { + id: 'course.courses.CourseUserProgress.expCounter', + defaultMessage: '{exp} EXP', + }, + expTotal: { + id: 'course.courses.CourseUserProgress.expTotal', + defaultMessage: '{exp} EXP total', + }, + expToNextLevel: { + id: 'course.courses.CourseUserProgress.expToNextLevel', + defaultMessage: '{exp} EXP to next level', + }, + seeAllAchievements: { + id: 'course.courses.CourseUserProgress.seeAllAchievements', + defaultMessage: 'See all achievements', + }, +}); + +interface CourseUserProgressProps { + from: CourseUserProgressData; +} + +const formatEXP = (exp: number): string => + (exp < 1_000_000_000_000 ? standardFormatter : compactFormatter).format(exp); + +const CourseUserProgress = (props: CourseUserProgressProps): JSX.Element => { + const { from: data } = props; + + const { t } = useTranslation(); + + return ( +
+
+ {data.nextLevelExpDelta === 'max' ? ( +
+ + {t(translations.max)} + + + + {t(translations.expCounter, { + exp: formatEXP(data.exp ?? 0), + small: (chunk) => {chunk}, + })} + +
+ ) : ( +
+ {data.nextLevelExpDelta && ( + + {t(translations.expToNextLevel, { + exp: standardFormatter.format(data?.nextLevelExpDelta ?? 0), + small: (chunk) => {chunk}, + })} + + )} + + + {t(translations.expTotal, { + exp: formatEXP(data.exp ?? 0), + small: (chunk) => {chunk}, + })} + +
+ )} + + {Boolean(data.recentAchievements?.length) && ( +
e.stopPropagation()} + > + +
+ )} +
+
+ ); +}; + +export default CourseUserProgress; diff --git a/client/app/bundles/course/container/Sidebar/LevelRing.tsx b/client/app/bundles/course/container/Sidebar/LevelRing.tsx new file mode 100644 index 00000000000..fbf2692a665 --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/LevelRing.tsx @@ -0,0 +1,54 @@ +import { ReactNode, useEffect, useState } from 'react'; +import { CircularProgress, Typography } from '@mui/material'; +import { CourseUserProgressData } from 'types/course/courses'; + +interface LevelRingProps { + in: CourseUserProgressData; + children?: ReactNode; +} + +const LevelRing = (props: LevelRingProps): JSX.Element => { + const { in: progress } = props; + + const [percentage, setPercentage] = useState(); + + /** + * Programmatically set the percentage on load to trigger animation. + */ + useEffect(() => { + setPercentage(progress.nextLevelPercentage); + }, [progress.nextLevelPercentage]); + + return ( +
+
+ + + +
+ + {props.children} + +
+ + {progress.level} + +
+
+ ); +}; + +export default LevelRing; diff --git a/client/app/bundles/course/container/Sidebar/PinSidebarButton.tsx b/client/app/bundles/course/container/Sidebar/PinSidebarButton.tsx new file mode 100644 index 00000000000..378e55696ec --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/PinSidebarButton.tsx @@ -0,0 +1,46 @@ +import { defineMessages } from 'react-intl'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import { Typography } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + minimiseSidebar: { + id: 'components.SidebarContainer.minimiseSidebar', + defaultMessage: 'Minimise sidebar', + }, + pinSidebar: { + id: 'components.SidebarContainer.pinSidebar', + defaultMessage: 'Pin sidebar', + }, +}); + +interface PinSidebarButtonProps { + open?: boolean; + onClick?: () => void; +} + +const PinSidebarButton = (props: PinSidebarButtonProps): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ {props.open ? : } + + + {props.open + ? t(translations.minimiseSidebar) + : t(translations.pinSidebar)} + +
+ ); +}; + +export default PinSidebarButton; diff --git a/client/app/bundles/course/container/Sidebar/Sidebar.tsx b/client/app/bundles/course/container/Sidebar/Sidebar.tsx new file mode 100644 index 00000000000..1c21f5f5c79 --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/Sidebar.tsx @@ -0,0 +1,71 @@ +import { ComponentRef, forwardRef } from 'react'; +import { defineMessages } from 'react-intl'; +import { CourseLayoutData } from 'types/course/courses'; + +import BrandingHead from 'lib/components/navigation/BrandingHead'; +import useTranslation from 'lib/hooks/useTranslation'; + +import CourseItem from './CourseItem'; +import CourseUserItem from './CourseUserItem'; +import SidebarAccordion from './SidebarAccordion'; +import SidebarContainer from './SidebarContainer'; +import SidebarItem from './SidebarItem'; + +const translations = defineMessages({ + administration: { + id: 'course.courses.Sidebar.administration', + defaultMessage: 'Administration', + }, +}); + +interface SidebarProps { + from: CourseLayoutData; + onChangeVisibility?: (visible: boolean) => void; + activePath?: string; +} + +const Sidebar = forwardRef, SidebarProps>( + (props, ref): JSX.Element => { + const { from: data, onChangeVisibility, activePath } = props; + + const { t } = useTranslation(); + + return ( + +
+ + + +
+ +
+ {data.sidebar && ( +
+ + + {data.sidebar.map((item) => ( + + ))} +
+ )} + + {data.adminSidebar && ( + + )} +
+
+ ); + }, +); + +Sidebar.displayName = 'Sidebar'; + +export default Sidebar; diff --git a/client/app/bundles/course/container/Sidebar/SidebarAccordion.tsx b/client/app/bundles/course/container/Sidebar/SidebarAccordion.tsx new file mode 100644 index 00000000000..054a8c90d2b --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/SidebarAccordion.tsx @@ -0,0 +1,50 @@ +import { ExpandMore } from '@mui/icons-material'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Typography, +} from '@mui/material'; +import { SidebarItemData } from 'types/course/courses'; + +import SidebarItem from './SidebarItem'; + +interface SidebarAccordionProps { + containing: SidebarItemData[]; + title: string; + activePath?: string; +} + +const SidebarAccordion = (props: SidebarAccordionProps): JSX.Element => { + const { containing: items, title } = props; + + return ( + + } + > + {title} + + + + {items?.map((item) => ( + + ))} + + + ); +}; + +export default SidebarAccordion; diff --git a/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx b/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx new file mode 100644 index 00000000000..036b9ae00a6 --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/SidebarContainer.tsx @@ -0,0 +1,196 @@ +import { + forwardRef, + MouseEventHandler, + ReactNode, + useEffect, + useImperativeHandle, + useLayoutEffect, + useState, +} from 'react'; +import { useLocation } from 'react-router-dom'; + +import useMedia from 'lib/hooks/useMedia'; + +import PinSidebarButton from './PinSidebarButton'; + +interface ContainerProps { + children: ReactNode; + className?: string; +} + +const GUTTER_WIDTH_PX = 20 as const; + +interface FloatingContainerProps extends ContainerProps { + onMouseEnter?: MouseEventHandler; + onMouseLeave?: MouseEventHandler; +} + +const FloatingContainer = (props: FloatingContainerProps): JSX.Element => ( +
+ {props.children} +
+); + +const AppearOnLeftGutter = (props: ContainerProps): JSX.Element => { + const [guttered, setGuttered] = useState(true); + const [inside, setInside] = useState(false); + + useLayoutEffect(() => { + const body = document.body; + + const watchAndShowSidebar = (e: MouseEvent): void => { + setGuttered(e.clientX <= GUTTER_WIDTH_PX); + }; + + body.addEventListener('mousemove', watchAndShowSidebar); + body.addEventListener('mouseleave', watchAndShowSidebar); + + return () => { + body.removeEventListener('mousemove', watchAndShowSidebar); + body.removeEventListener('mouseleave', watchAndShowSidebar); + }; + }, []); + + const appear = guttered || inside; + + return ( + <> + setInside(true)} + onMouseLeave={(): void => setInside(false)} + > + + + +
+
+
+ + ); +}; + +interface ControlledContainerProps extends ContainerProps { + open?: boolean; +} + +const Hoverable = (props: ControlledContainerProps): JSX.Element => { + const Component = props.open ? 'aside' : AppearOnLeftGutter; + + return {props.children}; +}; + +const Collapsible = (props: ControlledContainerProps): JSX.Element => { + const smallScreen = useMedia.MinWidth('md'); + + if (smallScreen) + return ( + + + + ); + + return ( + + ); +}; + +interface SidebarContainerRef { + show: () => void; +} + +interface SidebarContainerProps extends ContainerProps { + onChangeVisibility?: (visible: boolean) => void; +} + +const SidebarContainer = forwardRef( + (props, ref): JSX.Element => { + const smallScreen = useMedia.MinWidth('md'); + const mobile = useMedia.PointerCoarse(); + + const [pinned, setPinned] = useState(); + const [lastState, setLastState] = useState(pinned); + + useEffect(() => { + props.onChangeVisibility?.(pinned ?? true); + }, [pinned]); + + useImperativeHandle(ref, () => ({ + show: () => + mobile + ? setPinned((value) => !value) + : document.body.dispatchEvent(new MouseEvent('mousemove')), + })); + + useLayoutEffect(() => { + if (smallScreen) { + setPinned(false); + setLastState(pinned ?? true); + } else { + setPinned(lastState ?? true); + } + }, [smallScreen]); + + const location = useLocation(); + + // Minimises the sidebar when a navigation is made. On small screen + // mobile devices, the sidebar covers majority of the screen. + useEffect(() => { + if (!(mobile && smallScreen)) return; + + setPinned(false); + }, [location.pathname]); + + const Container = mobile ? Collapsible : Hoverable; + + return ( + + {props.children} + + {(mobile || !smallScreen) && ( +
+ setPinned((value) => !value)} + open={pinned} + /> +
+ )} +
+ ); + }, +); + +SidebarContainer.displayName = 'SidebarContainer'; + +export default SidebarContainer; diff --git a/client/app/bundles/course/container/Sidebar/SidebarItem.tsx b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx new file mode 100644 index 00000000000..56b5b561df6 --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/SidebarItem.tsx @@ -0,0 +1,91 @@ +import { useLayoutEffect, useRef } from 'react'; +import { defineMessages } from 'react-intl'; +import { Link, useLocation } from 'react-router-dom'; +import { Badge, Typography } from '@mui/material'; +import { SidebarItemData } from 'types/course/courses'; + +import { defensivelyGetIcon } from 'lib/constants/icons'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface SidebarItemProps { + of: SidebarItemData; + square?: boolean; + exact?: boolean; + activePath?: string; +} + +const SidebarItem = (props: SidebarItemProps): JSX.Element => { + const { of: item, square, exact, activePath } = props; + + const location = useLocation(); + const activeUrl = activePath ?? location.pathname + location.search; + + const isActive = exact + ? activeUrl === item.path + : activeUrl.startsWith(item.path); + + const Icon = defensivelyGetIcon(item.icon, isActive ? 'filled' : 'outlined'); + + const ref = useRef(null); + + useLayoutEffect(() => { + if (!isActive) return; + + ref.current?.scrollIntoView({ behavior: 'auto', block: 'nearest' }); + }, [isActive]); + + return ( + +
+ + + + + + {item.label} + +
+ + ); +}; + +const translations = defineMessages({ + home: { + id: 'course.courses.SidebarItem.home', + defaultMessage: 'Home', + }, +}); + +const HomeSidebarItem = (props: { to: string }): JSX.Element => { + const { t } = useTranslation(); + + return ( + + ); +}; + +export default Object.assign(SidebarItem, { Home: HomeSidebarItem }); diff --git a/client/app/bundles/course/container/Sidebar/index.ts b/client/app/bundles/course/container/Sidebar/index.ts new file mode 100644 index 00000000000..e842a8591f8 --- /dev/null +++ b/client/app/bundles/course/container/Sidebar/index.ts @@ -0,0 +1 @@ +export { default } from './Sidebar'; diff --git a/client/app/bundles/course/container/index.ts b/client/app/bundles/course/container/index.ts new file mode 100644 index 00000000000..379f1d945fc --- /dev/null +++ b/client/app/bundles/course/container/index.ts @@ -0,0 +1 @@ +export { default as CourseContainer } from './CourseContainer'; diff --git a/client/app/lib/components/core/LoadingEllipsis.tsx b/client/app/lib/components/core/LoadingEllipsis.tsx new file mode 100644 index 00000000000..701385266f3 --- /dev/null +++ b/client/app/lib/components/core/LoadingEllipsis.tsx @@ -0,0 +1,13 @@ +import { Slide, Typography } from '@mui/material'; + +const LoadingEllipsis = (): JSX.Element => ( + + + . + . + . + + +); + +export default LoadingEllipsis; diff --git a/client/app/lib/components/core/PopupMenu.tsx b/client/app/lib/components/core/PopupMenu.tsx new file mode 100644 index 00000000000..ec185e36684 --- /dev/null +++ b/client/app/lib/components/core/PopupMenu.tsx @@ -0,0 +1,136 @@ +import { + ComponentProps, + createContext, + ReactNode, + useContext, + useRef, +} from 'react'; +import { Link } from 'react-router-dom'; +import { + Divider, + List, + ListItem, + ListItemButton, + ListItemText, + ListSubheader, + Popover, + Typography, +} from '@mui/material'; + +interface PopupMenuContextProps { + close: () => void; +} + +const PopupMenuContext = createContext({ + close: () => {}, +}); + +interface PopupMenuProps extends Partial> { + onClose: () => void; + anchorEl?: HTMLElement | null; + children?: ReactNode; +} + +const PopupMenu = (props: PopupMenuProps): JSX.Element => { + const { anchorEl, onClose, children } = props; + + const ref = useRef(null); + + return ( + + {/* eslint-disable-next-line react/jsx-no-constructed-context-values */} + + {children} + + + ); +}; + +interface PopupMenuButtonProps { + onClick?: () => void; + to?: string; + children?: ReactNode; + textProps?: ComponentProps; + disabled?: boolean; +} + +const PopupMenuButton = (props: PopupMenuButtonProps): JSX.Element => { + const { to: href } = props; + + const { close } = useContext(PopupMenuContext); + + const handleClick = (): void => { + close(); + props.onClick?.(); + }; + + const button = ( + + + + {props.children} + + + + ); + + return href && !props.disabled ? {button} : button; +}; + +interface PopupMenuTextProps extends ComponentProps { + children?: ReactNode; +} + +const PopupMenuText = (props: PopupMenuTextProps): JSX.Element => { + const { children, ...typographyProps } = props; + + return ( + + + {children} + + + ); +}; + +interface PopupMenuListProps { + className?: string; + header?: string; + children?: ReactNode; +} + +const PopupMenuList = (props: PopupMenuListProps): JSX.Element => { + return ( + + {props.header} + + ) + } + > + {props.children} + + ); +}; + +export default Object.assign(PopupMenu, { + Button: PopupMenuButton, + Text: PopupMenuText, + List: PopupMenuList, + Item: ListItem, + Divider, +}); diff --git a/client/app/lib/components/core/layouts/Footer.tsx b/client/app/lib/components/core/layouts/Footer.tsx new file mode 100644 index 00000000000..25196808b27 --- /dev/null +++ b/client/app/lib/components/core/layouts/Footer.tsx @@ -0,0 +1,67 @@ +import { defineMessages } from 'react-intl'; +import { Typography } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + termsOfService: { + id: 'app.Footer.termsOfService', + defaultMessage: 'Terms of Service', + }, + privacyPolicy: { + id: 'app.Footer.privacyPolicy', + defaultMessage: 'Privacy Policy', + }, + contactUs: { + id: 'app.Footer.contactUs', + defaultMessage: 'Contact Us', + }, + instructorsGuide: { + id: 'app.Footer.instructorsGuide', + defaultMessage: "Instructors' Guide", + }, + github: { + id: 'app.Footer.github', + defaultMessage: 'GitHub', + }, + copyright: { + id: 'app.Footer.copyright', + defaultMessage: 'Copyright © {year} Coursemology. All rights reserved.', + }, +}); + +/** + * TODO: Populate with appropriate links when pages are ready. + */ +const Footer = (): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+
+ {t(translations.termsOfService)} + + {t(translations.privacyPolicy)} + + + {t(translations.contactUs)} + + + + {t(translations.instructorsGuide)} + + + + {t(translations.github)} + +
+ + + {t(translations.copyright, { year: '2023' })} + +
+ ); +}; + +export default Footer; diff --git a/client/app/lib/components/core/layouts/layout.scss b/client/app/lib/components/core/layouts/layout.scss index bee0769fb06..ef380d41da2 100644 --- a/client/app/lib/components/core/layouts/layout.scss +++ b/client/app/lib/components/core/layouts/layout.scss @@ -1,37 +1,3 @@ -body { - padding-top: 60px; - - & > nav { - z-index: 1201 !important; - } -} - -.sidebarContainer { - & > div { - z-index: 1; - - @media (min-width: 600px) { - flex-basis: 33.33333%; - max-width: 33.33333%; - } - - @media (min-width: 900px) { - flex-basis: 25%; - max-width: 25%; - } - - @media (min-width: 1200px) { - flex-basis: 16.66667%; - max-width: 16.66667%; - } - } -} - -footer { - background-color: #ffffff; - z-index: 2; -} - img { max-width: 100%; } diff --git a/client/app/lib/components/navigation/BrandingHead.tsx b/client/app/lib/components/navigation/BrandingHead.tsx new file mode 100644 index 00000000000..79edde5bf08 --- /dev/null +++ b/client/app/lib/components/navigation/BrandingHead.tsx @@ -0,0 +1,161 @@ +import { ReactNode, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { AdminPanelSettingsOutlined, ChevronRight } from '@mui/icons-material'; +import { Avatar, IconButton, Typography } from '@mui/material'; + +import PopupMenu from 'lib/components/core/PopupMenu'; +import { useAppContext } from 'lib/containers/AppContainer'; +import useTranslation from 'lib/hooks/useTranslation'; + +import UserPopupMenuList from './UserPopupMenuList'; + +const translations = defineMessages({ + coursemology: { + id: 'app.BrandingItem.coursemology', + defaultMessage: 'Coursemology', + }, + adminPanel: { + id: 'course.courses.BrandingItem.adminPanel', + defaultMessage: 'System Admin Panel', + }, + instanceAdminPanel: { + id: 'course.courses.BrandingItem.instanceAdminPanel', + defaultMessage: 'Instance Admin Panel', + }, + superuser: { + id: 'course.courses.BrandingItem.superuser', + defaultMessage: 'Superuser', + }, +}); + +interface BrandingHeadProps { + title?: string | null; +} + +const AdminMenuButton = (): JSX.Element | null => { + const { t } = useTranslation(); + + const { user } = useAppContext(); + + const isSuperAdmin = user?.role === 'administrator'; + const isInstanceAdmin = user?.instanceRole === 'administrator'; + + const [anchorElement, setAnchorElement] = useState(); + + if (!(isSuperAdmin || isInstanceAdmin)) return null; + + return ( + <> + setAnchorElement(e.currentTarget)} + > + + + + setAnchorElement(undefined)} + > + + {isSuperAdmin && ( + + {t(translations.adminPanel)} + + )} + + {(isSuperAdmin || isInstanceAdmin) && ( + + {t(translations.instanceAdminPanel)} + + )} + + + + ); +}; + +const Brand = (): JSX.Element => { + const { t } = useTranslation(); + + // TODO: Remove `reloadDocument` once fully SPA + return ( + + {t(translations.coursemology)} + + ); +}; + +const UserMenuButton = (): JSX.Element | null => { + const { user } = useAppContext(); + + const [anchorElement, setAnchorElement] = useState(); + + if (!user) return null; + + return ( + <> +
setAnchorElement(e.currentTarget)} + role="button" + tabIndex={0} + > + + + + {user.name} + +
+ + setAnchorElement(undefined)} + > + + + + ); +}; + +const BrandingHeadContainer = (props: { children: ReactNode }): JSX.Element => ( +
+ {props.children} +
+); + +const BrandingHead = (props: BrandingHeadProps): JSX.Element => ( + +
+ + + {props.title && ( +
+ + {props.title} +
+ )} +
+ +
+ + +
+
+); + +const MiniBrandingHead = (): JSX.Element => ( + + + + +); + +export default Object.assign(BrandingHead, { Mini: MiniBrandingHead }); diff --git a/client/app/lib/components/navigation/UserPopupMenuList.tsx b/client/app/lib/components/navigation/UserPopupMenuList.tsx new file mode 100644 index 00000000000..a38af961c7c --- /dev/null +++ b/client/app/lib/components/navigation/UserPopupMenuList.tsx @@ -0,0 +1,49 @@ +import { ComponentProps } from 'react'; +import { defineMessages } from 'react-intl'; + +import GlobalAPI from 'api'; +import PopupMenu from 'lib/components/core/PopupMenu'; +import { useAppContext } from 'lib/containers/AppContainer'; +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + accountSettings: { + id: 'course.courses.CourseUserItem.accountSettings', + defaultMessage: 'Account settings', + }, + signOut: { + id: 'course.courses.CourseUserItem.signOut', + defaultMessage: 'Sign out', + }, +}); + +const UserPopupMenuList = ( + props: Pick, 'header'>, +): JSX.Element => { + const { t } = useTranslation(); + + const { signOutUrl } = useAppContext(); + + const signOut = async (): Promise => { + if (!signOutUrl) return; + + await GlobalAPI.users.signOut(signOutUrl); + + // TODO: Reset Redux store and navigate via React Router once SPA. + window.location.href = '/'; + }; + + return ( + + + {t(translations.accountSettings)} + + + + {t(translations.signOut)} + + + ); +}; + +export default UserPopupMenuList; diff --git a/client/app/lib/hooks/useMedia.ts b/client/app/lib/hooks/useMedia.ts new file mode 100644 index 00000000000..908ae2a3040 --- /dev/null +++ b/client/app/lib/hooks/useMedia.ts @@ -0,0 +1,17 @@ +import { useMediaQuery } from '@mui/material'; +import { Breakpoint, useTheme } from '@mui/material/styles'; + +const PointerCoarse = (): boolean => useMediaQuery('(pointer: coarse)'); +const PointerFine = (): boolean => useMediaQuery('(pointer: fine)'); + +const MinWidth = (breakpoint: Breakpoint): boolean => { + const theme = useTheme(); + return useMediaQuery(theme.breakpoints.down(breakpoint)); +}; + +const MaxWidth = (breakpoint: Breakpoint): boolean => { + const theme = useTheme(); + return useMediaQuery(theme.breakpoints.up(breakpoint)); +}; + +export default { PointerCoarse, PointerFine, MinWidth, MaxWidth }; diff --git a/client/app/lib/hooks/useTranslation.ts b/client/app/lib/hooks/useTranslation.ts index 8bbfb06de46..32f0720c40b 100644 --- a/client/app/lib/hooks/useTranslation.ts +++ b/client/app/lib/hooks/useTranslation.ts @@ -18,6 +18,12 @@ interface TranslationHook { t: MessageTranslator; } +export const translatable = (object: unknown): object is Descriptor => + typeof object === 'object' && + object !== null && + 'id' in object && + 'defaultMessage' in object; + const useTranslation = (): TranslationHook => { const intl = useIntl(); diff --git a/client/app/theme/index.css b/client/app/theme/index.css index 99fd4c07603..4feebe69d02 100644 --- a/client/app/theme/index.css +++ b/client/app/theme/index.css @@ -14,7 +14,7 @@ } .key { - @apply rounded-xl border border-solid py-0.5 px-2; + @apply rounded-xl border border-solid px-2 py-0.5; } /* For Firefox 64+ and Firefox for Android 64+ */ @@ -27,3 +27,57 @@ display: none; } } + +.sidebar-handle-enter { + animation-timing-function: ease-in-out; + animation-name: sidebar-handle-emphasize; + animation-duration: 400ms; + animation-delay: -50ms; +} + +@keyframes sidebar-handle-emphasize { + 0% { + transform: translateX(-200%); + } + 20% { + transform: translateX(0.5rem); + animation-timing-function: cubic-bezier(0.68, -0.6, 0.32, 1.6); + } + 100% { + transform: translateX(0); + } +} + +.sidebar-handle-exit { + @apply -translate-x-[200%] scale-y-0 transition-all; +} + +.bouncing-dot { + display: inline-block; + animation-name: bouncing-dot-bounce; + animation-duration: 700ms; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; +} + +.bouncing-dot:nth-child(2) { + animation-delay: 125ms; +} + +.bouncing-dot:nth-child(3) { + animation-delay: 250ms; +} + +@keyframes bouncing-dot-bounce { + 0% { + transform: none; + } + + 33% { + transform: translateY(-0.2em); + } + + 66% { + transform: none; + } +} From 7f78bc0477f83300f5b0283b2ad1ef720be707e8 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 15:24:00 +0800 Subject: [PATCH 26/76] feat(components): add `Page`, deprecate `PageHeader` --- .../app/lib/components/core/layouts/Page.tsx | 78 +++++++++++++ .../lib/components/navigation/PageHeader.tsx | 58 ++++++---- .../lib/components/navigation/TitleBar.jsx | 32 ------ .../navigation/__test__/PageHeader.test.tsx | 8 +- .../__snapshots__/PageHeader.test.tsx.snap | 106 +++++++----------- 5 files changed, 159 insertions(+), 123 deletions(-) create mode 100644 client/app/lib/components/core/layouts/Page.tsx delete mode 100644 client/app/lib/components/navigation/TitleBar.jsx diff --git a/client/app/lib/components/core/layouts/Page.tsx b/client/app/lib/components/core/layouts/Page.tsx new file mode 100644 index 00000000000..bc36a3ca5fa --- /dev/null +++ b/client/app/lib/components/core/layouts/Page.tsx @@ -0,0 +1,78 @@ +import { ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { ArrowBack } from '@mui/icons-material'; +import { IconButton, Typography } from '@mui/material'; + +interface PageProps { + title?: ReactNode; + children?: ReactNode; + actions?: ReactNode; + backTo?: string | boolean; + className?: string; + unpadded?: boolean; +} + +interface PageSectionProps { + className?: string; + children?: ReactNode; +} + +const Page = (props: PageProps): JSX.Element => { + const { backTo: route } = props; + + const navigate = useNavigate(); + + return ( +
+ {(props.title || props.actions) && ( +
+
+ {props.title && ( +
+ {route && ( + + route === true ? navigate(-1) : navigate(route) + } + > + + + )} + + {props.title} +
+ )} + + {props.actions && ( +
{props.actions}
+ )} +
+
+ )} + +
+ {props.children} +
+
+ ); +}; + +const PaddedPageSection = (props: PageSectionProps): JSX.Element => ( +
{props.children}
+); + +const UnpaddedPageSection = (props: PageSectionProps): JSX.Element => ( +
+ {props.children} +
+); + +export default Object.assign(Page, { + PaddedSection: PaddedPageSection, + UnpaddedSection: UnpaddedPageSection, +}); diff --git a/client/app/lib/components/navigation/PageHeader.tsx b/client/app/lib/components/navigation/PageHeader.tsx index e17bd035476..e81513e7332 100644 --- a/client/app/lib/components/navigation/PageHeader.tsx +++ b/client/app/lib/components/navigation/PageHeader.tsx @@ -1,37 +1,51 @@ -import { FC, ReactNode } from 'react'; +import { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import ArrowBack from '@mui/icons-material/ArrowBack'; -import { IconButton } from '@mui/material'; +import { AppBar, IconButton, Toolbar, Typography } from '@mui/material'; -import TitleBar from 'lib/components/navigation/TitleBar'; - -interface Props { - title: ReactNode | string; +interface PageHeaderProps { + title: NonNullable; returnLink?: string; toolbars?: ReactNode; children?: ReactNode; } -const PageHeader: FC = (props) => { +/** + * @deprecated Pages should use `Page` and the `title` prop instead. + */ +const PageHeader = (props: PageHeaderProps): JSX.Element => { const { title, returnLink, toolbars, children } = props; const navigate = useNavigate(); return ( - navigate(returnLink)} - > - - - ) : null - } - iconElementRight={children ?? toolbars ?? null} - title={title} - /> + + +
+ {returnLink && ( + navigate(returnLink)} + > + + + )} + + + {title} + +
+ +
+ {children ?? toolbars ?? null} +
+
+
); }; diff --git a/client/app/lib/components/navigation/TitleBar.jsx b/client/app/lib/components/navigation/TitleBar.jsx deleted file mode 100644 index 4f5a83fb177..00000000000 --- a/client/app/lib/components/navigation/TitleBar.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { AppBar, Toolbar, Typography } from '@mui/material'; -import PropTypes from 'prop-types'; - -// Hide menu icon by default. To revert once sidebar has been ported to MaterialUI. -const TitleBar = ({ iconElementLeft, iconElementRight, title, ...props }) => ( -
- - -
{iconElementLeft}
- - - - {iconElementRight} -
-
-
-); - -TitleBar.propTypes = { - onClick: PropTypes.func, - iconElementLeft: PropTypes.node, - iconElementRight: PropTypes.node, - title: PropTypes.node, -}; - -export default TitleBar; diff --git a/client/app/lib/components/navigation/__test__/PageHeader.test.tsx b/client/app/lib/components/navigation/__test__/PageHeader.test.tsx index a48ea080742..669e840655a 100644 --- a/client/app/lib/components/navigation/__test__/PageHeader.test.tsx +++ b/client/app/lib/components/navigation/__test__/PageHeader.test.tsx @@ -11,13 +11,13 @@ describe('', () => { }); it('renders TitleBar and shows the title', () => { - expect(documentBody.getByTestId('TitleBar')).toBeVisible(); expect(documentBody.getByText('Test Title 1')).toBeVisible(); }); it('does not show the return/back button', () => { - expect(documentBody.queryByTestId('ArrowBackIconButton')).toBeNull(); - expect(documentBody.queryByTestId('ArrowBack')).toBeNull(); + expect( + documentBody.queryByTestId('ArrowBackIconButton'), + ).not.toBeInTheDocument(); }); it('matches the snapshot', () => { @@ -34,13 +34,11 @@ describe('', () => { }); it('renders TitleBar and shows the title', () => { - expect(documentBody.getByTestId('TitleBar')).toBeVisible(); expect(documentBody.getByText('Test Title 2')).toBeVisible(); }); it('shows the return/back button', () => { expect(documentBody.getByTestId('ArrowBackIconButton')).toBeVisible(); - expect(documentBody.getByTestId('ArrowBack')).toBeVisible(); }); it('matches the snapshot', () => { diff --git a/client/app/lib/components/navigation/__test__/__snapshots__/PageHeader.test.tsx.snap b/client/app/lib/components/navigation/__test__/__snapshots__/PageHeader.test.tsx.snap index 93641addd9d..4b081cd2b96 100644 --- a/client/app/lib/components/navigation/__test__/__snapshots__/PageHeader.test.tsx.snap +++ b/client/app/lib/components/navigation/__test__/__snapshots__/PageHeader.test.tsx.snap @@ -3,59 +3,47 @@ exports[` when there is a return link matches the snapshot 1`] = `
-
-
-
- -
+ + +
- + Test Title 2
-
-
+
+
+
@@ -66,36 +54,26 @@ exports[` when there is a return link matches the snapshot 1`] = ` exports[` when there is no return link matches the snapshot 1`] = `
-
-
-
- + Test Title 1
-
-
+
+
+
From daffaf0b28b1b375040c3b1f160e8171c4b27076 Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Sat, 24 Jun 2023 22:11:40 +0800 Subject: [PATCH 27/76] feat(courses): render string url to course logo --- app/views/course/courses/_course_list_data.json.jbuilder | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/course/courses/_course_list_data.json.jbuilder b/app/views/course/courses/_course_list_data.json.jbuilder index 1759132aca5..064096f615a 100644 --- a/app/views/course/courses/_course_list_data.json.jbuilder +++ b/app/views/course/courses/_course_list_data.json.jbuilder @@ -3,5 +3,5 @@ json.id course.id json.title course.title json.description format_ckeditor_rich_text(course.description) -json.logoUrl display_course_logo(course) +json.logoUrl url_to_course_logo(course) json.startAt course.start_at From 28b8e6ffd3ac945a852ea18c58f0a27cff5d6dcc Mon Sep 17 00:00:00 2001 From: Phillmont Muktar <51525686+purfectliterature@users.noreply.github.com> Date: Mon, 29 May 2023 15:30:52 +0800 Subject: [PATCH 28/76] feat(app): migrate to new router, combine bundles --- .../assessments/authenticate.json.jbuilder | 3 + .../assessments/index.json.jbuilder | 2 + .../assessment/assessments/show.json.jbuilder | 2 + client/app/App.tsx | 11 +- client/app/RoutedApp.tsx | 337 -------- .../app/bundles/course/achievement/handles.ts | 17 + .../pages/AchievementsIndex/index.tsx | 28 +- .../admin/components/SettingsNavigation.tsx | 103 ++- .../components/misc/AnnouncementCard.tsx | 34 +- .../pages/AnnouncementsIndex/index.tsx | 50 +- .../app/bundles/course/assessment/handles.ts | 93 +++ .../AssessmentEdit/AssessmentEditPage.jsx | 14 +- .../assessment/pages/AssessmentEdit/index.tsx | 5 +- .../pages/AssessmentMonitoring/index.tsx | 5 +- .../pages/AssessmentStatistics/index.jsx | 13 +- .../pages/AssessmentsIndex/index.tsx | 59 +- .../NewForumPostResponsePage.tsx | 4 +- .../multiple-responses/NewMcqMrqPage.tsx | 17 +- .../NewProgrammingQuestionPage.tsx | 6 +- .../question/scribing/ScribingQuestion.tsx | 6 +- .../text-responses/NewTextResponsePage.tsx | 22 +- .../question/voice-responses/NewVoicePage.tsx | 4 +- .../skills/pages/SkillsIndex/index.tsx | 4 +- .../submission/pages/LogsIndex/index.tsx | 4 +- .../pages/SubmissionEditIndex/index.jsx | 5 +- .../pages/SubmissionsIndex/index.jsx | 5 +- .../assessment/submission/translations.ts | 4 + .../submissions/SubmissionsIndex.tsx | 38 +- .../components/misc/SubmissionFilter.tsx | 4 - .../components/tables/SubmissionsTable.tsx | 18 +- .../bundles/course/assessment/translations.ts | 32 + .../components/buttons/TodoIgnoreButton.tsx | 1 - .../components/misc/CourseAnnouncements.tsx | 20 +- .../components/misc/CourseNotifications.tsx | 45 +- .../components/misc/NotificationCard.tsx | 31 +- .../components/tables/PendingTodosTable.tsx | 48 +- .../course/courses/pages/CourseShow/index.tsx | 79 +- .../courses/pages/CoursesIndex/index.tsx | 23 +- .../topics/pages/CommentIndex/index.tsx | 29 +- .../duplication/pages/Duplication/index.jsx | 29 +- .../pages/UserRequests/index.tsx | 4 +- .../pages/DisbursementIndex/index.tsx | 4 +- client/app/bundles/course/forum/handles.ts | 32 + .../course/forum/pages/ForumShow/index.tsx | 19 +- .../forum/pages/ForumTopicShow/index.tsx | 18 +- .../course/forum/pages/ForumsIndex/index.tsx | 14 +- .../course/group/pages/GroupIndex/index.jsx | 4 +- .../pages/LeaderboardIndex/index.tsx | 24 +- .../containers/LearningMap/index.jsx | 5 +- .../ColumnVisibilityDropdown/index.jsx | 2 +- .../containers/LessonPlanLayout/index.jsx | 13 +- .../pages/LessonPlanEdit/index.jsx | 35 +- .../LessonPlanItem/AdminTools.jsx | 29 +- .../LessonPlanShow/MilestoneAdminTools.jsx | 31 +- .../pages/LessonPlanShow/index.jsx | 32 +- .../course/level/components/LevelRow.jsx | 10 +- .../course/level/pages/LevelsIndex/index.jsx | 17 +- .../components/tables/TableMaterialRow.tsx | 13 +- .../components/tables/TableSubfolderRow.tsx | 7 +- .../components/tables/WorkbinTable.tsx | 19 +- .../course/material/folders/handles.ts | 44 + .../course/material/folders/operations.ts | 4 - .../folders/pages/FolderShow/index.tsx | 70 +- .../course/material/folders/selectors.ts | 4 - .../bundles/course/material/folders/store.ts | 4 - .../bundles/course/material/folders/types.ts | 2 - .../reference-timelines/TimelineDesigner.tsx | 5 +- .../components/DayCalendar/DayCalendar.tsx | 2 +- .../TimelinesOverview/TimelinesOverview.tsx | 4 +- .../views/DayView/DayView.tsx | 4 +- .../views/DayView/ItemsSidebar.tsx | 2 +- .../app/bundles/course/statistics/handles.ts | 33 + .../pages/StatisticsIndex/index.jsx | 15 +- .../survey/containers/SurveyLayout/index.jsx | 26 +- client/app/bundles/course/survey/handles.ts | 28 + .../survey/pages/ResponseEdit/index.jsx | 13 +- .../survey/pages/ResponseIndex/index.jsx | 7 +- .../pages/ResponseIndex/translations.js | 4 + .../survey/pages/SurveyIndex/SurveysTable.jsx | 6 +- .../course/survey/pages/SurveyIndex/index.jsx | 20 +- .../survey/pages/SurveyResults/index.jsx | 11 +- .../survey/pages/SurveyShow/SurveyDetails.jsx | 8 +- .../UserEmailSubscriptions.tsx | 13 +- .../UserEmailSubscriptionsTable.jsx | 11 +- .../user-notification/PopupNotifierOutlet.tsx | 15 - .../users/components/misc/UserProfileCard.tsx | 6 +- .../navigation/UserManagementTabs.tsx | 10 +- client/app/bundles/course/users/handles.ts | 75 ++ .../pages/ExperiencePointsRecords/index.tsx | 16 +- .../course/users/pages/UsersIndex/index.tsx | 11 +- client/app/bundles/course/video/handles.ts | 71 ++ .../course/video/pages/VideosIndex/index.tsx | 9 +- .../pages/VideoSubmissionShow/index.tsx | 8 +- .../pages/VideoSubmissionsIndex/index.tsx | 8 +- .../bundles/user/AccountSettings/index.tsx | 4 +- client/app/bundles/user/translations.ts | 4 + client/app/router.tsx | 774 ++++++++++++++++++ client/app/store.ts | 17 +- .../types/course/assessment/assessments.ts | 11 + client/app/utilities/index.ts | 2 + 100 files changed, 1960 insertions(+), 1007 deletions(-) delete mode 100644 client/app/RoutedApp.tsx create mode 100644 client/app/bundles/course/achievement/handles.ts create mode 100644 client/app/bundles/course/assessment/handles.ts create mode 100644 client/app/bundles/course/forum/handles.ts create mode 100644 client/app/bundles/course/material/folders/handles.ts create mode 100644 client/app/bundles/course/statistics/handles.ts create mode 100644 client/app/bundles/course/survey/handles.ts delete mode 100644 client/app/bundles/course/user-notification/PopupNotifierOutlet.tsx create mode 100644 client/app/bundles/course/users/handles.ts create mode 100644 client/app/bundles/course/video/handles.ts create mode 100644 client/app/router.tsx create mode 100644 client/app/utilities/index.ts diff --git a/app/views/course/assessment/assessments/authenticate.json.jbuilder b/app/views/course/assessment/assessments/authenticate.json.jbuilder index 9a878a3df1a..cc3b172cc33 100644 --- a/app/views/course/assessment/assessments/authenticate.json.jbuilder +++ b/app/views/course/assessment/assessments/authenticate.json.jbuilder @@ -1,6 +1,9 @@ # frozen_string_literal: true json.id @assessment.id +json.title @assessment.title +json.tabTitle "#{@category.title}: #{@tab.title}" +json.tabUrl course_assessments_path(course_id: current_course, category: @category, tab: @tab) json.isAuthenticated false json.isStartTimeBegin !assessment_not_started(@assessment_time) json.startAt @assessment_time.start_at diff --git a/app/views/course/assessment/assessments/index.json.jbuilder b/app/views/course/assessment/assessments/index.json.jbuilder index cf5375327ab..f6afc62cf6d 100644 --- a/app/views/course/assessment/assessments/index.json.jbuilder +++ b/app/views/course/assessment/assessments/index.json.jbuilder @@ -23,6 +23,8 @@ json.display do end json.tabId @tab.id + json.tabTitle "#{@category.title}: #{@tab.title}" + json.tabUrl course_assessments_path(course_id: current_course, category: @category, tab: @tab) end json.assessments @assessments do |assessment| diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index c25dafba1b3..c724cdb987b 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -12,6 +12,8 @@ can_manage = can?(:manage, assessment) json.id assessment.id json.title assessment.title +json.tabTitle "#{@category.title}: #{@tab.title}" +json.tabUrl course_assessments_path(course_id: current_course, category: @category, tab: @tab) json.description assessment.description unless @assessment.description.blank? json.autograded assessment.autograded? json.hasTodo assessment.has_todo if can_manage diff --git a/client/app/App.tsx b/client/app/App.tsx index aae8aadd2e1..4539ec6e7ab 100644 --- a/client/app/App.tsx +++ b/client/app/App.tsx @@ -1,18 +1,13 @@ -import { BrowserRouter } from 'react-router-dom'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; import Providers from 'lib/components/wrappers/Providers'; -import NotificationPopup from 'lib/containers/NotificationPopup'; -import RoutedApp from './RoutedApp'; +import router from './router'; import { store } from './store'; const App = (): JSX.Element => ( - - - - - + ); diff --git a/client/app/RoutedApp.tsx b/client/app/RoutedApp.tsx deleted file mode 100644 index 3ac11f515db..00000000000 --- a/client/app/RoutedApp.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import { Navigate, Route, Routes } from 'react-router-dom'; - -import GlobalAnnouncementIndex from 'bundles/announcements/GlobalAnnouncementIndex'; -import AchievementShow from 'bundles/course/achievement/pages/AchievementShow'; -import AchievementsIndex from 'bundles/course/achievement/pages/AchievementsIndex'; -import SettingsNavigation from 'bundles/course/admin/components/SettingsNavigation'; -import AnnouncementSettings from 'bundles/course/admin/pages/AnnouncementsSettings'; -import AssessmentSettings from 'bundles/course/admin/pages/AssessmentSettings'; -import CodaveriSettings from 'bundles/course/admin/pages/CodaveriSettings'; -import CommentsSettings from 'bundles/course/admin/pages/CommentsSettings'; -import ComponentSettings from 'bundles/course/admin/pages/ComponentSettings'; -import CourseSettings from 'bundles/course/admin/pages/CourseSettings'; -import ForumsSettings from 'bundles/course/admin/pages/ForumsSettings'; -import LeaderboardSettings from 'bundles/course/admin/pages/LeaderboardSettings'; -import LessonPlanSettings from 'bundles/course/admin/pages/LessonPlanSettings'; -import MaterialsSettings from 'bundles/course/admin/pages/MaterialsSettings'; -import NotificationSettings from 'bundles/course/admin/pages/NotificationSettings'; -import SidebarSettings from 'bundles/course/admin/pages/SidebarSettings'; -import VideosSettings from 'bundles/course/admin/pages/VideosSettings'; -import AnnouncementsIndex from 'bundles/course/announcements/pages/AnnouncementsIndex'; -import AssessmentEdit from 'bundles/course/assessment/pages/AssessmentEdit'; -import AssessmentMonitoring from 'bundles/course/assessment/pages/AssessmentMonitoring'; -import AssessmentShow from 'bundles/course/assessment/pages/AssessmentShow'; -import AssessmentsIndex from 'bundles/course/assessment/pages/AssessmentsIndex'; -import AssessmentStatisticsPage from 'bundles/course/assessment/pages/AssessmentStatistics'; -import EditForumPostResponsePage from 'bundles/course/assessment/question/forum-post-responses/EditForumPostResponsePage'; -import NewForumPostResponsePage from 'bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage'; -import EditMcqMrqPage from 'bundles/course/assessment/question/multiple-responses/EditMcqMrqPage'; -import NewMcqMrqPage from 'bundles/course/assessment/question/multiple-responses/NewMcqMrqPage'; -import EditProgrammingQuestionPage from 'bundles/course/assessment/question/programming/EditProgrammingQuestionPage'; -import NewProgrammingQuestionPage from 'bundles/course/assessment/question/programming/NewProgrammingQuestionPage'; -import ScribingQuestion from 'bundles/course/assessment/question/scribing/ScribingQuestion'; -import EditTextResponse from 'bundles/course/assessment/question/text-responses/EditTextResponsePage'; -import NewTextResponse from 'bundles/course/assessment/question/text-responses/NewTextResponsePage'; -import EditVoicePage from 'bundles/course/assessment/question/voice-responses/EditVoicePage'; -import NewVoicePage from 'bundles/course/assessment/question/voice-responses/NewVoicePage'; -import AssessmentSessionNew from 'bundles/course/assessment/sessions/pages/AssessmentSessionNew'; -import SkillsIndex from 'bundles/course/assessment/skills/pages/SkillsIndex'; -import LogsIndex from 'bundles/course/assessment/submission/pages/LogsIndex'; -import SubmissionEditIndex from 'bundles/course/assessment/submission/pages/SubmissionEditIndex'; -import AssessmentSubmissionsIndex from 'bundles/course/assessment/submission/pages/SubmissionsIndex'; -import SubmissionsIndex from 'bundles/course/assessment/submissions/SubmissionsIndex'; -import CourseShow from 'bundles/course/courses/pages/CourseShow'; -import CoursesIndex from 'bundles/course/courses/pages/CoursesIndex'; -import CommentIndex from 'bundles/course/discussion/topics/pages/CommentIndex'; -import Duplication from 'bundles/course/duplication/pages/Duplication'; -import UserRequests from 'bundles/course/enrol-requests/pages/UserRequests'; -import DisbursementIndex from 'bundles/course/experience-points/disbursement/pages/DisbursementIndex'; -import ForumShow from 'bundles/course/forum/pages/ForumShow'; -import ForumsIndex from 'bundles/course/forum/pages/ForumsIndex'; -import ForumTopicShow from 'bundles/course/forum/pages/ForumTopicShow'; -import GroupIndex from 'bundles/course/group/pages/GroupIndex'; -import GroupShow from 'bundles/course/group/pages/GroupShow'; -import LeaderboardIndex from 'bundles/course/leaderboard/pages/LeaderboardIndex'; -import LearningMap from 'bundles/course/learning-map/containers/LearningMap'; -import LessonPlanLayout from 'bundles/course/lesson-plan/containers/LessonPlanLayout'; -import LessonPlanEdit from 'bundles/course/lesson-plan/pages/LessonPlanEdit'; -import LessonPlanShow from 'bundles/course/lesson-plan/pages/LessonPlanShow'; -import LevelsIndex from 'bundles/course/level/pages/LevelsIndex'; -import FolderShow from 'bundles/course/material/folders/pages/FolderShow'; -import TimelineDesigner from 'bundles/course/reference-timelines/TimelineDesigner'; -import StatisticsIndex from 'bundles/course/statistics/pages/StatisticsIndex'; -import ResponseEdit from 'bundles/course/survey/pages/ResponseEdit'; -import ResponseIndex from 'bundles/course/survey/pages/ResponseIndex'; -import ResponseShow from 'bundles/course/survey/pages/ResponseShow'; -import SurveyIndex from 'bundles/course/survey/pages/SurveyIndex'; -import SurveyResults from 'bundles/course/survey/pages/SurveyResults'; -import SurveyShow from 'bundles/course/survey/pages/SurveyShow'; -import UserEmailSubscriptions from 'bundles/course/user-email-subscriptions/UserEmailSubscriptions'; -import InvitationsIndex from 'bundles/course/user-invitations/pages/InvitationsIndex'; -import InviteUsers from 'bundles/course/user-invitations/pages/InviteUsers'; -import ExperiencePointsRecords from 'bundles/course/users/pages/ExperiencePointsRecords'; -import ManageStaff from 'bundles/course/users/pages/ManageStaff'; -import ManageStudents from 'bundles/course/users/pages/ManageStudents'; -import PersonalTimes from 'bundles/course/users/pages/PersonalTimes'; -import PersonalTimesShow from 'bundles/course/users/pages/PersonalTimesShow'; -import CourseUserShow from 'bundles/course/users/pages/UserShow'; -import UsersIndex from 'bundles/course/users/pages/UsersIndex'; -import VideoShow from 'bundles/course/video/pages/VideoShow'; -import VideosIndex from 'bundles/course/video/pages/VideosIndex'; -import VideoSubmissionEdit from 'bundles/course/video/submission/pages/VideoSubmissionEdit'; -import VideoSubmissionShow from 'bundles/course/video/submission/pages/VideoSubmissionShow'; -import VideoSubmissionsIndex from 'bundles/course/video/submission/pages/VideoSubmissionsIndex'; -import UserVideoSubmissionsIndex from 'bundles/course/video-submissions/pages/UserVideoSubmissionsIndex'; -import AdminIndex from 'bundles/system/admin/admin/pages/AdminIndex'; -import AdminNavigator from 'bundles/system/admin/admin/pages/AdminNavigator'; -import AnnouncementIndex from 'bundles/system/admin/admin/pages/AnnouncementsIndex'; -import CourseIndex from 'bundles/system/admin/admin/pages/CoursesIndex'; -import InstancesIndex from 'bundles/system/admin/admin/pages/InstancesIndex'; -import UserIndex from 'bundles/system/admin/admin/pages/UsersIndex'; -import InstanceAdminIndex from 'bundles/system/admin/instance/instance/pages/InstanceAdminIndex'; -import InstanceAdminNavigator from 'bundles/system/admin/instance/instance/pages/InstanceAdminNavigator'; -import InstanceAnnouncementsIndex from 'bundles/system/admin/instance/instance/pages/InstanceAnnouncementsIndex'; -import InstanceComponentsIndex from 'bundles/system/admin/instance/instance/pages/InstanceComponentsIndex'; -import InstanceCoursesIndex from 'bundles/system/admin/instance/instance/pages/InstanceCoursesIndex'; -import InstanceUserRoleRequestsIndex from 'bundles/system/admin/instance/instance/pages/InstanceUserRoleRequestsIndex'; -import InstanceUsersIndex from 'bundles/system/admin/instance/instance/pages/InstanceUsersIndex'; -import InstanceUsersInvitations from 'bundles/system/admin/instance/instance/pages/InstanceUsersInvitations'; -import InstanceUsersInvite from 'bundles/system/admin/instance/instance/pages/InstanceUsersInvite'; -import AccountSettings from 'bundles/user/AccountSettings'; -import UserShow from 'bundles/users/pages/UserShow'; -import PopupNotifierOutlet from 'course/user-notification/PopupNotifierOutlet'; - -const RoutedApp = (): JSX.Element => ( - - - } index /> - - } path=":courseId"> - } index /> - - - } index /> - } path=":achievementId" /> - - - } path="announcements" /> - - - } index /> - } path="submissions" /> - } path="skills" /> - - - } index /> - } path="edit" /> - } path="monitoring" /> - } path="sessions/new" /> - - {/* @ts-ignore `connect` throws error when cannot find `store` as direct parent */} - } path="statistics" /> - - - } index /> - - - } path="edit" /> - } path="logs" /> - - - - - - } path="new" /> - - } - path=":questionId/edit" - /> - - - - } path="new" /> - } path=":questionId/edit" /> - - - - } path="new" /> - - } - path=":questionId/edit" - /> - - - - } path="new" /> - } path=":questionId/edit" /> - - - - } path="new" /> - } path=":questionId/edit" /> - - - - } path="new" /> - } path=":questionId/edit" /> - - - - - - } path="comments" /> - } path="duplication" /> - } path="enrol_requests" /> - - - } index /> - - - } index /> - } path="topics/:topicId" /> - - - - } path="groups"> - } path=":groupCategoryId" /> - - - {/* @ts-ignore `connect` throws error when cannot find `store` as direct parent */} - } path="lesson_plan"> - } index /> - } path="edit" /> - - - } path="leaderboard" /> - } path="learning_map" /> - } path="levels" /> - } path="materials/folders/:folderId" /> - } path="statistics" /> - } path="timelines" /> - - - } index /> - } path="personal_times" /> - } path="invite" /> - - } - path="disburse_experience_points" - /> - - - } index /> - } path="personal_times" /> - - } - path="video_submissions" - /> - - } - path="experience_points_records" - /> - - } - path="manage_email_subscription" - /> - - - } path="user_invitations" /> - } path="students" /> - } path="staff" /> - - - } index /> - - - } index /> - - - } index /> - - - } index /> - } path="edit" /> - - - - - - - } index /> - - - } index /> - } path="results" /> - - - } index /> - - - } index /> - } path="edit" /> - - - - - - } path="admin"> - } index /> - } path="components" /> - } path="sidebar" /> - } path="notifications" /> - } path="announcements" /> - } path="assessments" /> - } path="materials" /> - } path="forums" /> - } path="leaderboard" /> - } path="comments" /> - } path="videos" /> - } path="lesson_plan" /> - } path="codaveri" /> - - - - - } path="announcements" /> - - } path="users/:userId" /> - } path="user/profile/edit" /> - - } path="admin"> - } index /> - } path="announcements" /> - } path="users" /> - } path="instances" /> - } path="courses" /> - - - {/* `admin/instance` cannot be nested in `admin/*` because it has a different container element */} - } path="admin/instance"> - } index /> - } path="announcements" /> - } path="components" /> - } path="courses" /> - } path="users" /> - } path="users/invite" /> - } path="user_invitations" /> - } path="role_requests" /> - - - {/* `/role_requests` will be routed without `InstanceAdminNavigator` and its sidebar. - Here, we redirect `/role_requests` to `/admin/instance/role_requests`. But until - we are loading the page without Rails' router, fresh loads to `/role_requests` will - go to 404. Once we are 100% SPA, this is a non-issue. */} - } - path="role_requests" - /> - -); - -export default RoutedApp; diff --git a/client/app/bundles/course/achievement/handles.ts b/client/app/bundles/course/achievement/handles.ts new file mode 100644 index 00000000000..99718ef12e5 --- /dev/null +++ b/client/app/bundles/course/achievement/handles.ts @@ -0,0 +1,17 @@ +import { getIdFromUnknown } from 'utilities'; + +import CourseAPI from 'api/course'; +import { DataHandle } from 'lib/hooks/router/dynamicNest'; + +const getAchievementTitle = async (achievementId: number): Promise => { + const { data } = await CourseAPI.achievements.fetch(achievementId); + return data.achievement.title; +}; + +export const achievementHandle: DataHandle = (match) => { + const achievementId = getIdFromUnknown(match.params?.achievementId); + if (!achievementId) + throw new Error(`Invalid achievement id: ${achievementId}`); + + return { getData: () => getAchievementTitle(achievementId) }; +}; diff --git a/client/app/bundles/course/achievement/pages/AchievementsIndex/index.tsx b/client/app/bundles/course/achievement/pages/AchievementsIndex/index.tsx index 6bbfb04d75e..d1eb24f3f8e 100644 --- a/client/app/bundles/course/achievement/pages/AchievementsIndex/index.tsx +++ b/client/app/bundles/course/achievement/pages/AchievementsIndex/index.tsx @@ -3,8 +3,8 @@ import { defineMessages } from 'react-intl'; import { toast } from 'react-toastify'; import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -37,6 +37,10 @@ const translations = defineMessages({ id: 'course.achievement.AchievementsIndex.toggleFailure', defaultMessage: 'Failed to update achievement.', }, + achievements: { + id: 'course.achievement.AchievementsIndex.achievements', + defaultMessage: 'Achievements', + }, }); const AchievementsIndex: FC = () => { @@ -92,14 +96,14 @@ const AchievementsIndex: FC = () => { }); return ( - <> - + {isLoading ? ( ) : ( @@ -116,8 +120,10 @@ const AchievementsIndex: FC = () => { /> )} - + ); }; -export default AchievementsIndex; +const handle = translations.achievements; + +export default Object.assign(AchievementsIndex, { handle }); diff --git a/client/app/bundles/course/admin/components/SettingsNavigation.tsx b/client/app/bundles/course/admin/components/SettingsNavigation.tsx index 9b3f6ce378d..d9af55245f6 100644 --- a/client/app/bundles/course/admin/components/SettingsNavigation.tsx +++ b/client/app/bundles/course/admin/components/SettingsNavigation.tsx @@ -1,17 +1,26 @@ +import { createContext, useCallback, useContext, useState } from 'react'; +import { defineMessages } from 'react-intl'; import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; + LoaderFunction, + Outlet, + useLoaderData, + useLocation, + useNavigate, +} from 'react-router-dom'; import { Chip } from '@mui/material'; import { CourseAdminItems } from 'types/course/admin/course'; import CourseAPI from 'api/course'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import Preload from 'lib/components/wrappers/Preload'; +import { DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + courseSettings: { + id: 'course.admin.courseSettings', + defaultMessage: 'Course Settings', + }, +}); const fetchItems = async (): Promise => { const response = await CourseAPI.admin.course.items(); @@ -23,18 +32,10 @@ const ItemsReloaderContext = createContext(() => {}); export const useItemsReloader = (): (() => void) => useContext(ItemsReloaderContext); -interface LoadedSettingsNavigationProps { - data: CourseAdminItems; -} - -const LoadedSettingsNavigation = ( - props: LoadedSettingsNavigationProps, -): JSX.Element => { - const [items, setItems] = useState(props.data); +const SettingsNavigation = (): JSX.Element => { + const data = useLoaderData() as CourseAdminItems; - useEffect(() => { - fetchItems().then(setItems); - }, []); + const [items, setItems] = useState(data); const navigate = useNavigate(); const { pathname } = useLocation(); @@ -46,31 +47,49 @@ const LoadedSettingsNavigation = ( if (!items) return ; return ( - -
-
- {items.map(({ title, path }) => ( - navigate(path)} - variant={path === pathname ? 'filled' : 'outlined'} - /> - ))} -
+ + +
+
+ {items.map(({ title, path }) => ( + navigate(path)} + variant={path === pathname ? 'filled' : 'outlined'} + /> + ))} +
- -
-
+ +
+
+ ); }; -const SettingsNavigation = (): JSX.Element => ( - } while={fetchItems}> - {(data): JSX.Element => } - -); +const loader: LoaderFunction = fetchItems; + +const handle: DataHandle = (match, location) => { + const items = match.data as CourseAdminItems; + const currentItem = items.find(({ path }) => path === location.pathname); + + return { + shouldRevalidate: true, + getData: () => ({ + content: [ + { + title: translations.courseSettings, + }, + { + title: currentItem?.title, + url: currentItem?.path, + }, + ], + }), + }; +}; -export default SettingsNavigation; +export default Object.assign(SettingsNavigation, { loader, handle }); diff --git a/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx b/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx index 6c7c26c0d40..6b2576af88c 100644 --- a/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx +++ b/client/app/bundles/course/announcements/components/misc/AnnouncementCard.tsx @@ -2,8 +2,7 @@ import { FC, memo, useState } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; import { toast } from 'react-toastify'; import { DateRange, PushPin } from '@mui/icons-material'; -import { Link } from '@mui/material'; -import { grey } from '@mui/material/colors'; +import { Link, Paper, Typography } from '@mui/material'; import equal from 'fast-deep-equal'; import { Operation } from 'store'; import { @@ -116,21 +115,16 @@ const AnnouncementCard: FC = (props) => { ); }; - const backgroundColor = announcement.isUnread ? '#ffe8e8' : '#ffffff'; - return ( <> -
@@ -148,7 +142,7 @@ const AnnouncementCard: FC = (props) => { )} -

= (props) => { fontWeight: 'bold', overflowWrap: 'anywhere', }} + variant="h5" > {announcement.title} -

+
{showEditOptions && updateOperation && deleteOperation && (
@@ -186,15 +181,20 @@ const AnnouncementCard: FC = (props) => { )}
- + {formatFullDateTime(announcement.startTime)}{' '} {intl.formatMessage(translations.timeSeparator)} {renderUserLink()} - -
+ -
+ {showEditOptions && updateOperation && ( = (props) => { - const { intl } = props; +const AnnouncementsIndex = (): JSX.Element => { + const { t } = useTranslation(); // For new announcements form dialog const [isOpen, setIsOpen] = useState(false); @@ -56,33 +55,28 @@ const AnnouncementsIndex: FC = (props) => { useEffect(() => { dispatch(fetchAnnouncements()) - .catch(() => - toast.error(intl.formatMessage(translations.fetchAnnouncementsFailure)), - ) + .catch(() => toast.error(t(translations.fetchAnnouncementsFailure))) .finally(() => setIsLoading(false)); }, [dispatch]); return ( - <> - , - ] - : undefined - } - /> + + ) + } + title={t(translations.header)} + > {isLoading ? ( ) : ( <> {announcements.length === 0 ? ( - + ) : ( = (props) => { /> )} - + ); }; -export default injectIntl(AnnouncementsIndex); +const handle = translations.header; + +export default Object.assign(AnnouncementsIndex, { handle }); diff --git a/client/app/bundles/course/assessment/handles.ts b/client/app/bundles/course/assessment/handles.ts new file mode 100644 index 00000000000..13eab45b374 --- /dev/null +++ b/client/app/bundles/course/assessment/handles.ts @@ -0,0 +1,93 @@ +import { isAuthenticatedAssessmentData } from 'types/course/assessment/assessments'; +import { getIdFromUnknown } from 'utilities'; + +import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +import { fetchAssessment, fetchAssessments } from './operations'; + +const getTabTitle = async ( + categoryId?: number, + tabId?: number, +): Promise => { + const { display } = await fetchAssessments(categoryId, tabId); + + return { + activePath: display.tabUrl.split('&tab')[0], + content: { + url: display.tabUrl, + title: display.tabTitle, + }, + }; +}; + +const getTabTitleFromAssessmentId = async ( + assessmentId: number, +): Promise => { + const data = await fetchAssessment(assessmentId); + + return { + activePath: data.tabUrl.split('&tab')[0], + content: { + url: data.tabUrl, + title: data.tabTitle, + }, + }; +}; + +/** + * Gets the crumb data and active path for assessments pages, + * except Submissions and Skills. + */ +export const assessmentsHandle: DataHandle = (match, location) => { + if (location.pathname.includes('assessments/s')) return null; + + let promise: Promise; + + const assessmentId = getIdFromUnknown(match.params?.assessmentId); + if (assessmentId) { + promise = getTabTitleFromAssessmentId(assessmentId); + } else { + const searchParams = new URLSearchParams(location.search); + const categoryId = getIdFromUnknown(searchParams.get('category')); + const tabId = getIdFromUnknown(searchParams.get('tab')); + promise = getTabTitle(categoryId, tabId); + } + + return { shouldRevalidate: true, getData: () => promise }; +}; + +export const assessmentHandle: DataHandle = (match) => { + const assessmentId = getIdFromUnknown(match.params?.assessmentId); + if (!assessmentId) throw new Error(`Invalid assessment id: ${assessmentId}`); + + return { + getData: async (): Promise => { + const data = await fetchAssessment(assessmentId); + return data.title; + }, + }; +}; + +export const questionHandle: DataHandle = (match, location) => { + if (location.pathname.endsWith('new')) return null; + + const assessmentId = getIdFromUnknown(match.params?.assessmentId); + if (!assessmentId) throw new Error(`Invalid assessment id: ${assessmentId}`); + + return { + getData: async (): Promise => { + const data = await fetchAssessment(assessmentId); + if (!isAuthenticatedAssessmentData(data)) return null; + + const question = data.questions?.find( + ({ editUrl }) => editUrl === location.pathname, + ); + + if (!question) return null; + + return question.title + ? `${question.defaultTitle}: ${question.title}` + : question.defaultTitle; + }, + }; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx b/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx index 2ef7d0ba32b..402aac2df1d 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/AssessmentEditPage.jsx @@ -5,7 +5,7 @@ import { Navigate } from 'react-router-dom'; import { Button } from '@mui/material'; import PropTypes from 'prop-types'; -import PageHeader from 'lib/components/navigation/PageHeader'; +import Page from 'lib/components/core/layouts/Page'; import { achievementTypesConditionAttributes } from 'lib/types'; import AssessmentForm from '../../components/AssessmentForm'; @@ -67,8 +67,8 @@ class AssessmentEditPage extends Component { // TODO: Add a source router props that can be used to determine where // did the user come from, and initialise a Back button that goes there. return ( -
- + - - + } + className="space-y-5" + title={intl.formatMessage(translations.editAssessment)} + > {this.state.redirectUrl && } -
+ ); } } diff --git a/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx index 107a257e9e1..d4f14ab8eab 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentEdit/index.tsx @@ -4,6 +4,7 @@ import { getAssessmentId } from 'lib/helpers/url-helpers'; import { DEFAULT_MONITORING_OPTIONS } from '../../constants'; import { fetchAssessmentEditData } from '../../operations'; +import translations from '../../translations'; import { categoryAndTabTitle } from '../../utils'; import AssessmentEditPage from './AssessmentEditPage'; @@ -60,4 +61,6 @@ const AssessmentEdit = (): JSX.Element => { ); }; -export default AssessmentEdit; +const handle = translations.edit; + +export default Object.assign(AssessmentEdit, { handle }); diff --git a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx index 34992f4d7bf..dbc033cb522 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentMonitoring/index.tsx @@ -2,6 +2,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; import { fetchMonitoringData } from '../../operations'; +import translations from '../../translations'; import PulseGrid from './PulseGrid'; @@ -13,4 +14,6 @@ const AssessmentMonitoring = (): JSX.Element => { ); }; -export default AssessmentMonitoring; +const handle = translations.pulsegrid; + +export default Object.assign(AssessmentMonitoring, { handle }); diff --git a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx index 38a7358833f..5b1e697547e 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx +++ b/client/app/bundles/course/assessment/pages/AssessmentStatistics/index.jsx @@ -24,6 +24,10 @@ import AncestorSelect from './AncestorSelect'; import StatisticsPanel from './StatisticsPanel'; const translations = defineMessages({ + statistics: { + id: 'course.assessment.statistics.statistics', + defaultMessage: 'Statistics', + }, header: { id: 'course.assessment.statistics.header', defaultMessage: 'Statistics for {title}', @@ -198,6 +202,11 @@ AssessmentStatisticsPage.propTypes = { ancestorAllStudents: PropTypes.arrayOf(courseUserShape), }; -export default connect(({ assessments }) => assessments.statisticsPage)( - injectIntl(AssessmentStatisticsPage), +const handle = translations.statistics; + +export default Object.assign( + connect(({ assessments }) => assessments.statisticsPage)( + injectIntl(AssessmentStatisticsPage), + ), + { handle }, ); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx index 2c0c91c74fe..3d9e09bcd69 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx @@ -1,10 +1,9 @@ -import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Tab, Tabs } from '@mui/material'; import { AssessmentsListData } from 'types/course/assessment/assessments'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import Preload from 'lib/components/wrappers/Preload'; import { fetchAssessments } from '../../operations'; @@ -14,57 +13,49 @@ import NewAssessmentFormButton from './NewAssessmentFormButton'; const AssessmentsIndex = (): JSX.Element => { const [params, setParams] = useSearchParams(); - const [currentTab, setCurrentTab] = - useState(); + const categoryId = parseInt(params.get('category') ?? '', 10) || undefined; + const tabId = parseInt(params.get('tab') ?? '', 10) || undefined; const fetchAssessmentsInTab = (): Promise => { - const categoryId = parseInt(params.get('category') ?? '', 10) || undefined; - const tabId = - currentTab ?? (parseInt(params.get('tab') ?? '', 10) || undefined); - return fetchAssessments(categoryId, tabId); }; return ( } - syncsWith={[currentTab]} + syncsWith={[categoryId, tabId]} while={fetchAssessmentsInTab} > {(data, refreshable): JSX.Element => ( - <> - , - ] - : undefined - } - /> - + + ) + } + title={data.display.category.title} + unpadded + > {data.display.category.tabs.length > 1 && ( { - setCurrentTab(id); setParams({ category: data.display.category.id.toString(), tab: id.toString(), }); window.scrollTo({ top: 0, behavior: 'smooth' }); }} - value={currentTab ?? data.display.tabId} + value={tabId ?? data.display.tabId} variant="scrollable" > {data.display.category.tabs.map((tab) => ( @@ -74,7 +65,7 @@ const AssessmentsIndex = (): JSX.Element => { )} {refreshable()} - + )} ); diff --git a/client/app/bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage.tsx b/client/app/bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage.tsx index 3d0af502e78..b2699173e1f 100644 --- a/client/app/bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage.tsx +++ b/client/app/bundles/course/assessment/question/forum-post-responses/NewForumPostResponsePage.tsx @@ -45,4 +45,6 @@ const NewForumPostResponsePage = (): JSX.Element => { ); }; -export default NewForumPostResponsePage; +const handle = translations.newForumPostResponse; + +export default Object.assign(NewForumPostResponsePage, { handle }); diff --git a/client/app/bundles/course/assessment/question/multiple-responses/NewMcqMrqPage.tsx b/client/app/bundles/course/assessment/question/multiple-responses/NewMcqMrqPage.tsx index fbd4982f9e7..e978e306de1 100644 --- a/client/app/bundles/course/assessment/question/multiple-responses/NewMcqMrqPage.tsx +++ b/client/app/bundles/course/assessment/question/multiple-responses/NewMcqMrqPage.tsx @@ -8,6 +8,7 @@ import { import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; +import { DataHandle } from 'lib/hooks/router/dynamicNest'; import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; @@ -33,13 +34,15 @@ const NEW_MCQ_MRQ_TEMPLATE: McqMrqData['question'] = { randomizeOptions: false, }; +const getMcqMrqType = (params: URLSearchParams): McqMrqFormData['mcqMrqType'] => + params.get('multiple_choice') === 'true' ? 'mcq' : 'mrq'; + const NewMcqMrqPage = (): JSX.Element => { const { t } = useTranslation(); const [params] = useSearchParams(); - const isMcq = params.get('multiple_choice') === 'true'; + const type = getMcqMrqType(params); - const type: McqMrqFormData['mcqMrqType'] = isMcq ? 'mcq' : 'mrq'; const [fetchData, FormComponent] = newMcqMrqAdapters[type]; const handleSubmit = (data: McqMrqData): Promise => @@ -58,4 +61,12 @@ const NewMcqMrqPage = (): JSX.Element => { ); }; -export default NewMcqMrqPage; +const handle: DataHandle = (_, location) => { + const searchParams = new URLSearchParams(location.search); + + return getMcqMrqType(searchParams) === 'mcq' + ? translations.newMultipleChoice + : translations.newMultipleResponse; +}; + +export default Object.assign(NewMcqMrqPage, { handle }); diff --git a/client/app/bundles/course/assessment/question/programming/NewProgrammingQuestionPage.tsx b/client/app/bundles/course/assessment/question/programming/NewProgrammingQuestionPage.tsx index 5e640ae65d5..52a53d00871 100644 --- a/client/app/bundles/course/assessment/question/programming/NewProgrammingQuestionPage.tsx +++ b/client/app/bundles/course/assessment/question/programming/NewProgrammingQuestionPage.tsx @@ -8,6 +8,8 @@ import { import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; +import translations from '../../translations'; + import buildFormData from './commons/builder'; import { create, fetchEdit, fetchNew, update } from './operations'; import ProgrammingForm from './ProgrammingForm'; @@ -67,4 +69,6 @@ const NewProgrammingQuestionPage = (): JSX.Element => { ); }; -export default NewProgrammingQuestionPage; +const handle = translations.newProgramming; + +export default Object.assign(NewProgrammingQuestionPage, { handle }); diff --git a/client/app/bundles/course/assessment/question/scribing/ScribingQuestion.tsx b/client/app/bundles/course/assessment/question/scribing/ScribingQuestion.tsx index 0e268caaf9c..2d93df33c17 100644 --- a/client/app/bundles/course/assessment/question/scribing/ScribingQuestion.tsx +++ b/client/app/bundles/course/assessment/question/scribing/ScribingQuestion.tsx @@ -5,6 +5,8 @@ import { getScribingId } from 'lib/helpers/url-helpers'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; +import assessmentsTranslations from '../../translations'; + import translations from './ScribingQuestionForm/translations'; import { fetchScribingQuestion, fetchSkills } from './operations'; import ScribingQuestionForm from './ScribingQuestionForm'; @@ -38,4 +40,6 @@ const ScribingQuestion = (): JSX.Element => { ); }; -export default ScribingQuestion; +const handle = assessmentsTranslations.newScribing; + +export default Object.assign(ScribingQuestion, { handle }); diff --git a/client/app/bundles/course/assessment/question/text-responses/NewTextResponsePage.tsx b/client/app/bundles/course/assessment/question/text-responses/NewTextResponsePage.tsx index 427cf2f0e73..ccacd59eb07 100644 --- a/client/app/bundles/course/assessment/question/text-responses/NewTextResponsePage.tsx +++ b/client/app/bundles/course/assessment/question/text-responses/NewTextResponsePage.tsx @@ -8,6 +8,7 @@ import { import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Preload from 'lib/components/wrappers/Preload'; +import { DataHandle } from 'lib/hooks/router/dynamicNest'; import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; @@ -52,15 +53,16 @@ const newTextResponseAdapter: Record< ], }; +const getQuestionType = ( + params: URLSearchParams, +): TextResponseFormData['questionType'] => + params.get('file_upload') === 'true' ? 'file_upload' : 'text_response'; + const NewTextResponsePage = (): JSX.Element => { const { t } = useTranslation(); const [params] = useSearchParams(); - const isFileUpload = params.get('file_upload') === 'true'; - - const type: TextResponseFormData['questionType'] = isFileUpload - ? 'file_upload' - : 'text_response'; + const type = getQuestionType(params); const [fetchData, FormComponent, initialFormValue] = newTextResponseAdapter[type]; @@ -81,4 +83,12 @@ const NewTextResponsePage = (): JSX.Element => { ); }; -export default NewTextResponsePage; +const handle: DataHandle = (_, location) => { + const searchParams = new URLSearchParams(location.search); + + return getQuestionType(searchParams) === 'file_upload' + ? translations.newFileUpload + : translations.newTextResponse; +}; + +export default Object.assign(NewTextResponsePage, { handle }); diff --git a/client/app/bundles/course/assessment/question/voice-responses/NewVoicePage.tsx b/client/app/bundles/course/assessment/question/voice-responses/NewVoicePage.tsx index af074b29219..a33ff5c2db4 100644 --- a/client/app/bundles/course/assessment/question/voice-responses/NewVoicePage.tsx +++ b/client/app/bundles/course/assessment/question/voice-responses/NewVoicePage.tsx @@ -39,4 +39,6 @@ const NewVoicePage = (): JSX.Element => { ); }; -export default NewVoicePage; +const handle = translations.newAudioResponse; + +export default Object.assign(NewVoicePage, { handle }); diff --git a/client/app/bundles/course/assessment/skills/pages/SkillsIndex/index.tsx b/client/app/bundles/course/assessment/skills/pages/SkillsIndex/index.tsx index 03fe8baf3ce..833af0e9008 100644 --- a/client/app/bundles/course/assessment/skills/pages/SkillsIndex/index.tsx +++ b/client/app/bundles/course/assessment/skills/pages/SkillsIndex/index.tsx @@ -189,4 +189,6 @@ const SkillsIndex: FC = (props) => { ); }; -export default injectIntl(SkillsIndex); +const handle = translations.skills; + +export default Object.assign(injectIntl(SkillsIndex), { handle }); diff --git a/client/app/bundles/course/assessment/submission/pages/LogsIndex/index.tsx b/client/app/bundles/course/assessment/submission/pages/LogsIndex/index.tsx index a4479bad152..2ab10bf5f77 100644 --- a/client/app/bundles/course/assessment/submission/pages/LogsIndex/index.tsx +++ b/client/app/bundles/course/assessment/submission/pages/LogsIndex/index.tsx @@ -35,4 +35,6 @@ const LogsIndex = (): JSX.Element => { ); }; -export default LogsIndex; +const handle = translations.accessLogs; + +export default Object.assign(LogsIndex, { handle }); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx index 959ba6561a9..ead9514cf20 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionEditIndex/index.jsx @@ -18,6 +18,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import withRouter from 'lib/components/navigation/withRouter'; import { getUrlParameter } from 'lib/helpers/url-helpers'; +import assessmentsTranslations from '../../../translations'; import { autogradeSubmission, enterStudentView, @@ -486,8 +487,10 @@ function mapStateToProps({ assessments: { submission } }) { }; } +const handle = assessmentsTranslations.attempt; + const SubmissionEditIndex = withRouter( withHeartbeatWorker(connect(mapStateToProps)(VisibleSubmissionEditIndex)), ); -export default SubmissionEditIndex; +export default Object.assign(SubmissionEditIndex, { handle }); diff --git a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx index a91d9b8c92c..2d3b42c8e79 100644 --- a/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx +++ b/client/app/bundles/course/assessment/submission/pages/SubmissionsIndex/index.jsx @@ -24,6 +24,7 @@ import ConfirmationDialog from 'lib/components/core/dialogs/ConfirmationDialog'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import withRouter from 'lib/components/navigation/withRouter'; +import assessmentsTranslations from '../../../translations'; import { purgeSubmissionStore } from '../../actions'; import { deleteAllSubmissions, @@ -553,5 +554,7 @@ function mapStateToProps({ assessments: { submission } }) { }; } +const handle = assessmentsTranslations.submissions; + const SubmissionsIndex = connect(mapStateToProps)(VisibleSubmissionsIndex); -export default withRouter(SubmissionsIndex); +export default Object.assign(withRouter(SubmissionsIndex), { handle }); diff --git a/client/app/bundles/course/assessment/submission/translations.ts b/client/app/bundles/course/assessment/submission/translations.ts index ed11e44d722..8162014d5cc 100644 --- a/client/app/bundles/course/assessment/submission/translations.ts +++ b/client/app/bundles/course/assessment/submission/translations.ts @@ -1,6 +1,10 @@ import { defineMessages } from 'react-intl'; const translations = defineMessages({ + submissionsHeader: { + id: 'course.assessment.submission.submissionsHeader', + defaultMessage: 'Submissions: {assessment}', + }, studentView: { id: 'course.assessment.submission.studentView', defaultMessage: 'Student View', diff --git a/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx b/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx index 90bc6f1cea2..e11a14b8c28 100644 --- a/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx +++ b/client/app/bundles/course/assessment/submissions/SubmissionsIndex.tsx @@ -8,8 +8,8 @@ import { } from 'types/course/assessment/submissions'; import BackendPagination from 'lib/components/core/layouts/BackendPagination'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -133,8 +133,7 @@ const SubmissionsIndex = (): JSX.Element => { }, [dispatch]); return ( - <> - + {pageIsLoading ? ( ) : ( @@ -150,18 +149,21 @@ const SubmissionsIndex = (): JSX.Element => { tabValue={tabValue} /> - 1} - tabCategories={tabs.categories} - /> + {submissionPermissions.canManage && tabValue > 1 && ( + + + + )} {!isTabChanging && ( { )} )} - + ); }; -export default SubmissionsIndex; +const handle = translations.header; + +export default Object.assign(SubmissionsIndex, { handle }); diff --git a/client/app/bundles/course/assessment/submissions/components/misc/SubmissionFilter.tsx b/client/app/bundles/course/assessment/submissions/components/misc/SubmissionFilter.tsx index cc615e99200..1e00fceb046 100644 --- a/client/app/bundles/course/assessment/submissions/components/misc/SubmissionFilter.tsx +++ b/client/app/bundles/course/assessment/submissions/components/misc/SubmissionFilter.tsx @@ -9,7 +9,6 @@ import { } from 'types/course/assessment/submissions'; interface Props extends WrappedComponentProps { - showDetailFilter: boolean; filter: SubmissionFilterData; tabCategories: { id: number; title: string }[]; @@ -50,7 +49,6 @@ const translations = defineMessages({ const SubmissionFilter: FC = (props) => { const { intl, - showDetailFilter, filter, tabCategories, categoryNum, @@ -60,8 +58,6 @@ const SubmissionFilter: FC = (props) => { } = props; const disableButton = Object.values(selectedFilter).every((x) => x === null); - if (!showDetailFilter) return null; - return ( diff --git a/client/app/bundles/course/assessment/submissions/components/tables/SubmissionsTable.tsx b/client/app/bundles/course/assessment/submissions/components/tables/SubmissionsTable.tsx index 7315f72543c..20952c9f992 100644 --- a/client/app/bundles/course/assessment/submissions/components/tables/SubmissionsTable.tsx +++ b/client/app/bundles/course/assessment/submissions/components/tables/SubmissionsTable.tsx @@ -5,7 +5,6 @@ import { Chip, Link, Stack, - Table, TableBody, TableCell, TableHead, @@ -15,6 +14,8 @@ import palette from 'theme/palette'; import { SubmissionMiniEntity } from 'types/course/assessment/submissions'; import CustomTooltip from 'lib/components/core/CustomTooltip'; +import Page from 'lib/components/core/layouts/Page'; +import TableContainer from 'lib/components/core/layouts/TableContainer'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Note from 'lib/components/core/Note'; import { getAssessmentURL, getCourseUserURL } from 'lib/helpers/url-builders'; @@ -63,11 +64,11 @@ const translations = defineMessages({ }, tableHeaderTotalGrade: { id: 'course.assessment.submissions.SubmissionsTable.tableHeaderTotalGrade', - defaultMessage: 'Total Grade', + defaultMessage: 'Grade', }, tableHeaderExp: { id: 'course.assessment.submissions.SubmissionsTable.tableHeaderExp', - defaultMessage: 'Exp Awarded', + defaultMessage: 'EXP', }, gradeTooltip: { id: 'course.assessment.submissions.SubmissionsTable.gradeTooltip', @@ -114,14 +115,15 @@ const SubmissionsTable: FC = (props) => { return ; } - if (submissions.length === 0) { + if (submissions.length === 0) return ( - + + + ); - } return ( - + @@ -264,7 +266,7 @@ const SubmissionsTable: FC = (props) => { ))} -
+ ); }; diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 5edf8abf388..07bd69afe9b 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -436,6 +436,38 @@ const translations = defineMessages({ id: 'course.assessment.show.forumPostResponse', defaultMessage: 'Forum Post Response', }, + newMultipleChoice: { + id: 'course.assessment.show.newMultipleChoice', + defaultMessage: 'New Multiple Choice Question (MCQ)', + }, + newMultipleResponse: { + id: 'course.assessment.show.newMultipleResponse', + defaultMessage: 'New Multiple Response Question (MRQ)', + }, + newTextResponse: { + id: 'course.assessment.show.newTextResponse', + defaultMessage: 'New Text Response Question', + }, + newAudioResponse: { + id: 'course.assessment.show.newAudioResponse', + defaultMessage: 'New Audio Response Question', + }, + newFileUpload: { + id: 'course.assessment.show.newFileUpload', + defaultMessage: 'New File Upload Question', + }, + newProgramming: { + id: 'course.assessment.show.newProgramming', + defaultMessage: 'New Programming Question', + }, + newScribing: { + id: 'course.assessment.show.newScribing', + defaultMessage: 'New Scribing Question', + }, + newForumPostResponse: { + id: 'course.assessment.show.newForumPostResponse', + defaultMessage: 'New Forum Post Response Question', + }, newQuestion: { id: 'course.assessment.show.newQuestion', defaultMessage: 'New Question', diff --git a/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx b/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx index 3a1f92e54a0..4c975c8283b 100644 --- a/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx +++ b/client/app/bundles/course/courses/components/buttons/TodoIgnoreButton.tsx @@ -52,7 +52,6 @@ const TodoIgnoreButton: FC = (props) => { onIgnore(); }} style={{ width: 80 }} - variant="outlined" > {intl.formatMessage(translations.ignoreButtonText)} diff --git a/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx b/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx index 3d083a81471..deb31086e7f 100644 --- a/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx +++ b/client/app/bundles/course/courses/components/misc/CourseAnnouncements.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; -import { Stack } from '@mui/material'; +import { Stack, Typography } from '@mui/material'; import { AnnouncementEntity } from 'types/course/announcements'; import AnnouncementCard from '../../../announcements/components/misc/AnnouncementCard'; @@ -12,11 +12,7 @@ interface Props extends WrappedComponentProps { const translations = defineMessages({ announcementHeader: { id: 'course.courses.CourseAnnouncements.announcementHeader', - defaultMessage: 'Announcements', - }, - noAnnouncements: { - id: 'course.courses.CourseAnnouncements.noAnnouncements', - defaultMessage: 'There are currently no announcements', + defaultMessage: 'Latest announcements', }, }); @@ -24,12 +20,10 @@ const CourseAnnouncements: FC = (props) => { const { intl, announcements } = props; return ( - <> -

{intl.formatMessage(translations.announcementHeader)}

- - {announcements === null && ( - <>{intl.formatMessage(translations.noAnnouncements)} - )} +
+ + {intl.formatMessage(translations.announcementHeader)} + {announcements && ( @@ -42,7 +36,7 @@ const CourseAnnouncements: FC = (props) => { ))} )} - +
); }; diff --git a/client/app/bundles/course/courses/components/misc/CourseNotifications.tsx b/client/app/bundles/course/courses/components/misc/CourseNotifications.tsx index d4372e62783..133920ca483 100644 --- a/client/app/bundles/course/courses/components/misc/CourseNotifications.tsx +++ b/client/app/bundles/course/courses/components/misc/CourseNotifications.tsx @@ -1,7 +1,6 @@ import { FC, memo } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; -import { Stack } from '@mui/material'; -import { grey } from '@mui/material/colors'; +import { Paper, Typography } from '@mui/material'; import equal from 'fast-deep-equal'; import { NotificationData } from 'types/course/notifications'; @@ -14,7 +13,7 @@ interface Props extends WrappedComponentProps { const translations = defineMessages({ latestActivityHeader: { id: 'course.courses.CourseNotifications.latestActivity', - defaultMessage: 'Latest Activity', + defaultMessage: 'Latest Activities', }, }); @@ -26,30 +25,22 @@ const CourseNotifications: FC = (props) => { } return ( - <> -

{intl.formatMessage(translations.latestActivityHeader)}

-
- - {notifications.map((notification, index) => ( - - ))} - -
- +
+ + {intl.formatMessage(translations.latestActivityHeader)} + + + + {notifications.map((notification, index) => ( + + ))} + +
); }; diff --git a/client/app/bundles/course/courses/components/misc/NotificationCard.tsx b/client/app/bundles/course/courses/components/misc/NotificationCard.tsx index f0578d5d608..6bf93edb06b 100644 --- a/client/app/bundles/course/courses/components/misc/NotificationCard.tsx +++ b/client/app/bundles/course/courses/components/misc/NotificationCard.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; -import { Avatar } from '@mui/material'; +import { Link } from 'react-router-dom'; +import { Avatar, Typography } from '@mui/material'; import { NotificationData } from 'types/course/notifications'; import { @@ -109,28 +110,30 @@ const NotificationCard: FC = (props) => { } return ( -
+
+
-
- + + {notification.userInfo.name} - + + {` ${concatMessage} `} + {notification.actableName && ( - {notification.actableName} + {notification.actableName} )} + {notification.levelNumber && `${notification.levelNumber}`} -
- + + + {formatFullDateTime(notification.createdAt)} - +
); diff --git a/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx b/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx index b3e63ba5aed..033f9c65733 100644 --- a/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx +++ b/client/app/bundles/course/courses/components/tables/PendingTodosTable.tsx @@ -1,18 +1,20 @@ import { CSSProperties, FC, memo, useEffect, useState } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { KeyboardArrowDown } from '@mui/icons-material'; import { Button, Link, Stack, - Table, TableBody, TableCell, TableHead, TableRow, + Typography, } from '@mui/material'; import equal from 'fast-deep-equal'; import { TodoData } from 'types/course/lesson-plan/todos'; +import TableContainer from 'lib/components/core/layouts/TableContainer'; import PersonalStartEndTime from 'lib/components/extensions/PersonalStartEndTime'; import { getAssessmentAttemptURL, @@ -58,15 +60,15 @@ const translations = defineMessages({ }, tableHeaderStartAt: { id: 'course.courses.PendingTodosTable.tableHeaderStartAt', - defaultMessage: 'Start At', + defaultMessage: 'Starts at', }, tableHeaderEndAt: { id: 'course.courses.PendingTodosTable.tableHeaderEndAt', - defaultMessage: 'End At', + defaultMessage: 'Ends at', }, tableSeeMore: { id: 'course.courses.PendingTodosTable.tableSeeMore', - defaultMessage: 'SEE MORE', + defaultMessage: 'See {n} more', }, accessButtonRespond: { id: 'course.courses.PendingTodosTable.accessButtonRespond', @@ -74,7 +76,7 @@ const translations = defineMessages({ }, accessButtonEnterPassword: { id: 'course.courses.PendingTodosTable.accessButtonEnterPassword', - defaultMessage: 'Enter Password', + defaultMessage: 'Unlock', }, accessButtonAttempt: { id: 'course.courses.PendingTodosTable.accessButtonAttempt', @@ -229,23 +231,25 @@ const PendingTodosTable: FC = (props) => { }; return ( - <> -

{header}

- +
+ {header} + + - + {intl.formatMessage(translations.tableHeaderTitle)} - + {intl.formatMessage(translations.tableHeaderStartAt)} - + {intl.formatMessage(translations.tableHeaderEndAt)} + {shavedTodos.map((todo) => ( = (props) => { ))} -
+ + {todos.length > shavedTodos.length && ( -
-
)} - + ); }; diff --git a/client/app/bundles/course/courses/pages/CourseShow/index.tsx b/client/app/bundles/course/courses/pages/CourseShow/index.tsx index 172d96b9a82..b40e1e31921 100644 --- a/client/app/bundles/course/courses/pages/CourseShow/index.tsx +++ b/client/app/bundles/course/courses/pages/CourseShow/index.tsx @@ -2,11 +2,11 @@ import { FC, useEffect, useState } from 'react'; import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { Grid } from '@mui/material'; +import { Typography } from '@mui/material'; import AvatarWithLabel from 'lib/components/core/AvatarWithLabel'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import CourseAnnouncements from '../../components/misc/CourseAnnouncements'; @@ -59,54 +59,59 @@ const CourseShow: FC = (props) => { } return ( - <> - - + {!course.permissions.isCurrentCourseUser && ( <> - - -
- {course.registrationInfo && ( - - )} -
-
- -

- {intl.formatMessage(translations.descriptionHeader)} -

-
-
-
-

{intl.formatMessage(translations.instructorsHeader)}

- - {course.instructors?.map((instructor) => ( - -
+
+ {course.registrationInfo && ( + + )} +
+ +
+ + {intl.formatMessage(translations.descriptionHeader)} + + + +
+ +
+ + {intl.formatMessage(translations.instructorsHeader)} + + +
+ {course.instructors?.map((instructor) => ( +
- - ))} - + ))} +
+
)} {(course.permissions.isCurrentCourseUser || course.permissions.canManage) && ( <> - + {Boolean(course.currentlyActiveAnnouncements?.length) && ( + + )} {course.assessmentTodos && ( = (props) => { )} - + ); }; diff --git a/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx b/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx index d72a564b423..b9a211c488d 100644 --- a/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx +++ b/client/app/bundles/course/courses/pages/CoursesIndex/index.tsx @@ -1,10 +1,11 @@ import { FC, ReactElement, useEffect, useState } from 'react'; import { defineMessages } from 'react-intl'; +import { useSearchParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Button } from '@mui/material'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -50,7 +51,9 @@ const translations = defineMessages({ const CoursesIndex: FC = () => { const { t } = useTranslation(); - const [isNewCourseDialogOpen, setIsNewCourseDialogOpen] = useState(false); + + const [params] = useSearchParams(); + const [isLoading, setIsLoading] = useState(true); const [isRoleRequestDialogOpen, setRoleRequestDialogOpen] = useState(false); const courses = useAppSelector(getAllCourseMiniEntities); @@ -63,6 +66,13 @@ const CoursesIndex: FC = () => { const dispatch = useAppDispatch(); + const shouldOpenNewCourseDialog = + Boolean(params.get('new')) && coursesPermissions?.canCreate; + + const [isNewCourseDialogOpen, setIsNewCourseDialogOpen] = useState( + shouldOpenNewCourseDialog, + ); + useEffect(() => { dispatch(fetchCourses()) .finally(() => setIsLoading(false)) @@ -102,8 +112,7 @@ const CoursesIndex: FC = () => { } return ( - <> - + {isLoading ? ( ) : ( @@ -124,8 +133,10 @@ const CoursesIndex: FC = () => { )} )} - + ); }; -export default CoursesIndex; +const handle = translations.header; + +export default Object.assign(CoursesIndex, { handle }); diff --git a/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx b/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx index 938e2177194..23b58efadf3 100644 --- a/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx +++ b/client/app/bundles/course/discussion/topics/pages/CommentIndex/index.tsx @@ -15,9 +15,9 @@ import { CommentTabTypes, } from 'types/course/comments'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import CustomBadge from 'lib/components/extensions/CustomBadge'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import TopicList from '../../components/lists/TopicList'; @@ -199,24 +199,29 @@ const CommentIndex: FC = (props) => { }, [dispatch]); return ( - <> - + {isLoading ? ( ) : ( <> - + + + + )} - + ); }; -export default injectIntl(CommentIndex); +const handle = translations.comments; + +export default Object.assign(injectIntl(CommentIndex), { handle }); diff --git a/client/app/bundles/course/duplication/pages/Duplication/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/index.jsx index 46cbed98840..1af8f581603 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/index.jsx +++ b/client/app/bundles/course/duplication/pages/Duplication/index.jsx @@ -300,15 +300,20 @@ Duplication.propTypes = { intl: PropTypes.object, }; -export default connect(({ duplication }) => ({ - isLoading: duplication.isLoading, - isChangingCourse: duplication.isChangingCourse, - isCourseSelected: !!duplication.destinationCourseId, - duplicationMode: duplication.duplicationMode, - modesAllowed: duplication.sourceCourse.duplicationModesAllowed, - enabledComponents: duplication.sourceCourse.enabledComponents, - currentHost: duplication.currentHost, - currentCourseId: duplication.currentCourseId, - sourceCourse: duplication.sourceCourse, - sourceCourses: duplication.sourceCourses, -}))(injectIntl(Duplication)); +const handle = translations.duplicateData; + +export default Object.assign( + connect(({ duplication }) => ({ + isLoading: duplication.isLoading, + isChangingCourse: duplication.isChangingCourse, + isCourseSelected: !!duplication.destinationCourseId, + duplicationMode: duplication.duplicationMode, + modesAllowed: duplication.sourceCourse.duplicationModesAllowed, + enabledComponents: duplication.sourceCourse.enabledComponents, + currentHost: duplication.currentHost, + currentCourseId: duplication.currentCourseId, + sourceCourse: duplication.sourceCourse, + sourceCourses: duplication.sourceCourses, + }))(injectIntl(Duplication)), + { handle }, +); diff --git a/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx b/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx index a27c387efcd..4807dda3690 100644 --- a/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx +++ b/client/app/bundles/course/enrol-requests/pages/UserRequests/index.tsx @@ -131,4 +131,6 @@ const UserRequests: FC = (props) => { ); }; -export default injectIntl(UserRequests); +const handle = translations.manageUsersHeader; + +export default Object.assign(injectIntl(UserRequests), { handle }); diff --git a/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx b/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx index 22befe6b7af..82ff2ba6765 100644 --- a/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx +++ b/client/app/bundles/course/experience-points/disbursement/pages/DisbursementIndex/index.tsx @@ -117,4 +117,6 @@ const DisbursementIndex: FC = (props) => { ); }; -export default injectIntl(DisbursementIndex); +const handle = translations.disbursements; + +export default Object.assign(injectIntl(DisbursementIndex), { handle }); diff --git a/client/app/bundles/course/forum/handles.ts b/client/app/bundles/course/forum/handles.ts new file mode 100644 index 00000000000..d3a262c5814 --- /dev/null +++ b/client/app/bundles/course/forum/handles.ts @@ -0,0 +1,32 @@ +import CourseAPI from 'api/course'; +import { DataHandle } from 'lib/hooks/router/dynamicNest'; + +const getForumTitle = async (forumId: string): Promise => { + const response = await CourseAPI.forum.forums.fetch(forumId); + return response.data.forum.name; +}; + +const getTopicTitle = async ( + forumId: string, + topicId: string, +): Promise => { + const response = await CourseAPI.forum.topics.fetch(forumId, topicId); + return response.data.topic.title; +}; + +export const forumHandle: DataHandle = (match) => { + const forumId = match.params?.forumId; + if (!forumId) throw new Error(`Invalid forum id: ${forumId}`); + + return { getData: () => getForumTitle(forumId) }; +}; + +export const forumTopicHandle: DataHandle = (match) => { + const forumId = match.params?.forumId; + if (!forumId) throw new Error(`Invalid forum id: ${forumId}`); + + const topicId = match.params?.topicId; + if (!topicId) throw new Error(`Invalid topic id: ${topicId}`); + + return { getData: () => getTopicTitle(forumId, topicId) }; +}; diff --git a/client/app/bundles/course/forum/pages/ForumShow/index.tsx b/client/app/bundles/course/forum/pages/ForumShow/index.tsx index 605d22bc99c..82569fc43f8 100644 --- a/client/app/bundles/course/forum/pages/ForumShow/index.tsx +++ b/client/app/bundles/course/forum/pages/ForumShow/index.tsx @@ -4,9 +4,8 @@ import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; -import ScrollToTop from 'lib/components/navigation/ScrollToTop'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -118,14 +117,12 @@ const ForumShow: FC = () => { const forumPageHeaderTitle = forum ? forum.name : t(translations.header); return ( - <> - - - + {!isLoading && isOpen && ( { ) : ( )} - + ); }; diff --git a/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx b/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx index 170b63524c6..38cb08157a4 100644 --- a/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx +++ b/client/app/bundles/course/forum/pages/ForumTopicShow/index.tsx @@ -5,10 +5,9 @@ import { toast } from 'react-toastify'; import { Box } from '@mui/material'; import { TopicType } from 'types/course/forums'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import Note from 'lib/components/core/Note'; -import PageHeader from 'lib/components/navigation/PageHeader'; -import ScrollToTop from 'lib/components/navigation/ScrollToTop'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -137,16 +136,13 @@ const ForumTopicShow: FC = () => { ); return ( - <> - - - + {isLoading ? : renderBody} - + ); }; diff --git a/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx b/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx index ed21bde0c27..1f2072c4640 100644 --- a/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx +++ b/client/app/bundles/course/forum/pages/ForumsIndex/index.tsx @@ -3,9 +3,8 @@ import { defineMessages } from 'react-intl'; import { toast } from 'react-toastify'; import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; -import ScrollToTop from 'lib/components/navigation/ScrollToTop'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -99,18 +98,19 @@ const ForumsIndex: FC = () => { ); return ( - <> - - + {!isLoading && isForumNewDialogOpen && ( setIsForumNewDialogOpen(false)} open={isForumNewDialogOpen} /> )} + {isLoading ? : } - + ); }; -export default ForumsIndex; +const handle = translations.header; + +export default Object.assign(ForumsIndex, { handle }); diff --git a/client/app/bundles/course/group/pages/GroupIndex/index.jsx b/client/app/bundles/course/group/pages/GroupIndex/index.jsx index dbe4f80f020..c909f17d66e 100644 --- a/client/app/bundles/course/group/pages/GroupIndex/index.jsx +++ b/client/app/bundles/course/group/pages/GroupIndex/index.jsx @@ -102,4 +102,6 @@ GroupIndex.propTypes = { intl: PropTypes.object.isRequired, }; -export default injectIntl(GroupIndex); +const handle = translations.groups; + +export default Object.assign(injectIntl(GroupIndex), { handle }); diff --git a/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx b/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx index aa525dfff28..9935f2650f7 100644 --- a/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx +++ b/client/app/bundles/course/leaderboard/pages/LeaderboardIndex/index.tsx @@ -12,8 +12,8 @@ import { useTheme } from '@mui/material/styles'; import useMediaQuery from '@mui/material/useMediaQuery'; import palette from 'theme/palette'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import LeaderboardTable from '../../components/tables/LeaderboardTable'; @@ -80,14 +80,14 @@ const LeaderboardIndex: FC = (props) => { const isGroupHidden = groupLeaderboardPoints.length === 0; return ( - <> - + {isLoading ? ( ) : ( @@ -218,8 +218,10 @@ const LeaderboardIndex: FC = (props) => { )} - + ); }; -export default injectIntl(LeaderboardIndex); +const handle = translations.leaderboard; + +export default Object.assign(injectIntl(LeaderboardIndex), { handle }); diff --git a/client/app/bundles/course/learning-map/containers/LearningMap/index.jsx b/client/app/bundles/course/learning-map/containers/LearningMap/index.jsx index ea38a40bc72..60d856f619f 100644 --- a/client/app/bundles/course/learning-map/containers/LearningMap/index.jsx +++ b/client/app/bundles/course/learning-map/containers/LearningMap/index.jsx @@ -3,6 +3,7 @@ import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import { fetchNodes } from 'course/learning-map/operations'; +import translations from 'course/learning-map/translations'; import Canvas from '../Canvas'; import Dashboard from '../Dashboard'; @@ -38,4 +39,6 @@ LearningMap.propTypes = { isLoading: PropTypes.bool.isRequired, }; -export default connect(mapStateToProps)(LearningMap); +const handle = translations.defaultDashboardMessage; + +export default Object.assign(connect(mapStateToProps)(LearningMap), { handle }); diff --git a/client/app/bundles/course/lesson-plan/containers/ColumnVisibilityDropdown/index.jsx b/client/app/bundles/course/lesson-plan/containers/ColumnVisibilityDropdown/index.jsx index 744567a4e6f..c37a2db4a92 100644 --- a/client/app/bundles/course/lesson-plan/containers/ColumnVisibilityDropdown/index.jsx +++ b/client/app/bundles/course/lesson-plan/containers/ColumnVisibilityDropdown/index.jsx @@ -15,7 +15,7 @@ const { ITEM_TYPE, START_AT, BONUS_END_AT, END_AT, PUBLISHED } = fields; const translations = defineMessages({ label: { id: 'course.lessonPlan.ColumnVisibilityDropdown.label', - defaultMessage: 'Toggle Column Visibility', + defaultMessage: 'Columns', }, }); diff --git a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx index c948d15c798..390168beab7 100644 --- a/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx +++ b/client/app/bundles/course/lesson-plan/containers/LessonPlanLayout/index.jsx @@ -73,7 +73,12 @@ LessonPlanLayout.propTypes = { children: PropTypes.node.isRequired, }; -export default connect(({ lessonPlan }) => ({ - isLoading: lessonPlan.lessonPlan.isLoading, - groups: lessonPlan.lessonPlan.groups, -}))(LessonPlanLayout); +const handle = translations.lessonPlan; + +export default Object.assign( + connect(({ lessonPlan }) => ({ + isLoading: lessonPlan.lessonPlan.isLoading, + groups: lessonPlan.lessonPlan.groups, + }))(LessonPlanLayout), + { handle }, +); diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx index 12d7e27f81a..580248d3978 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanEdit/index.jsx @@ -1,10 +1,9 @@ import { Component } from 'react'; import { FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; -import { Card, CardContent } from '@mui/material'; import PropTypes from 'prop-types'; -import PageHeader from 'lib/components/navigation/PageHeader'; +import Page from 'lib/components/core/layouts/Page'; import { lessonPlanTypesGroups } from 'lib/types'; import { fields } from '../../constants'; @@ -55,18 +54,6 @@ export class LessonPlanEdit extends Component { return rows; }; - // eslint-disable-next-line class-methods-use-this - renderHeader = () => ( - - - - - - - - - ); - renderTableHeader() { const { columnsVisible } = this.props; @@ -93,18 +80,26 @@ export class LessonPlanEdit extends Component { const { groups } = this.props; return ( - <> - } /> - - {this.props.canManageLessonPlan && this.renderHeader()} - + + + + + + + ) + } + title={} + >
{this.renderTableHeader()} {groups.map(this.renderGroup)}
- +
); } } diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx index 5751f80c376..7ff13f41489 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/LessonPlanItem/AdminTools.jsx @@ -4,7 +4,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; -import { Button } from '@mui/material'; +import { IconButton } from '@mui/material'; import PropTypes from 'prop-types'; import { showDeleteConfirmation } from 'lib/actions'; @@ -38,16 +38,9 @@ const translations = defineMessages({ const styles = { tools: { top: 16, - right: 66, + right: 20, position: 'absolute', }, - edit: { - minWidth: 40, - }, - delete: { - minWidth: 40, - marginLeft: 10, - }, }; class AdminTools extends PureComponent { @@ -115,21 +108,13 @@ class AdminTools extends PureComponent { return ( - - + ); } diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx index 16006fd8813..e4fc28a6ab1 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/MilestoneAdminTools.jsx @@ -4,7 +4,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import { connect } from 'react-redux'; import Delete from '@mui/icons-material/Delete'; import Edit from '@mui/icons-material/Edit'; -import { Button } from '@mui/material'; +import { IconButton } from '@mui/material'; import PropTypes from 'prop-types'; import { showDeleteConfirmation } from 'lib/actions'; @@ -35,17 +35,6 @@ const translations = defineMessages({ }, }); -const styles = { - edit: { - minWidth: 40, - }, - delete: { - minWidth: 40, - marginLeft: 10, - marginRight: 20, - }, -}; - class MilestoneAdminTools extends PureComponent { deleteMilestoneHandler = () => { const { @@ -98,21 +87,13 @@ class MilestoneAdminTools extends PureComponent { return ( - - + ); } diff --git a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/index.jsx b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/index.jsx index ccb15194b94..9e968ee3b35 100644 --- a/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/index.jsx +++ b/client/app/bundles/course/lesson-plan/pages/LessonPlanShow/index.jsx @@ -2,10 +2,9 @@ import { Component } from 'react'; import { FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; import { scroller } from 'react-scroll'; -import { Card, CardContent } from '@mui/material'; import PropTypes from 'prop-types'; -import PageHeader from 'lib/components/navigation/PageHeader'; +import Page from 'lib/components/core/layouts/Page'; import moment from 'lib/moment'; import { lessonPlanTypesGroups } from 'lib/types'; @@ -81,25 +80,22 @@ export class LessonPlanShow extends Component { ); } - // eslint-disable-next-line class-methods-use-this - renderHeader = () => ( - - - - - - - - ); - render() { return ( - <> - } /> - - {this.props.canManageLessonPlan && this.renderHeader()} + + + + + + ) + } + title={} + > {this.props.groups.map((group) => this.renderGroup(group))} - + ); } } diff --git a/client/app/bundles/course/level/components/LevelRow.jsx b/client/app/bundles/course/level/components/LevelRow.jsx index da75c349d75..4a9d756f493 100644 --- a/client/app/bundles/course/level/components/LevelRow.jsx +++ b/client/app/bundles/course/level/components/LevelRow.jsx @@ -1,7 +1,7 @@ import { Component } from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; import Delete from '@mui/icons-material/Delete'; -import { Button, TableCell, TableRow, TextField } from '@mui/material'; +import { IconButton, TableCell, TableRow, TextField } from '@mui/material'; import PropTypes from 'prop-types'; const translations = defineMessages({ @@ -34,17 +34,17 @@ class LevelRow extends Component { const { deleteLevel, disabled, levelNumber } = this.props; return ( - + ); } diff --git a/client/app/bundles/course/level/pages/LevelsIndex/index.jsx b/client/app/bundles/course/level/pages/LevelsIndex/index.jsx index c8ae125298f..e753a865b16 100644 --- a/client/app/bundles/course/level/pages/LevelsIndex/index.jsx +++ b/client/app/bundles/course/level/pages/LevelsIndex/index.jsx @@ -240,9 +240,14 @@ LevelsIndex.propTypes = { dispatch: PropTypes.func.isRequired, }; -export default connect(({ levels }) => ({ - canManage: levels.canManage, - isLoading: levels.isLoading, - isSaving: levels.isSaving, - levels: levels.levels, -}))(LevelsIndex); +const handle = translations.levelHeader; + +export default Object.assign( + connect(({ levels }) => ({ + canManage: levels.canManage, + isLoading: levels.isLoading, + isSaving: levels.isSaving, + levels: levels.levels, + }))(LevelsIndex), + { handle }, +); diff --git a/client/app/bundles/course/material/folders/components/tables/TableMaterialRow.tsx b/client/app/bundles/course/material/folders/components/tables/TableMaterialRow.tsx index f63f6bee631..e04374a292f 100644 --- a/client/app/bundles/course/material/folders/components/tables/TableMaterialRow.tsx +++ b/client/app/bundles/course/material/folders/components/tables/TableMaterialRow.tsx @@ -21,7 +21,7 @@ const TableMaterialRow: FC = (props) => { return ( - + @@ -55,7 +55,6 @@ const TableMaterialRow: FC = (props) => { = (props) => { {!isCurrentCourseStudent && ( = (props) => { - )} - + = (props) => { return ( - + @@ -88,7 +88,6 @@ const TableSubfolderRow: FC = (props) => { = (props) => { {!isCurrentCourseStudent && ( = (props) => { )} = (props) => { }; return ( - + - - {columnHeaderWithSort('Name')} - - - {columnHeaderWithSort('Last Modified')} - + {columnHeaderWithSort('Name')} + {columnHeaderWithSort('Last Modified')} {!isCurrentCourseStudent && ( - - {columnHeaderWithSort('Start At')} - + {columnHeaderWithSort('Start At')} )} @@ -167,7 +162,7 @@ const WorkbinTable: FC = (props) => { ); })} -
+ ); }; diff --git a/client/app/bundles/course/material/folders/handles.ts b/client/app/bundles/course/material/folders/handles.ts new file mode 100644 index 00000000000..a3e04c8e8c5 --- /dev/null +++ b/client/app/bundles/course/material/folders/handles.ts @@ -0,0 +1,44 @@ +import { getIdFromUnknown } from 'utilities'; + +import CourseAPI from 'api/course'; +import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const getFolderTitle = async ( + courseUrl: string, + folderId: number, +): Promise => { + const { data } = await CourseAPI.folders.fetch(folderId); + + const workbinUrl = `${courseUrl}/materials/folders/${data.breadcrumbs[0].id}`; + + return { + activePath: workbinUrl, + content: data.breadcrumbs.map((crumb) => ({ + title: crumb.name, + url: `materials/folders/${crumb.id}`, + })), + }; +}; + +/** + * `shouldRevalidate` here relies on the invariant that `folderHandle` is attached to + * a route that is stable across different folders, i.e., `/materials/folders`. This route + * will be the pathname that the Dynamic Nest API's builder uses to reconcile the children + * of the Workbin's crumb. + * + * Note that Workbin has only ONE crumb, but multiple children in its `content`. This strategy + * is due to the fact that the Workbin's URL does not reflect any information of the nesting of + * the folders. Thus, `useMatches` cannot notify `useDynamicNest` of the changes in the routes, + * e.g., `useDynamicNest` cannot know if we move out from Folder 2 to Folder 1 from the URL. + */ +export const folderHandle: DataHandle = (match) => { + const folderId = getIdFromUnknown(match.params?.folderId); + if (!folderId) throw new Error(`Invalid folder id: ${folderId}`); + + const courseUrl = `/courses/${match.params.courseId}`; + + return { + shouldRevalidate: true, + getData: () => getFolderTitle(courseUrl, folderId), + }; +}; diff --git a/client/app/bundles/course/material/folders/operations.ts b/client/app/bundles/course/material/folders/operations.ts index 400049ab9ea..5405d5c36e7 100644 --- a/client/app/bundles/course/material/folders/operations.ts +++ b/client/app/bundles/course/material/folders/operations.ts @@ -71,7 +71,6 @@ export function loadFolder(folderId: number): Operation { data.currFolderInfo, data.subfolders, data.materials, - data.breadcrumbs, data.advanceStartAt, data.permissions, ), @@ -93,7 +92,6 @@ export function createFolder( data.currFolderInfo, data.subfolders, data.materials, - data.breadcrumbs, data.advanceStartAt, data.permissions, ), @@ -114,7 +112,6 @@ export function updateFolder( data.currFolderInfo, data.subfolders, data.materials, - data.breadcrumbs, data.advanceStartAt, data.permissions, ), @@ -167,7 +164,6 @@ export function uploadMaterials( data.currFolderInfo, data.subfolders, data.materials, - data.breadcrumbs, data.advanceStartAt, data.permissions, ), diff --git a/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx b/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx index 5ccae448ed1..e042b2a4255 100644 --- a/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx +++ b/client/app/bundles/course/material/folders/pages/FolderShow/index.tsx @@ -1,12 +1,10 @@ import { FC, ReactElement, useEffect, useState } from 'react'; import { defineMessages } from 'react-intl'; -import { Link, useParams } from 'react-router-dom'; -import { Breadcrumbs, Paper } from '@mui/material'; -import { grey } from '@mui/material/colors'; +import { useParams } from 'react-router-dom'; import EditButton from 'lib/components/core/buttons/EditButton'; +import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; -import PageHeader from 'lib/components/navigation/PageHeader'; import { getWorkbinFolderURL } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; @@ -19,7 +17,6 @@ import MaterialUpload from '../../components/misc/MaterialUpload'; import WorkbinTable from '../../components/tables/WorkbinTable'; import { loadFolder } from '../../operations'; import { - getBreadcrumbs, getCurrFolderInfo, getFolderMaterials, getFolderPermissions, @@ -50,7 +47,6 @@ const FolderShow: FC = () => { const subfolders = useAppSelector(getFolderSubfolders); const materials = useAppSelector(getFolderMaterials); const currFolderInfo = useAppSelector(getCurrFolderInfo); - const breadcrumbs = useAppSelector(getBreadcrumbs); const permissions = useAppSelector(getFolderPermissions); const [isLoading, setIsLoading] = useState(true); @@ -114,52 +110,20 @@ const FolderShow: FC = () => { }; return ( - <> - - - {breadcrumbs.map((breadcrumb, index) => { - if (index === breadcrumbs.length - 1) { - return ( - - {breadcrumb.name} - - ); - } - return ( - - {breadcrumb.name} - - ); - })} - - - + { handleClose={(): void => setIsMaterialUploadOpen(false)} isOpen={isMaterialUploadOpen} /> - + ); }; diff --git a/client/app/bundles/course/material/folders/selectors.ts b/client/app/bundles/course/material/folders/selectors.ts index 31d98ee47df..6de5dbe3686 100644 --- a/client/app/bundles/course/material/folders/selectors.ts +++ b/client/app/bundles/course/material/folders/selectors.ts @@ -24,10 +24,6 @@ export function getFolderMaterials(state: AppState) { ); } -export function getBreadcrumbs(state: AppState) { - return getLocalState(state).breadcrumbs; -} - export function getAdvanceStartAt(state: AppState) { return getLocalState(state).advanceStartAt; } diff --git a/client/app/bundles/course/material/folders/store.ts b/client/app/bundles/course/material/folders/store.ts index 2b789daebf5..63c026adbd8 100644 --- a/client/app/bundles/course/material/folders/store.ts +++ b/client/app/bundles/course/material/folders/store.ts @@ -35,7 +35,6 @@ const initialState: FoldersState = { }, subfolders: createEntityStore(), materials: createEntityStore(), - breadcrumbs: [], advanceStartAt: 0, permissions: { isCurrentCourseStudent: false, @@ -66,7 +65,6 @@ const reducer = produce((draft: FoldersState, action: FoldersActionType) => { saveListToStore(draft.materials, materialsEntityList); draft.advanceStartAt = action.advanceStartAt; - draft.breadcrumbs = action.breadcrumbs; draft.permissions = action.permissions; break; } @@ -114,7 +112,6 @@ export const actions = { }, subfolders: FolderListData[], materials: MaterialListData[], - breadcrumbs: { id: number; name: string }[], advanceStartAt: number, permissions: FolderPermissions, ): SaveFolderAction => { @@ -123,7 +120,6 @@ export const actions = { currFolderInfo, subfolders, materials, - breadcrumbs, advanceStartAt, permissions, }; diff --git a/client/app/bundles/course/material/folders/types.ts b/client/app/bundles/course/material/folders/types.ts index fba9fb3f365..f8309731629 100644 --- a/client/app/bundles/course/material/folders/types.ts +++ b/client/app/bundles/course/material/folders/types.ts @@ -29,7 +29,6 @@ export interface SaveFolderAction { subfolders: FolderListData[]; materials: MaterialListData[]; - breadcrumbs: { id: number; name: string }[]; advanceStartAt: number; permissions: FolderPermissions; } @@ -67,7 +66,6 @@ export interface FoldersState { subfolders: EntityStore; materials: EntityStore; - breadcrumbs: { id: number; name: string }[]; advanceStartAt: number; permissions: FolderPermissions; } diff --git a/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx b/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx index 951971d8a27..fb46a67a843 100644 --- a/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx +++ b/client/app/bundles/course/reference-timelines/TimelineDesigner.tsx @@ -5,6 +5,7 @@ import { useAppDispatch } from 'lib/hooks/store'; import DayView from './views/DayView'; import { LastSavedProvider } from './contexts'; import { fetchTimelines } from './operations'; +import translations from './translations'; const TimelineDesigner = (): JSX.Element => { const dispatch = useAppDispatch(); @@ -21,4 +22,6 @@ const TimelineDesigner = (): JSX.Element => { ); }; -export default TimelineDesigner; +const handle = translations.timelineDesigner; + +export default Object.assign(TimelineDesigner, { handle }); diff --git a/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx b/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx index 918d15f674e..719499bdd2c 100644 --- a/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx +++ b/client/app/bundles/course/reference-timelines/components/DayCalendar/DayCalendar.tsx @@ -54,7 +54,7 @@ const DayCalendar = forwardRef( return (
-