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
37 changes: 37 additions & 0 deletions tcf_website/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ class Meta:
]


class CourseAutocompleteSerializer(serializers.ModelSerializer):
"""DRF Serializer for autocomplete course"""

subdepartment = serializers.CharField(
source="subdepartment.mnemonic", read_only=True
)

class Meta:
model = Course
fields = ["id", "title", "number", "subdepartment"]


class InstructorSerializer(serializers.ModelSerializer):
"""DRF Serializer for Instructor"""

Expand All @@ -160,6 +172,17 @@ class Meta:
fields = "__all__"


class InstructorAutocompleteSerializer(serializers.ModelSerializer):
"""DRF Serializer for autocomplete instructor"""

class Meta:
model = Instructor
fields = [
"id",
"full_name",
]


class ClubCategorySerializer(serializers.ModelSerializer):
"""DRF Serializer for ClubCategory"""

Expand All @@ -176,3 +199,17 @@ class ClubSerializer(serializers.ModelSerializer):
class Meta:
model = Club
fields = "__all__"


class ClubAutocompleteSerializer(serializers.ModelSerializer):
"""DEF Serializer for Club autocomplete"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix typo in docstring.

The docstring has "DEF Serializer" but should be "DRF Serializer" (Django REST Framework).

🔎 Proposed fix
-    """DEF Serializer for Club autocomplete"""
+    """DRF Serializer for Club autocomplete"""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""DEF Serializer for Club autocomplete"""
"""DRF Serializer for Club autocomplete"""
🤖 Prompt for AI Agents
In tcf_website/api/serializers.py around line 205, the docstring contains a typo
"DEF Serializer for Club autocomplete"; update the docstring to read "DRF
Serializer for Club autocomplete" to correctly reference Django REST Framework
and save the file.


category_name = serializers.CharField(source="category.name", read_only=True)

class Meta:
model = Club
fields = (
"id",
"name",
"category_name",
)
183 changes: 183 additions & 0 deletions tcf_website/static/search/autocomplete.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
document.addEventListener("DOMContentLoaded", function () {
const searchInput = document.getElementById("search-input");
if (!searchInput) return;

// Create container for autocomplete suggestions
const suggestionsContainer = document.createElement("div");
suggestionsContainer.classList.add("autocomplete-suggestions");

const searchbarWrapper = searchInput.closest(".searchbar-wrapper");
if (searchbarWrapper) {
searchbarWrapper.appendChild(suggestionsContainer);
} else {
return;
}

let debounceTimeout = null;
let currentRequestController = null;

// Debounce to reduce API calls
function debounce(func, delay) {
return function (...args) {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() => func(...args), delay);
};
}

function getSearchMode() {
const checked = document.querySelector('input[name="mode"]:checked');
return checked ? checked.value : "courses";
}

async function fetchSuggestions(query) {
if (!query.trim()) {
clearSuggestions();
return;
}

// Cancel previous request if still in-flight
if (currentRequestController) {
currentRequestController.abort();
}

currentRequestController = new AbortController();
const signal = currentRequestController.signal;

const mode = getSearchMode();

try {
const response = await fetch(
`/api/autocomplete/?q=${encodeURIComponent(query)}&mode=${mode}`,
{ signal: signal },

Check warning on line 51 in tcf_website/static/search/autocomplete.js

View workflow job for this annotation

GitHub Actions / eslint

Expected property shorthand
);

if (!response.ok) {
clearSuggestions();
return;
}

const data = await response.json();
renderSuggestions(data);
} catch (error) {
if (error.name !== "AbortError") {
console.error("Autocomplete fetch error:", error);
}
clearSuggestions();
}
}

// Render suggestions
function renderSuggestions(data) {
suggestionsContainer.innerHTML = "";

const hasCourses = data?.courses?.length > 0;
const hasInstructors = data?.instructors?.length > 0;
const hasClubs = data?.clubs?.length > 0;

if (!hasCourses && !hasInstructors && !hasClubs) {
suggestionsContainer.style.display = "none";
return;
}

const MAX_RESULTS = 8;
const courses = (data.courses || []).slice(0, MAX_RESULTS);
const instructors = (data.instructors || []).slice(0, MAX_RESULTS);
const clubs = (data.clubs || []).slice(0, MAX_RESULTS);

// Add group headers for clarity
if (hasCourses) {
const header = document.createElement("div");
header.classList.add("autocomplete-header");
header.textContent = "Courses";
suggestionsContainer.appendChild(header);
}

// Courses first
courses.forEach((course) => {
const item = document.createElement("div");
item.classList.add("autocomplete-item");
item.style.cursor = "pointer";
item.textContent = `${course.subdepartment} ${course.number} — ${course.title}`;
item.addEventListener("click", () => {
searchInput.value = `${course.subdepartment} ${course.number}`;
clearSuggestions();
if (searchInput.form) {
searchInput.form.submit();
}
});
suggestionsContainer.appendChild(item);
});

if (instructors.length > 0) {
const header = document.createElement("div");
header.classList.add("autocomplete-header");
header.textContent = "Instructors";
suggestionsContainer.appendChild(header);
}

// Instructor results
instructors.forEach((instructor) => {
const item = document.createElement("div");
item.classList.add("autocomplete-item");
item.style.cursor = "pointer";
item.textContent = instructor.full_name;
item.addEventListener("click", () => {
searchInput.value = instructor.full_name;
clearSuggestions();
if (searchInput.form) {
searchInput.form.submit();
}
});
suggestionsContainer.appendChild(item);
});

if (clubs.length > 0) {
const header = document.createElement("div");
header.classList.add("autocomplete-header");
header.textContent = "Clubs";
suggestionsContainer.appendChild(header);
}

// Club results
clubs.forEach((club) => {
const item = document.createElement("div");
item.classList.add("autocomplete-item");
item.style.cursor = "pointer";
item.textContent = club.name;
item.addEventListener("click", () => {
searchInput.value = club.name;
clearSuggestions();
if (searchInput.form) {
searchInput.form.submit();
}
});
suggestionsContainer.appendChild(item);
});

suggestionsContainer.style.display = "block";
}

// Helper to clear dropdown
function clearSuggestions() {
suggestionsContainer.innerHTML = "";
suggestionsContainer.style.display = "none";
}

// Hide dropdown when clicking outside
document.addEventListener("click", (event) => {
if (
!suggestionsContainer.contains(event.target) &&
event.target !== searchInput
) {
clearSuggestions();
}
});

// Debounced input listener
searchInput.addEventListener(
"input",
debounce((event) => {
fetchSuggestions(event.target.value);
}, 250),
);
});
55 changes: 55 additions & 0 deletions tcf_website/static/search/searchbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,58 @@
background-color: #cbd5e0;
border-radius: 2px;
}

