diff --git a/Changelog.md b/Changelog.md
index 3967191066..a08acff8a9 100644
--- a/Changelog.md
+++ b/Changelog.md
@@ -26,6 +26,7 @@
- Fix version mismatch between container client and database server (#7916)
- Fixed filter Canvas Test Student from roster sync (#7926)
- Fix: include original total mark in JSON response for remark requests (#7945)
+- Fixed `(hidden)` assignment labeling for assignments with `visible_on` and/or `visible_until` set (#7944)
### 🔧 Internal changes
- Fixed flaky test `can bulk assign duplicated TAs to grade entry students` in `/spec/models/grade_entry_student_spec.rb` (#7958)
diff --git a/app/helpers/assessments_helper.rb b/app/helpers/assessments_helper.rb
new file mode 100644
index 0000000000..234a831e0c
--- /dev/null
+++ b/app/helpers/assessments_helper.rb
@@ -0,0 +1,7 @@
+module AssessmentsHelper
+ def formatted_assessment_visibility_label(assessment, base_text)
+ return t('assignments.hidden', assignment_text: base_text) if assessment.currently_hidden?
+
+ base_text
+ end
+end
diff --git a/app/models/assessment.rb b/app/models/assessment.rb
index f01834018b..d905b4af76 100644
--- a/app/models/assessment.rb
+++ b/app/models/assessment.rb
@@ -89,6 +89,14 @@ def upcoming(*)
self.due_date > Time.current
end
+ def currently_hidden?
+ return true if is_hidden
+ return true if visible_on.present? && Time.current < visible_on
+ return true if visible_until.present? && Time.current > visible_until
+
+ false
+ end
+
# Returns grade distribution histogram bins of the grades for this assessment, using the grades in
# self.completed_result_marks.
def grade_distribution_array(intervals = 20)
diff --git a/app/views/assignments/_list_manage.html.erb b/app/views/assignments/_list_manage.html.erb
index 20b8842bb5..83d6c5ec24 100644
--- a/app/views/assignments/_list_manage.html.erb
+++ b/app/views/assignments/_list_manage.html.erb
@@ -20,7 +20,7 @@
<% assignments.each do |assignment| %>
<% route = { controller: 'assignments', action: action, id: assignment.id } %>
<% assignment_text = "#{h(assignment.short_identifier)}: #{h(assignment.description)}" %>
- <% assignment_text = t('assignments.hidden', assignment_text: assignment_text) if assignment.is_hidden %>
+ <% assignment_text = formatted_assessment_visibility_label(assignment, assignment_text) %>
|
- <% if assignment.is_hidden %>
- <%= link_to truncate(t('assignments.hidden',
- assignment_text:
- "#{h(assignment.short_identifier)}: #{h(assignment.description)}")),
- view_summary_course_assignment_path(@current_course, assignment.id),
- data: { remote: true, id: assignment.short_identifier },
- class: (assignment.id == @current_assignment.id ? "inactive" : "") %>
- <% else %>
- <%= link_to assignment.short_identifier + ': ' + assignment.description,
- view_summary_course_assignment_path(@current_course, assignment.id),
- data: { remote: true, id: assignment.short_identifier },
- class: (assignment.id == @current_assignment.id ? "inactive" : "") %>
- <% end %>
+ <% assignment_text = formatted_assessment_visibility_label(assignment,
+ "#{h(assignment.short_identifier)}: #{h(assignment.description)}") %>
+ <%= link_to truncate(assignment_text),
+ view_summary_course_assignment_path(@current_course, assignment.id),
+ data: { remote: true, id: assignment.short_identifier },
+ class: (assignment.id == @current_assignment.id ? "inactive" : "") %>
|
diff --git a/app/views/layouts/menus/_instructor_ta_sub_menu_assessments.html.erb b/app/views/layouts/menus/_instructor_ta_sub_menu_assessments.html.erb
index 5005f99771..89f58b221b 100644
--- a/app/views/layouts/menus/_instructor_ta_sub_menu_assessments.html.erb
+++ b/app/views/layouts/menus/_instructor_ta_sub_menu_assessments.html.erb
@@ -11,9 +11,7 @@
<% if assessment.is_a?(Assignment) && assessment.is_peer_review? %>
<% title = "#{assessment.parent_assignment.short_identifier} #{PeerReview.model_name.human}" %>
<% end %>
- <% if assessment.is_hidden %>
- <% title = t('assignments.hidden', assignment_text: title) %>
- <% end %>
+ <% title = formatted_assessment_visibility_label(assessment, title) %>
<%= title %>
<% end %>
diff --git a/app/views/shared/_assignment_dropdown_link.html.erb b/app/views/shared/_assignment_dropdown_link.html.erb
index 0eaa679989..1795309d6d 100644
--- a/app/views/shared/_assignment_dropdown_link.html.erb
+++ b/app/views/shared/_assignment_dropdown_link.html.erb
@@ -1,8 +1,6 @@
-
<% link_text = assignment.short_identifier %>
- <% if assignment.is_hidden %>
- <% link_text = t('assignments.hidden', assignment_text: link_text) %>
- <% end %>
+ <% link_text = formatted_assessment_visibility_label(assignment, link_text) %>
<%= link_to link_text,
switch_course_assignment_path(@current_course, assignment.id),
diff --git a/doc/markus-contributors.txt b/doc/markus-contributors.txt
index 3915d2ac4e..07ac50d6ac 100644
--- a/doc/markus-contributors.txt
+++ b/doc/markus-contributors.txt
@@ -177,6 +177,7 @@ Parker Hutcheson
Partoo Vafaeikia
Paymahn Moghadasian
Peter Guanjie Zhao
+Philip Kukulak
Pranav Rao
Rafael Padilha
Raine Yang
diff --git a/spec/controllers/assignments_controller_spec.rb b/spec/controllers/assignments_controller_spec.rb
index 88130f18df..090b2c57fd 100644
--- a/spec/controllers/assignments_controller_spec.rb
+++ b/spec/controllers/assignments_controller_spec.rb
@@ -1,5 +1,6 @@
describe AssignmentsController do
include AutomatedTestsHelper
+ include ActiveSupport::Testing::TimeHelpers
# TODO: add 'role is from a different course' shared tests to each route test below
@@ -410,6 +411,83 @@
end
end
+ describe '#index rendered visibility labels' do
+ render_views
+
+ let(:role) { create(:instructor) }
+ let(:course) { role.course }
+ let(:current_time) { Time.zone.local(2026, 5, 9, 12, 0, 0) }
+
+ visibility_cases = [
+ { label: 'explicitly hidden', options: -> { { is_hidden: true } }, hidden: true },
+ { label: 'future start', options: -> { { visible_on: 1.day.from_now } }, hidden: true },
+ { label: 'past end', options: -> { { visible_until: 1.day.ago } }, hidden: true },
+ { label: 'window future',
+ options: -> { { visible_on: 1.day.from_now, visible_until: 2.days.from_now } }, hidden: true },
+ { label: 'window expired',
+ options: -> { { visible_on: 2.days.ago, visible_until: 1.day.ago } }, hidden: true },
+ { label: 'plain visible', options: -> { {} }, hidden: false },
+ { label: 'past start', options: -> { { visible_on: 1.day.ago } }, hidden: false },
+ { label: 'future end', options: -> { { visible_until: 1.day.from_now } }, hidden: false },
+ { label: 'window current',
+ options: -> { { visible_on: 1.day.ago, visible_until: 1.day.from_now } }, hidden: false },
+ { label: 'starts now', options: -> { { visible_on: Time.current } }, hidden: false },
+ { label: 'ends now', options: -> { { visible_until: Time.current } }, hidden: false }
+ ]
+
+ before { travel_to current_time }
+
+ visibility_cases.each do |vc|
+ it "renders #{vc[:hidden] ? 'the hidden label' : 'no hidden label'} for #{vc[:label]}" do
+ assignment = create(:assignment, course: course, **vc[:options].call)
+ get_as role, :index, params: { course_id: course.id }
+
+ base_text = "#{assignment.short_identifier}: #{assignment.description}"
+ hidden_text = I18n.t('assignments.hidden', assignment_text: base_text)
+
+ if vc[:hidden]
+ expect(response.body).to include(hidden_text)
+ else
+ expect(response.body).to include(base_text)
+ expect(response.body).not_to include(hidden_text)
+ end
+ end
+ end
+ end
+
+ describe '#edit rendered visibility labels' do
+ render_views
+
+ let(:role) { create(:instructor) }
+ let(:course) { role.course }
+ let(:current_time) { Time.zone.local(2026, 5, 9, 12, 0, 0) }
+ let(:scheduled_target) { create(:assignment, course: course, visible_on: 1.day.from_now) }
+ let(:currently_visible) { create(:assignment, course: course, visible_until: 1.day.from_now) }
+
+ before do
+ travel_to current_time
+ scheduled_target
+ currently_visible
+ end
+
+ it 'renders the hidden label in both the current assignment submenu title and the dropdown list item' do
+ get_as role, :edit, params: { course_id: course.id, id: scheduled_target.id }
+
+ current_title = I18n.t('assignments.hidden', assignment_text: scheduled_target.short_identifier)
+ doc = response.parsed_body
+ dropdown = doc.at_css('li#dropdown > div.dropdown')
+ dropdown_text = dropdown.children.find { |node| node.text? && !node.text.strip.empty? }.text.strip
+ target_path = switch_course_assignment_path(course, scheduled_target.id)
+ visible_path = switch_course_assignment_path(course, currently_visible.id)
+ target_link = dropdown.at_css("ul a[href='#{target_path}'][title='#{scheduled_target.description}']")
+ visible_link = dropdown.at_css("ul a[href='#{visible_path}'][title='#{currently_visible.description}']")
+
+ expect(dropdown_text).to eq(current_title)
+ expect(target_link.text).to eq(current_title)
+ expect(visible_link.text).to eq(currently_visible.short_identifier)
+ end
+ end
+
describe '#set_boolean_graders_options' do
let!(:assignment) { create(:assignment) }
diff --git a/spec/controllers/courses_controller_spec.rb b/spec/controllers/courses_controller_spec.rb
index 42a3a6d2fc..1fe9233147 100644
--- a/spec/controllers/courses_controller_spec.rb
+++ b/spec/controllers/courses_controller_spec.rb
@@ -1,4 +1,6 @@
describe CoursesController do
+ include ActiveSupport::Testing::TimeHelpers
+
let(:instructor) { create(:instructor) }
let(:course) { instructor.course }
let(:student) { create(:student, course: course) }
@@ -358,6 +360,53 @@
end
end
+ describe '#show rendered visibility labels' do
+ render_views
+
+ let(:current_time) { Time.zone.local(2026, 5, 9, 12, 0, 0) }
+
+ # short_identifier and description keep text short so (hidden) is never truncated in _dashboard_list
+ visibility_cases = [
+ { label: 'future start',
+ options: -> { { short_identifier: 'A1', description: 'a', visible_on: 1.day.from_now } },
+ hidden: true },
+ { label: 'past end',
+ options: -> { { short_identifier: 'A2', description: 'a', visible_until: 1.day.ago } },
+ hidden: true },
+ { label: 'plain visible',
+ options: -> { { short_identifier: 'A3', description: 'a' } },
+ hidden: false },
+ { label: 'future end',
+ options: -> { { short_identifier: 'A4', description: 'a', visible_until: 1.day.from_now } },
+ hidden: false },
+ { label: 'starts now',
+ options: -> { { short_identifier: 'A5', description: 'a', visible_on: Time.current } },
+ hidden: false },
+ { label: 'ends now',
+ options: -> { { short_identifier: 'A6', description: 'a', visible_until: Time.current } },
+ hidden: false }
+ ]
+
+ before { travel_to current_time }
+
+ visibility_cases.each do |vc|
+ it "renders #{vc[:hidden] ? 'the hidden label' : 'no hidden label'} for #{vc[:label]}" do
+ assignment = create(:assignment, course: course, **vc[:options].call)
+ get_as instructor, :show, params: { id: course }
+
+ base_text = "#{assignment.short_identifier}: #{assignment.description}"
+ hidden_text = I18n.t('assignments.hidden', assignment_text: base_text)
+
+ if vc[:hidden]
+ expect(response.body).to include(hidden_text)
+ else
+ expect(response.body).to include(base_text)
+ expect(response.body).not_to include(hidden_text)
+ end
+ end
+ end
+ end
+
describe '#upload_assignments' do
it_behaves_like 'a controller supporting upload', route_name: :upload_assignments do
let(:params) { { id: course.id } }
|