diff --git a/Gemfile b/Gemfile index a5e1b962..9c3375b1 100644 --- a/Gemfile +++ b/Gemfile @@ -60,7 +60,7 @@ gem 'prawn' gem 'prawn-qrcode' gem 'prawn-table' gem 'rails_12factor', group: :production -gem 'react_on_rails', '17.0.1' +gem 'react_on_rails', '17.1.0' gem 'ruby-vips', '~> 2.0' gem "solid_cable", "~> 4.0" gem 'stopwords-filter2', require: 'stopwords' @@ -98,7 +98,7 @@ group :development, :test do gem 'report_builder' gem 'rspec' gem 'rails_best_practices' - gem 'rubocop', '~> 1.82', require: false + gem 'rubocop', '~> 1.91', require: false gem 'reek' gem 'rubocop-thread_safety' gem 'rubocop-performance' diff --git a/Gemfile.lock b/Gemfile.lock index 5ac0ec9d..044defaa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,8 +108,8 @@ GEM auth-sanitizer (0.2.3) version_gem (~> 1.1, >= 1.1.14) aws-eventstream (1.4.0) - aws-partitions (1.1284.0) - aws-sdk-core (3.255.0) + aws-partitions (1.1287.0) + aws-sdk-core (3.257.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -118,11 +118,11 @@ GEM jmespath (~> 1, >= 1.6.1) logger rexml (~> 3.4, >= 3.4.2) - aws-sdk-kms (1.131.0) - aws-sdk-core (~> 3, >= 3.255.0) + aws-sdk-kms (1.132.0) + aws-sdk-core (~> 3, >= 3.256.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.231.0) - aws-sdk-core (~> 3, >= 3.255.0) + aws-sdk-s3 (1.232.1) + aws-sdk-core (~> 3, >= 3.256.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -262,7 +262,7 @@ GEM json (>= 2) faker (3.8.0) i18n (>= 1.8.11, < 2) - faraday (2.14.3) + faraday (2.14.4) faraday-net_http (>= 2.0, < 3.5) json logger @@ -289,7 +289,7 @@ GEM i18n_data (1.1.0) simple_po_parser (~> 1.1) image_processing (2.1.0) - io-console (0.9.2) + io-console (0.9.4) irb (1.18.0) pp (>= 0.6.0) prism (>= 1.3.0) @@ -300,7 +300,7 @@ GEM activesupport (>= 7.0.0) jmespath (1.6.2) json (3.0.2) - jwt (3.2.0) + jwt (3.3.0) base64 keypairs (2.0.1) actionpack (>= 7.1, < 9) @@ -332,7 +332,7 @@ GEM minitest (6.0.6) drb (~> 2.0) prism (~> 1.5) - msgpack (1.8.4) + msgpack (1.8.5) multi_test (1.1.0) multi_xml (0.9.1) bigdecimal (>= 3.1, < 5) @@ -345,7 +345,7 @@ GEM net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.3.0) + net-protocol (0.4.0) timeout net-smtp (0.5.1) net-protocol @@ -396,7 +396,7 @@ GEM ostruct (0.6.3) package_json (0.2.0) parallel (2.2.0) - parallel_tests (5.7.0) + parallel_tests (5.8.0) parallel parser (3.3.12.0) ast (~> 2.4.1) @@ -424,7 +424,7 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-proxy (2.0.0) + rack-proxy (2.0.1) rack (>= 2.0, < 4) rack-session (2.1.2) base64 (>= 0.1.0) @@ -496,7 +496,7 @@ GEM prism (>= 1.6.0) rbs (>= 4.0.0) tsort - react_on_rails (17.0.1) + react_on_rails (17.1.0) addressable connection_pool execjs (~> 2.5) @@ -536,7 +536,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.7) - rubocop (1.90.0) + rubocop (1.91.0) json (>= 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -563,9 +563,9 @@ GEM rack (>= 1.1) rubocop (>= 1.89.0, < 2.0) rubocop-ast (>= 1.44.0, < 2.0) - rubocop-thread_safety (0.7.3) + rubocop-thread_safety (0.8.0) lint_roller (~> 1.1) - rubocop (~> 1.72, >= 1.72.1) + rubocop (~> 1.89) rubocop-ast (>= 1.44.0, < 2.0) ruby-lsp (0.26.11) language_server-protocol (~> 3.17.0) @@ -577,7 +577,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - rubyzip (3.6.0) + rubyzip (3.7.0) safely_block (1.0.0) sdoc (2.6.5) rdoc (>= 5.0) @@ -622,7 +622,7 @@ GEM bigdecimal (~> 3.1) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2026.3) + tzinfo-data (1.2026.4) tzinfo (>= 1.0.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) @@ -708,11 +708,11 @@ DEPENDENCIES rails-erd rails_12factor rails_best_practices - react_on_rails (= 17.0.1) + react_on_rails (= 17.1.0) reek report_builder rspec - rubocop (~> 1.82) + rubocop (~> 1.91) rubocop-capybara rubocop-performance rubocop-rails @@ -753,10 +753,10 @@ CHECKSUMS ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 auth-sanitizer (0.2.3) sha256=db10aac92cfbe4c64ab637eebcbe1d67395d1694798041362173370f59933e3c aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1284.0) sha256=026432da13da430a31ba7c30c0c210b35fa7d738399977f033d4a5a354de58dc - aws-sdk-core (3.255.0) sha256=2bac7fbc8796e4e2eb8e6a6edebcb880d7023a922af97b15d2a8a26c9283343f - aws-sdk-kms (1.131.0) sha256=b60d28045cd93c604142cb691b15c7ddc1e6738c4ee5a90db4f2b91f0ada1d15 - aws-sdk-s3 (1.231.0) sha256=a9fc98c6f03f0e71c7215d48ff8844d436421df7f9486301d56bdf1f368a3364 + aws-partitions (1.1287.0) sha256=76f4c73011654553566c5db5bbef0f24738bb016e923910e8b1b3f9cb7d989ca + aws-sdk-core (3.257.0) sha256=a92ad19c7ff668cec6688ee3200ee562da80647a3bbeed9fda02e8b0ca6492c6 + aws-sdk-kms (1.132.0) sha256=094b0097bb3be9d5c1ac87e971ca7f5aebb746801dfb5f0f576480bf98dca69c + aws-sdk-s3 (1.232.1) sha256=124440f3a44fbc28cc3a2dec6eaeeea2a236bfc33772005010a19baa62654870 aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 @@ -814,7 +814,7 @@ CHECKSUMS erubis (2.7.0) sha256=63653f5174a7997f6f1d6f465fbe1494dcc4bdab1fb8e635f6216989fb1148ba execjs (2.10.2) sha256=466cf2b49e50c0cfdff8fdb88a7a983dfab7ac0f6185c317ccb434a75c224c88 faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc - faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 + faraday (2.14.4) sha256=9bb4408c44621b0dbaaaa39e5dbfc4dbe58cc6751b6eb94d16ae9e1f2f2fab6e faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df @@ -833,12 +833,12 @@ CHECKSUMS i18n (1.15.2) sha256=00f9eb62412fe593b2a65a97daa75300d37abb8f7202ec748e94b6d46a9dd1b5 i18n_data (1.1.0) sha256=fb7ae43bd03ec4dc2e6e62e726aac05aa868c170ba1444c056e2ab0d872bb2ed image_processing (2.1.0) sha256=ca3814b1b909fc5fab68b261c50eaffef11e29a6907143e9cf1efc14b6dc6f47 - io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + io-console (0.9.4) sha256=a403304f32928ee85e56d457b4612ca43f2703b561a3258844992bd24085fbb1 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df - jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 + jwt (3.3.0) sha256=44cc34fbc341c148233cf66641522b4bce0dfb1ce55e8073f05c56f44f4ee546 keypairs (2.0.1) sha256=032e2a8cc2009c578100b5537ad642f2c50d3a0b00d4bc433a2d1a039f80ed91 language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 @@ -853,14 +853,14 @@ CHECKSUMS memoist3 (1.0.0) sha256=686e42402cf150a362050c23143dc57b0ef88f8c344943ff8b7845792b50d56f mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 - msgpack (1.8.4) sha256=4411c22d350dd1c20250f7eada3cca2695438c2f769cf0782f0cd065d90a3e7b + msgpack (1.8.5) sha256=97419eaa7b35dfe3e462c0e6c1a3dfdcbfc8756605c09e46e4c6daef127517ba multi_test (1.1.0) sha256=e9e550cdd863fb72becfe344aefdcd4cbd26ebf307847f4a6c039a4082324d10 multi_xml (0.9.1) sha256=7ce766b59c17241ed62976caeae1fae9b2431b263398c35396239a68c4a64e57 mysql2 (0.5.7) sha256=ba09ede515a0ae8a7192040a1b778c0fb0f025fa5877e9be895cd325fa5e9d7b net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 net-imap (0.6.7) sha256=b5c9573be975d856de252ee851871724da66aa2d449b2482d5690bd12bc23660 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 - net-protocol (0.3.0) sha256=ba310c3d4f1cad46bb1ab20336b06669b1ff8f7c568d9cb9342b32a718547472 + net-protocol (0.4.0) sha256=f9f90c8c6f64794a45160bf422177a336db12809de3beeeaa26b88fcd2e8c853 net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f @@ -880,7 +880,7 @@ CHECKSUMS ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 package_json (0.2.0) sha256=92c022dc2999a9e57e835f1ec1a84c9a6c2be08e0fd68886c6f86d94101b6b4d parallel (2.2.0) sha256=e1059c5fd7b649558a0aec38a769f06a42942bdb40503d005a59c352fe011cd8 - parallel_tests (5.7.0) sha256=3f1762c46ca2c223b8af8ef877217f9d76974e191bfa934f2580b58bcf1d005c + parallel_tests (5.8.0) sha256=181898cbee2ffb1c796274ce31d27f792a421324609bfc7e9b5531d929d91eda parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828 pdf-core (0.10.0) sha256=0a5d101e2063c01e3f941e1ee47cbb97f1adfc1395b58372f4f65f1300f3ce91 powerpoint (1.8) @@ -895,7 +895,7 @@ CHECKSUMS racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.7) sha256=93e13e1c24f93556671d85d2d79fa228c3485815c50d7e2f265b5330c6528fb7 rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac - rack-proxy (2.0.0) sha256=4f1d435d82afe93bc916d1226df8be307c1b808551f0ecdb56e0b668fd5756e6 + rack-proxy (2.0.1) sha256=e7afa56a48728c22ded1b7c435210918d7b07bdbc61ca8f9fd687c48951ae274 rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 @@ -915,7 +915,7 @@ CHECKSUMS rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 - react_on_rails (17.0.1) sha256=fec671b8f93201892f0dd32ab0cc34961eb3de4d1d4a4f273b817eb701277b64 + react_on_rails (17.1.0) sha256=3c8914dbe61e862932f9dc08c0adb55dac049e0f923fc9a753f6c2beaf279767 reek (6.5.0) sha256=d26d3a492773b2bbc228888067a21afe33ac07954a17dbd64cdeae42c4c69be1 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d @@ -930,17 +930,17 @@ CHECKSUMS rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c - rubocop (1.90.0) sha256=9eb4c065b5c5154e4ef554c547972f3905a9eb6b53e657e580b6796b54bf8242 + rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0 rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db rubocop-capybara (3.0.0) sha256=7a64655238acda7f8f3c87e37ac825a64c615a79c17c253f1a28270dc3768c4b rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce rubocop-rails (2.37.0) sha256=6e1645add5060e0328f8ddda0d820f55697c591394398bf14bb9dccb62f14b7e - rubocop-thread_safety (0.7.3) sha256=067cdd52fbf5deffc18995437e45b5194236eaff4f71de3375a1f6052e48f431 + rubocop-thread_safety (0.8.0) sha256=71fe5e7894fb9acfbd4c2dcd394feabc35bd690f75b0bc846e1504d9e31f9034 ruby-lsp (0.26.11) sha256=4cc8f1587237ff8c9031680b5491df901a82559eeaa1ae96863ffe700b32583d ruby-lsp-rails (0.4.8) sha256=f09d1f926d4063deeb2f3049311925c20dfe6c912371e3bcd04a265a865c44ae ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 - rubyzip (3.6.0) sha256=268994d44d62282d1cfd99bf10eae48d7267199158ad7ea3e1fee2da9458b695 + rubyzip (3.7.0) sha256=65c19294da75297a939006f3516deacc33185fbd721ff1954f7a231db6d3e121 safely_block (1.0.0) sha256=bea80e084e620adeada62fd98cf461bbb9888d40dc0f06d337df9d01e1a658e5 sdoc (2.6.5) sha256=7998b61c10775ee3179ccb0253774332d139e2c0649c251091f5868107a09b86 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 @@ -962,7 +962,7 @@ CHECKSUMS tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f ttfunk (1.8.0) sha256=a7cbc7e489cc46e979dde04d34b5b9e4f5c8f1ee5fc6b1a7be39b829919d20ca tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b - tzinfo-data (1.2026.3) sha256=478fbc5356f13c1004cf8372b1336f3dad4055c96340fc4c881a3738da8cf7f9 + tzinfo-data (1.2026.4) sha256=22f5f02608f14938ab8b78513a70a22f0b0277614a07273d3bedb3ea45ece104 unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uniform_notifier (1.18.0) sha256=4787785556f66f6418486da0f1d78b3239aaff98e2e7938fb05e2062b0ffce9d diff --git a/app/javascript/components/ActivityLib.tsx b/app/javascript/components/ActivityLib.tsx index c672e1ac..7706b415 100644 --- a/app/javascript/components/ActivityLib.tsx +++ b/app/javascript/components/ActivityLib.tsx @@ -9,95 +9,95 @@ export function iconForType(type: string) { switch (type.toLowerCase()) { case "home": iconData.className = "home"; - iconData.icon = ; + iconData.icon = ; break; case "welcome": iconData.className = "welcome"; - iconData.icon = ; + iconData.icon = ; break; case "profile": iconData.className = "profile"; - iconData.icon = ; + iconData.icon = ; break; case "perspective": iconData.className = "perspective"; - iconData.icon = ; + iconData.icon = ; break; case "concept": case "concepts": iconData.className = "concept"; - iconData.icon = ; + iconData.icon = ; break; case "group experience": case "experience": case "experiences": iconData.className = "experience"; - iconData.icon = ; + iconData.icon = ; break; case "project": case "assessment": case "assessments": iconData.className = "assessment"; - iconData.icon = ; + iconData.icon = ; break; case "terms list": case "bingo_game": case "bingo games": iconData.className = "bingo_game"; - iconData.icon = ; + iconData.icon = ; break; case "group assignment": case "assignment": case "assignments": iconData.className = "assignment"; - iconData.icon = ; + iconData.icon = ; break; case "submission": iconData.className = "submission"; - iconData.icon = ; + iconData.icon = ; break; case "rubric": case "rubrics": iconData.className = "rubric"; - iconData.icon = ; + iconData.icon = ; break; case "course": case "courses": iconData.className = "course"; - iconData.icon = ; + iconData.icon = ; break; case "user": case "users": iconData.className = "user"; - iconData.icon = ; + iconData.icon = ; break; case "reporting": iconData.className = "reporting"; - iconData.icon = ; + iconData.icon = ; break; case "administration": case "admin": iconData.className = "admin"; - iconData.icon = ; + iconData.icon = ; break; case "school": case "schools": iconData.className = "school"; - iconData.icon = ; + iconData.icon = ; break; case "consent_form": case "consent_forms": iconData.className = "consent_form"; - iconData.icon = ; + iconData.icon = ; break; case 'demo': case 'demonstration': iconData.className = "demonstration"; - iconData.icon = ; + iconData.icon = ; break; default: iconData.className = `unit-${type}`; - iconData.icon = ; + iconData.icon = ; } return ( <> diff --git a/app/javascript/components/AppStatusBar.tsx b/app/javascript/components/AppStatusBar.tsx index 4841c46e..490cc0d4 100644 --- a/app/javascript/components/AppStatusBar.tsx +++ b/app/javascript/components/AppStatusBar.tsx @@ -6,26 +6,38 @@ import { acknowledgeMsg } from "./infrastructure/StatusSlice"; import { Toast } from "primereact/toast"; -export default function AppStatusBar(props) { - const messages = useTypedSelector(state => { - return state.status.messages; +type AppMessage = { + text: string; + priority: "error" | "info" | "warning"; + dismissed: boolean; +}; + +export default function AppStatusBar() { + const messages = useTypedSelector((state): AppMessage[] => { + return state.status.messages ?? []; }); const dispatch = useDispatch(); - const toast = React.useRef(null); + const toast = React.useRef(null); useEffect(() => { - messages.forEach((message, index) => { + messages.forEach((message: AppMessage, index: number) => { if (!message.dismissed) { - toast.current.show({ - severity: message.priority, - summary: message.priority, - detail: message.text, - life: 30000 - }); + if (toast.current) { + toast.current.show({ + severity: message.priority, + summary: message.priority, + detail: message.text, + life: 30000 + }); + } dispatch(acknowledgeMsg(index)); } }); - }, [messages]); + }, [dispatch, messages]); - return ; + return ( + <> + + + ); } diff --git a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx index df371b99..1ab908f1 100644 --- a/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx +++ b/app/javascript/components/BingoBoards/BingoGameDataAdmin.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useState, useEffect, useMemo } from "react"; +import React, { Suspense, useState, useEffect, useMemo, useRef } from "react"; import { useNavigate, useParams } from "react-router"; import { useDispatch } from "react-redux"; @@ -10,7 +10,7 @@ import { Button } from "primereact/button"; import { useTranslation } from "react-i18next"; import { useTypedSelector } from "../infrastructure/AppReducers"; -import { startTask, endTask } from "../infrastructure/StatusSlice"; +import { startTask, endTask, useDirtyStatus, DIRTY_STATUS } from "../infrastructure/StatusSlice"; import axios from "axios"; import { Editor } from "primereact/editor"; import EditorToolbar from "../toolbars/EditorToolbar"; @@ -41,7 +41,8 @@ export default function BingoGameDataAdmin(props) { const { t, i18n } = useTranslation(`${category}s`); - const [dirty, setDirty] = useState(false); + const [dirty, setDirty] = useDirtyStatus( bingoGameIdParam === "new" ? DIRTY_STATUS.DIRTY : DIRTY_STATUS.CLEAN ); + const suppressDirtyRef = useRef(false); const [curTab, setCurTab] = useState(0); const [messages, setMessages] = useState({}); const [gameProjects, setGameProjects] = useState([ @@ -85,7 +86,10 @@ export default function BingoGameDataAdmin(props) { }, [endpointStatus]); useEffect(() => { - setDirty(true); + if (suppressDirtyRef.current || null === bingoGameId ) { + return; + } + setDirty(DIRTY_STATUS.DIRTY); }, [ gameTopic, gameDescriptionEditor, @@ -161,9 +165,8 @@ export default function BingoGameDataAdmin(props) { setGameGroupDiscount(bingo_game.group_discount || 0); setGameGroupProjectId(bingo_game.project_id); setFoundWords(data.found_words); + setDirty(DIRTY_STATUS.CLEAN); - //getBingoGameData(); - //setDirty(false); navigate(`../${courseIdParam}/bingo_game/${bingoGameId}`, { replace: true }); @@ -173,6 +176,7 @@ export default function BingoGameDataAdmin(props) { }) .finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }); }; @@ -200,7 +204,7 @@ export default function BingoGameDataAdmin(props) { }; const getBingoGameData = () => { - setDirty(true); + suppressDirtyRef.current = true; dispatch(startTask()); var url = endpoints.baseUrl + "/"; if (null === bingoGameId) { @@ -240,13 +244,14 @@ export default function BingoGameDataAdmin(props) { setGameGroupDiscount(bingo_game.group_discount || 0); setGameGroupProjectId(bingo_game.project_id); setFoundWords(data.found_words); - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); return [{ id: -1, name: "no data" }]; }) .finally(() => { + suppressDirtyRef.current = false; dispatch(endTask()); }); }; @@ -258,11 +263,11 @@ export default function BingoGameDataAdmin(props) { return deadline; }, [gameEndDate, gameLeadTime]); - const save_btn = dirty ? ( + const save_btn = ( }> - ) : null; + ); const group_options = gameGroupOption ? ( }> diff --git a/app/javascript/components/BingoBoards/CandidateListEntry.tsx b/app/javascript/components/BingoBoards/CandidateListEntry.tsx index 5bd70f65..09fb9794 100644 --- a/app/javascript/components/BingoBoards/CandidateListEntry.tsx +++ b/app/javascript/components/BingoBoards/CandidateListEntry.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { useParams } from "react-router"; import { Panel } from "primereact/panel"; @@ -10,7 +10,9 @@ import { startTask, endTask, addMessage, - Priorities + Priorities, + useDirtyStatus, + DIRTY_STATUS } from "../infrastructure/StatusSlice"; import { useTypedSelector } from "../infrastructure/AppReducers"; import axios from "axios"; @@ -38,7 +40,8 @@ export default function CandidateListEntry(props: Props) { const { bingoGameId } = useParams(); - const [dirty, setDirty] = useState(false); + const [dirty, setDirty] = useDirtyStatus(); + const suppressDirtyRef = useRef(false); const dispatch = useDispatch(); const [candidateListId, setCandidateListId] = useState(0); @@ -56,8 +59,8 @@ export default function CandidateListEntry(props: Props) { const [requestCollaborationUrl, setRequestCollaborationUrl] = useState(""); const getCandidateList = () => { + suppressDirtyRef.current = true; dispatch(startTask()); - setDirty(true); const url = props.rootPath === undefined ? `${endpoints.baseUrl}${bingoGameId}.json` @@ -81,7 +84,7 @@ export default function CandidateListEntry(props: Props) { setHelpRequested(data.help_requested); setRequestCollaborationUrl(data.request_collaboration_url); - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); @@ -159,7 +162,7 @@ export default function CandidateListEntry(props: Props) { setHelpRequested(data.help_requested); setOthersRequestedHelp(data.others_requested_help); - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); dispatch(addMessage(data.messages.main, new Date(), Priorities.INFO)); } else { data.messages.forEach(message => { @@ -182,7 +185,11 @@ export default function CandidateListEntry(props: Props) { }, [endpointStatus]); useEffect(() => { - setDirty(true); + if (suppressDirtyRef.current) { + suppressDirtyRef.current = false; + return; + } + setDirty(DIRTY_STATUS.DIRTY); }, [candidates]); // TODO: Fix the check to see if the form is dirty @@ -209,7 +216,7 @@ export default function CandidateListEntry(props: Props) { setCandidates(prepCandidates(data.candidates, data.expected_count)); setHelpRequested(data.help_requested); setOthersRequestedHelp(data.others_requested_help); - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); diff --git a/app/javascript/components/BingoBoards/CandidatesReviewTable.tsx b/app/javascript/components/BingoBoards/CandidatesReviewTable.tsx index b0875aa1..c5d6f730 100644 --- a/app/javascript/components/BingoBoards/CandidatesReviewTable.tsx +++ b/app/javascript/components/BingoBoards/CandidatesReviewTable.tsx @@ -1,12 +1,12 @@ /* eslint-disable no-console */ -import React, { useState, useEffect, ReactNode } from "react"; +import React, { useState, useEffect, ReactNode, useRef } from "react"; import { useParams } from "react-router"; import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; import RemoteAutosuggest from "./RemoteAutosuggest"; import { useTypedSelector } from "../infrastructure/AppReducers"; -import { startTask, endTask } from "../infrastructure/StatusSlice"; +import { startTask, endTask, useDirtyStatus, DIRTY_STATUS } from "../infrastructure/StatusSlice"; import axios from "axios"; import parse from "html-react-parser"; @@ -90,10 +90,14 @@ export default function CandidatesReviewTable(props: Props) { const [acceptableUniqueConcepts, setAcceptableUniqueConcepts] = useState(0); const dispatch = useDispatch(); - const [dirty, setDirty] = useState(false); + const [dirty, setDirty] = useDirtyStatus( ); + const suppressDirtyRef = useRef(false); useEffect(() => { - setDirty(true); + if( suppressDirtyRef.current ) { + return; + } + setDirty(DIRTY_STATUS.DIRTY); }, [reviewComplete, candidates]); useEffect(() => { @@ -160,6 +164,7 @@ export default function CandidatesReviewTable(props: Props) { ? `${endpoints.baseUrl}${bingoGameId}.json` : `/${props.rootPath}${endpoints.baseUrl}${bingoGameId}.json`; + suppressDirtyRef.current = true; axios .get(url, {}) .then(response => { @@ -184,17 +189,18 @@ export default function CandidatesReviewTable(props: Props) { setCandidates(data.candidates); setReviewStatus(t("review.data_loaded_msg")); - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); }) .finally(() => { dispatch(endTask()); + suppressDirtyRef.current = false; }); }; const saveFeedback = () => { - setDirty(false); + suppressDirtyRef.current = true; dispatch(startTask("saving")); setReviewStatus(t("review.saving_msg")); @@ -210,8 +216,8 @@ export default function CandidatesReviewTable(props: Props) { }) .then(response => { const data = response.data; - setDirty(typeof data.success !== "undefined"); setReviewStatus(data.notice); + setDirty( typeof data.success !== "undefined" ? DIRTY_STATUS.CLEAN : DIRTY_STATUS.DIRTY ); }) .catch(error => { const fail_data = new Object(); @@ -222,6 +228,7 @@ export default function CandidatesReviewTable(props: Props) { }) .finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }); }; diff --git a/app/javascript/components/ConceptsTable.tsx b/app/javascript/components/ConceptsTable.tsx index bbf09952..322bc5a0 100644 --- a/app/javascript/components/ConceptsTable.tsx +++ b/app/javascript/components/ConceptsTable.tsx @@ -15,7 +15,7 @@ import { Column } from "primereact/column"; import { Dialog } from "primereact/dialog"; import { useDispatch } from "react-redux"; -import { startTask, endTask, addMessage, Priorities } from "./infrastructure/StatusSlice"; +import { startTask, endTask, addMessage, Priorities, useDirtyStatus, DIRTY_STATUS } from "./infrastructure/StatusSlice"; import { InputText } from "primereact/inputtext"; enum OPT_COLS { @@ -50,8 +50,15 @@ export default function ConceptsTable() { const [sortBy, setSortBy] = useState("name"); const [sortDirection, setSortDirection] = useState(SortDirection.DESC); + const [dirty, setDirty] = useDirtyStatus(); const [editing, setEditing] = useState(false); - const [dirty, setDirty] = useState(false); + const startEditing = () => { + setEditing(true); + } + const stopEditing = () => { + setEditing(false); + setDirty( DIRTY_STATUS.CLEAN ); + } const [conceptName, setConceptName] = useState(""); const [conceptId, setConceptId] = useState(-1); @@ -63,7 +70,7 @@ export default function ConceptsTable() { const setName = newName => { setConceptName(newName); - setDirty(true); + setDirty(DIRTY_STATUS.DIRTY); }; const getConcepts = () => { @@ -88,8 +95,8 @@ export default function ConceptsTable() { const drillDown = event => { setConceptId(event.data.id); setConceptName(event.data.name); - setEditing(true); - setDirty(false); + startEditing(); + setDirty(DIRTY_STATUS.CLEAN); }; const updateConcept = (id, name) => { dispatch(startTask("load")); @@ -111,7 +118,7 @@ export default function ConceptsTable() { setConcepts(tmpConcepts); setConceptsRaw(tmpConcepts); //statusActions.endTask("load"); - setEditing(false); + stopEditing(); dispatch( addMessage(t("update_success"), new Date(), Priorities.INFO ) ); }) .catch(error => { @@ -195,12 +202,12 @@ export default function ConceptsTable() { setEditing(false)} + onHide={() => stopEditing()} aria-labelledby="edit" header={t("edit.title")} footer={ <> - + ); const revCopyBtn = !notSubmitted ? ( ); //Retrieve the latest data const getContributions = () => { + suppressDirtyRef.current = true; const url = props.rootPath === undefined ? `${endpoints.baseUrl}${projectId}.json` @@ -189,7 +203,7 @@ export default function InstallmentReport(props: Props) { setInstallment(data.installment); setContributions(contributions); - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); setGroup(data.group); setProject(data.installment.project); @@ -199,6 +213,7 @@ export default function InstallmentReport(props: Props) { }) .finally(() => { dispatch(endTask()); + suppressDirtyRef.current = false; }); }; //Store what we've got @@ -239,6 +254,7 @@ export default function InstallmentReport(props: Props) { }, {} ); + suppressDirtyRef.current = true; setContributions(receivedContributions); navigate('/home'); } @@ -251,7 +267,7 @@ export default function InstallmentReport(props: Props) { addMessage( t("success"), new Date(), Priorities.INFO) ); } - setDirty(false); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); diff --git a/app/javascript/components/course_admin/CourseDataAdmin.tsx b/app/javascript/components/course_admin/CourseDataAdmin.tsx index da2bfc65..0f60bd22 100644 --- a/app/javascript/components/course_admin/CourseDataAdmin.tsx +++ b/app/javascript/components/course_admin/CourseDataAdmin.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import axios from "axios"; import { Outlet, Route, Routes, useNavigate, useParams } from "react-router"; //Redux store stuff @@ -6,10 +6,10 @@ import { useDispatch } from "react-redux"; import { startTask, endTask, - setDirty, - setClean, addMessage, - Priorities + Priorities, + useDirtyStatus, + DIRTY_STATUS } from "../infrastructure/StatusSlice"; @@ -68,9 +68,6 @@ export default function CourseDataAdmin() { ); const [curTab, setCurTab] = useState(0); - const dirty = useTypedSelector(state => { - return state.status.dirtyStatus[category]; - }); const [messages, setMessages] = useState({}); let { courseIdParam } = useParams(); @@ -78,6 +75,8 @@ export default function CourseDataAdmin() { const [courseId, setCourseId] = useState( parseInt("new" === courseIdParam ? null : courseIdParam) ); + const [dirty, setDirty] = useDirtyStatus( courseId === null || courseId === undefined ? DIRTY_STATUS.DIRTY : DIRTY_STATUS.CLEAN ); + const suppressDirtyRef = React.useRef(false); const [course, setCourse] = useState({ id: courseId, @@ -108,8 +107,8 @@ export default function CourseDataAdmin() { const getCourse = () => { dispatch(startTask()); - dispatch(setDirty(category)); - + setDirty(DIRTY_STATUS.DIRTY); + suppressDirtyRef.current = true; const url = isNaN(courseId) ? `${endpoints.baseUrl}/new.json` : `${endpoints.baseUrl}/${courseId}.json`; @@ -140,13 +139,14 @@ export default function CourseDataAdmin() { } setCourse(localCourse); - dispatch(setClean(category)); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error:", error); }).finally(() => { dispatch(endTask()); + suppressDirtyRef.current = false; }) }; @@ -158,6 +158,7 @@ export default function CourseDataAdmin() { ? `${endpoints.baseUrl}/new.json` : `${endpoints.baseUrl}/${courseId}.json`; + suppressDirtyRef.current = true; axios({ method: method, url: url, @@ -204,9 +205,6 @@ export default function CourseDataAdmin() { setCourse(localCourse); setCourseId(localCourse.id); navigate(`../${localCourse.id}`, { replace: true }); - - - dispatch(setClean(category)); } postNewMessage(data.messages); }) @@ -214,6 +212,7 @@ export default function CourseDataAdmin() { console.log("error:", error); }).finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }) }; @@ -235,9 +234,14 @@ export default function CourseDataAdmin() { }, [endpointStatus]); useEffect(() => { - dispatch(setDirty(category)); + if (suppressDirtyRef.current ) { + return; + } + setDirty(DIRTY_STATUS.DIRTY); }, [ course, + course.id, course.number, course.description, course.start_date, course.end_date, + course.school_id, course.consent_form_id, course.timezone ]); const postNewMessage = msgs => { @@ -245,14 +249,14 @@ export default function CourseDataAdmin() { setMessages(msgs); }; - const saveButton = dirty ? ( + const saveButton = (
-
- ) : null; + ); const setCourseValue = (field, value) => { setCourse(course => { @@ -266,7 +270,7 @@ export default function CourseDataAdmin() { {t('edit.number')} { @@ -281,7 +285,7 @@ export default function CourseDataAdmin() { {t('edit.name')} { @@ -296,7 +300,7 @@ export default function CourseDataAdmin() { {t('edit.description')} { @@ -334,7 +338,7 @@ export default function CourseDataAdmin() { }} optionLabel="name" optionValue="id" - placeholder="Select a School" + placeholder={t('edit.select_school_plchldr')} showClear={false} /> ) : ( @@ -358,7 +362,7 @@ export default function CourseDataAdmin() { }} optionLabel="name" optionValue="name" - placeholder="Select a Time Zone" + placeholder={t('edit.selct_timezone_plchldr')} showClear={false} /> ) : ( @@ -382,7 +386,7 @@ export default function CourseDataAdmin() { }} optionValue="id" optionLabel="name" - placeholder="Select a Consent Form" + placeholder={t('edit.select_consent_form_plchldr')} showClear={true} /> diff --git a/app/javascript/components/experiences/Experience.tsx b/app/javascript/components/experiences/Experience.tsx index 25a65e9c..5d4d400f 100644 --- a/app/javascript/components/experiences/Experience.tsx +++ b/app/javascript/components/experiences/Experience.tsx @@ -1,4 +1,4 @@ -import React, { Suspense, useState, useEffect } from "react"; +import React, { Suspense, useState, useEffect, useRef } from "react"; import { useParams } from "react-router"; import { useNavigate } from "react-router"; @@ -7,9 +7,10 @@ import { useDispatch } from "react-redux"; import { startTask, endTask, - setClean, addMessage, - Priorities + Priorities, + useDirtyStatus, + DIRTY_STATUS } from "../infrastructure/StatusSlice"; import { useTranslation } from "react-i18next"; @@ -34,6 +35,8 @@ export default function Experience(props) { state => state.context.status.endpointsLoaded ); const { experienceId } = useParams(); + const [dirty, setDirty] = useDirtyStatus(); + const suppressDirtyRef = useRef(false); const dispatch = useDispatch(); const [t] = useTranslation(`${category}s`); @@ -91,6 +94,7 @@ export default function Experience(props) { const getNext = () => { const url = `${endpoints.baseUrl}${experienceId}.json`; dispatch(startTask()); + suppressDirtyRef.current = true; axios(url, {}) .then(response => { const data = response.data; @@ -134,11 +138,13 @@ export default function Experience(props) { }) .finally(() => { dispatch(endTask()); + suppressDirtyRef.current = false; }); }; //Store what we've got const saveDiagnosis = (behaviorId, otherName, comment, resetFunc) => { dispatch(startTask("saving")); + suppressDirtyRef.current = true; const url = endpoints.diagnosisUrl + ".json"; axios .patch(url, { @@ -159,19 +165,21 @@ export default function Experience(props) { resetFunc(); dispatch(addMessage(data.messages.main, new Date(), Priorities.INFO)); - dispatch(setClean("diagnosis")); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); }) .finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }); }; //React const saveReaction = (behaviorId, otherName, improvements, resetFunc) => { dispatch(startTask("saving")); + suppressDirtyRef.current = true; const url = endpoints.reactionUrl + ".json"; axios .patch(url, { @@ -187,7 +195,7 @@ export default function Experience(props) { //Process Experience resetFunc(); dispatch(addMessage(data.messages.main, new Date(), Priorities.INFO)); - dispatch(setClean("reaction")); + setDirty(DIRTY_STATUS.CLEAN); navigate("/home"); }) .catch(error => { @@ -195,6 +203,7 @@ export default function Experience(props) { }) .finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }); }; diff --git a/app/javascript/components/experiences/ExperienceDataAdmin.tsx b/app/javascript/components/experiences/ExperienceDataAdmin.tsx index d16eb392..a9209d55 100644 --- a/app/javascript/components/experiences/ExperienceDataAdmin.tsx +++ b/app/javascript/components/experiences/ExperienceDataAdmin.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import { useParams } from "react-router"; import { useNavigate } from "react-router"; import { useTranslation } from "react-i18next"; @@ -10,10 +10,10 @@ import { useDispatch } from "react-redux"; import { startTask, endTask, - setDirty, - setClean, addMessage, - Priorities + Priorities, + useDirtyStatus, + DIRTY_STATUS } from "../infrastructure/StatusSlice"; import { useTypedSelector } from "../infrastructure/AppReducers"; import axios from "axios"; @@ -57,14 +57,13 @@ export default function ExperienceDataAdmin(props) { const { experienceIdParam, courseIdParam } = useParams(); const [curTab, setCurTab] = useState(0); - const dirty = useTypedSelector(state => { - return state.status.dirtyStatus[category]; - }); const dispatch = useDispatch(); const [messages, setMessages] = useState({}); const [experienceId, setExperienceId] = useState( "new" === experienceIdParam ? null : experienceIdParam ); + const [dirty, setDirty] = useDirtyStatus( null === experienceIdParam ? DIRTY_STATUS.DIRTY : DIRTY_STATUS.CLEAN ); + const suppressDirtyRef = useRef(false); const [experienceName, setExperienceName] = useState(""); const [experienceLeadTime, setExperienceLeadTime] = useState(0); @@ -87,7 +86,7 @@ export default function ExperienceDataAdmin(props) { const getExperience = () => { dispatch(startTask()); - dispatch(setDirty(category)); + suppressDirtyRef.current = true; var url = endpoints.baseUrl + "/"; if (null == experienceId) { url = url + "new/" + courseIdParam + ".json"; @@ -117,19 +116,20 @@ export default function ExperienceDataAdmin(props) { setResponseWords(data.response_words); - dispatch(setClean(category)); + setDirty( DIRTY_STATUS.CLEAN ); }) .catch(error => { console.log("error", error); }) .finally(() => { dispatch(endTask()); - dispatch(setClean(category)); + suppressDirtyRef.current = false; }); }; const saveExperience = () => { const method = null == experienceId ? "POST" : "PATCH"; dispatch(startTask("saving")); + suppressDirtyRef.current = true; const url = endpoints.baseUrl + @@ -187,6 +187,7 @@ export default function ExperienceDataAdmin(props) { }) .finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }); }; @@ -197,7 +198,10 @@ export default function ExperienceDataAdmin(props) { }, [endpointStatus]); useEffect(() => { - dispatch(setDirty(category)); + if (suppressDirtyRef.current) { + return; + } + setDirty( DIRTY_STATUS.DIRTY ); }, [ experienceName, experienceLeadTime, diff --git a/app/javascript/components/infrastructure/DirtyIndicator.tsx b/app/javascript/components/infrastructure/DirtyIndicator.tsx new file mode 100644 index 00000000..8039352d --- /dev/null +++ b/app/javascript/components/infrastructure/DirtyIndicator.tsx @@ -0,0 +1,63 @@ +import React, { useEffect } from "react"; +import { useLocation } from "react-router"; +import { useTypedSelector } from "./AppReducers"; +import { useTranslation } from "react-i18next"; +import { DIRTY_STATUS, NON_PATH_TERMS } from "./StatusSlice"; + +type Props = { +}; +export default function DirtyIndicator(props: Props) { + + const category = 'home'; + const { t } = useTranslation(category); + const location = useLocation(); + + const panelHasDirtyChanges = useTypedSelector(state => { + const dirtyStatus = state.status.dirtyStatus as Record; + const panels = location.pathname.split('/') || []; + const ultiPanel = panels.at(-1); + const dataPanel = NON_PATH_TERMS.includes(ultiPanel) || Number(panels.at(-1)) > 0 ? panels.at(-2) : panels.at(-1); + + switch (dirtyStatus[dataPanel || '']) { + case null: + case undefined: + return DIRTY_STATUS.NONE; + case 0: + case false: + return DIRTY_STATUS.CLEAN; + default: + return DIRTY_STATUS.DIRTY; + } + }); + + return ( + panelHasDirtyChanges !== DIRTY_STATUS.NONE ? ( +
+
+ +
+
+ + ) : null + ) +} \ No newline at end of file diff --git a/app/javascript/components/infrastructure/StatusSlice.ts b/app/javascript/components/infrastructure/StatusSlice.ts index 9f15839b..a5b4a7cf 100644 --- a/app/javascript/components/infrastructure/StatusSlice.ts +++ b/app/javascript/components/infrastructure/StatusSlice.ts @@ -1,16 +1,27 @@ +import { useEffect, useRef, useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { data, useLocation } from "react-router"; import { createSlice } from "@reduxjs/toolkit"; +import { Root } from "react-dom/client"; +import { RootState } from "./AppReducers"; export enum Priorities { ERROR = "error", INFO = "info", - WARNING = "warning" + WARNING = "warning", +} + +export enum DIRTY_STATUS { + CLEAN = false, + DIRTY = true, + NONE = null, } //const initialState : StatusRootState = { const initialState = { tasks: {}, messages: [], - dirtyStatus: {} + dirtyStatus: {}, }; // Slice @@ -27,22 +38,23 @@ const statusSlice = createSlice({ return { payload: localTaskName, meta: null, - error: null + error: null, }; - } + }, }, endTask: { reducer(state, action) { - state.tasks[action.payload] = Math.max(0, state.tasks[action.payload] || 0) - 1; + state.tasks[action.payload] = + Math.max(0, state.tasks[action.payload] || 0) - 1; }, prepare(taskName?: string) { const localTaskName = taskName || "default"; return { payload: localTaskName, meta: null, - error: null + error: null, }; - } + }, }, setDirty(state, action) { state.dirtyStatus[action.payload] = true; @@ -50,6 +62,9 @@ const statusSlice = createSlice({ setClean(state, action) { state.dirtyStatus[action.payload] = false; }, + unsetDirty(state, action) { + delete state.dirtyStatus[action.payload]; + }, addMessage: { reducer(state, action) { state.messages.push(action.payload); @@ -60,12 +75,12 @@ const statusSlice = createSlice({ text: text, priority: priority, msgTime: msgTime.toJSON(), - dismissed: false + dismissed: false, }, meta: null, - error: null + error: null, }; - } + }, }, acknowledgeMsg(state, action) { state.messages.map((message, index) => { @@ -73,8 +88,8 @@ const statusSlice = createSlice({ message.dismissed = true; } }); - } - } + }, + }, }); const { actions, reducer } = statusSlice; @@ -83,7 +98,69 @@ export const { endTask, setDirty, setClean, + unsetDirty, addMessage, - acknowledgeMsg + acknowledgeMsg, } = actions; + +export const NON_PATH_TERMS = ["new", "null", "undefined"]; + +export function useDirtyStatus( + initialDirty: DIRTY_STATUS = DIRTY_STATUS.CLEAN, + flagKey: string | null = null, +) { + const dispatch = useDispatch(); + const location = useLocation(); + const hasInitialized = useRef(false); + + const panels = location.pathname.split("/") || []; + const ultiPanel = panels.at(-1); + const dataPanel = + flagKey || (NON_PATH_TERMS.includes(ultiPanel) || Number(ultiPanel) > 0 ? panels.at(-2) : ultiPanel); + + const isDirty = useSelector((state: RootState) => { + const status = state.status.dirtyStatus as Record; + + switch (status[dataPanel || ""]) { + case null: + case undefined: + return DIRTY_STATUS.NONE; + case 0: + case false: + return DIRTY_STATUS.CLEAN; + default: + return DIRTY_STATUS.DIRTY; + } + }); + + useEffect(() => { + if (!hasInitialized.current && dataPanel) { + hasInitialized.current = true; + if (initialDirty === DIRTY_STATUS.DIRTY) { + dispatch(setDirty(dataPanel)); + } else if (initialDirty === DIRTY_STATUS.CLEAN) { + dispatch(setClean(dataPanel)); + } else if (initialDirty === DIRTY_STATUS.NONE) { + dispatch(unsetDirty(dataPanel)); + // do nothing + } + } + }, [initialDirty, dataPanel, dispatch]); + + const setDirtyStatus = useCallback( + (status: DIRTY_STATUS.CLEAN | DIRTY_STATUS.DIRTY) => { + if (!dataPanel) return; + + if (status === DIRTY_STATUS.DIRTY) { + dispatch(setDirty(dataPanel)); + } else if (status === DIRTY_STATUS.CLEAN) { + dispatch(setClean(dataPanel)); + } + }, + [dispatch, dataPanel], + ); + + return [isDirty, setDirtyStatus] as const; +} + export default reducer; diff --git a/app/javascript/components/profile/ProfileDataAdmin.tsx b/app/javascript/components/profile/ProfileDataAdmin.tsx index 9bbca5dc..49dc0768 100644 --- a/app/javascript/components/profile/ProfileDataAdmin.tsx +++ b/app/javascript/components/profile/ProfileDataAdmin.tsx @@ -11,7 +11,7 @@ import UserActivityList from "./UserActivityList"; //import i18n from './i18n'; import { useTranslation } from "react-i18next"; import { useDispatch } from "react-redux"; -import { startTask, endTask, addMessage, Priorities } from "../infrastructure/StatusSlice"; +import { startTask, endTask, addMessage, Priorities, useDirtyStatus, DIRTY_STATUS } from "../infrastructure/StatusSlice"; import { useTypedSelector } from "../infrastructure/AppReducers"; import { useTour } from "../infrastructure/TourContext"; import { @@ -30,7 +30,7 @@ import { Skeleton } from "primereact/skeleton"; import { TabPanel, TabView } from "primereact/tabview"; import { Panel } from "primereact/panel"; import { Calendar } from "primereact/calendar"; -import {ConfirmDialog, confirmDialog} from "primereact/confirmdialog"; +import { ConfirmDialog, confirmDialog } from "primereact/confirmdialog"; import { Dropdown } from "primereact/dropdown"; import { InputSwitch } from "primereact/inputswitch"; import { InputText } from "primereact/inputtext"; @@ -40,6 +40,7 @@ import { AutoComplete } from "primereact/autocomplete"; import { ColorPicker } from "primereact/colorpicker"; import { FloatLabel } from "primereact/floatlabel"; import { useBlocker, useNavigate } from "react-router"; +import { set } from "mockdate"; type Props = { // profileId: number; @@ -61,6 +62,8 @@ export default function ProfileDataAdmin(props: Props) { const endpointStatus = useTypedSelector( state => state.context.status.endpointsLoaded ); + const [dirty, setDirty] = useDirtyStatus(); + const { t } = useTranslation(`${category}s`); const lookupStatus = useTypedSelector( state => state.context.status.lookupsLoaded @@ -114,7 +117,7 @@ export default function ProfileDataAdmin(props: Props) { }; const setProfileImpairment = (imp: string[]) => { - const temp = {...user}; + const temp = { ...user }; temp['impairment_visual'] = imp.includes("visual"); temp['impairment_auditory'] = imp.includes("auditory"); @@ -126,21 +129,18 @@ export default function ProfileDataAdmin(props: Props) { }; const lastRetrieved = useTypedSelector(state => state.profile.lastRetrieved); - const dirty = useTypedSelector(state => { - return state.profile.lastRetrieved !== state.profile.lastSet; - }); const navigate = useNavigate(); - const blocker = useBlocker( ( args )=>{ - if( args.nextLocation.state === 'unblocked' || !dirty ){ + const blocker = useBlocker((args) => { + if (args.nextLocation.state === 'unblocked' || !dirty) { return false; } else { - confirmUnsavedChanges( args ); + confirmUnsavedChanges(args); return true; } }) - const confirmUnsavedChanges = ( args ) => { + const confirmUnsavedChanges = (args) => { confirmDialog({ message: t("confirm_leave"), header: t("confirm_leave_hdr"), @@ -148,11 +148,11 @@ export default function ProfileDataAdmin(props: Props) { modal: true, accept: () => { resetProfile(); - navigate( args.nextLocation.pathname, + navigate(args.nextLocation.pathname, { state: 'unblocked' } - ); + ); }, reject: () => { // Do nothing @@ -310,6 +310,7 @@ export default function ProfileDataAdmin(props: Props) { if (1 === foundSelectedStates.length) { setProfileHomeState(foundSelectedStates[0].id); } + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); @@ -349,7 +350,7 @@ export default function ProfileDataAdmin(props: Props) { showProgress: true, onDestroyed: () => { setTourCompleted(true); - const u = {...user, welcomed: true}; + const u = { ...user, welcomed: true }; setProfile(u); } }); @@ -402,7 +403,7 @@ export default function ProfileDataAdmin(props: Props) { ]); profileDriver.drive(); setTourCompleted(true); - const u = {...user, welcomed: true}; + const u = { ...user, welcomed: true }; setProfile(u); } }, [existingProfile, user.welcomed, t]); @@ -427,11 +428,13 @@ export default function ProfileDataAdmin(props: Props) { ] = useState(languages); const saveButton = useMemo( - () => ( - - ), + ) + }, [dirty, user.welcomed, tourCompleted] ); @@ -442,40 +445,40 @@ export default function ProfileDataAdmin(props: Props) { ); const emailPanel = useMemo(() => { - return( - - - {0 < user.emails.length ? ( - - ) : null} - - - {t("password_change")} - - + return ( + + + {0 < user.emails.length ? ( + + ) : null} + + + {t("password_change")} + + ) }, [user.emails]); const detailsComponent = lookupStatus ? ( setCurPanel([0,...(event.index.filter( i => i !== 0 ))])} + onTabChange={event => setCurPanel([0, ...(event.index.filter(i => i !== 0))])} activeIndex={[...curPanel]} - > + > + > @@ -521,13 +524,13 @@ export default function ProfileDataAdmin(props: Props) { > - - setProfileTheme(event.value)} /> + + setProfileTheme(event.value)} /> @@ -537,7 +540,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_language" name="profile_language" value={localProfileLanguage} - suggestions={Object.values( suggestedLocalProfileLanguages )} + suggestions={Object.values(suggestedLocalProfileLanguages)} field="name" forceSelection={true} dropdown @@ -546,7 +549,7 @@ export default function ProfileDataAdmin(props: Props) { const query = event.query.toLocaleLowerCase(); setSuggestedLocalProfileLanguages( languages.filter(lang => - ['en','ko', 'es'].includes(lang.code) && + ['en', 'ko', 'es'].includes(lang.code) && lang.name.toLowerCase().includes(query) ) ); @@ -585,7 +588,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_timezone" name="profile_timezone" value={user.timezone || 0} - options={Object.values( timezones )} + options={Object.values(timezones)} optionValue="name" optionLabel="name" onChange={event => setProfileTimezone(String(event.value))} @@ -614,7 +617,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_school" name="profile_school" value={user.school_id || 0} - options={Object.values( schools )} + options={Object.values(schools)} optionValue="id" optionLabel="name" onChange={event => setProfileSchool(Number(event.value))} @@ -633,7 +636,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_cip_code" name="profile_cip_code" value={user.cip_code_id || 0} - options={Object.values( cipCodes )} + options={Object.values(cipCodes)} optionValue="id" optionLabel="name" onChange={event => setProfileCipCode(Number(event.value))} @@ -678,7 +681,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_country" name="profile_country" value={user.country || 0} - options={ Object.values( countries )} + options={Object.values(countries)} optionValue="code" optionLabel="name" onChange={event => { @@ -725,7 +728,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_home_language" name="profile_home_language" value={localHomeLanguage} - suggestions={Object.values( suggestedLocalHomeLanguages )} + suggestions={Object.values(suggestedLocalHomeLanguages)} field="name" forceSelection={true} dropdown @@ -761,7 +764,7 @@ export default function ProfileDataAdmin(props: Props) { itemID="profile_gender" value={user.gender_id || 0} onChange={event => setProfileGender(Number(event.value))} - options={Object.values( genders )} + options={Object.values(genders)} optionLabel="name" optionValue="id" placeholder={t("demographics.gender")} @@ -803,7 +806,7 @@ export default function ProfileDataAdmin(props: Props) { options={impairmentOptions} onChange={event => { - if( event.value ) { + if (event.value) { setProfileImpairment(event.value) } //event.originalEvent?.currentTarget.blur( ) @@ -825,7 +828,7 @@ export default function ProfileDataAdmin(props: Props) { return ( - { - return state.status.dirtyStatus[category]; - }); const [messages, setMessages] = useState({}); const dispatch = useDispatch(); @@ -62,6 +59,9 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { const [projectId, setProjectId] = useState( "new" === projectIdParam ? null : Number(projectIdParam) ); + const [dirty, setDirty] = useDirtyStatus( projectIdParam === "new" ? DIRTY_STATUS.DIRTY : DIRTY_STATUS.CLEAN ); + const suppressDirtyRef = useRef(false); + const [projectName, setProjectName] = useState(""); const [projectDescription, setProjectDescription] = useState(""); const now = new Date(); @@ -86,13 +86,13 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { const getProject = () => { dispatch(startTask()); - dispatch(setDirty(category)); var url = endpoints.baseUrl + "/"; if (null == projectId) { url = url + "new/" + courseIdParam + ".json"; } else { url = url + projectId + ".json"; } + suppressDirtyRef.current = true; axios .get(url, {}) .then(response => { @@ -118,10 +118,11 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { setProjectStyleId(project.style_id); setProjectStartDOW(project.start_dow); setProjectEndDOW(project.end_dow); - dispatch(setClean(category)); + setDirty(DIRTY_STATUS.CLEAN); }) .finally(() => { dispatch(endTask()); + suppressDirtyRef.current = false; }); }; const saveProject = () => { @@ -134,6 +135,7 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { (null == projectId ? courseIdParam : projectId) + ".json"; + suppressDirtyRef.current = true; axios({ method: method, url: url, @@ -173,11 +175,11 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { const course = data.course; setCourseName(course.name); - dispatch(setClean(category)); setMessages(data.messages); dispatch( addMessage(data.messages.status, new Date(), Priorities.INFO) ); + setDirty(DIRTY_STATUS.CLEAN); navigate(`../${courseIdParam}/project/${project.id}`, { replace: true }); @@ -193,6 +195,7 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { }) .finally(() => { dispatch(endTask("saving")); + suppressDirtyRef.current = false; }); }; useEffect(() => { @@ -207,7 +210,10 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { }, [endpointStatus]); useEffect(() => { - dispatch(setDirty(category)); + if (suppressDirtyRef.current || projectId == null) { + return; + } + setDirty(DIRTY_STATUS.DIRTY); }, [ projectName, projectDescription, @@ -220,11 +226,11 @@ export default function ProjectDataAdmin(props: ProjectDataAdminProps) { projectEndDOW ]); - const saveButton = dirty ? ( - - ) : null; + ); //Later I want to call the activate/deactivate right here const toggleActive = () => { diff --git a/app/javascript/components/projects/ProjectGroups.tsx b/app/javascript/components/projects/ProjectGroups.tsx index 084ba928..cdb86f60 100644 --- a/app/javascript/components/projects/ProjectGroups.tsx +++ b/app/javascript/components/projects/ProjectGroups.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useMemo } from "react"; import axios from "axios"; -import { startTask, endTask } from "../infrastructure/StatusSlice"; +import { startTask, endTask, useDirtyStatus, DIRTY_STATUS } from "../infrastructure/StatusSlice"; import { IUser } from '../infrastructure/ProfileSlice'; import { useDispatch } from "react-redux"; import { useTranslation } from "react-i18next"; @@ -36,7 +36,7 @@ export default function ProjectGroups(props: Props) { const category = "projects"; const { t } = useTranslation(category); - const [dirty, setDirty] = useState(false); + const [dirty, setDirty] = useDirtyStatus(); const [working, setWorking] = useState(true); const [message, setMessage] = useState(""); const [filterText, setFilterText] = useState(""); @@ -87,7 +87,7 @@ export default function ProjectGroups(props: Props) { }, []); const setGroup = (student_id: number, group_id: number) => { - setDirty(true); + setDirty(DIRTY_STATUS.DIRTY); setStudentsRaw(prev => ({ ...prev, [student_id]: { @@ -99,7 +99,7 @@ export default function ProjectGroups(props: Props) { const setGroupName = (event: React.ChangeEvent, group_id: number) => { const newName = event.target.value; - setDirty(true); + setDirty(DIRTY_STATUS.DIRTY); setGroupsRaw(prev => ({ ...prev, [group_id]: { @@ -124,11 +124,11 @@ export default function ProjectGroups(props: Props) { } }; }); - setDirty(true); + setDirty(DIRTY_STATUS.DIRTY); }; const removeGroup = (event, group_id: number) => { - setDirty(true); + setDirty(DIRTY_STATUS.DIRTY); setStudentsRaw(prev => { const updated = { ...prev }; Object.values(updated).forEach(student => { @@ -224,7 +224,6 @@ export default function ProjectGroups(props: Props) { .then(response => { const data = response.data; setWorking(false); - setDirty(false); setSuggestedGroupsRaw(null); setSuggestedStudentsRaw(null); setSuggestedGroups([]); @@ -232,6 +231,7 @@ export default function ProjectGroups(props: Props) { setGroupsRaw(data.groups); setStudentsRaw(data.students); setMessage(data.message == null ? "" : data.message); + setDirty(DIRTY_STATUS.CLEAN); }) .catch(error => { console.log("error", error); diff --git a/app/javascript/components/toolbars/AppHeader.tsx b/app/javascript/components/toolbars/AppHeader.tsx index bf56e5ff..ba77863e 100644 --- a/app/javascript/components/toolbars/AppHeader.tsx +++ b/app/javascript/components/toolbars/AppHeader.tsx @@ -10,7 +10,11 @@ import { Toolbar } from "primereact/toolbar"; import { Skeleton } from "primereact/skeleton"; import AppBreadCrumb from "./AppBreadcrumb"; -export default function AppHeader(props) { +type Props = { + identifier?: string; +} + +export default function AppHeader(props: Props) { const endpointSet = "home"; const [t] = useTranslation( endpointSet); const endpoints = useTypedSelector( @@ -22,7 +26,7 @@ export default function AppHeader(props) { const working = useTypedSelector(state => { let accum = 0; - if (undefined === props.identifier) { + if (undefined !== props.identifier) { accum = state.status.tasks[props.identifier]; } else { accum = Number( diff --git a/app/javascript/components/toolbars/HelpMenu.tsx b/app/javascript/components/toolbars/HelpMenu.tsx index b1ec73ed..8a1a6844 100644 --- a/app/javascript/components/toolbars/HelpMenu.tsx +++ b/app/javascript/components/toolbars/HelpMenu.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { useLocation } from "react-router"; // Icons -import {driver } from "driver.js"; +import { driver } from "driver.js"; import "driver.js/dist/driver.css"; import { useTranslation } from "react-i18next"; @@ -11,6 +11,7 @@ import { useTour } from "../infrastructure/TourContext"; import { Button } from "primereact/button"; import { Sidebar } from "primereact/sidebar"; import LangButton from "./LangButton"; +import DirtyIndicator from "../infrastructure/DirtyIndicator"; type Props = { lookupUrl: string; @@ -75,44 +76,54 @@ export default function HelpMenu(props: Props) { const pathComponents = location.pathname.split("/"); return ( - - setShowInfo(false)} - > - {candidateFeedbackInfo()} - - -