diff --git a/Gemfile b/Gemfile index d753f8a..9cd17e0 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ source 'https://rubygems.org' gem 'kamal', '~> 2.10', require: false # Deploy this application anywhere as a Docker container [https://kamal-deploy.org] gem 'pg', '~> 1.6' # Use postgresql as the database for Active Record gem 'puma', '~> 7.2' # Use the Puma web server [https://github.com/puma/puma] -gem 'rails', '~> 8.1.3.1' # Base framework +gem 'rails', '~> 8.1.4.1' # Base framework gem 'thruster', '~> 0.1', require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] # CSS and JS related diff --git a/Gemfile.lock b/Gemfile.lock index 3424a66..66f4a4f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,29 +3,29 @@ GEM specs: action_text-trix (2.1.18) railties - actioncable (8.1.3.1) - actionpack (= 8.1.3.1) - activesupport (= 8.1.3.1) + actioncable (8.1.4.1) + actionpack (= 8.1.4.1) + activesupport (= 8.1.4.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.3.1) - actionpack (= 8.1.3.1) - activejob (= 8.1.3.1) - activerecord (= 8.1.3.1) - activestorage (= 8.1.3.1) - activesupport (= 8.1.3.1) + actionmailbox (8.1.4.1) + actionpack (= 8.1.4.1) + activejob (= 8.1.4.1) + activerecord (= 8.1.4.1) + activestorage (= 8.1.4.1) + activesupport (= 8.1.4.1) mail (>= 2.8.0) - actionmailer (8.1.3.1) - actionpack (= 8.1.3.1) - actionview (= 8.1.3.1) - activejob (= 8.1.3.1) - activesupport (= 8.1.3.1) + actionmailer (8.1.4.1) + actionpack (= 8.1.4.1) + actionview (= 8.1.4.1) + activejob (= 8.1.4.1) + activesupport (= 8.1.4.1) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.3.1) - actionview (= 8.1.3.1) - activesupport (= 8.1.3.1) + actionpack (8.1.4.1) + actionview (= 8.1.4.1) + activesupport (= 8.1.4.1) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,36 +33,36 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.3.1) + actiontext (8.1.4.1) action_text-trix (~> 2.1.15) - actionpack (= 8.1.3.1) - activerecord (= 8.1.3.1) - activestorage (= 8.1.3.1) - activesupport (= 8.1.3.1) + actionpack (= 8.1.4.1) + activerecord (= 8.1.4.1) + activestorage (= 8.1.4.1) + activesupport (= 8.1.4.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.3.1) - activesupport (= 8.1.3.1) + actionview (8.1.4.1) + activesupport (= 8.1.4.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - activejob (8.1.3.1) - activesupport (= 8.1.3.1) + rails-html-sanitizer (~> 1.6, >= 1.7.2) + activejob (8.1.4.1) + activesupport (= 8.1.4.1) globalid (>= 0.3.6) - activemodel (8.1.3.1) - activesupport (= 8.1.3.1) - activerecord (8.1.3.1) - activemodel (= 8.1.3.1) - activesupport (= 8.1.3.1) + activemodel (8.1.4.1) + activesupport (= 8.1.4.1) + activerecord (8.1.4.1) + activemodel (= 8.1.4.1) + activesupport (= 8.1.4.1) timeout (>= 0.4.0) - activestorage (8.1.3.1) - actionpack (= 8.1.3.1) - activejob (= 8.1.3.1) - activerecord (= 8.1.3.1) - activesupport (= 8.1.3.1) + activestorage (8.1.4.1) + actionpack (= 8.1.4.1) + activejob (= 8.1.4.1) + activerecord (= 8.1.4.1) + activesupport (= 8.1.4.1) marcel (~> 1.0) - activesupport (8.1.3.1) + activesupport (8.1.4.1) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) @@ -191,9 +191,9 @@ GEM rexml lint_roller (1.1.0) logger (1.7.0) - loofah (2.25.2) + loofah (2.25.3) crass (~> 1.0.2) - nokogiri (>= 1.12.0) + nokogiri (>= 1.19.5) mail (2.9.1) logger mini_mime (>= 0.1.1) @@ -226,17 +226,17 @@ GEM net-ssh (7.3.8) logger nio4r (2.7.5) - nokogiri (1.19.4-aarch64-linux-gnu) + nokogiri (1.19.7-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.4-aarch64-linux-musl) + nokogiri (1.19.7-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.19.4-arm-linux-gnu) + nokogiri (1.19.7-arm-linux-gnu) racc (~> 1.4) - nokogiri (1.19.4-arm-linux-musl) + nokogiri (1.19.7-arm-linux-musl) racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-gnu) + nokogiri (1.19.7-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-musl) + nokogiri (1.19.7-x86_64-linux-musl) racc (~> 1.4) oauth (1.1.7) auth-sanitizer (~> 0.2, >= 0.2.1) @@ -301,33 +301,33 @@ GEM rack (>= 1.3) rackup (2.3.1) rack (>= 3) - rails (8.1.3.1) - actioncable (= 8.1.3.1) - actionmailbox (= 8.1.3.1) - actionmailer (= 8.1.3.1) - actionpack (= 8.1.3.1) - actiontext (= 8.1.3.1) - actionview (= 8.1.3.1) - activejob (= 8.1.3.1) - activemodel (= 8.1.3.1) - activerecord (= 8.1.3.1) - activestorage (= 8.1.3.1) - activesupport (= 8.1.3.1) + rails (8.1.4.1) + actioncable (= 8.1.4.1) + actionmailbox (= 8.1.4.1) + actionmailer (= 8.1.4.1) + actionpack (= 8.1.4.1) + actiontext (= 8.1.4.1) + actionview (= 8.1.4.1) + activejob (= 8.1.4.1) + activemodel (= 8.1.4.1) + activerecord (= 8.1.4.1) + activestorage (= 8.1.4.1) + activesupport (= 8.1.4.1) bundler (>= 1.15.0) - railties (= 8.1.3.1) + railties (= 8.1.4.1) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.7.1) - loofah (~> 2.25, >= 2.25.2) + rails-html-sanitizer (1.7.2) + loofah (~> 2.25, >= 2.25.3) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rails-i18n (8.1.0) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) - railties (8.1.3.1) - actionpack (= 8.1.3.1) - activesupport (= 8.1.3.1) + railties (8.1.4.1) + actionpack (= 8.1.4.1) + activesupport (= 8.1.4.1) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) @@ -482,7 +482,7 @@ DEPENDENCIES puma (~> 7.2) pundit (~> 2.5.2) rack-attack (~> 6.8.0) - rails (~> 8.1.3.1) + rails (~> 8.1.4.1) rails-i18n (~> 8.0) rondo_form (~> 1.0) rqrcode (~> 3.0) @@ -501,17 +501,17 @@ DEPENDENCIES CHECKSUMS action_text-trix (2.1.18) sha256=3fdb83f8bff4145d098be283cdd47ac41caf5110bfa6df4695ed7127d7fb3642 - actioncable (8.1.3.1) sha256=e318528295c878a3efdfe25f0f2267c80cb7a76eba41bb5f64d44aa380a3d91b - actionmailbox (8.1.3.1) sha256=5f704972097d843ade8e435e93694a1dac732b926df1717aceba1f3840082b1c - actionmailer (8.1.3.1) sha256=88ea441b28ff02a0c6c006468892642a3d9942affce9d294e81a74504aa5c43c - actionpack (8.1.3.1) sha256=974cb7154548e81f470b1b0f247b99cb38e87825899dca58610596e2817723d0 - actiontext (8.1.3.1) sha256=5da729d833d1a29cddb1eee938878e55e503d2613e00e735f5daf58c2ba98af2 - actionview (8.1.3.1) sha256=2da68b8414c47b43bfbed1ce69c5afe1c04f78c267aacb5660a4cab5ca12cfb6 - activejob (8.1.3.1) sha256=1c8dd275df930df40deecffec63d913a550a33fd94bd298f69721dd96939954a - activemodel (8.1.3.1) sha256=99cc02ce2faec371d14440949d85787ebd23a907c9baef0a9d4bcd4d21888f88 - activerecord (8.1.3.1) sha256=0a2fb6c28f4938f6b013a3a549bec0a7e37d535f3dc8990e804bcc3258c0403b - activestorage (8.1.3.1) sha256=f555254f387b1cffa499d2fd3115d12635eadc5b15206a8534316a67036163ef - activesupport (8.1.3.1) sha256=85458765f25ea48b9019c46b6bb3fa5683197bf4280d9f06710a6e8d7a831376 + actioncable (8.1.4.1) sha256=007670f7297973e94809e9f32a56624a7aa60b6e9d6696cd078064ce86d1c7fc + actionmailbox (8.1.4.1) sha256=18b43dd842bf3a3e31b8e38b7917c21e48c128fc8b263c893481cc66ee1a8f78 + actionmailer (8.1.4.1) sha256=a7b71dedf744e48dd6751e97350634edce9a2f7beeceac332dcccb91729ad066 + actionpack (8.1.4.1) sha256=8c6f2e7cbd2653ef0539bde1ff20eb3e2e264e366affe56f0ba7f06d3670cb4b + actiontext (8.1.4.1) sha256=fb59611dc16e2b6bbfcec66793acfae6cdd64b1e35ef3f9175424f31e62c38da + actionview (8.1.4.1) sha256=d25b4d9cf8d0c0195ffcfe8b2cf3fcbe6fea54fd7c17f1a9cb8182881463f12b + activejob (8.1.4.1) sha256=82c4c9311ea1016d5f7f134a0c5f9b6527cad5a6237f5f3f60aa17984ef36229 + activemodel (8.1.4.1) sha256=063caf105372fbc98b2d789e4be23de63ac5c043f7d9330f04e2caddb7012805 + activerecord (8.1.4.1) sha256=bd65599ca0b5d210937f835d42c7aff69d271741c75aab022730be6cbce67e87 + activestorage (8.1.4.1) sha256=0f4e2f114f3584723a80a2c089aacc6170e765a0b2c385c9782e26ed78f05c66 + activesupport (8.1.4.1) sha256=16ac4f341ca65c85a1fcdb8ac73f68891aae9e3c4a6f9c3a27daed8b00017ed7 addressable (2.9.2) sha256=9117c7e7d6ee07230e75ce41fb9401ae55354dc654fcd0e862ba41d539e9a610 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 auth-sanitizer (0.2.1) sha256=917dcf01f7589b06c26726afe90568cf2fb29d37a1f94facfc664f11967cc577 @@ -568,7 +568,7 @@ CHECKSUMS letter_opener_web (3.0.0) sha256=3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 - loofah (2.25.2) sha256=2007f746959ac65552456e04b433e83deb22759ab38c838b4445c70e43425918 + loofah (2.25.3) sha256=545f14eef0cc2a7de80df560530d593232b7c07e515c5c4e37532d9cb5b0185c mail (2.9.1) sha256=06574eca475253d6c18145dd70af80d0eb970182d55053497c5f4d797ea160e8 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 @@ -584,12 +584,12 @@ CHECKSUMS net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 net-ssh (7.3.8) sha256=16bb1fccc4d9a580decdeea8ddb98e8544df29b7e6ac8cb94d4b379cfe4bee9f nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 - nokogiri (1.19.4-aarch64-linux-gnu) sha256=1269fb644a6de405057a53dd5c762b1209b43ca7424f839454d3dbc677c31a8f - nokogiri (1.19.4-aarch64-linux-musl) sha256=35c65b9ce72b3bb03207bdbe7067915019dc18c1b9b59139684bd6690fdd01af - nokogiri (1.19.4-arm-linux-gnu) sha256=a301313e38bb065d68239e79734bcd6f56fb6efaacebde29e9abf2a4735340ca - nokogiri (1.19.4-arm-linux-musl) sha256=588923c101bcfa78869734d247d25b598674323e7f22474fc468f6e5647311eb - nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a - nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b + nokogiri (1.19.7-aarch64-linux-gnu) sha256=8f5e15d299602456406e1f03a9f3456cceb33a0722811e818d2ee2214eb4c84a + nokogiri (1.19.7-aarch64-linux-musl) sha256=efe0c3602e6e54521bc21c681930ce5568e2d11203de17400710b2b3ca2e4cb4 + nokogiri (1.19.7-arm-linux-gnu) sha256=7fd1907125691aa89e838cc91161761c758b4e17bb32f0dd9b5515a82cc759af + nokogiri (1.19.7-arm-linux-musl) sha256=28d0ff0c87b6bb2e56580323a6fb049d5d4a02b94f0958fdd47e9ac146991c77 + nokogiri (1.19.7-x86_64-linux-gnu) sha256=25865ccb462f714e712fdf8906fde07849cb6704a808e3d629d75baa5af5dd3b + nokogiri (1.19.7-x86_64-linux-musl) sha256=464d1f3dfb0d53cf54c0c6a7e23e8e91cf68ec3cafc2b27dc4a7682f2d9bf546 oauth (1.1.7) sha256=a8cd8a96b8b90d738714da628b5f4a36565e278d1d28d82def3b3664bd71f069 oauth-tty (1.0.10) sha256=430af9708e177f5a7200f1b96165bebe0cb30466431bec41acbe02e7ae17a909 omniauth (2.1.4) sha256=42a05b0496f0d22e1dd85d42aaf602f064e36bb47a6826a27ab55e5ba608763c @@ -619,11 +619,11 @@ CHECKSUMS rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 - rails (8.1.3.1) sha256=ccd11a36bfc171bf9c66d585d14c0ece91c0c9dde840aae60c0118d6f5c9c52a + rails (8.1.4.1) sha256=365c3a0ad2713bd6e60628e5e826622d4ca8dee60b5a7e560e9b20844e9650bc rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d - rails-html-sanitizer (1.7.1) sha256=e797a7c9b01e567307e317c576b49ab4168017e63eea4dba9ce3cb587e2f22c2 + rails-html-sanitizer (1.7.2) sha256=a21ef45b7a6639fc8e2282473c547514cb86ae271616162a946bf89148fa2424 rails-i18n (8.1.0) sha256=52d5fd6c0abef28d84223cc05647f6ae0fd552637a1ede92deee9545755b6cf3 - railties (8.1.3.1) sha256=2388a232579a00cefea4487de66c8553c3408c1300abdc6cf1799d86ffb04487 + railties (8.1.4.1) sha256=ff2ef4b5a62cbc89811f88be11c776e2ed71b14dd012eae6a494d37994aa9782 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index b37785f..288876c 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -48,5 +48,6 @@ @import "./components/settings.css"; @import "./components/grading.css"; @import "./components/sheets.css"; +@import "./components/student_sheets.css"; @import "./components/wizard.css"; diff --git a/app/assets/stylesheets/components/badges.css b/app/assets/stylesheets/components/badges.css index dde6db9..cb09a3e 100644 --- a/app/assets/stylesheets/components/badges.css +++ b/app/assets/stylesheets/components/badges.css @@ -236,9 +236,20 @@ margin-top: 12px; } + .gh-preview-thumb-row>span { + min-width: 0; + } + .gh-preview-thumb-row b { display: block; font: 680 15px/1.25 var(--gh-f-ui); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .gh-preview-thumb-row .gh-small { + overflow-wrap: anywhere; } @media (width <= theme(--breakpoint-mobile)) { diff --git a/app/assets/stylesheets/components/card.css b/app/assets/stylesheets/components/card.css index bf6d6a4..74826d6 100644 --- a/app/assets/stylesheets/components/card.css +++ b/app/assets/stylesheets/components/card.css @@ -1,5 +1,4 @@ @layer components { - .gh-card { --gh-cc: 12px; --gh-frame: var(--gh-item); @@ -35,14 +34,16 @@ height: 100%; padding: 5px; background: var(--gh-frame); - clip-path: polygon(var(--gh-c) 0, - calc(100% - var(--gh-c)) 0, - 100% var(--gh-c), - 100% calc(100% - var(--gh-c)), - calc(100% - var(--gh-c)) 100%, - var(--gh-c) 100%, - 0 calc(100% - var(--gh-c)), - 0 var(--gh-c)); + clip-path: polygon( + var(--gh-c) 0, + calc(100% - var(--gh-c)) 0, + 100% var(--gh-c), + 100% calc(100% - var(--gh-c)), + calc(100% - var(--gh-c)) 100%, + var(--gh-c) 100%, + 0 calc(100% - var(--gh-c)), + 0 var(--gh-c) + ); } .gh-card-inset { @@ -51,14 +52,16 @@ flex-direction: column; height: 100%; background: var(--gh-card); - clip-path: polygon(var(--gh-c) 0, - calc(100% - var(--gh-c)) 0, - 100% var(--gh-c), - 100% calc(100% - var(--gh-c)), - calc(100% - var(--gh-c)) 100%, - var(--gh-c) 100%, - 0 calc(100% - var(--gh-c)), - 0 var(--gh-c)); + clip-path: polygon( + var(--gh-c) 0, + calc(100% - var(--gh-c)) 0, + 100% var(--gh-c), + 100% calc(100% - var(--gh-c)), + calc(100% - var(--gh-c)) 100%, + var(--gh-c) 100%, + 0 calc(100% - var(--gh-c)), + 0 var(--gh-c) + ); } .gh-mini-thumb { @@ -102,6 +105,18 @@ font-stretch: 80%; text-wrap: balance; hyphens: auto; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + } + + .gh-card .gh-rule-text, + .gh-card .gh-card-flavor, + .gh-card .gh-badge-rule-text { + overflow-wrap: anywhere; } .gh-card-footer { @@ -126,19 +141,23 @@ place-items: center; overflow: hidden; background: - repeating-linear-gradient(135deg, + repeating-linear-gradient( + 135deg, color-mix(in srgb, var(--gh-frame) 12%, transparent) 0 6px, - transparent 6px 12px), + transparent 6px 12px + ), color-mix(in srgb, var(--gh-frame) 15%, var(--gh-card)); color: color-mix(in srgb, var(--gh-frame) 58%, var(--gh-ink)); - clip-path: polygon(var(--gh-c) 0, - calc(100% - var(--gh-c)) 0, - 100% var(--gh-c), - 100% calc(100% - var(--gh-c)), - calc(100% - var(--gh-c)) 100%, - var(--gh-c) 100%, - 0 calc(100% - var(--gh-c)), - 0 var(--gh-c)); + clip-path: polygon( + var(--gh-c) 0, + calc(100% - var(--gh-c)) 0, + 100% var(--gh-c), + 100% calc(100% - var(--gh-c)), + calc(100% - var(--gh-c)) 100%, + var(--gh-c) 100%, + 0 calc(100% - var(--gh-c)), + 0 var(--gh-c) + ); } @media (prefers-color-scheme: dark) { @@ -202,7 +221,7 @@ overflow: hidden; } - .gh-progress-bar>i { + .gh-progress-bar > i { --gh-c: 4px; position: absolute; left: 0; @@ -223,7 +242,7 @@ vertical-align: middle; } - .gh-currency-token>span { + .gh-currency-token > span { --gh-c: 29%; display: grid; place-items: center; @@ -276,15 +295,21 @@ position: absolute; z-index: -1; inset: 0; - background: linear-gradient(to top, var(--gh-rank-edge) 0 2px, var(--gh-rank) 2px); - clip-path: polygon(var(--gh-c) 0, - calc(100% - var(--gh-c)) 0, - 100% var(--gh-c), - 100% calc(100% - var(--gh-c)), - calc(100% - var(--gh-c)) 100%, - var(--gh-c) 100%, - 0 calc(100% - var(--gh-c)), - 0 var(--gh-c)); + background: linear-gradient( + to top, + var(--gh-rank-edge) 0 2px, + var(--gh-rank) 2px + ); + clip-path: polygon( + var(--gh-c) 0, + calc(100% - var(--gh-c)) 0, + 100% var(--gh-c), + 100% calc(100% - var(--gh-c)), + calc(100% - var(--gh-c)) 100%, + var(--gh-c) 100%, + 0 calc(100% - var(--gh-c)), + 0 var(--gh-c) + ); } .gh-price-badge b { @@ -343,6 +368,29 @@ margin: 16px auto 0; } + .gh-item-preview-card + :is(.gh-rule-text, .gh-card-flavor, .gh-badge-rule-text), + .gh-preview-flip-card + :is(.gh-rule-text, .gh-card-flavor, .gh-badge-rule-text) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + line-clamp: 4; + overflow: hidden; + } + + .gh-item-preview-card .gh-rule-text, + .gh-preview-flip-card .gh-rule-text { + padding-bottom: 0; + margin-bottom: 12px; + } + + .gh-item-preview-card .gh-card-flavor, + .gh-preview-flip-card .gh-card-flavor { + -webkit-line-clamp: 3; + line-clamp: 3; + } + .gh-preview-flip-card .gh-card-art { aspect-ratio: 16 / 10; } diff --git a/app/assets/stylesheets/components/chrome.css b/app/assets/stylesheets/components/chrome.css index 354bd64..b54daf0 100644 --- a/app/assets/stylesheets/components/chrome.css +++ b/app/assets/stylesheets/components/chrome.css @@ -1,5 +1,4 @@ @layer components { - .gh-app-shell { height: 100vh; height: 100dvh; @@ -88,14 +87,16 @@ z-index: -1; inset: 0; background: var(--gh-card-2); - clip-path: polygon(var(--gh-c) 0, - calc(100% - var(--gh-c)) 0, - 100% var(--gh-c), - 100% calc(100% - var(--gh-c)), - calc(100% - var(--gh-c)) 100%, - var(--gh-c) 100%, - 0 calc(100% - var(--gh-c)), - 0 var(--gh-c)); + clip-path: polygon( + var(--gh-c) 0, + calc(100% - var(--gh-c)) 0, + 100% var(--gh-c), + 100% calc(100% - var(--gh-c)), + calc(100% - var(--gh-c)) 100%, + var(--gh-c) 100%, + 0 calc(100% - var(--gh-c)), + 0 var(--gh-c) + ); } .gh-balance-button:hover::before { @@ -477,12 +478,12 @@ width: 82px; } - .gh-sidebar--collapsed .gh-sidebar-link>span, - .gh-sidebar--collapsed .gh-sidebar-collapse-toggle>span, + .gh-sidebar--collapsed .gh-sidebar-link > span, + .gh-sidebar--collapsed .gh-sidebar-collapse-toggle > span, .gh-sidebar--collapsed .gh-sidebar-section-label, .gh-sidebar--collapsed .gh-sidebar-group-list-item-text, .gh-sidebar--collapsed .gh-current-group-card-header, - .gh-sidebar--collapsed .gh-current-group-card-switch-trigger>span { + .gh-sidebar--collapsed .gh-current-group-card-switch-trigger > span { display: none; } @@ -547,7 +548,10 @@ display: flex; } - .gh-topbar:has(.gh-group-switcher) .gh-topbar-logo, + .gh-topbar:has(.gh-group-switcher) .gh-topbar-logo { + flex: 0 0 auto; + } + .gh-topbar:has(.gh-group-switcher) .gh-topbar-spacer { display: none; } diff --git a/app/assets/stylesheets/components/ranks.css b/app/assets/stylesheets/components/ranks.css index 3dbff98..75a94c7 100644 --- a/app/assets/stylesheets/components/ranks.css +++ b/app/assets/stylesheets/components/ranks.css @@ -162,9 +162,13 @@ .gh-rank-form-rung b { display: block; font: 660 14.5px/1.2 var(--gh-f-ui); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .gh-rank-form-rung small { + overflow-wrap: anywhere; font-size: 12.5px; color: var(--gh-ink-3); } diff --git a/app/assets/stylesheets/components/student.css b/app/assets/stylesheets/components/student.css index b7143a3..1da52fd 100644 --- a/app/assets/stylesheets/components/student.css +++ b/app/assets/stylesheets/components/student.css @@ -1,5 +1,4 @@ @layer components { - .gh-student-page { padding: 22px 24px; } @@ -48,7 +47,7 @@ padding: 14px 18px 4px 0; } - .gh-student-stat+.gh-student-stat { + .gh-student-stat + .gh-student-stat { padding-left: 18px; border-left: 1px solid var(--gh-line); } @@ -182,17 +181,23 @@ cursor: pointer; } - .gh-segmented-toggle-3-option input:checked+span { + .gh-segmented-toggle-3-option input:checked + span { background: var(--gh-card); color: var(--gh-ink); box-shadow: inset 0 -3px 0 var(--gh-rank); } - .gh-segmented-toggle-3-option input:focus-visible+span { + .gh-segmented-toggle-3-option input:focus-visible + span { outline: 2px solid var(--gh-focus); outline-offset: -2px; } + @media (width > theme(--breakpoint-mobile)) { + .gh-dialog:has([data-controller~="currency-adjust"]) { + margin-top: 12vh; + } + } + .gh-adjustment-preview { display: grid; grid-template-columns: auto 1fr; @@ -322,7 +327,7 @@ border-top: 1px solid var(--gh-line); } - .gh-student-stat+.gh-student-stat { + .gh-student-stat + .gh-student-stat { padding-left: 0; border-left: 0; } diff --git a/app/assets/stylesheets/components/student_sheets.css b/app/assets/stylesheets/components/student_sheets.css new file mode 100644 index 0000000..2d244a0 --- /dev/null +++ b/app/assets/stylesheets/components/student_sheets.css @@ -0,0 +1,84 @@ +@layer components { + .gh-student-sheet { + margin-top: 16px; + padding: 16px 20px 18px; + } + + .gh-student-sheet-summary { + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + list-style: none; + } + + .gh-student-sheet-summary::-webkit-details-marker { + display: none; + } + + .gh-student-sheet-heading { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + } + + .gh-student-sheet-heading .gh-h3 { + overflow-wrap: anywhere; + } + + .gh-student-sheet-chevron { + font-size: 13px; + color: var(--gh-ink-2); + transition: transform 0.15s; + } + + .gh-student-sheet[open] .gh-student-sheet-chevron { + transform: rotate(180deg); + } + + .gh-student-sheet .gh-progress-bar { + margin-top: 10px; + } + + .gh-student-sheet-categories { + margin-top: 12px; + } + + .gh-student-sheet-category { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding: 10px 0; + border-top: 1px solid var(--gh-line); + } + + .gh-student-sheet-category b { + display: block; + font: 640 15px/1.3 var(--gh-f-ui); + overflow-wrap: anywhere; + } + + .gh-student-sheet-category small { + display: block; + margin-top: 2px; + font-style: italic; + color: var(--gh-ink-2); + overflow-wrap: anywhere; + } + + .gh-student-sheet-reward { + display: inline-flex; + align-items: center; + gap: 6px; + font: 760 15px var(--gh-f-ui); + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .gh-student-sheet-category--earned .gh-student-sheet-reward { + color: var(--gh-earn); + } +} diff --git a/app/assets/stylesheets/components/teachers.css b/app/assets/stylesheets/components/teachers.css index bbf2f67..18e2ab4 100644 --- a/app/assets/stylesheets/components/teachers.css +++ b/app/assets/stylesheets/components/teachers.css @@ -1,5 +1,4 @@ @layer components { - .gh-teachers-panel { margin-top: 22px; padding: 6px 8px; @@ -86,7 +85,44 @@ color: inherit; } + .gh-teacher-invite-form { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--gh-line); + } + + .gh-teacher-invite-form .gh-field-hint { + margin-top: 4px; + } + + .gh-teacher-invite-row { + display: flex; + gap: 8px; + } + + .gh-teacher-invite-row .gh-text-input { + flex: 1; + min-width: 0; + } + + .gh-teacher-invitations > .gh-h3, + .gh-teacher-invitations > .gh-field-hint { + padding: 0 12px; + } + + .gh-teacher-invitations > .gh-h3 { + padding-top: 12px; + } + + .gh-teacher-invitations > .gh-field-hint { + margin: 4px 0 10px; + } + @media (width <= theme(--breakpoint-mobile)) { + .gh-teacher-invite-row { + flex-direction: column; + } + .gh-teacher-row { grid-template-columns: minmax(0, 1fr) auto; gap: 6px 12px; @@ -96,11 +132,11 @@ display: none; } - .gh-teacher-row> :not(:last-child) { + .gh-teacher-row > :not(:last-child) { grid-column: 1; } - .gh-teacher-row> :last-child { + .gh-teacher-row > :last-child { grid-column: 2; grid-row: 1 / 4; align-self: center; diff --git a/app/controllers/activity_groups_controller.rb b/app/controllers/activity_groups_controller.rb index 91f642c..7c509b6 100644 --- a/app/controllers/activity_groups_controller.rb +++ b/app/controllers/activity_groups_controller.rb @@ -6,11 +6,18 @@ class ActivityGroupsController < ApplicationController BULK_RANGE = (2..20) before_action :set_story_group - before_action :authorize_story_group_manage! + before_action :authorize_story_group_read!, only: :index + before_action :authorize_story_group_manage!, except: :index before_action :set_presentation, only: %i[new confirm_destroy] before_action :set_activity_group, only: %i[edit update destroy confirm_destroy] def index + if (membership = student_membership) + @student_sheets = StudentSheetList.new(story_group: @story_group, membership: membership) + return render :student_index + end + + authorize_story_group_manage! @index = SheetIndex.new(@story_group) @fresh_sheet_ids = Array(flash[:fresh_sheet_ids]).to_set(&:to_i) end @@ -59,6 +66,10 @@ def destroy private + def student_membership + GroupChrome.for(user: @current_user, story_group: @story_group)&.student_membership + end + def set_story_group @story_group = StoryGroup.find(params.expect(:story_group_id)) end diff --git a/app/controllers/auth/passwordless_controller.rb b/app/controllers/auth/passwordless_controller.rb index 482e48e..31bfa7b 100644 --- a/app/controllers/auth/passwordless_controller.rb +++ b/app/controllers/auth/passwordless_controller.rb @@ -40,7 +40,7 @@ def verify def create email = params.expect(:email) - user = User.find_by(email: email) + user = User.find_by(email: email) || invited_teacher(email) service = BypassLoginService.new(email: email) resend = session[:pending_login_email].present? @@ -67,6 +67,12 @@ def create private + def invited_teacher(email) + TeacherInvitation.find_by(email: TeacherInvitation.normalize_value_for(:email, email.to_s))&.create_invitee! + rescue ActiveRecord::RecordInvalid + nil + end + def clear_pending_login session.delete(:pending_login_email) session.delete(:pending_login_sent_at) diff --git a/app/controllers/auth/teacher_invitations_controller.rb b/app/controllers/auth/teacher_invitations_controller.rb new file mode 100644 index 0000000..6ef710b --- /dev/null +++ b/app/controllers/auth/teacher_invitations_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class Auth::TeacherInvitationsController < ApplicationController + include Auth::SignIn + + skip_before_action :authenticate! + + def show + invitation = TeacherInvitation.find_by_accept_token(params.expect(:token)) + return redirect_to login_path, alert: t('auth.flash.invalid_invitation') if invitation.nil? + + user = User.find_by(email: invitation.email) || invitation.create_invitee! + session[:return_to] = story_group_path(invitation.story_group) + + sign_in_and_redirect(user) + rescue ActiveRecord::RecordInvalid + redirect_to login_path, alert: t('auth.flash.invitation_failed') + end +end diff --git a/app/controllers/auth/usos_controller.rb b/app/controllers/auth/usos_controller.rb index 6536820..3203073 100644 --- a/app/controllers/auth/usos_controller.rb +++ b/app/controllers/auth/usos_controller.rb @@ -21,6 +21,11 @@ def create redirect_to root_path, alert: t('auth.flash.invalid_auth') end + def failure + Rails.logger.info("USOS login failed: #{params[:message]} (strategy: #{params[:strategy]})") + redirect_to login_path, alert: t('auth.flash.usos_failure') + end + private def find_provider diff --git a/app/controllers/concerns/auth/sign_in.rb b/app/controllers/concerns/auth/sign_in.rb index 4b8164a..74a7150 100644 --- a/app/controllers/concerns/auth/sign_in.rb +++ b/app/controllers/concerns/auth/sign_in.rb @@ -7,18 +7,26 @@ module Auth::SignIn def sign_in_and_redirect(user) return_to = session[:return_to] + joined = TeacherInvitationAcceptor.new(user).call reset_session session[:user_id] = user.id - redirect_to after_login_path(return_to), notice: t('auth.flash.signed_in') + redirect_to after_login_path(return_to, joined), notice: sign_in_notice(joined) end - def after_login_path(path) + def after_login_path(path, joined = []) return path if local_path?(path) + return story_group_path(joined.first) if joined.any? home_path end + def sign_in_notice(joined) + return t('auth.flash.signed_in') if joined.empty? + + t('auth.flash.joined_as_teacher', groups: joined.map(&:name).to_sentence) + end + def local_path?(path) path&.start_with?('/') && !path.start_with?('//') end diff --git a/app/controllers/concerns/teacher_invitation_delivery.rb b/app/controllers/concerns/teacher_invitation_delivery.rb new file mode 100644 index 0000000..ac80660 --- /dev/null +++ b/app/controllers/concerns/teacher_invitation_delivery.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module TeacherInvitationDelivery + private + + def deliver_teacher_invitation(result) + if result.status == :added + teacher_invitation_mail(result.email, story_group_url(@story_group)).added_email.deliver_later + else + link = auth_teacher_invitation_url(token: result.invitation.accept_token) + teacher_invitation_mail(result.email, link).invitation_email.deliver_later + end + end + + def teacher_invitation_mail(email, link) + TeacherInvitationMailer.with(email: email, + link: link, + group_name: @story_group.name, + inviter_name: @current_user.full_name,) + end +end diff --git a/app/controllers/currency_adjustments_controller.rb b/app/controllers/currency_adjustments_controller.rb index 1fb30ee..7f90b45 100644 --- a/app/controllers/currency_adjustments_controller.rb +++ b/app/controllers/currency_adjustments_controller.rb @@ -16,7 +16,7 @@ def create @form = CurrencyAdjustmentForm.from_params(@student, params) if @form.save(granted_by_user: @current_user) - redirect_outside_turbo_frame story_group_student_path(@story_group, @student, tab: 'hist'), + redirect_outside_turbo_frame story_group_students_path(@story_group), notice: notice_for(@form) else render :new, status: :unprocessable_content diff --git a/app/controllers/teacher_invitations_controller.rb b/app/controllers/teacher_invitations_controller.rb new file mode 100644 index 0000000..11d0849 --- /dev/null +++ b/app/controllers/teacher_invitations_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class TeacherInvitationsController < ApplicationController + include StoryGroupAuthorization + + before_action :set_story_group + before_action :authorize_story_group_teachers! + + def destroy + invitation = @story_group.teacher_invitations.find(params.expect(:id)) + invitation.destroy! + + redirect_to story_group_teachers_path(@story_group), status: :see_other, + notice: t('teachers.invite.flash.cancelled', + email: invitation.email,) + end + + private + + def set_story_group + @story_group = StoryGroup.find(params.expect(:story_group_id)) + end +end diff --git a/app/controllers/teachers_controller.rb b/app/controllers/teachers_controller.rb index d9bb0e5..dbfe466 100644 --- a/app/controllers/teachers_controller.rb +++ b/app/controllers/teachers_controller.rb @@ -2,16 +2,18 @@ class TeachersController < ApplicationController include StoryGroupAuthorization + include TeacherInvitationDelivery before_action :set_story_group before_action :authorize_story_group_manage! - before_action :authorize_story_group_teachers!, only: %i[new create destroy confirm_destroy] + before_action :authorize_story_group_teachers!, only: %i[new create invite destroy confirm_destroy] before_action :set_presentation before_action :set_teacher, only: %i[destroy confirm_destroy] def index @list = TeacherList.new(story_group: @story_group, memberships: policy_scope(@story_group.teacher_memberships),) + @invitations = @story_group.teacher_invitations.newest_first.to_a if policy(@story_group).manage_teachers? end def new @@ -33,6 +35,23 @@ def create end end + def invite + email = params.dig(:teacher_invitation, :email) + result = TeacherInviter.new(story_group: @story_group, inviter: @current_user, email: email).call + + if result.ok? + deliver_teacher_invitation(result) + redirect_outside_turbo_frame story_group_teachers_path(@story_group), + notice: t("teachers.invite.flash.#{result.status}", email: result.email) + else + @teacher = @story_group.teacher_memberships.build + @invite_email = result.email + @invite_error = result.error + set_pool + render :new, status: :unprocessable_content + end + end + def destroy name = @teacher.full_name @teacher.destroy diff --git a/app/mailers/teacher_invitation_mailer.rb b/app/mailers/teacher_invitation_mailer.rb new file mode 100644 index 0000000..219685f --- /dev/null +++ b/app/mailers/teacher_invitation_mailer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class TeacherInvitationMailer < ApplicationMailer + def invitation_email + assign_common + mail to: @email, subject: t('.subject', group: @group_name) + end + + def added_email + assign_common + mail to: @email, subject: t('.subject', group: @group_name) + end + + private + + def assign_common + @email = params[:email] + @link = params[:link] + @group_name = params[:group_name] + @inviter_name = params[:inviter_name] + end +end diff --git a/app/models/notification.rb b/app/models/notification.rb index 80d95c9..8742be8 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -14,7 +14,7 @@ class Notification < ApplicationRecord user, :notifications, target: 'gh-notification-dot', - partial: 'layouts/notification_dot', + partial: 'layouts/notification_count', locals: { count: user.notifications.unread.count }, ) } diff --git a/app/models/story_group.rb b/app/models/story_group.rb index 34f35b4..63921e7 100644 --- a/app/models/story_group.rb +++ b/app/models/story_group.rb @@ -18,6 +18,7 @@ def ranking_mode_label(mode) = I18n.t("story_groups.ranking_modes.#{mode}") has_many :teacher_memberships, class_name: 'StoryGroupTeacher', foreign_key: 'story_group_id', dependent: :destroy has_many :students, through: :student_memberships, source: :user has_many :teachers, through: :teacher_memberships, source: :user + has_many :teacher_invitations, dependent: :destroy has_many :activity_group_templates, dependent: :destroy has_many :activity_groups, dependent: :destroy has_many :invites, class_name: 'StoryGroupInvite', foreign_key: 'story_group_id', dependent: :destroy diff --git a/app/models/teacher_invitation.rb b/app/models/teacher_invitation.rb new file mode 100644 index 0000000..6524aab --- /dev/null +++ b/app/models/teacher_invitation.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class TeacherInvitation < ApplicationRecord + LINK_EXPIRES_IN = 7.days + + belongs_to :story_group + belongs_to :invited_by, class_name: 'User' + + normalizes :email, with: ->(email) { email.strip.downcase } + encrypts :email, deterministic: true + + validates :email, presence: true, length: { maximum: 255 }, + format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true } + validates :email, uniqueness: { scope: :story_group_id } + + scope :newest_first, -> { order(created_at: :desc, id: :desc) } + + class << self + def find_by_accept_token(token) + find_signed(token, purpose: :accept) + end + end + + def create_invitee! + User.create!(email: email, role: :teacher, organization: story_group.owner&.organization) + end + + def accept_token + signed_id(purpose: :accept, expires_in: LINK_EXPIRES_IN) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index f0edfad..237076c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,6 +20,8 @@ class User < ApplicationRecord has_many :granted_currency_transactions, class_name: 'CurrencyTransaction', foreign_key: 'granted_by_user_id', dependent: :nullify, inverse_of: :granted_by_user has_one :login_token, dependent: :destroy + has_many :sent_teacher_invitations, class_name: 'TeacherInvitation', foreign_key: 'invited_by_id', + dependent: :destroy, inverse_of: :invited_by belongs_to :organization, optional: true @@ -50,7 +52,7 @@ def administers?(story_group) end def needs_account_setup? - organization_admin? && first_login? + first_login? && (organization_admin? || full_name.blank?) end def create_login_token!(expires_in: LoginToken::EXPIRES_IN) diff --git a/app/services/student_sheet_list.rb b/app/services/student_sheet_list.rb new file mode 100644 index 0000000..be859ef --- /dev/null +++ b/app/services/student_sheet_list.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class StudentSheetList + Category = Data.define(:record, :earned) do + def description = record.didactic_description + def story = record.story_description.presence + def reward = record.reward.to_i + def earned? = earned + end + + Sheet = Data.define(:record, :categories) do + def id = record.id + def name = record.name + def max_reward = categories.sum(&:reward) + def earned = categories.select(&:earned?).sum(&:reward) + def complete? = categories.all?(&:earned?) + end + + attr_reader :story_group, :membership + + def initialize(story_group:, membership:) + @story_group = story_group + @membership = membership + end + + def sheets + @sheets ||= sheet_records.filter_map do |record| + categories = record.activity_group_categories.reject(&:hidden?).map do |category| + Category.new(record: category, earned: earned_ids.include?(category.id)) + end + + Sheet.new(record: record, categories: categories) if categories.any? + end + end + + def any? = sheets.any? + + private + + def sheet_records + @sheet_records ||= story_group.activity_groups.kept.newest_first.includes(:activity_group_categories).to_a + end + + def earned_ids + @earned_ids ||= membership.students_activity_group_categories.pluck(:activity_group_category_id).to_set + end +end diff --git a/app/services/teacher_invitation_acceptor.rb b/app/services/teacher_invitation_acceptor.rb new file mode 100644 index 0000000..9e1dd9d --- /dev/null +++ b/app/services/teacher_invitation_acceptor.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class TeacherInvitationAcceptor + def initialize(user) + @user = user + end + + def call + return [] if @user.email.blank? + + invitations = TeacherInvitation.where(email: @user.email).includes(:story_group).to_a + return [] if invitations.empty? + + User.transaction do + @user.update!(role: :teacher) if @user.student? + invitations.filter_map { |invitation| accept(invitation) } + end + end + + private + + def accept(invitation) + story_group = invitation.story_group + invitation.destroy! + + return if story_group.owner_id == @user.id + return if story_group.teacher_memberships.exists?(user: @user) + + story_group.teacher_memberships.create!(user: @user) + story_group + end +end diff --git a/app/services/teacher_inviter.rb b/app/services/teacher_inviter.rb new file mode 100644 index 0000000..d58ce7e --- /dev/null +++ b/app/services/teacher_inviter.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class TeacherInviter + Result = Data.define(:status, :email, :user, :invitation, :error) do + def initialize(status:, email:, user: nil, invitation: nil, error: nil) + super + end + + def ok? = error.nil? + end + + STAFF_ROLES = %w[teacher organization_admin global_admin].freeze + + def initialize(story_group:, inviter:, email:) + @story_group = story_group + @inviter = inviter + @email = TeacherInvitation.normalize_value_for(:email, email.to_s) + end + + def call + user = User.find_by(email: @email) if @email.present? + user ? add_existing(user) : invite + end + + private + + def add_existing(user) + return failure(:in_group) if in_group?(user) + return failure(:student_account) unless STAFF_ROLES.include?(user.role) + + @story_group.teacher_memberships.create!(user: user) + Result.new(status: :added, email: @email, user: user) + end + + def invite + invitation = @story_group.teacher_invitations.find_or_initialize_by(email: @email) + status = invitation.persisted? ? :resent : :invited + invitation.invited_by = @inviter + + return Result.new(status: :invalid, email: @email, error: invitation.errors[:email].first) unless invitation.save + + Result.new(status: status, email: @email, invitation: invitation) + end + + def in_group?(user) + @story_group.owner_id == user.id || @story_group.teacher_memberships.exists?(user: user) + end + + def failure(key) + Result.new(status: key, email: @email, error: I18n.t("teachers.invite.errors.#{key}")) + end +end diff --git a/app/values/navigation.rb b/app/values/navigation.rb index 044109f..afb9515 100644 --- a/app/values/navigation.rb +++ b/app/values/navigation.rb @@ -82,6 +82,7 @@ def student_items(chrome) section(:my_items, story_group_student_students_items_path(group, membership), 'fa-flask', short: true,), + section(:sheets, story_group_activity_groups_path(group), 'fa-table'), *shared_items(group), section(:history, story_group_student_currency_transactions_path(group, membership), diff --git a/app/views/activity_groups/student_index.html.haml b/app/views/activity_groups/student_index.html.haml new file mode 100644 index 0000000..905c1ce --- /dev/null +++ b/app/views/activity_groups/student_index.html.haml @@ -0,0 +1,49 @@ +-# haml-lint:disable InlineStyles +- content_for :title, t('.title') +- currency = @story_group.currency_name + +%section.gh-panel.gh-page-header + .gh-page-header-text + %h1.gh-h1= t('.title') + %p.gh-lead= t('.lead', currency: currency) + +- if @student_sheets.any? + - @student_sheets.sheets.each_with_index do |sheet, index| + %details.gh-panel.gh-student-sheet{ open: index.zero? } + %summary.gh-student-sheet-summary + %span.gh-student-sheet-heading + %h2.gh-h3= sheet.name + %span.gh-small + = t('.earned', earned: sheet.earned, max: sheet.max_reward, currency: currency) + - if sheet.complete? + %span.gh-rank-status.gh-rank-status--achieved + %i.fa-solid.fa-check{ 'aria-hidden': 'true' } + = t('.complete') + %i.fa-solid.fa-chevron-down.gh-student-sheet-chevron{ 'aria-hidden': 'true' } + + .gh-progress-bar.gh-octagon-clip{ + role: 'progressbar', + 'aria-valuemin': 0, + 'aria-valuemax': sheet.max_reward, + 'aria-valuenow': sheet.earned, + 'aria-label': t('.progress_label', name: sheet.name), + } + %i.gh-octagon-clip{ style: "--gh-p: #{gh_percent(sheet.earned, sheet.max_reward)}%" } + + %ul.gh-student-sheet-categories + - sheet.categories.each do |category| + %li.gh-student-sheet-category{ class: ('gh-student-sheet-category--earned' if category.earned?) } + %div + %b= category.description + - if category.story + %small.gh-small= category.story + %span.gh-student-sheet-reward + - if category.earned? + %i.fa-solid.fa-check{ 'aria-hidden': 'true' } + %span.sr-only= t('.earned_category') + %span= "+#{category.reward}" + = render 'layouts/currency_token', story_group: @story_group, size: 20 +- else + %section.gh-panel.gh-empty-state + %h2.gh-h2= t('.empty_title') + %p.gh-lead= t('.empty_body', currency: currency) diff --git a/app/views/layouts/_notification_count.html.haml b/app/views/layouts/_notification_count.html.haml new file mode 100644 index 0000000..555467e --- /dev/null +++ b/app/views/layouts/_notification_count.html.haml @@ -0,0 +1,5 @@ +-# The badge inside `#gh-notification-dot`. Broadcasts send only this: +-# Turbo keeps turbo-permanent elements when a stream renders, so sending the +-# permanent wrapper too would drop the new count in favour of the old one. +- if count.positive? + %span.gh-notification-count.gh-octagon-clip{ 'aria-hidden': 'true' }= count diff --git a/app/views/layouts/_notification_dot.html.haml b/app/views/layouts/_notification_dot.html.haml index a99a909..73c775d 100644 --- a/app/views/layouts/_notification_dot.html.haml +++ b/app/views/layouts/_notification_dot.html.haml @@ -1,3 +1,2 @@ #gh-notification-dot.gh-icon-button-badge-slot{ data: { turbo_permanent: true } } - - if count.positive? - %span.gh-notification-count.gh-octagon-clip{ 'aria-hidden': 'true' }= count + = render 'layouts/notification_count', count: count diff --git a/app/views/passwordless_mailer/token_email.html.haml b/app/views/passwordless_mailer/token_email.html.haml index f4d8343..e768921 100644 --- a/app/views/passwordless_mailer/token_email.html.haml +++ b/app/views/passwordless_mailer/token_email.html.haml @@ -8,7 +8,7 @@ %h1{ style: 'margin:0 0 16px;font-size:22px;color:#221A2B;' }= t('.title') %p{ style: 'margin:0 0 12px;font-size:15px;line-height:1.5;color:#584C64;' } - = t('.greeting', name: @first_name) + = @first_name.present? ? t('.greeting', name: @first_name) : t('.greeting_anonymous') %p{ style: 'margin:0 0 24px;font-size:15px;line-height:1.5;color:#584C64;' } = t('.body') diff --git a/app/views/passwordless_mailer/token_email.text.haml b/app/views/passwordless_mailer/token_email.text.haml index 74ad001..30cbd64 100644 --- a/app/views/passwordless_mailer/token_email.text.haml +++ b/app/views/passwordless_mailer/token_email.text.haml @@ -1,6 +1,6 @@ Gamification Hub -= t('.greeting', name: @first_name) += @first_name.present? ? t('.greeting', name: @first_name) : t('.greeting_anonymous') = t('.text_body') diff --git a/app/views/shared/_invitation_email.html.haml b/app/views/shared/_invitation_email.html.haml index de93e34..57e1c3a 100644 --- a/app/views/shared/_invitation_email.html.haml +++ b/app/views/shared/_invitation_email.html.haml @@ -1,5 +1,10 @@ --# Same frame as passwordless_mailer/token_email, for organization invitations. -- content_for :email_styles, Rails.root.join('app/assets/stylesheets/components/invitation_email.css').read.html_safe +:ruby + # Same frame as passwordless_mailer/token_email, for organization and + # teacher invitations. `copy` picks alternate wording from + # teacher_invitation_mailer.copy.; missing keys fall back to ours. + copy = local_assigns[:copy] && "teacher_invitation_mailer.copy.#{local_assigns[:copy]}" + txt = ->(key) { copy ? t("#{copy}.#{key}", default: t(".#{key}")) : t(".#{key}") } + content_for :email_styles, Rails.root.join('app/assets/stylesheets/components/invitation_email.css').read.html_safe %table.gh-invitation-email{ cellpadding: 0, cellspacing: 0, border: 0, width: '100%' } %tr @@ -9,17 +14,17 @@ %td.gh-invitation-email-body %p.gh-invitation-email-brand Gamification Hub - %h1.gh-invitation-email-title= t('.title') + %h1.gh-invitation-email-title= txt.call(:title) %p.gh-invitation-email-lead= lead - %p.gh-invitation-email-text= t('.text') + %p.gh-invitation-email-text= txt.call(:text) %table{ cellpadding: 0, cellspacing: 0, border: 0 } %tr %td.gh-invitation-email-button{ align: 'center' } - %a.gh-invitation-email-button-link{ href: token_link }= t('.button') + %a.gh-invitation-email-button-link{ href: token_link }= txt.call(:button) - %p.gh-invitation-email-note= t('.note') + %p.gh-invitation-email-note= txt.call(:note) %p.gh-invitation-email-url %a{ href: token_link }= token_link - %p.gh-invitation-email-footer= t('.footer') + %p.gh-invitation-email-footer= txt.call(:footer) diff --git a/app/views/shared/_invitation_email.text.haml b/app/views/shared/_invitation_email.text.haml index d65a452..88440c3 100644 --- a/app/views/shared/_invitation_email.text.haml +++ b/app/views/shared/_invitation_email.text.haml @@ -1,11 +1,14 @@ +:ruby + copy = local_assigns[:copy] && "teacher_invitation_mailer.copy.#{local_assigns[:copy]}" + txt = ->(key) { copy ? t("#{copy}.#{key}", default: t(".#{key}")) : t(".#{key}") } Gamification Hub = lead -= t('.text_plain') += txt.call(:text_plain) = token_link -= t('.valid_for') += txt.call(:valid_for) -= t('.footer') += txt.call(:footer) diff --git a/app/views/students/show.html.haml b/app/views/students/show.html.haml index 78e4a79..fd3fbd1 100644 --- a/app/views/students/show.html.haml +++ b/app/views/students/show.html.haml @@ -3,6 +3,10 @@ - name = @student.full_name %section.gh-panel.gh-student-page + = link_to story_group_students_path(@story_group), class: 'gh-link-button gh-back-link' do + %i.fa-solid.fa-chevron-left{ 'aria-hidden': 'true' } + %span= t('.back') + .gh-student-page-header %span.gh-avatar.gh-octagon-clip{ 'data-gh-h': gh_avatar_hue(name), 'aria-hidden': 'true' }= gh_initials(name) %div diff --git a/app/views/teacher_invitation_mailer/added_email.html.haml b/app/views/teacher_invitation_mailer/added_email.html.haml new file mode 100644 index 0000000..53c1bf9 --- /dev/null +++ b/app/views/teacher_invitation_mailer/added_email.html.haml @@ -0,0 +1,4 @@ += render 'shared/invitation_email', + lead: t('.lead', inviter: @inviter_name, group: @group_name), + token_link: @link, + copy: 'added_email' diff --git a/app/views/teacher_invitation_mailer/added_email.text.haml b/app/views/teacher_invitation_mailer/added_email.text.haml new file mode 100644 index 0000000..53c1bf9 --- /dev/null +++ b/app/views/teacher_invitation_mailer/added_email.text.haml @@ -0,0 +1,4 @@ += render 'shared/invitation_email', + lead: t('.lead', inviter: @inviter_name, group: @group_name), + token_link: @link, + copy: 'added_email' diff --git a/app/views/teacher_invitation_mailer/invitation_email.html.haml b/app/views/teacher_invitation_mailer/invitation_email.html.haml new file mode 100644 index 0000000..b7c649d --- /dev/null +++ b/app/views/teacher_invitation_mailer/invitation_email.html.haml @@ -0,0 +1,4 @@ += render 'shared/invitation_email', + lead: t('.lead', inviter: @inviter_name, group: @group_name), + token_link: @link, + copy: 'invitation_email' diff --git a/app/views/teacher_invitation_mailer/invitation_email.text.haml b/app/views/teacher_invitation_mailer/invitation_email.text.haml new file mode 100644 index 0000000..b7c649d --- /dev/null +++ b/app/views/teacher_invitation_mailer/invitation_email.text.haml @@ -0,0 +1,4 @@ += render 'shared/invitation_email', + lead: t('.lead', inviter: @inviter_name, group: @group_name), + token_link: @link, + copy: 'invitation_email' diff --git a/app/views/teachers/_form.html.haml b/app/views/teachers/_form.html.haml index 423ac56..053735a 100644 --- a/app/views/teachers/_form.html.haml +++ b/app/views/teachers/_form.html.haml @@ -59,8 +59,10 @@ %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } %span= error - .gh-dialog-button-row - - if @in_modal - %button.gh-btn.gh-btn--secondary{ type: 'button', data: { action: 'dialog#close' } }= t('shared.actions.close') - - else - = link_to t('shared.actions.close'), story_group_teachers_path(@story_group), class: 'gh-btn gh-btn--secondary' += render 'invite_form' + +.gh-dialog-button-row + - if @in_modal + %button.gh-btn.gh-btn--secondary{ type: 'button', data: { action: 'dialog#close' } }= t('shared.actions.close') + - else + = link_to t('shared.actions.close'), story_group_teachers_path(@story_group), class: 'gh-btn gh-btn--secondary' diff --git a/app/views/teachers/_invitations.html.haml b/app/views/teachers/_invitations.html.haml new file mode 100644 index 0000000..e5b39a5 --- /dev/null +++ b/app/views/teachers/_invitations.html.haml @@ -0,0 +1,26 @@ +%section.gh-panel.gh-teachers-panel.gh-teacher-invitations + %h2.gh-h3= t('teachers.invitations.title') + %p.gh-field-hint= t('teachers.invitations.hint') + + - invitations.each do |invitation| + .gh-teacher-row + %span.gh-student-name-cell + %span.gh-avatar.gh-avatar--sm.gh-octagon-clip{ 'aria-hidden': 'true' } + %i.fa-solid.fa-envelope + %span.gh-name-cell + %b= invitation.email + %small= t('teachers.invitations.pending') + + %span.gh-teacher-role= t('teachers.invitations.role') + + %span + %span.gh-mobile-field-label= t('teachers.invitations.sent') + %span.gh-small= teacher_added_on(invitation.updated_at) + + %span.gh-teacher-row-actions + = button_to story_group_teacher_invitation_path(@story_group, invitation), + method: :delete, + class: 'gh-btn gh-btn--secondary gh-btn--small', + 'aria-label': t('teachers.invitations.cancel_label', email: invitation.email) do + %i.fa-solid.fa-xmark{ 'aria-hidden': 'true' } + = t('teachers.invitations.cancel') diff --git a/app/views/teachers/_invite_form.html.haml b/app/views/teachers/_invite_form.html.haml new file mode 100644 index 0000000..be87ddd --- /dev/null +++ b/app/views/teachers/_invite_form.html.haml @@ -0,0 +1,26 @@ +- error = @invite_error + += form_with url: invite_story_group_teachers_path(@story_group), + method: :post, + scope: :teacher_invitation, + class: 'gh-teacher-invite-form', + data: { turbo_frame: (@in_modal ? 'modal' : nil) } do |f| + %h3.gh-h3= t('teachers.invite.title') + %p.gh-field-hint= t('teachers.invite.hint') + + .gh-form-field + = f.label :email, t('teachers.invite.email') + .gh-teacher-invite-row + .gh-text-input{ class: ('gh-text-input--invalid' if error) } + = f.email_field :email, value: @invite_email, required: true, autocomplete: 'off', + placeholder: t('teachers.invite.placeholder'), + 'aria-invalid': (error ? 'true' : nil), + 'aria-describedby': (error ? 'gh-teacher-invite-error' : nil) + %button.gh-btn{ type: 'submit' } + %i.fa-solid.fa-paper-plane{ 'aria-hidden': 'true' } + = t('teachers.invite.submit') + + - if error + %p.gh-field-error#gh-teacher-invite-error{ role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + %span= error diff --git a/app/views/teachers/index.html.haml b/app/views/teachers/index.html.haml index 49b9096..50e97cd 100644 --- a/app/views/teachers/index.html.haml +++ b/app/views/teachers/index.html.haml @@ -21,3 +21,6 @@ %span = render partial: 'row', collection: @list.rows, as: :row, locals: { manage: manage } + +- if @invitations.present? + = render 'invitations', invitations: @invitations diff --git a/config/initializers/field_error_proc.rb b/config/initializers/field_error_proc.rb new file mode 100644 index 0000000..0346b8c --- /dev/null +++ b/config/initializers/field_error_proc.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ActionView::Base.field_error_proc = proc { |html_tag, _instance| html_tag } diff --git a/config/locales/views/activity_groups.en.yml b/config/locales/views/activity_groups.en.yml index 2630208..1880b08 100644 --- a/config/locales/views/activity_groups.en.yml +++ b/config/locales/views/activity_groups.en.yml @@ -13,6 +13,16 @@ en: empty_title: "You don't have any templates yet" empty_body: "A template is a set of categories you reward students for. From one template you then create sheets — one per class." + student_index: + title: "Grade sheets" + lead: "What you can earn %{currency} for in this group. The newest sheets are at the top." + earned: "Earned %{earned} of %{max} %{currency}" + complete: "All earned" + earned_category: "Earned" + progress_label: "Progress in %{name}" + empty_title: "No sheets yet" + empty_body: "Your teacher hasn't added any grade sheets yet. Once they do, you'll see here what you can earn %{currency} for." + template_panel: meta: one: "Template: %{count} category, up to %{max} %{currency} per student per sheet." diff --git a/config/locales/views/activity_groups.pl.yml b/config/locales/views/activity_groups.pl.yml index ea52990..cccf3be 100644 --- a/config/locales/views/activity_groups.pl.yml +++ b/config/locales/views/activity_groups.pl.yml @@ -13,6 +13,16 @@ pl: empty_title: "Nie masz jeszcze żadnego szablonu" empty_body: "Szablon to zestaw kategorii, za które nagradzasz studentów. Z jednego szablonu tworzysz potem kolejne arkusze — po jednym na zajęcia." + student_index: + title: "Arkusze ocen" + lead: "Za co możesz zdobyć %{currency} w tej grupie. Najnowsze arkusze są na górze." + earned: "Zdobyto %{earned} z %{max} %{currency}" + complete: "Wszystko zdobyte" + earned_category: "Zdobyte" + progress_label: "Postęp w arkuszu %{name}" + empty_title: "Brak arkuszy" + empty_body: "Prowadzący nie dodał jeszcze arkuszy ocen. Gdy to zrobi, zobaczysz tu, za co możesz zdobyć %{currency}." + template_panel: meta: one: "Szablon: %{count} kategoria, do %{max} %{currency} na studenta za arkusz." diff --git a/config/locales/views/auth.en.yml b/config/locales/views/auth.en.yml index 24b201e..6fa06b5 100644 --- a/config/locales/views/auth.en.yml +++ b/config/locales/views/auth.en.yml @@ -7,6 +7,10 @@ en: resent: "We've sent a new link. The previous one no longer works." invalid_provider: "Invalid login provider." invalid_auth: "Invalid authorization attempt." + usos_failure: "Couldn't log in with USOS. Please try again." + joined_as_teacher: "Logged in. You joined as a teacher: %{groups}." + invalid_invitation: "The invitation link has expired or was already used. Log in or ask for a new invitation." + invitation_failed: "We couldn't accept the invitation. Please contact your university's administrator." passwordless: new: diff --git a/config/locales/views/auth.pl.yml b/config/locales/views/auth.pl.yml index 9bba17d..6926f03 100644 --- a/config/locales/views/auth.pl.yml +++ b/config/locales/views/auth.pl.yml @@ -7,6 +7,10 @@ pl: resent: "Wysłaliśmy nowy link. Poprzedni już nie działa." invalid_provider: "Nieprawidłowy dostawca logowania." invalid_auth: "Nieprawidłowa próba autoryzacji." + usos_failure: "Nie udało się zalogować przez USOS. Spróbuj ponownie." + joined_as_teacher: "Zalogowano. Dołączono jako nauczyciel do: %{groups}." + invalid_invitation: "Link z zaproszeniem wygasł albo zaproszenie zostało już przyjęte. Zaloguj się albo poproś o nowe zaproszenie." + invitation_failed: "Nie udało się przyjąć zaproszenia. Skontaktuj się z administratorem uczelni." passwordless: new: diff --git a/config/locales/views/passwordless_mailer.en.yml b/config/locales/views/passwordless_mailer.en.yml index 065c952..e61d999 100644 --- a/config/locales/views/passwordless_mailer.en.yml +++ b/config/locales/views/passwordless_mailer.en.yml @@ -4,6 +4,7 @@ en: subject: "Logging in to Gamification Hub" title: "Log in" greeting: "Hi, %{name}!" + greeting_anonymous: "Hi!" body: "Click the button below to log in. No password needed." text_body: "To log in, open the link below. No password needed." button: "Log in" diff --git a/config/locales/views/passwordless_mailer.pl.yml b/config/locales/views/passwordless_mailer.pl.yml index 05f04bd..3766397 100644 --- a/config/locales/views/passwordless_mailer.pl.yml +++ b/config/locales/views/passwordless_mailer.pl.yml @@ -4,6 +4,7 @@ pl: subject: "Logowanie do systemu Gamification Hub" title: "Zaloguj się" greeting: "Cześć, %{name}!" + greeting_anonymous: "Cześć!" body: "Kliknij przycisk poniżej, aby zalogować się. Hasło nie jest potrzebne." text_body: "Aby zalogować się, otwórz poniższy link. Hasło nie jest potrzebne." button: "Zaloguj się" diff --git a/config/locales/views/students.en.yml b/config/locales/views/students.en.yml index af2f0ee..4689874 100644 --- a/config/locales/views/students.en.yml +++ b/config/locales/views/students.en.yml @@ -107,6 +107,7 @@ en: show: breadcrumb: "Students" + back: "Back to students" award_badge: "Award a badge" adjust_currency: "Adjust currency" rank: "Rank" diff --git a/config/locales/views/students.pl.yml b/config/locales/views/students.pl.yml index 1e46773..0b3ea81 100644 --- a/config/locales/views/students.pl.yml +++ b/config/locales/views/students.pl.yml @@ -121,6 +121,7 @@ pl: show: breadcrumb: "Studenci" + back: "Wróć do listy studentów" award_badge: "Przyznaj odznakę" adjust_currency: "Koryguj walutę" rank: "Ranga" diff --git a/config/locales/views/teacher_invitation_mailer.en.yml b/config/locales/views/teacher_invitation_mailer.en.yml new file mode 100644 index 0000000..684a503 --- /dev/null +++ b/config/locales/views/teacher_invitation_mailer.en.yml @@ -0,0 +1,23 @@ +en: + teacher_invitation_mailer: + invitation_email: + subject: "Invitation to teach %{group}" + lead: "%{inviter} invites you to run the group %{group} in Gamification Hub as a teacher." + added_email: + subject: "You were added to %{group}" + lead: "%{inviter} added you as a teacher of the group %{group} in Gamification Hub." + + copy: + invitation_email: + title: "Group invitation" + text: "Click the button below to log in. The group will be on your list straight away." + text_plain: "To log in and join the group, open the link below." + button: "Accept invitation" + added_email: + title: "New group" + text: "The group is already on your list. Click the button to open it." + text_plain: "The group is already on your list. Open it at:" + button: "Open the group" + note: "If the button doesn't work, copy this address into your browser:" + valid_for: "" + footer: "If this is a mistake, contact the person who added you." diff --git a/config/locales/views/teacher_invitation_mailer.pl.yml b/config/locales/views/teacher_invitation_mailer.pl.yml new file mode 100644 index 0000000..803c115 --- /dev/null +++ b/config/locales/views/teacher_invitation_mailer.pl.yml @@ -0,0 +1,23 @@ +pl: + teacher_invitation_mailer: + invitation_email: + subject: "Zaproszenie do prowadzenia grupy %{group}" + lead: "%{inviter} zaprasza Cię do prowadzenia grupy %{group} w Gamification Hub jako nauczyciel." + added_email: + subject: "Dodano Cię do grupy %{group}" + lead: "%{inviter} dodał(a) Cię jako nauczyciela do grupy %{group} w Gamification Hub." + + copy: + invitation_email: + title: "Zaproszenie do grupy" + text: "Kliknij przycisk poniżej, aby się zalogować. Grupa pojawi się na Twojej liście od razu." + text_plain: "Aby się zalogować i dołączyć do grupy, otwórz poniższy link." + button: "Przyjmij zaproszenie" + added_email: + title: "Nowa grupa" + text: "Grupa jest już na Twojej liście. Kliknij przycisk, aby ją otworzyć." + text_plain: "Grupa jest już na Twojej liście. Otwórz ją pod adresem:" + button: "Otwórz grupę" + note: "Jeśli przycisk nie działa, skopiuj ten adres do przeglądarki:" + valid_for: "" + footer: "Jeśli to pomyłka, napisz do osoby, która Cię dodała." diff --git a/config/locales/views/teachers.en.yml b/config/locales/views/teachers.en.yml index cd77f1f..2c607a8 100644 --- a/config/locales/views/teachers.en.yml +++ b/config/locales/views/teachers.en.yml @@ -55,9 +55,38 @@ en: body: "They'll lose access to the group. Rewards they awarded stay with the students." remove: "Remove from group" + invite: + title: "Invite by e-mail" + hint: "Not on the list? Send an invitation. They'll join the group as a teacher on their first login." + email: "E-mail address" + placeholder: "john.smith@example.com" + submit: "Send invitation" + flash: + added: "%{email} already has a teacher account — added to the group and notified." + invited: "Invitation sent to %{email}." + resent: "Invitation sent again to %{email}." + cancelled: "Invitation for %{email} cancelled." + errors: + in_group: "This person is already in the group." + student_account: "This is a student account. You can only invite a teacher or a new e-mail address." + + invitations: + title: "Pending invitations" + hint: "These people will join the group as teachers on their first login." + pending: "Waiting for first login" + role: "Invited teacher" + sent: "Sent" + cancel: "Cancel" + cancel_label: "Cancel the invitation for %{email}" + activerecord: errors: models: + teacher_invitation: + attributes: + email: + blank: "Enter an e-mail address." + invalid: "That doesn't look like an e-mail address." story_group_teacher: attributes: user_id: diff --git a/config/locales/views/teachers.pl.yml b/config/locales/views/teachers.pl.yml index a3828d2..81d6b93 100644 --- a/config/locales/views/teachers.pl.yml +++ b/config/locales/views/teachers.pl.yml @@ -59,9 +59,38 @@ pl: body: "Straci dostęp do grupy. Nagrody, które przyznał, zostają u studentów." remove: "Usuń z grupy" + invite: + title: "Zaproś e-mailem" + hint: "Nie ma tej osoby na liście? Wyślij zaproszenie. Po pierwszym logowaniu trafi do grupy jako nauczyciel." + email: "Adres e-mail" + placeholder: "jan.nowak@example.com" + submit: "Wyślij zaproszenie" + flash: + added: "%{email} ma już konto nauczyciela — dodano do grupy i wysłano powiadomienie." + invited: "Wysłano zaproszenie na adres %{email}." + resent: "Wysłano ponownie zaproszenie na adres %{email}." + cancelled: "Anulowano zaproszenie dla %{email}." + errors: + in_group: "Ta osoba jest już w grupie." + student_account: "To konto studenta. Zaprosić można tylko nauczyciela albo nowy adres e-mail." + + invitations: + title: "Oczekujące zaproszenia" + hint: "Te osoby trafią do grupy jako nauczyciele przy pierwszym logowaniu." + pending: "Czeka na pierwsze logowanie" + role: "Zaproszony nauczyciel" + sent: "Wysłano" + cancel: "Anuluj" + cancel_label: "Anuluj zaproszenie dla %{email}" + activerecord: errors: models: + teacher_invitation: + attributes: + email: + blank: "Podaj adres e-mail." + invalid: "To nie wygląda na adres e-mail." story_group_teacher: attributes: user_id: diff --git a/config/routes.rb b/config/routes.rb index aebd47f..27de20f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,7 +66,9 @@ end resources :teachers, only: %i[new index create destroy] do get :confirm_destroy, on: :member + post :invite, on: :collection end + resources :teacher_invitations, only: :destroy resources :students, except: %i[new create] do member do post :update_lives @@ -136,9 +138,11 @@ namespace 'auth' do get '/:provider/callback', to: 'usos#create', as: :callback + get 'failure', to: 'usos#failure', as: :failure resource :passwordless, only: %i[new create], controller: 'passwordless' get 'passwordless/verify', to: 'passwordless#verify', as: :passwordless_verify get 'passwordless/inbox', to: 'passwordless#inbox', as: :passwordless_inbox + get 'teacher_invitations/:token', to: 'teacher_invitations#show', as: :teacher_invitation end get '/login', to: 'sessions#new', as: :login diff --git a/db/migrate/20261104120000_create_teacher_invitations.rb b/db/migrate/20261104120000_create_teacher_invitations.rb new file mode 100644 index 0000000..eddc53c --- /dev/null +++ b/db/migrate/20261104120000_create_teacher_invitations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateTeacherInvitations < ActiveRecord::Migration[8.1] + def change + create_table :teacher_invitations do |t| + t.references :story_group, null: false, foreign_key: true, index: false + t.references :invited_by, null: false, foreign_key: { to_table: :users } + t.string :email, null: false + + t.timestamps + end + + add_index :teacher_invitations, %i[story_group_id email], unique: true + add_index :teacher_invitations, :email + end +end diff --git a/db/queue_schema.rb b/db/queue_schema.rb index 3262920..2181653 100644 --- a/db/queue_schema.rb +++ b/db/queue_schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 0) do +ActiveRecord::Schema[8.1].define(version: 1) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" diff --git a/db/schema.rb b/db/schema.rb index a18450e..b4ab81f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_10_26_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_11_04_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -277,6 +277,17 @@ t.index ["story_group_student_id"], name: "index_students_items_on_story_group_student_id" end + create_table "teacher_invitations", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "email", null: false + t.bigint "invited_by_id", null: false + t.bigint "story_group_id", null: false + t.datetime "updated_at", null: false + t.index ["email"], name: "index_teacher_invitations_on_email" + t.index ["invited_by_id"], name: "index_teacher_invitations_on_invited_by_id" + t.index ["story_group_id", "email"], name: "index_teacher_invitations_on_story_group_id_and_email", unique: true + end + create_table "users", force: :cascade do |t| t.datetime "created_at", null: false t.string "email" @@ -324,5 +335,7 @@ add_foreign_key "students_badges", "story_group_students" add_foreign_key "students_items", "items" add_foreign_key "students_items", "story_group_students" + add_foreign_key "teacher_invitations", "story_groups" + add_foreign_key "teacher_invitations", "users", column: "invited_by_id" add_foreign_key "users", "organizations" end diff --git a/test/controllers/activity_groups_controller_test.rb b/test/controllers/activity_groups_controller_test.rb index b3852b4..57b7f28 100644 --- a/test/controllers/activity_groups_controller_test.rb +++ b/test/controllers/activity_groups_controller_test.rb @@ -179,6 +179,36 @@ class ActivityGroupsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to root_url end + test 'a student sees visible categories, their rewards and what they earned' do + student_user = FactoryBot.create(:user) + student = FactoryBot.create(:story_group_student, story_group: @story_group, user: student_user) + @category.update!(didactic_description: 'Report', reward: 7) + FactoryBot.create(:activity_group_category, activity_group: @activity_group, position: 1, + didactic_description: 'Secret', hidden: true,) + FactoryBot.create(:students_activity_group_category, student: student, activity_group_category: @category) + sign_out + sign_in student_user + + get story_group_activity_groups_url(@story_group) + + assert_response :success + assert_select '.gh-student-sheet h2', 'Lab 1' + assert_select '.gh-student-sheet-category--earned b', 'Report' + assert_select '.gh-student-sheet-reward', /\+7/ + assert_select 'b', text: 'Secret', count: 0 + assert_select '.gh-template-panel', false + end + + test 'a student can not open sheet management' do + student_user = FactoryBot.create(:user) + FactoryBot.create(:story_group_student, story_group: @story_group, user: student_user) + sign_out + sign_in student_user + + get edit_story_group_activity_group_url(@story_group, @activity_group) + assert_redirected_to root_url + end + test 'should not access groups for story group not managed by teacher' do other_story_group = FactoryBot.create(:story_group) get story_group_activity_groups_url(other_story_group) diff --git a/test/controllers/auth/usos_controller_test.rb b/test/controllers/auth/usos_controller_test.rb new file mode 100644 index 0000000..ba3d59d --- /dev/null +++ b/test/controllers/auth/usos_controller_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require 'test_helper' + +class Auth::UsosControllerTest < ActionDispatch::IntegrationTest + test 'failed USOS login redirects to login with an alert instead of 404' do + get auth_failure_path(message: 'invalid_credentials', strategy: 'uam_usos') + + assert_redirected_to login_path + assert_equal I18n.t('auth.flash.usos_failure'), flash[:alert] + end +end diff --git a/test/controllers/teacher_invitations_flow_test.rb b/test/controllers/teacher_invitations_flow_test.rb new file mode 100644 index 0000000..f77e717 --- /dev/null +++ b/test/controllers/teacher_invitations_flow_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TeacherInvitationsFlowTest < ActionDispatch::IntegrationTest + setup do + @organization = FactoryBot.create(:organization, max_members: 10) + @owner = FactoryBot.create(:user, :teacher, organization: @organization) + @story_group = FactoryBot.create(:story_group, owner: @owner) + end + + test 'the owner invites an unknown address and an e-mail goes out' do + sign_in @owner + + assert_enqueued_emails 1 do + assert_difference('TeacherInvitation.count') do + post invite_story_group_teachers_url(@story_group), + params: { teacher_invitation: { email: 'new@example.com' } } + end + end + + assert_turbo_redirected_to story_group_teachers_url(@story_group) + + get story_group_teachers_url(@story_group) + assert_select '.gh-teacher-invitations b', 'new@example.com' + end + + test 'a refused invitation re-renders the dialog with the error' do + FactoryBot.create(:user, :student, email: 'student@example.com') + sign_in @owner + + post invite_story_group_teachers_url(@story_group), + params: { teacher_invitation: { email: 'student@example.com' } } + + assert_response :unprocessable_content + assert_select '#gh-teacher-invite-error', /konto studenta/ + end + + test 'a supporting teacher can not invite' do + helper = FactoryBot.create(:user, :teacher) + FactoryBot.create(:story_group_teacher, story_group: @story_group, user: helper) + sign_in helper + + assert_no_difference('TeacherInvitation.count') do + post invite_story_group_teachers_url(@story_group), + params: { teacher_invitation: { email: 'new@example.com' } } + end + end + + test 'the owner cancels a pending invitation' do + invitation = FactoryBot.create(:teacher_invitation, story_group: @story_group, invited_by: @owner) + sign_in @owner + + delete story_group_teacher_invitation_url(@story_group, invitation) + + assert_redirected_to story_group_teachers_url(@story_group) + assert_not TeacherInvitation.exists?(invitation.id) + end + + test 'the e-mailed link creates the account, joins the group and asks for a name' do + invitation = FactoryBot.create(:teacher_invitation, story_group: @story_group, invited_by: @owner, + email: 'new@example.com',) + + assert_difference('User.count') do + get auth_teacher_invitation_url(token: invitation.accept_token) + end + + user = User.find_by!(email: 'new@example.com') + assert_predicate user, :teacher? + assert_equal @organization, user.organization + assert_includes @story_group.teachers.reload, user + assert_redirected_to story_group_url(@story_group) + + follow_redirect! + assert_redirected_to edit_account_setup_url + end + + test 'a used or forged link is refused' do + get auth_teacher_invitation_url(token: 'forged') + + assert_redirected_to login_url + end + + test 'typing an invited address on the login page sends a login link' do + FactoryBot.create(:teacher_invitation, story_group: @story_group, invited_by: @owner, email: 'new@example.com') + + assert_enqueued_emails 1 do + post auth_passwordless_url, params: { email: 'new@example.com' } + end + + assert_predicate User.find_by!(email: 'new@example.com'), :teacher? + end + + test 'any sign-in applies a pending invitation' do + user = FactoryBot.create(:user, :student) + FactoryBot.create(:teacher_invitation, story_group: @story_group, invited_by: @owner, email: user.email) + + sign_in user + + assert_redirected_to story_group_url(@story_group) + assert_includes @story_group.teachers.reload, user + end +end diff --git a/test/factories/teacher_invitations.rb b/test/factories/teacher_invitations.rb new file mode 100644 index 0000000..375a9cb --- /dev/null +++ b/test/factories/teacher_invitations.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :teacher_invitation do + association :story_group + association :invited_by, factory: :user + sequence(:email) { |n| "invited#{n}@example.com" } + end +end diff --git a/test/mailers/previews/teacher_invitation_mailer_preview.rb b/test/mailers/previews/teacher_invitation_mailer_preview.rb new file mode 100644 index 0000000..e8707b6 --- /dev/null +++ b/test/mailers/previews/teacher_invitation_mailer_preview.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class TeacherInvitationMailerPreview < ActionMailer::Preview + def invitation_email + mailer.invitation_email + end + + def added_email + mailer.added_email + end + + private + + def mailer + TeacherInvitationMailer.with(email: 'jan.nowak@example.com', + link: 'http://example.com/test', + group_name: 'Fizyka 1', + inviter_name: 'Anna Kowalska',) + end +end diff --git a/test/mailers/teacher_invitation_mailer_test.rb b/test/mailers/teacher_invitation_mailer_test.rb new file mode 100644 index 0000000..195e887 --- /dev/null +++ b/test/mailers/teacher_invitation_mailer_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TeacherInvitationMailerTest < ActionMailer::TestCase + test 'invitation_email carries the link and the group' do + mail = mailer.invitation_email + + assert_equal 'Zaproszenie do prowadzenia grupy Fizyka 1', mail.subject + assert_equal ['new@example.com'], mail.to + assert_match 'Anna Kowalska zaprasza Cię do prowadzenia grupy Fizyka 1', mail.html_part.body.decoded + assert_match 'Przyjmij zaproszenie', mail.html_part.body.decoded + assert_match 'http://example.com/accept', mail.text_part.body.decoded + end + + test 'added_email tells an existing teacher where the group is' do + mail = mailer.added_email + + assert_equal 'Dodano Cię do grupy Fizyka 1', mail.subject + assert_match 'Otwórz grupę', mail.html_part.body.decoded + assert_no_match 'ważny przez 7 dni', mail.text_part.body.decoded + end + + private + + def mailer + TeacherInvitationMailer.with(email: 'new@example.com', + link: 'http://example.com/accept', + group_name: 'Fizyka 1', + inviter_name: 'Anna Kowalska',) + end +end diff --git a/test/services/teacher_invitation_acceptor_test.rb b/test/services/teacher_invitation_acceptor_test.rb new file mode 100644 index 0000000..b1dbfee --- /dev/null +++ b/test/services/teacher_invitation_acceptor_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TeacherInvitationAcceptorTest < ActiveSupport::TestCase + setup do + @story_group = FactoryBot.create(:story_group, owner: FactoryBot.create(:user, :teacher)) + @invitation = FactoryBot.create(:teacher_invitation, story_group: @story_group, email: 'new@example.com') + end + + test 'joins the invited groups as a teacher and removes the invitations' do + user = FactoryBot.create(:user, :student, email: 'new@example.com') + + joined = TeacherInvitationAcceptor.new(user).call + + assert_equal [@story_group], joined + assert_predicate user.reload, :teacher? + assert_includes @story_group.teachers.reload, user + assert_not TeacherInvitation.exists?(@invitation.id) + end + + test 'does not demote an admin' do + admin = FactoryBot.create(:user, :organization_admin, email: 'new@example.com') + + TeacherInvitationAcceptor.new(admin).call + + assert_predicate admin.reload, :organization_admin? + end + + test 'does nothing for an address without invitations' do + user = FactoryBot.create(:user, :student) + + assert_empty TeacherInvitationAcceptor.new(user).call + assert_predicate user.reload, :student? + end +end diff --git a/test/services/teacher_inviter_test.rb b/test/services/teacher_inviter_test.rb new file mode 100644 index 0000000..0753fbe --- /dev/null +++ b/test/services/teacher_inviter_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'test_helper' + +class TeacherInviterTest < ActiveSupport::TestCase + setup do + @owner = FactoryBot.create(:user, :teacher) + @story_group = FactoryBot.create(:story_group, owner: @owner) + end + + test 'adds an existing teacher account straight away' do + teacher = FactoryBot.create(:user, :teacher, email: 'anna@example.com') + + result = invite(' Anna@Example.com ') + + assert_equal :added, result.status + assert_includes @story_group.teachers.reload, teacher + assert_empty @story_group.teacher_invitations + end + + test 'stores an invitation for an unknown address' do + result = invite('new.teacher@example.com') + + assert_equal :invited, result.status + assert_equal 'new.teacher@example.com', result.invitation.email + assert_equal @owner, result.invitation.invited_by + end + + test 'inviting the same address again resends instead of duplicating' do + invite('new.teacher@example.com') + + assert_no_difference('TeacherInvitation.count') do + assert_equal :resent, invite('NEW.teacher@example.com').status + end + end + + test 'refuses an existing student account' do + FactoryBot.create(:user, :student, email: 'student@example.com') + + result = invite('student@example.com') + + assert_not result.ok? + assert_equal :student_account, result.status + assert_empty @story_group.teachers + end + + test 'refuses someone already in the group' do + assert_equal :in_group, invite(@owner.email).status + end + + test 'refuses a malformed address' do + result = invite('not-an-email') + + assert_not result.ok? + assert_equal 'To nie wygląda na adres e-mail.', result.error + end + + private + + def invite(email) + TeacherInviter.new(story_group: @story_group, inviter: @owner, email: email).call + end +end