Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
59 changes: 52 additions & 7 deletions app/controllers/courses_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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
Expand Down Expand Up @@ -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 }
Expand All @@ -130,15 +146,19 @@ 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',
locals: {
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',
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
10 changes: 5 additions & 5 deletions app/controllers/homescreen_controller.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions app/views/courses/_groups_tab.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

<div class="flex items-baseline gap-4">
<h2 class="text-3xl font-normal text-gray-900 tracking-tight" style="font-family: 'DM Sans', Roboto, Arial, sans-serif;">Groups</h2>
<span class="text-sm font-medium text-gray-500"><%= pluralize(@total_group_count, "group") %></span>
<span class="text-sm font-medium text-gray-500"><%= pluralize(@group_list.size, "group") %></span>
</div>

<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
Expand Down Expand Up @@ -87,7 +87,9 @@
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 %>

<%= render "courses/add_students_modal", course: @course %>
</section>
20 changes: 10 additions & 10 deletions app/views/courses/_groups_table.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
<%# --- locals: course, groups, projects_by_owner, total_count, displayed_count, show_all %>
<%# --- locals: course, groups, projects_by_owner, total_count, displayed_count, show_all, state, filters_active %>
<%# htmx re-render target on the Groups tab, rendered from BOTH the page path and
the courses#show htmx branch, so it only ever uses locals. `state` is the
controller's :matched / :no_matches / :empty verdict (ADR 0018); the copy
for each is chosen here, below. %>

<div id="groups-table-container"
class="overflow-x-auto pb-8 border border-gray-200 rounded-lg"
Expand All @@ -21,9 +25,6 @@
</thead>
<tbody class="divide-y divide-gray-200">
<% if groups.present? %>
<% any_filter = params[:search_query].present? ||
(params[:lecturer_filter].present? && params[:lecturer_filter] != 'all') ||
(params[:status_filter].present? && params[:status_filter] != 'all') %>
<% groups.each do |group| %>
<% group_project = projects_by_owner[['ProjectGroup', group.id]]
group_status = group_project&.current_status || 'not_submitted'
Expand All @@ -34,14 +35,13 @@
project: group_project,
status: group_status,
supervisor: supervisor,
expanded: any_filter %>
expanded: filters_active %>
<% end %>
<% else %>
<tr>
<td colspan="5" class="px-6 py-12 text-center text-sm text-gray-500 italic">
<%= params[:status_filter].present? && params[:status_filter] != 'all' ? "No groups found with #{params[:status_filter].titleize} status." : "No groups have been created yet." %>
</td>
</tr>
<%= render "shared/table_empty_state",
colspan: 5,
heading: state == :no_matches ? "No groups match your current filters." : "No groups have been created yet.",
hint: state == :no_matches ? "Try adjusting your search or filters." : nil %>
<% end %>
</tbody>
</table>
Expand Down
4 changes: 3 additions & 1 deletion app/views/courses/_people_tab.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@
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 %>
6 changes: 4 additions & 2 deletions app/views/courses/_students_section.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<%# --- locals: course, students, student_group_map, student_enrolment_map, total_student_count, total_count, displayed_count, show_all %>
<%# --- locals: course, students, student_group_map, student_enrolment_map, total_student_count, total_count, displayed_count, show_all, state, filters_active %>
<%# The students-select controller scopes this whole section: it drives the
single-select radios, the Actions dropdown dispatch, and survives the
htmx table swaps (delegated listeners + htmx:afterSwap reset). %>
Expand Down Expand Up @@ -82,7 +82,9 @@
student_enrolment_map: student_enrolment_map,
total_count: total_count,
displayed_count: displayed_count,
show_all: show_all %>
show_all: show_all,
state: state,
filters_active: filters_active %>

<%= render "courses/add_students_modal", course: course %>
</section>
13 changes: 8 additions & 5 deletions app/views/courses/_students_table.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<%# --- locals: course, students, student_group_map, student_enrolment_map, total_count, displayed_count, show_all %>
<%# --- locals: course, students, student_group_map, student_enrolment_map, total_count, displayed_count, show_all, state, filters_active %>
<%# htmx re-render target on the People tab — rendered from BOTH the page path and
the courses#show htmx branch, so it only ever uses locals. Never reach for
student_status/student_project_for (see plan §5). %>
student_status/student_project_for (see plan §5). `state` is the controller's
:matched / :no_matches / :empty verdict (ADR 0018); the copy for each is
chosen here, below. %>

<div id="students-table-container" class="overflow-x-auto border border-gray-200 rounded-lg">
<table class="w-full min-w-[480px] table-fixed">
Expand All @@ -24,9 +26,10 @@
enrolment: student_enrolment_map[student.id] %>
<% end %>
<% else %>
<tr>
<td colspan="4" class="px-6 py-12 text-center text-sm text-gray-500 italic">No students have been enrolled yet.</td>
</tr>
<%= render "shared/table_empty_state",
colspan: 4,
heading: state == :no_matches ? "No students match your current filters." : "No students have been enrolled yet.",
hint: state == :no_matches ? "Try adjusting your search or filters." : nil %>
<% end %>
</tbody>
</table>
Expand Down
4 changes: 3 additions & 1 deletion app/views/courses/_topic_directory_tab.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
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 "shared/empty_state",
heading: "Topics are disabled for this course",
Expand Down
29 changes: 17 additions & 12 deletions app/views/courses/_topics_by_supervisor_list.html.erb
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
<%# Topics Directory body — the htmx-swappable container (mirror of
_groups_table/_students_table: hx-target + hx-swap="outerHTML" replaces this
div). Locals: course, topics_by_supervisor, current_user_enrolment.
Supervisor groups: header (name + gray "(You)" for the viewer's own group +
topic count + chevron) over a list of _topic_item rows. Groups render
expanded by default (mockup). Under an active search/filter, non-matching
groups are dropped entirely rather than shown empty. When no supervisors
exist at all, the lone empty-state sentence renders. %>
div). Locals: course, topics_by_supervisor, current_user_enrolment, state,
filters_active. Supervisor groups: header (name + gray "(You)" for the
viewer's own group + topic count + chevron) over a list of _topic_item rows.
Groups render expanded by default (mockup). Under an active search/filter,
non-matching groups are dropped entirely rather than shown empty — so a
filter that matches nothing drops every group and the lone empty-state
sentence below takes over. `state` is the controller's :matched /
:no_matches / :empty verdict (ADR 0018); the copy for each is chosen here. %>

<%
any_filter = params[:search_query].present? ||
(params[:topic_filter].present? && params[:topic_filter] != 'all')
visible = any_filter ? topics_by_supervisor.reject { |_lecturer, topics| topics.empty? } : topics_by_supervisor
%>
<% visible = filters_active ? topics_by_supervisor.reject { |_lecturer, topics| topics.empty? } : topics_by_supervisor %>

<div id="topics-by-supervisor-container" class="flex flex-col">
<% if visible.empty? %>
<p class="text-[14px] text-on-surface-variant py-3">No topics are currently available.</p>
<div class="py-3">
<p class="text-[14px] text-on-surface-variant">
<%= state == :no_matches ? "No topics match your current filters." : "No topics are currently available." %>
</p>
<% if state == :no_matches %>
<p class="mt-1 text-[14px] text-on-surface-muted">Try adjusting your search or filters.</p>
<% end %>
</div>
<% else %>
<% visible.each do |lecturer, topics| %>
<div>
Expand Down
7 changes: 5 additions & 2 deletions app/views/courses/_topics_section.html.erb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<%# Topics Directory — the browse-first Topics panel of courses/show (ADR 0013).
Locals: course, lecturers, topics_by_supervisor, current_user_enrolment.
Locals: course, lecturers, topics_by_supervisor, current_user_enrolment,
state, filters_active.
The section carries data-controller="expandable-rows" so the supervisor
header toggles and the Collapse all / Expand all control keep working when
htmx swaps the inner #topics-by-supervisor-container (mirror of the Groups
Expand Down Expand Up @@ -67,5 +68,7 @@
<%= render "courses/topics_by_supervisor_list",
course: course,
topics_by_supervisor: topics_by_supervisor,
current_user_enrolment: current_user_enrolment %>
current_user_enrolment: current_user_enrolment,
state: state,
filters_active: filters_active %>
</section>
Loading
Loading