/* Autocomplete suggestions positioning */
.searchbar-wrapper {
position: relative;
}

/* Remove focus outline from search input */
#search-input:focus {
outline: none !important;
box-shadow: none !important;
border-color: inherit;
}

.autocomplete-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-top: 0;
max-height: 400px;
overflow: hidden;
display: none;
padding: 0.5rem 0;
}

.autocomplete-header {
padding: 0.75rem 1rem;
font-weight: 600;
font-size: 0.875rem;
color: #4a5568;
background-color: #f7fafc;
border-bottom: 1px solid #e2e8f0;
margin: 0;
}

.autocomplete-item {
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid #f7fafc;
margin: 0;
transition: background-color 0.15s ease;
}

.autocomplete-item:hover {
background-color: #f7fafc;
}

.autocomplete-item:last-child {
border-bottom: none;
}
Comment on lines +306 to +360
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Restore visible focus indication and allow dropdown scrolling

#search-input:focus removes both outline and box-shadow with !important, which effectively hides focus state for keyboard users and is an accessibility regression. Also, .autocomplete-suggestions uses overflow: hidden, so taller result sets will be clipped instead of scrollable.

Consider:

-/* Remove focus outline from search input */
-#search-input:focus {
-  outline: none !important;
-  box-shadow: none !important;
-  border-color: inherit;
-}
+/* Provide a visible, accessible focus style for the search input */
+#search-input:focus {
+  outline: 2px solid #d75626;
+  outline-offset: 2px;
+}

and:

-  max-height: 400px;
-  overflow: hidden;
+  max-height: 400px;
+  overflow-y: auto;

This keeps the custom look while preserving keyboard focus visibility and avoiding clipped suggestions.

7 changes: 6 additions & 1 deletion tcf_website/templates/club/mode_toggle.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
<label for="search-mode-courses" class="mode-label" role="radio">Courses</label>
<label for="search-mode-clubs" class="mode-label" role="radio">Clubs</label>
<div class="toggle-indicator"></div>
{% if is_club %}
<input type="hidden" id="search-mode" value="clubs">
{% else %}
<input type="hidden" id="search-mode" value="courses">
{% endif %}
</div>
{% else %}
<div class="mode-toggle-container {% if container_class %}{{ container_class }}{% endif %}">
Expand All @@ -22,4 +27,4 @@
<div class="mode-toggle-slider {% if is_club %}slide-right{% endif %}"></div>
</div>
</div>
{% endif %}
{% endif %}
Loading