diff --git a/app/javascript/controllers/index.ts b/app/javascript/controllers/index.ts index 44beb41..86dd3a4 100644 --- a/app/javascript/controllers/index.ts +++ b/app/javascript/controllers/index.ts @@ -22,6 +22,7 @@ import "@controllers/invite_form_controller" import "@controllers/invite_picker_controller" import "@controllers/item_form_controller" import "@controllers/join_code_controller" +import "@controllers/leave_guard_controller" import "@controllers/list_search_controller" import "@controllers/lives_stepper_controller" import "@controllers/menu_controller" diff --git a/app/javascript/controllers/leave_guard_controller.ts b/app/javascript/controllers/leave_guard_controller.ts new file mode 100644 index 0000000..d2808ff --- /dev/null +++ b/app/javascript/controllers/leave_guard_controller.ts @@ -0,0 +1,88 @@ +import { application } from "@controllers/application" +import { Controller } from "@hotwired/stimulus" + +type VisitEvent = CustomEvent<{ url: string }> + +class LeaveGuardController extends Controller { + static targets = ["form", "dialog"] + + declare readonly formTarget: HTMLFormElement + declare readonly hasFormTarget: boolean + declare readonly dialogTarget: HTMLDialogElement + + private bypass = false + private pendingUrl: string | null = null + + connect() { + window.addEventListener("turbo:before-visit", this.onBeforeVisit) + window.addEventListener("beforeunload", this.onBeforeUnload) + } + + disconnect() { + window.removeEventListener("turbo:before-visit", this.onBeforeVisit) + window.removeEventListener("beforeunload", this.onBeforeUnload) + } + + // Submitting the form saves the changes, so leaving afterwards is fine. + submitting() { + this.bypass = true + } + + stay() { + this.pendingUrl = null + this.dialogTarget.close() + } + + leave() { + const url = this.pendingUrl + + this.bypass = true + this.pendingUrl = null + this.dialogTarget.close() + + if (url) Turbo.visit(url) + } + + closeOnBackdrop(event: MouseEvent) { + if (event.target !== event.currentTarget) return + + this.stay() + } + + private onBeforeVisit = (event: Event) => { + if (!this.guarding) return + + event.preventDefault() + this.pendingUrl = (event as VisitEvent).detail.url + + if (!this.dialogTarget.open) this.dialogTarget.showModal() + } + + private onBeforeUnload = (event: BeforeUnloadEvent) => { + if (!this.guarding) return + + event.preventDefault() + } + + private get guarding(): boolean { + return !this.bypass && this.dirty + } + + private get dirty(): boolean { + if (!this.hasFormTarget) return false + + return Array.from(this.formTarget.elements).some((field) => { + if (field instanceof HTMLInputElement && ["checkbox", "radio"].includes(field.type)) { + return field.checked !== field.defaultChecked + } + + if (field instanceof HTMLInputElement || field instanceof HTMLTextAreaElement) { + return field.type !== "hidden" && field.value !== field.defaultValue + } + + return false + }) + } +} + +application.register("leave-guard", LeaveGuardController) diff --git a/app/models/badge.rb b/app/models/badge.rb index b853194..318e740 100644 --- a/app/models/badge.rb +++ b/app/models/badge.rb @@ -19,7 +19,7 @@ class Badge < ApplicationRecord validates :discount, numericality: { greater_than_or_equal_to: 0, - less_than_or_equal_to: 100, + less_than_or_equal_to: Discount::CAP_VALUE, message: :out_of_range, } diff --git a/app/models/rank.rb b/app/models/rank.rb index de8917e..3728410 100644 --- a/app/models/rank.rb +++ b/app/models/rank.rb @@ -11,7 +11,7 @@ class Rank < ApplicationRecord validates :discount, numericality: { greater_than_or_equal_to: 0, - less_than_or_equal_to: 100, + less_than_or_equal_to: Discount::CAP_VALUE, message: :out_of_range, } diff --git a/app/models/students_item.rb b/app/models/students_item.rb index 40d9e81..b87b0de 100644 --- a/app/models/students_item.rb +++ b/app/models/students_item.rb @@ -4,6 +4,6 @@ class StudentsItem < ApplicationRecord belongs_to :story_group_student belongs_to :item - validates :discount_applied, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 50 } + validates :discount_applied, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Discount::CAP_VALUE } validates :price_paid, presence: true, numericality: { greater_than_or_equal_to: 0 } end diff --git a/app/values/item_card.rb b/app/values/item_card.rb index 9f9243d..f63bc55 100644 --- a/app/values/item_card.rb +++ b/app/values/item_card.rb @@ -52,7 +52,7 @@ def discount_badges end def ladder_discount - discounts = ranks.filter_map { |rank| rank.discount&.clamp(0, 100) } + discounts = ranks.filter_map { |rank| rank.discount&.clamp(0, Discount::CAP_VALUE) } discounts.max.to_i end @@ -97,7 +97,7 @@ def badge_requirements end def rank_discount - discounts = eligible_ranks.filter_map { |rank| rank.discount&.clamp(0, 100) } + discounts = eligible_ranks.filter_map { |rank| rank.discount&.clamp(0, Discount::CAP_VALUE) } discounts.max.to_i end diff --git a/app/views/badges/_form.html.haml b/app/views/badges/_form.html.haml index 6cd285d..86d04c1 100644 --- a/app/views/badges/_form.html.haml +++ b/app/views/badges/_form.html.haml @@ -92,7 +92,7 @@ = f.label :discount, t('.discount') .gh-price-field-row .gh-text-input.gh-text-input--numeric{ class: ('gh-text-input--invalid' if error.call(:discount)) } - = f.number_field :discount, min: 0, max: 100, step: 1, + = f.number_field :discount, min: 0, max: Discount::CAP_VALUE, step: 1, 'aria-invalid': (error.call(:discount) ? 'true' : nil), 'aria-describedby': (error.call(:discount) ? 'gh-badge-disc-error' : nil), data: { badge_form_target: 'discount', action: 'badge-form#refresh' } diff --git a/app/views/ranks/_form.html.haml b/app/views/ranks/_form.html.haml index 85f6d10..aab7d23 100644 --- a/app/views/ranks/_form.html.haml +++ b/app/views/ranks/_form.html.haml @@ -97,7 +97,7 @@ = f.label :discount, t('.discount') .gh-price-field-row .gh-text-input.gh-text-input--numeric{ class: ('gh-text-input--invalid' if error.call(:discount)) } - = f.number_field :discount, min: 0, max: 100, step: 1, + = f.number_field :discount, min: 0, max: Discount::CAP_VALUE, step: 1, 'aria-invalid': (error.call(:discount) ? 'true' : nil), 'aria-describedby': (error.call(:discount) ? 'gh-rank-disc-error' : nil), data: { rank_form_target: 'discount', action: 'rank-form#refresh' } diff --git a/app/views/students_activity_group_categories/_leave_dialog.html.haml b/app/views/students_activity_group_categories/_leave_dialog.html.haml new file mode 100644 index 0000000..abd52ad --- /dev/null +++ b/app/views/students_activity_group_categories/_leave_dialog.html.haml @@ -0,0 +1,20 @@ +%dialog.gh-dialog{ + 'aria-labelledby': 'gh-leave-title', + data: { + leave_guard_target: 'dialog', + action: 'close->leave-guard#stay click->leave-guard#closeOnBackdrop', + }, +} + .gh-dialog-body.gh-octagon-clip + %h2.gh-h2#gh-leave-title= t('.title') + + %p.gh-warning-note{ role: 'alert' } + %i.fa-solid.fa-triangle-exclamation{ 'aria-hidden': 'true' } + = t('.body') + + .gh-dialog-button-row + %button.gh-btn.gh-btn--secondary{type: 'button', autofocus: true, + data: { action: 'leave-guard#stay' },} + = t('.stay') + %button.gh-btn{ type: 'button', data: { action: 'leave-guard#leave' } } + = t('.leave') diff --git a/app/views/students_activity_group_categories/_review_dialog.html.haml b/app/views/students_activity_group_categories/_review_dialog.html.haml index 18e1872..a3dd466 100644 --- a/app/views/students_activity_group_categories/_review_dialog.html.haml +++ b/app/views/students_activity_group_categories/_review_dialog.html.haml @@ -1,11 +1,13 @@ -%dialog.gh-dialog.gh-dialog--grading-review{ 'aria-labelledby': 'gh-review-title', - data: { grading_target: 'dialog', - action: 'close->grading#closeReview click->grading#closeOnBackdrop' } } +%dialog.gh-dialog.gh-dialog--grading-review{ + 'aria-labelledby': 'gh-review-title', + data: { grading_target: 'dialog', + action: 'close->grading#closeReview click->grading#closeOnBackdrop', }, +} .gh-dialog-body.gh-octagon-clip - %h2#gh-review-title.gh-h2 + %h2.gh-h2#gh-review-title = t('.awarding') %span{ data: { grading_target: 'reviewSum' } } - = @story_group.currency_name + = story_group.currency_name %span{ data: { grading_target: 'reviewStudents' } } %p.gh-warning-note{ role: 'alert' } @@ -14,9 +16,11 @@ %ul.gh-review-list - students.each do |student| - - name = student.full_name - - open_categories = categories.reject { |category| @sheet.awarded?(student, category) } - - next if open_categories.empty? + :ruby + name = student.full_name + open_categories = categories.reject { |category| sheet.awarded?(student, category) } + next if open_categories.empty? + %li{ hidden: true, data: { grading_target: 'reviewRow', gh_student: student.id } } %span.gh-review-row-name %span.gh-avatar.gh-avatar--sm.gh-octagon-clip{ 'data-gh-h': gh_avatar_hue(name), 'aria-hidden': 'true' } @@ -24,18 +28,19 @@ = name %span.gh-review-row-chips - open_categories.each do |category| - %span.gh-review-chip.gh-octagon-clip{ hidden: true, - data: { grading_target: 'reviewChip', - gh_student: student.id, gh_column: category.id } } + %span.gh-review-chip.gh-octagon-clip{hidden: true, + data: { grading_target: 'reviewChip', + gh_student: student.id, + gh_column: category.id, },} = category.didactic_description %b= "+#{category.reward.to_i}" %span.gh-review-row-sum{ data: { grading_target: 'reviewRowSum' } } .gh-dialog-button-row - %button.gh-btn.gh-btn--secondary{ type: 'button', autofocus: true, - data: { action: 'grading#closeReview' } } + %button.gh-btn.gh-btn--secondary{type: 'button', autofocus: true, + data: { action: 'grading#closeReview' },} = t('shared.actions.back_to_editing') %button.gh-btn.gh-btn--gold{ type: 'submit', form: 'gh-grade-form' } = t('.confirm') %span{ data: { grading_target: 'reviewConfirmSum' } } - = @story_group.currency_name + = story_group.currency_name diff --git a/app/views/students_activity_group_categories/edit.html.haml b/app/views/students_activity_group_categories/edit.html.haml index b897f0e..4a64c2b 100644 --- a/app/views/students_activity_group_categories/edit.html.haml +++ b/app/views/students_activity_group_categories/edit.html.haml @@ -1,11 +1,17 @@ -- content_for :title, @activity_group.name -- categories = @sheet.categories -- students = @sheet.students -- template = @activity_group.activity_group_template - -- grid_data = { controller: 'grading', grading_i18n_value: t('.js').to_json } +:ruby + content_for :title, @activity_group.name + categories = @sheet.categories + students = @sheet.students + template = @activity_group.activity_group_template + grid_data = { + controller: 'grading leave-guard', grading_i18n_value: t('.js').to_json, + } .gh-grading-screen{ data: grid_data } + = link_to story_group_activity_groups_path(@story_group), class: 'gh-link-button gh-back-link' do + %i.fa-solid.fa-chevron-left{ 'aria-hidden': 'true' } + %span= t('.back') + %section.gh-panel.gh-page-header.gh-grading-head .gh-page-header-text %p.gh-breadcrumbs @@ -18,9 +24,12 @@ .gh-grading-head-actions %label.gh-search-input %i.fa-solid.fa-magnifying-glass{ 'aria-hidden': 'true' } - = search_field_tag :student_search, nil, placeholder: t('.search'), - 'aria-label': t('.search'), - data: { grading_target: 'search', action: 'input->grading#filter' } + = search_field_tag :student_search, + nil, + placeholder: t('.search'), + 'aria-label': t('.search'), + data: { grading_target: 'search', + action: 'input->grading#filter', } %ul.gh-cell-legend{ 'aria-label': t('.legend') } %li @@ -36,7 +45,13 @@ = t('.awarded_locked') - if @sheet.gradeable? - = form_with url: story_group_activity_group_students_activity_group_categories_path(@story_group, @activity_group), method: :patch, id: 'gh-grade-form', class: 'gh-grading-form' do + = form_with url: story_group_activity_group_students_activity_group_categories_path(@story_group, + @activity_group,), + method: :patch, + id: 'gh-grade-form', + class: 'gh-grading-form', + data: { leave_guard_target: 'form', + action: 'submit->leave-guard#submitting', } do %section.gh-panel.gh-grading-board .gh-grading-board-scroll %table.gh-grading-table @@ -51,21 +66,25 @@ %span.gh-price-badge %b= "+#{category.reward.to_i}" = render 'layouts/currency_token', story_group: @story_group, size: 16 - %button.gh-grading-column-select-all{ type: 'button', title: t('.select_column'), - data: { action: 'grading#toggleColumn', - grading_column_param: category.id } } + %button.gh-grading-column-select-all{type: 'button', title: t('.select_column'), + data: { action: 'grading#toggleColumn', + grading_column_param: category.id, },} %i.fa-solid.fa-check-double{ 'aria-hidden': 'true' } = t('.everyone') %th.gh-grading-th-total{ scope: 'col' }= t('.total') %tbody - students.each do |student| - - name = student.full_name - - awarded_total = @sheet.awarded_total(student) + :ruby + name = student.full_name + awarded_total = @sheet.awarded_total(student) %tr{ data: { grading_target: 'row', gh_name: name.to_s.downcase, gh_student: student.id } } %th.gh-grading-td-student{ scope: 'row' } %span - %span.gh-avatar.gh-avatar--sm.gh-octagon-clip{ 'data-gh-h': gh_avatar_hue(name), 'aria-hidden': 'true' } + %span.gh-avatar.gh-avatar--sm.gh-octagon-clip{ + 'data-gh-h': gh_avatar_hue(name), + 'aria-hidden': 'true', + } = gh_initials(name) = name @@ -74,22 +93,24 @@ %td - if @sheet.awarded?(student, category) - fresh = @awarded_pairs.include?([category.id, student.id]) - %span.gh-grading-cell.gh-grading-cell--awarded{ class: ('gh-grading-cell--stamp' if fresh), - title: t('.locked_title'), - role: 'img', - 'aria-label': t('.cell_awarded', label: label), - data: { action: 'click->grading#locked' } } + %span.gh-grading-cell.gh-grading-cell--awarded{ + class: ('gh-grading-cell--stamp' if fresh), + title: t('.locked_title'), + role: 'img', + 'aria-label': t('.cell_awarded', label: label), + data: { action: 'click->grading#locked' }, + } %i.fa-solid.fa-check{ 'aria-hidden': 'true' } %i.fa-solid.fa-lock.gh-grading-cell-lock-icon{ 'aria-hidden': 'true' } - else - %label.gh-grading-cell{ data: { grading_target: 'cell', - grading_reward: category.reward.to_i, - grading_column: category.id } } + %label.gh-grading-cell{data: { grading_target: 'cell', + grading_reward: category.reward.to_i, + grading_column: category.id, }} = check_box_tag "completions[#{student.id}][#{category.id}]", '1', false, - id: nil, - 'aria-label': t('.cell_empty', label: label), - data: { action: 'grading#toggle', - gh_label: label } + id: nil, + 'aria-label': t('.cell_empty', label: label), + data: { action: 'grading#toggle', + gh_label: label, } %i.fa-solid.fa-check{ 'aria-hidden': 'true' } %td.gh-grading-td-total @@ -107,14 +128,19 @@ = render 'layouts/currency_token', story_group: @story_group, size: 16 .gh-action-bar-buttons - %button.gh-btn.gh-btn--secondary{ type: 'button', hidden: true, - data: { grading_target: 'clear', action: 'grading#clear' } } + %button.gh-btn.gh-btn--secondary{type: 'button', hidden: true, + data: { grading_target: 'clear', action: 'grading#clear' },} = t('.clear') - %button.gh-btn{ type: 'submit', - data: { grading_target: 'submit', action: 'grading#review' } } + %button.gh-btn{type: 'submit', + data: { grading_target: 'submit', action: 'grading#review' },} = t('.review') - = render 'review_dialog', categories: categories, students: students + = render 'review_dialog', + categories: categories, + students: students, + story_group: @story_group, + sheet: @sheet + = render 'leave_dialog' - elsif categories.empty? %section.gh-panel.gh-empty-state @@ -122,7 +148,7 @@ %p.gh-lead= t('.no_columns_body') .gh-button-row = link_to t('.sheet_settings'), edit_story_group_activity_group_path(@story_group, @activity_group), - class: 'gh-btn' + class: 'gh-btn' - else %section.gh-panel.gh-empty-state %h2.gh-h2= t('.no_students_title') @@ -136,6 +162,6 @@ %i.fa-solid.fa-desktop{ 'aria-hidden': 'true' } %h1.gh-h2= @activity_group.name %p.gh-lead= t('.mobile_body', categories: t('.mobile_categories', count: categories.size), - students: t('.mobile_students', count: students.size)) + students: t('.mobile_students', count: students.size),) %p.gh-small= t('.mobile_hint') = link_to t('.back_home'), root_path, class: 'gh-btn gh-btn--secondary' diff --git a/config/locales/views/badges.en.yml b/config/locales/views/badges.en.yml index 725f72d..da985aa 100644 --- a/config/locales/views/badges.en.yml +++ b/config/locales/views/badges.en.yml @@ -127,4 +127,4 @@ en: didactic_description: blank: "Describe how to earn this badge." discount: - out_of_range: "The discount must be between 0 and 100%." + out_of_range: "The discount must be between 0 and 50%." diff --git a/config/locales/views/badges.pl.yml b/config/locales/views/badges.pl.yml index 94daadf..a640457 100644 --- a/config/locales/views/badges.pl.yml +++ b/config/locales/views/badges.pl.yml @@ -135,4 +135,4 @@ pl: didactic_description: blank: "Napisz, jak zdobyć tę odznakę." discount: - out_of_range: "Zniżka musi mieścić się między 0 a 100%." + out_of_range: "Zniżka musi mieścić się między 0 a 50%." diff --git a/config/locales/views/ranks.en.yml b/config/locales/views/ranks.en.yml index c0f22f4..21eefef 100644 --- a/config/locales/views/ranks.en.yml +++ b/config/locales/views/ranks.en.yml @@ -136,7 +136,7 @@ en: name: blank: "Enter the rank name." discount: - out_of_range: "The discount must be between 0 and 100%." + out_of_range: "The discount must be between 0 and 50%." required_currency_value: negative: "The threshold can't be negative." threshold_taken: "The %{rank} rank already has the threshold %{threshold}. Choose another one." diff --git a/config/locales/views/ranks.pl.yml b/config/locales/views/ranks.pl.yml index beb0e14..7f5ad2a 100644 --- a/config/locales/views/ranks.pl.yml +++ b/config/locales/views/ranks.pl.yml @@ -148,7 +148,7 @@ pl: name: blank: "Podaj nazwę rangi." discount: - out_of_range: "Zniżka musi mieścić się między 0 a 100%." + out_of_range: "Zniżka musi mieścić się między 0 a 50%." required_currency_value: negative: "Próg nie może być ujemny." threshold_taken: "Ranga %{rank} ma już próg %{threshold}. Wybierz inny." diff --git a/config/locales/views/story_groups.en.yml b/config/locales/views/story_groups.en.yml index 1fed5b6..c2c9519 100644 --- a/config/locales/views/story_groups.en.yml +++ b/config/locales/views/story_groups.en.yml @@ -232,7 +232,7 @@ en: art_section: "Group image" art_hint: "Shows on the group card, in the side panel and as the group background." currency_section: "Currency name" - currency_hint: "It's read after every number, so use the plural form — e.g. “Gold Coins”." + currency_hint: "The name always appears next to a number, so use the plural form - e.g. “Gold Coins”." currency_name: "Currency name" currency_placeholder: "e.g. Gold Coins" currency_missing: "Enter the currency name." @@ -247,7 +247,7 @@ en: ranking_mode: "What students see" ranking_mode_hint: "“Podium and own place” shows only the top three and the student's own place. “Full ranking” shows the whole list." start_section: "How do you want to start?" - start_hint: "Both ways give you a complete group. You can change everything later." + start_hint: "Each of these paths gives you a complete group. You can change everything later." start_label: "How to start" recommended: "Recommended for your first group" quick: "Quick start" @@ -276,7 +276,7 @@ en: csv_templates_label: "Templates" csv_templates_hint_html: "Columns: %{columns}." summary: "Summary" - summary_hint: "The group will be created empty. After creating it you'll see a list of steps to complete." + summary_hint: "The group will be created empty. You can add ranks, badges, items and grading sheets after creating it - you'll see a list of steps to complete." summary_currency: "Currency" summary_lives: "Starting lives" summary_ranking: "Ranking" @@ -297,8 +297,8 @@ en: create_quick: "Create group with the set" create_empty: "Create an empty group" create_csv: "Create group from import" - summary_hint_empty: "The group will be created empty. After creating it you'll see a list of steps to complete." - summary_hint_csv: "Data from the selected CSV files will be imported after the group is created." + summary_hint_empty: "The group will be created empty. You can add ranks, badges, items and grading sheets after creating it - you'll see a list of steps to complete." + summary_hint_csv: "Data from the selected CSV files will be imported after the group is created. You can add any missing ranks, badges, items and grading sheets later." name_placeholder: "Group name" currency_placeholder: "Carrots" ranking_off: "Off" @@ -315,7 +315,7 @@ en: preset_review: title: "The “%{pack}” set for %{classes} classes" - hint: "Remove what you don't need and adjust the numbers. You'll change names and descriptions after the group is created." + hint: "Remove what you don't need and adjust the numbers. You'll change names and descriptions, and add new ranks, badges, items and categories, after the group is created." ranks: "Ranks" starting_rank: "Starting rank" shop_discount: "−%{discount}% in the shop" diff --git a/config/locales/views/story_groups.pl.yml b/config/locales/views/story_groups.pl.yml index aa16c0a..5b81054 100644 --- a/config/locales/views/story_groups.pl.yml +++ b/config/locales/views/story_groups.pl.yml @@ -258,7 +258,7 @@ pl: art_section: "Grafika grupy" art_hint: "Pojawia się na karcie grupy, w panelu bocznym i jako tło grupy." currency_section: "Nazwa waluty" - currency_hint: "Czytamy ją po każdej liczbie, więc podaj formę, która pasuje do liczby mnogiej — np. „Marchewek”." + currency_hint: "Nazwa pojawia się zawsze razem z liczbą, więc podaj formę, która pasuje do liczby mnogiej - np. „Marchewek”." currency_name: "Nazwa waluty" currency_placeholder: "np. Złotych Monet" currency_missing: "Podaj nazwę waluty." @@ -273,7 +273,7 @@ pl: ranking_mode: "Co widzą studenci" ranking_mode_hint: "„Podium i własne miejsce” pokazuje tylko najlepszą trójkę i miejsce studenta. „Pełny ranking” pokazuje całą listę." start_section: "Jak chcesz zacząć?" - start_hint: "Obie drogi dają pełną grupę. Wszystko zmienisz później." + start_hint: "Każda z tych dróg daje pełną grupę. Wszystko zmienisz później." start_label: "Sposób startu" recommended: "Polecane na pierwszą grupę" quick: "Szybki start" @@ -302,7 +302,7 @@ pl: csv_templates_label: "Szablony" csv_templates_hint_html: "Kolumny: %{columns}." summary: "Podsumowanie" - summary_hint: "Grupa powstanie pusta. Po utworzeniu zobaczysz listę kroków do uzupełnienia." + summary_hint: "Grupa powstanie pusta. Rangi, odznaki, przedmioty i arkusze ocen dodasz po jej utworzeniu - zobaczysz listę kroków do uzupełnienia." summary_currency: "Waluta" summary_lives: "Życia na start" summary_ranking: "Ranking" @@ -323,8 +323,8 @@ pl: create_quick: "Utwórz grupę z zestawem" create_empty: "Utwórz pustą grupę" create_csv: "Utwórz grupę z importu" - summary_hint_empty: "Grupa powstanie pusta. Po utworzeniu zobaczysz listę kroków do uzupełnienia." - summary_hint_csv: "Dane z wybranych plików CSV zostaną zaimportowane po utworzeniu grupy." + summary_hint_empty: "Grupa powstanie pusta. Rangi, odznaki, przedmioty i arkusze ocen dodasz po jej utworzeniu - zobaczysz listę kroków do uzupełnienia." + summary_hint_csv: "Dane z wybranych plików CSV zostaną zaimportowane po utworzeniu grupy. Brakujące rangi, odznaki, przedmioty i arkusze ocen dodasz później." name_placeholder: "Nazwa grupy" currency_placeholder: "Marchewek" ranking_off: "Wyłączony" @@ -341,7 +341,7 @@ pl: preset_review: title: "Zestaw „%{pack}” dla %{classes} zajęć" - hint: "Usuń, czego nie potrzebujesz, i popraw liczby. Nazwy i opisy zmienisz po utworzeniu grupy." + hint: "Usuń, czego nie potrzebujesz, i popraw liczby. Nazwy i opisy zmienisz, a nowe rangi, odznaki, przedmioty i kategorie dodasz po utworzeniu grupy." ranks: "Rangi" starting_rank: "Ranga startowa" shop_discount: "−%{discount}% w sklepie" diff --git a/config/locales/views/students_activity_group_categories.en.yml b/config/locales/views/students_activity_group_categories.en.yml index fd461f0..2dd1fbe 100644 --- a/config/locales/views/students_activity_group_categories.en.yml +++ b/config/locales/views/students_activity_group_categories.en.yml @@ -7,7 +7,14 @@ en: one: "Awarded %{total} %{currency} to %{count} student." other: "Awarded %{total} %{currency} to %{count} students." + leave_dialog: + title: "Leave grading?" + body: "You have marked cells that haven't been awarded yet. If you leave now, your selections will be lost." + stay: "Stay and finish" + leave: "Leave without awarding" + edit: + back: "Back to sheets" lead: one: "%{categories}, %{count} student. Mark who earned rewards — you'll review everything once more before awarding." other: "%{categories}, %{count} students. Mark who earned rewards — you'll review everything once more before awarding." diff --git a/config/locales/views/students_activity_group_categories.pl.yml b/config/locales/views/students_activity_group_categories.pl.yml index 48a025c..a8ca4ca 100644 --- a/config/locales/views/students_activity_group_categories.pl.yml +++ b/config/locales/views/students_activity_group_categories.pl.yml @@ -9,7 +9,14 @@ pl: many: "Przyznano %{total} %{currency} %{count} studentom." other: "Przyznano %{total} %{currency} %{count} studenta." + leave_dialog: + title: "Opuścić ocenianie?" + body: "Masz zaznaczone pola, które nie zostały jeszcze przyznane. Jeśli teraz wyjdziesz, zaznaczenia przepadną." + stay: "Zostań i dokończ" + leave: "Wyjdź bez przyznawania" + edit: + back: "Wróć do arkuszy" lead: one: "%{categories}, %{count} student. Zaznacz, kto zdobył nagrody, a przed przyznaniem przejrzysz wszystko jeszcze raz." few: "%{categories}, %{count} studentów. Zaznacz, kto zdobył nagrody, a przed przyznaniem przejrzysz wszystko jeszcze raz." diff --git a/test/models/badge_test.rb b/test/models/badge_test.rb index f3a4460..12810ca 100644 --- a/test/models/badge_test.rb +++ b/test/models/badge_test.rb @@ -40,6 +40,20 @@ class BadgeTest < ActiveSupport::TestCase assert_equal true, badge.errors[:didactic_description].any? end + test 'badge discount cannot exceed 50%' do + badge = Badge.new(story_group: @story_group, name: 'Valid', didactic_description: 'Desc', discount: 51) + + assert_predicate badge, :invalid? + assert_equal true, badge.errors[:discount].any? + end + + test 'badge discount of exactly 50% is valid' do + badge = Badge.new(story_group: @story_group, name: 'Valid', didactic_description: 'Desc', discount: 50, + icon_glyph: Glyphs::BADGE.first,) + + assert_predicate badge, :valid? + end + test 'badge discount validation' do badge = Badge.new( name: 'Valid', diff --git a/test/models/rank_test.rb b/test/models/rank_test.rb index 2774e07..a556d64 100644 --- a/test/models/rank_test.rb +++ b/test/models/rank_test.rb @@ -28,6 +28,27 @@ class RankTest < ActiveSupport::TestCase assert_equal true, rank.errors[:discount].any? end + test 'rank discount cannot exceed 50%' do + rank = Rank.new( + name: 'Valid', + discount: 51, + required_currency_value: 100, + ) + assert_equal true, rank.invalid? + assert_equal true, rank.errors[:discount].any? + end + + test 'rank discount of exactly 50% is valid' do + rank = Rank.new( + story_group: @story_group, + name: 'Valid', + discount: 50, + required_currency_value: 100, + icon_glyph: 'chev1', + ) + assert_predicate rank, :valid? + end + test 'rank required currency value validation' do rank = Rank.new( name: 'Valid',