diff --git a/CONTEXT.md b/CONTEXT.md index 37e1fe9e..42a948c6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,14 @@ definitions. - **pinned (You) group** — the current viewer's own supervisor group in the topics directory, always first with a gray "(You)" suffix. Staff (lecturer/coordinator) only, pinned even at 0 topics; replaces the old "My Topics" section. Students browsing see plain A→Z with no pinned group. - **available badge** — on topics-directory rows only, an approved-and-unclaimed topic (`Topic#available?` = `approved? && proposed_project_instances.none?`) renders its pill as green "Available" *in place of* the "Approved" status pill (same approved-green palette — the swap is just the shared `pill_label` local, driven by `_topic_item`'s optional `available_pill:` local, default `false`). Non-available rows keep their real status pill. Always green — the mockup's blue "(You)" rows are an artifact. Display-only; never a substitute authorization gate (ADR 014). +## Course show (browse tables) + +- **browse table** — one of the three filterable lists on `courses/show`: Groups, Students (People tab), topics directory. Each is re-rendered wholesale on every filter change, so each has to answer for itself what an absence means. +- **base list** — a browse table's unfiltered, policy-scoped rows, held alongside its filtered form. The pair is what makes the two kinds of absence separable: the **base list** decides *which* one, never the unscoped list — a student who sees no approved topics has an empty **base list**, not a filtered one, because they filtered nothing out. +- **empty state** — a **browse table** with nothing in it, ever: no groups created, no students enrolled, nothing available to this viewer. Blames nothing and implies no filter is at fault. Distinct from **no-matches state**; the **profile empty state** below is the same idea on a different page. +- **no-matches state** — a **browse table** emptied by the active filters while its **base list** still holds rows. One generic pair of lines serves every filter ("No *nouns* match your current filters." + "Try adjusting your search or filters."), so there is no per-filter copy to fall out of date when a filter is added. +- **filters active** — at least one of a **browse table**'s filters narrows the list; a select's `all` does not count. Reported to the table as a local rather than re-derived, because it outlives the state: a search that *does* match is still **filters active** and must still auto-expand rows. + ## Participant profile - **participant profile** — the `courses#profile` page for one student or group, reached as `courses/profile/:participant_id/:participant_type` from the People/Groups rows (ADR 0016). Rendered in the **courses/show shell** (`bg-surface-tint`, inline `shared/sidebar` in the page's own `.flex`, `rounded-tl-panel` main — no `border-t border-l`) with content in a centered `max-w-5xl` reading column. diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index e831d08c..db9e97a1 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -3,6 +3,11 @@ # Handles CRUD for courses class CoursesController < ApplicationController + # Params that narrow a list. "all" is the selects' neutral choice and counts + # as inactive, as does an absent/blank param. + PARTICIPANT_FILTER_KEYS = %w[search_query lecturer_filter status_filter].freeze + TOPIC_FILTER_KEYS = %w[search_query topic_filter].freeze + before_action :set_course, only: %i[show add_students handle_add_students add_lecturers handle_add_lecturers settings handle_settings destroy export_csv profile update_coursecode update_email_domain grouping_preview] before_action :set_lecturer_enrolments, only: %i[settings handle_settings] @@ -25,7 +30,7 @@ def show # Topics Directory (topics_by_supervisor) data source — policy-scoped with # search/filter applied server-side; driving both the initial render and the # htmx re-render of _topics_by_supervisor_list. - @filtered_topic_list = filtered_topic_list + @filtered_topic_list = filtered_topic_list.to_a @topics_by_supervisor = topics_by_supervisor # set students projects @@ -105,17 +110,28 @@ def show @filtered_student_list = filtered_student_list @show_all = params[:show_all] == 'true' + # Counts the matches, taken before the truncation below: the table footer's + # "Showing X of Y" is about the current criteria, not the course total. @total_group_count = @filtered_group_list.count @total_student_count = @filtered_student_list.count - @total_count = @course.grouped? ? @total_group_count : @total_student_count - @total_count = @course.grouped? ? @filtered_group_list.count : @filtered_student_list.count unless @show_all @filtered_group_list = @filtered_group_list.first(Rails.application.config.participants_pagination_threshold) @filtered_student_list = @filtered_student_list.first(Rails.application.config.participants_pagination_threshold) end - @displayed_count = @course.grouped? ? @filtered_group_list.count : @filtered_student_list.count + # Empty vs no-matches, decided here rather than in the partials (ADR 0018). + # Each base is the unfiltered, policy-scoped list and is already loaded, so + # this costs no extra query. Computed after the truncation so the state and + # the list the partial receives can never disagree. + @group_list_state = list_state(@group_list, @filtered_group_list) + @student_list_state = list_state(@student_list, @filtered_student_list) + @topic_list_state = list_state(@topic_list, @filtered_topic_list) + + # The filter controls sit outside the htmx-swapped containers, so the + # partials are told a filter is active rather than re-deriving it from params. + @filters_active = filters_active?(PARTICIPANT_FILTER_KEYS) + @topic_filters_active = filters_active?(TOPIC_FILTER_KEYS) @capacity_result = SupervisorCapacityCalculator.new(@course).calculate @lecturer_capacity_info = @capacity_result.lecturer_capacities.index_by { |lc| lc.enrolment.user_id } @@ -130,7 +146,9 @@ def show projects_by_owner: @projects_by_owner, total_count: @total_group_count, displayed_count: @filtered_group_list.count, - show_all: @show_all + show_all: @show_all, + state: @group_list_state, + filters_active: @filters_active } elsif params[:section] == 'topics' render partial: 'topics_by_supervisor_list', @@ -138,7 +156,9 @@ def show course: @course, lecturers: @lecturers, topics_by_supervisor: @topics_by_supervisor, - current_user_enrolment: @current_user_enrolment + current_user_enrolment: @current_user_enrolment, + state: @topic_list_state, + filters_active: @topic_filters_active } else render partial: 'students_table', @@ -150,7 +170,9 @@ def show total_student_count: @student_list.count, total_count: @total_student_count, displayed_count: @filtered_student_list.count, - show_all: @show_all + show_all: @show_all, + state: @student_list_state, + filters_active: @filters_active } end nil @@ -869,6 +891,29 @@ def groups_by_status(status, group_list, course) # Participants Table Filters helpers + # A list renders one of three states, and only the first two are ever + # displayed — a non-empty list renders rows and never consults the state: + # + # :matched — the filter returned rows + # :no_matches — the base has rows, the filter returned none + # :empty — the base itself is empty; nothing has ever existed here + # + # The base is always the unfiltered, policy-scoped list, so a viewer whose + # policy scope hides everything is :empty rather than a false "no matches" + # (a student on a course with no approved topics has not filtered anything + # out). Both arguments are loaded by the time this runs (ADR 0018). + def list_state(base_list, filtered_list) + return :matched if filtered_list.any? + + base_list.any? ? :no_matches : :empty + end + + # True when any of the given filter params narrows the list. "all" is the + # selects' neutral choice, so it does not count. + def filters_active?(keys) + keys.any? { |key| params[key].present? && params[key] != 'all' } + end + def search_groups(group_list, query) downcased_query = query.downcase diff --git a/app/controllers/homescreen_controller.rb b/app/controllers/homescreen_controller.rb index 8e4fe321..57a6435c 100644 --- a/app/controllers/homescreen_controller.rb +++ b/app/controllers/homescreen_controller.rb @@ -1,5 +1,5 @@ -class HomescreenController < ApplicationController - def show - @courses = Current.user.courses.uniq - end -end +class HomescreenController < ApplicationController + def show + @courses = Current.user.courses_by_earliest_enrolment + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 0dac08a3..9cc9306b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -8,6 +8,14 @@ class User < ApplicationRecord has_many :enrolments, dependent: :destroy has_many :courses, through: :enrolments + # sort cards by earliest enrolment (coordinators can have multiple). + def courses_by_earliest_enrolment + Course.joins(:enrolments) + .where(enrolments: { user_id: id }) + .group('courses.id') + .order(Arel.sql('MIN(enrolments.created_at) DESC, courses.id DESC')) + end + has_many :project_group_members, dependent: :destroy has_many :project_groups, through: :project_group_members diff --git a/app/views/courses/_groups_tab.html.erb b/app/views/courses/_groups_tab.html.erb index e50c9e04..09adbdb5 100644 --- a/app/views/courses/_groups_tab.html.erb +++ b/app/views/courses/_groups_tab.html.erb @@ -7,7 +7,7 @@
| No students have been enrolled yet. | -
No topics are currently available.
++ <%= state == :no_matches ? "No topics match your current filters." : "No topics are currently available." %> +
+ <% if state == :no_matches %> +Try adjusting your search or filters.
+ <% end %> +<%= heading %>
+ <% if hint.present? %> +<%= hint %>
+ <% end %> +Roomier picker rows — density: :comfortable, title_weight: 500, hide_more_vert: true, meta_first_strong: true (used by the copy-topic modal, step 1).
+ Mapping to new topic in "<%= target.course_name %>" +
+