Skip to content

Conversation

@sunilkumar2170
Copy link

@sunilkumar2170 sunilkumar2170 commented Jan 21, 2026

Fix Professor Filters

What changed

  • Fixed professor filtering by name and department
  • Apply Filters button now correctly updates the professors list
  • Ensured filtering works without relying on environment variables

Issue

Fixes #21
issues link : #21

Screenshot 2026-01-22 001631

Summary by CodeRabbit

  • New Features
    • Added sorting controls for courses and professors
    • Implemented dynamic professor ratings fetched from backend
    • Added department-based grouping for course display
    • Enhanced filtering with difficulty ranges and minimum rating thresholds
    • Improved "no results" messaging when filters yield no matches

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 21, 2026

Walkthrough

This PR enhances the ItemList component with comprehensive sorting and filtering capabilities, adding asynchronous professor rating data aggregation from a backend, improved difficulty mapping logic, multi-criterion filtering (search, department, difficulty, rating threshold), department-based grouping, and supporting UI updates.

Changes

Cohort / File(s) Summary
ItemList component enhancements
src/components/courses/ItemList.tsx
Introduces sortBy state and sorting UI for item ordering; adds async professor data aggregation fetching ratings from backend and computing per-professor averages; refines getDifficultyRange mapping to numeric ranges with explicit boundaries; enhances filtering with explicit search queries across items/professors, department ID-based filtering, numeric difficulty ranges, and rating thresholds; implements multi-department sorting by department order; adds grouping of courses by department with master order preservation; adjusts rendering for grouped/flat views; adds "No results" banner.

Sequence Diagram(s)

sequenceDiagram
    participant Component as ItemList Component
    participant Backend as Backend (Supabase)
    participant Cache as Data Cache
    participant Filter as Filter Engine
    participant Renderer as View Renderer

    Component->>Backend: Fetch professor ratings data
    Backend-->>Component: Return ratings with metadata
    Component->>Component: Aggregate ratings by professor<br/>(compute avg: overall, workload, difficulty)
    Component->>Cache: Store aggregated professor data
    Component->>Filter: Apply filters (search, department,<br/>difficulty, rating threshold)
    Filter->>Filter: Process query checks across<br/>courses/professors
    Filter->>Filter: Department lookup by ID
    Filter->>Filter: Numeric difficulty range matching
    Filter-->>Component: Return filtered items
    Component->>Component: Sort by selected criterion<br/>& department order (if multi-dept)
    Component->>Component: Group courses by department<br/>(preserve master order)
    Component->>Renderer: Render grouped/flat view
    Renderer-->>Component: Display organized results
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 Whiskers twitch with glee so bright,
Professor names now searchable in sight!
Sorted, filtered, grouped with care,
Backend ratings floating through the air—
Order blooms where chaos was before!

🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (2 warnings)
Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The changeset includes several enhancements beyond the linked issue scope: sorting by multiple departments, course grouping by department, difficulty mapping refinements, and UI adjustments for both courses and professors. Separate course-related changes (sorting, grouping, difficulty mapping) into a distinct PR to keep the professor filtering fix focused and easier to review.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: professor filter by name and department' directly relates to the core change in the changeset, accurately summarizing the main improvement to professor filtering functionality.
Linked Issues check ✅ Passed The PR addresses the core requirement from issue #21 by implementing search functionality for professors by name, and extends it with department filtering and improved backend data fetching.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@src/components/courses/ItemList.tsx`:
- Around line 157-159: The minimum-rating check in ItemList.tsx incorrectly
allows unrated items through because it only applies when item.overall_rating >
0; change the logic in the component that filters items (look for the block
using item.overall_rating and filters.rating) to treat missing/unrated ratings
as 0 and perform the comparison whenever a rating threshold is set (e.g., if
filters.rating > 0, compare (normalized item rating || 0) < filters.rating and
return false when below threshold). Ensure you update the condition so unrated
(null/undefined/0) items are excluded when filters.rating > 0.
- Around line 65-115: The effect in useEffect (fetchProfessorAverages) can leave
stale itemsWithAvg when professors is empty and may set state after
type/professors change or unmount; update the effect to immediately clear
itemsWithAvg (call setItemsWithAvg([])) and setIsAggregating(false) when
professors.length === 0, and inside fetchProfessorAverages use a cancellation
flag (e.g., let cancelled = false; check !cancelled before any setItemsWithAvg
or setIsAggregating) and return a cleanup function that flips cancelled to true
to prevent late async updates when type/professors change or component unmounts.
- Around line 165-181: The sort dropdown isn't applied because sorting is not
performed inside the memo that produces filteredItems and sortBy is not included
in that memo's dependency list; update the useMemo that returns filteredItems to
apply sorting based on the sortBy value (handle cases like review_count → your
actual review-count field) before returning result, include sortBy in the
dependency array, and ensure the special-case department ordering logic
(currently using departmentProperties and Course casting) remains part of that
post-filter sort pipeline so filteredItems reflects the selected sort option.

Comment on lines 65 to +115
useEffect(() => {
if (type === 'professor' && professors.length > 0) {
if (type !== "professor" || professors.length === 0) return;

const fetchProfessorAverages = async () => {
setIsAggregating(true);

const fetchAverages = async () => {
const ids = professors.map((i) => i.id);
if (ids.length === 0) {
setItemsWithAvg(professors);
setIsAggregating(false);
return;
}
const ids = professors.map((p) => p.id);

const { data: ratings, error } = await supabase
.from("ratings")
.select("target_id, overall_rating, workload_rating, difficulty_rating")
.eq("target_type", "professor") // Fetch ratings for professors
.in("target_id", ids);

if (error) {
console.error("Error fetching professor averages:", error.message);
setItemsWithAvg(professors); // fallback to static data
setIsAggregating(false);
return;
}
const { data: ratings, error } = await supabase
.from("ratings")
.select("target_id, overall_rating, workload_rating, difficulty_rating")
.eq("target_type", "professor")
.in("target_id", ids);

if (error) {
console.error("Failed to load professor ratings:", error);
setItemsWithAvg(professors); // fallback
setIsAggregating(false);
return;
}

// Aggregate averages
const averages = (ratings || []).reduce((acc, r) => {
const averages = (ratings || []).reduce(
(acc, r) => {
if (!acc[r.target_id]) {
acc[r.target_id] = { overall: [], workload: [], difficulty: [] };
}
if (r.overall_rating !== null) acc[r.target_id].overall.push(r.overall_rating);
if (r.workload_rating !== null) acc[r.target_id].workload.push(r.workload_rating);
if (r.difficulty_rating !== null) acc[r.target_id].difficulty.push(r.difficulty_rating);
return acc;
}, {} as Record<string, { overall: number[]; workload: number[]; difficulty: number[] }>);

// Merge averages with professors
const calculateAverage = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;

const merged = professors.map((prof) => {
const avgData = averages[prof.id];
return {
...prof,
overall_rating: calculateAverage(avgData?.overall || []),
workload_rating: calculateAverage(avgData?.workload || []),
difficulty_rating: calculateAverage(avgData?.difficulty || []),
};
});

setItemsWithAvg(merged);
setIsAggregating(false);
};
},
{} as Record<string, { overall: number[]; workload: number[]; difficulty: number[] }>,
);

const calcAvg = (arr: number[]) => (arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0);

const merged = professors.map((prof) => {
const avg = averages[prof.id];
return {
...prof,
overall_rating: calcAvg(avg?.overall || []),
workload_rating: calcAvg(avg?.workload || []),
difficulty_rating: calcAvg(avg?.difficulty || []),
};
});

fetchAverages();
} else if (type === 'professor') {
// Handle case where professors array is empty
setItemsWithAvg(professors);
}
}, [type, professors]); // Run when professors array changes
setItemsWithAvg(merged);
setIsAggregating(false);
};

fetchProfessorAverages();
}, [type, professors]);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Prevent stale professor lists during async aggregation.

Line 66 returns early when professors is empty, leaving stale itemsWithAvg from a prior type. Also, the async fetch can still set state after type changes or unmount, which can overwrite course results. Clear the list on empty and guard state updates with a cancellation flag.

🐛 Suggested fix
   useEffect(() => {
-    if (type !== "professor" || professors.length === 0) return;
+    if (type !== "professor") return;
+    if (professors.length === 0) {
+      setItemsWithAvg([]);
+      return;
+    }
+
+    let cancelled = false;

     const fetchProfessorAverages = async () => {
       setIsAggregating(true);
@@
       if (error) {
         console.error("Failed to load professor ratings:", error);
-        setItemsWithAvg(professors); // fallback
-        setIsAggregating(false);
+        if (!cancelled) {
+          setItemsWithAvg(professors); // fallback
+          setIsAggregating(false);
+        }
         return;
       }
@@
-      setItemsWithAvg(merged);
-      setIsAggregating(false);
+      if (!cancelled) {
+        setItemsWithAvg(merged);
+        setIsAggregating(false);
+      }
     };

     fetchProfessorAverages();
+    return () => {
+      cancelled = true;
+    };
   }, [type, professors]);
📝 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
useEffect(() => {
if (type === 'professor' && professors.length > 0) {
if (type !== "professor" || professors.length === 0) return;
const fetchProfessorAverages = async () => {
setIsAggregating(true);
const fetchAverages = async () => {
const ids = professors.map((i) => i.id);
if (ids.length === 0) {
setItemsWithAvg(professors);
setIsAggregating(false);
return;
}
const ids = professors.map((p) => p.id);
const { data: ratings, error } = await supabase
.from("ratings")
.select("target_id, overall_rating, workload_rating, difficulty_rating")
.eq("target_type", "professor") // Fetch ratings for professors
.in("target_id", ids);
if (error) {
console.error("Error fetching professor averages:", error.message);
setItemsWithAvg(professors); // fallback to static data
setIsAggregating(false);
return;
}
const { data: ratings, error } = await supabase
.from("ratings")
.select("target_id, overall_rating, workload_rating, difficulty_rating")
.eq("target_type", "professor")
.in("target_id", ids);
if (error) {
console.error("Failed to load professor ratings:", error);
setItemsWithAvg(professors); // fallback
setIsAggregating(false);
return;
}
// Aggregate averages
const averages = (ratings || []).reduce((acc, r) => {
const averages = (ratings || []).reduce(
(acc, r) => {
if (!acc[r.target_id]) {
acc[r.target_id] = { overall: [], workload: [], difficulty: [] };
}
if (r.overall_rating !== null) acc[r.target_id].overall.push(r.overall_rating);
if (r.workload_rating !== null) acc[r.target_id].workload.push(r.workload_rating);
if (r.difficulty_rating !== null) acc[r.target_id].difficulty.push(r.difficulty_rating);
return acc;
}, {} as Record<string, { overall: number[]; workload: number[]; difficulty: number[] }>);
// Merge averages with professors
const calculateAverage = (arr: number[]) => arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
const merged = professors.map((prof) => {
const avgData = averages[prof.id];
return {
...prof,
overall_rating: calculateAverage(avgData?.overall || []),
workload_rating: calculateAverage(avgData?.workload || []),
difficulty_rating: calculateAverage(avgData?.difficulty || []),
};
});
setItemsWithAvg(merged);
setIsAggregating(false);
};
},
{} as Record<string, { overall: number[]; workload: number[]; difficulty: number[] }>,
);
const calcAvg = (arr: number[]) => (arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0);
const merged = professors.map((prof) => {
const avg = averages[prof.id];
return {
...prof,
overall_rating: calcAvg(avg?.overall || []),
workload_rating: calcAvg(avg?.workload || []),
difficulty_rating: calcAvg(avg?.difficulty || []),
};
});
fetchAverages();
} else if (type === 'professor') {
// Handle case where professors array is empty
setItemsWithAvg(professors);
}
}, [type, professors]); // Run when professors array changes
setItemsWithAvg(merged);
setIsAggregating(false);
};
fetchProfessorAverages();
}, [type, professors]);
useEffect(() => {
if (type !== "professor") return;
if (professors.length === 0) {
setItemsWithAvg([]);
return;
}
let cancelled = false;
const fetchProfessorAverages = async () => {
setIsAggregating(true);
const ids = professors.map((p) => p.id);
const { data: ratings, error } = await supabase
.from("ratings")
.select("target_id, overall_rating, workload_rating, difficulty_rating")
.eq("target_type", "professor")
.in("target_id", ids);
if (error) {
console.error("Failed to load professor ratings:", error);
if (!cancelled) {
setItemsWithAvg(professors); // fallback
setIsAggregating(false);
}
return;
}
const averages = (ratings || []).reduce(
(acc, r) => {
if (!acc[r.target_id]) {
acc[r.target_id] = { overall: [], workload: [], difficulty: [] };
}
if (r.overall_rating !== null) acc[r.target_id].overall.push(r.overall_rating);
if (r.workload_rating !== null) acc[r.target_id].workload.push(r.workload_rating);
if (r.difficulty_rating !== null) acc[r.target_id].difficulty.push(r.difficulty_rating);
return acc;
},
{} as Record<string, { overall: number[]; workload: number[]; difficulty: number[] }>,
);
const calcAvg = (arr: number[]) => (arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0);
const merged = professors.map((prof) => {
const avg = averages[prof.id];
return {
...prof,
overall_rating: calcAvg(avg?.overall || []),
workload_rating: calcAvg(avg?.workload || []),
difficulty_rating: calcAvg(avg?.difficulty || []),
};
});
if (!cancelled) {
setItemsWithAvg(merged);
setIsAggregating(false);
}
};
fetchProfessorAverages();
return () => {
cancelled = true;
};
}, [type, professors]);
🤖 Prompt for AI Agents
In `@src/components/courses/ItemList.tsx` around lines 65 - 115, The effect in
useEffect (fetchProfessorAverages) can leave stale itemsWithAvg when professors
is empty and may set state after type/professors change or unmount; update the
effect to immediately clear itemsWithAvg (call setItemsWithAvg([])) and
setIsAggregating(false) when professors.length === 0, and inside
fetchProfessorAverages use a cancellation flag (e.g., let cancelled = false;
check !cancelled before any setItemsWithAvg or setIsAggregating) and return a
cleanup function that flips cancelled to true to prevent late async updates when
type/professors change or component unmounts.

Comment on lines +157 to 159
// Minimum overall rating
if (item.overall_rating > 0 && item.overall_rating < filters.rating) {
return false;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Minimum rating filter lets unrated items through.

The check only filters when overall_rating > 0, so unrated items always pass even when filters.rating > 0. Normalize to 0 and compare whenever the threshold is set.

🐛 Suggested fix
-      if (item.overall_rating > 0 && item.overall_rating < filters.rating) {
+      const overall = item.overall_rating ?? 0;
+      if (filters.rating > 0 && overall < filters.rating) {
         return false;
       }
📝 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
// Minimum overall rating
if (item.overall_rating > 0 && item.overall_rating < filters.rating) {
return false;
// Minimum overall rating
const overall = item.overall_rating ?? 0;
if (filters.rating > 0 && overall < filters.rating) {
return false;
}
🤖 Prompt for AI Agents
In `@src/components/courses/ItemList.tsx` around lines 157 - 159, The
minimum-rating check in ItemList.tsx incorrectly allows unrated items through
because it only applies when item.overall_rating > 0; change the logic in the
component that filters items (look for the block using item.overall_rating and
filters.rating) to treat missing/unrated ratings as 0 and perform the comparison
whenever a rating threshold is set (e.g., if filters.rating > 0, compare
(normalized item rating || 0) < filters.rating and return false when below
threshold). Ensure you update the condition so unrated (null/undefined/0) items
are excluded when filters.rating > 0.

Comment on lines +165 to +181
// Special case: when multiple departments are selected → sort by click order
if (type === "course" && filters.departments.length > 1) {
result = result.toSorted((a, b) => {
const idA = departmentProperties.find((d) => d.name === (a as Course).department)?.id;
const idB = departmentProperties.find((d) => d.name === (b as Course).department)?.id;

// Get the index from the filters.departments array (e.g., ['MA', 'EE'])
const indexA = filters.departments.indexOf(deptIdA);
const indexB = filters.departments.indexOf(deptIdB);
const idxA = idA ? filters.departments.indexOf(idA) : -1;
const idxB = idB ? filters.departments.indexOf(idB) : -1;

// Handle cases where department might not be found (shouldn't happen)
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;

// This sorts by the click-order index (e.g., 0 for 'MA', 1 for 'EE')
return indexA - indexB;
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
return sorted;
}
// *** END OF FIX ***

return filtered; // Return the unsorted (but filtered) list

}, [itemsWithAvg, filters, type]); // Depend on filters and the processed items
return result;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Sort dropdown doesn’t affect list order.

sortBy updates the label but never changes filteredItems, so the dropdown appears broken. Apply sorting in this useMemo and add sortBy to its dependency list. (Swap review_count for the actual review-count field in your types.)

🧭 Suggested wiring
-    // Special case: when multiple departments are selected → sort by click order
+    // Apply selected sort
+    const byRating = (x: Course | Professor) => x.overall_rating ?? 0;
+    const byDifficulty = (x: Course | Professor) => x.difficulty_rating ?? 0;
+    const byReviews = (x: Course | Professor) =>
+      (x as Course & { review_count?: number }).review_count ?? 0;
+
+    if (sortBy) {
+      result = [...result].sort((a, b) => {
+        switch (sortBy) {
+          case "best-rated":
+            return byRating(b) - byRating(a);
+          case "most-reviewed":
+            return byReviews(b) - byReviews(a);
+          case "easiest":
+            return byDifficulty(a) - byDifficulty(b);
+          case "hardest":
+            return byDifficulty(b) - byDifficulty(a);
+          default:
+            return 0;
+        }
+      });
+    }
+
+    // Special case: when multiple departments are selected → sort by click order
     if (type === "course" && filters.departments.length > 1) {
       result = result.toSorted((a, b) => {
@@
     }
 
     return result;
-  }, [itemsWithAvg, filters, type]);
+  }, [itemsWithAvg, filters, type, sortBy]);
📝 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
// Special case: when multiple departments are selected → sort by click order
if (type === "course" && filters.departments.length > 1) {
result = result.toSorted((a, b) => {
const idA = departmentProperties.find((d) => d.name === (a as Course).department)?.id;
const idB = departmentProperties.find((d) => d.name === (b as Course).department)?.id;
// Get the index from the filters.departments array (e.g., ['MA', 'EE'])
const indexA = filters.departments.indexOf(deptIdA);
const indexB = filters.departments.indexOf(deptIdB);
const idxA = idA ? filters.departments.indexOf(idA) : -1;
const idxB = idB ? filters.departments.indexOf(idB) : -1;
// Handle cases where department might not be found (shouldn't happen)
if (indexA === -1 && indexB === -1) return 0;
if (indexA === -1) return 1;
if (indexB === -1) return -1;
// This sorts by the click-order index (e.g., 0 for 'MA', 1 for 'EE')
return indexA - indexB;
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
return sorted;
}
// *** END OF FIX ***
return filtered; // Return the unsorted (but filtered) list
}, [itemsWithAvg, filters, type]); // Depend on filters and the processed items
return result;
// Apply selected sort
const byRating = (x: Course | Professor) => x.overall_rating ?? 0;
const byDifficulty = (x: Course | Professor) => x.difficulty_rating ?? 0;
const byReviews = (x: Course | Professor) =>
(x as Course & { review_count?: number }).review_count ?? 0;
if (sortBy) {
result = [...result].sort((a, b) => {
switch (sortBy) {
case "best-rated":
return byRating(b) - byRating(a);
case "most-reviewed":
return byReviews(b) - byReviews(a);
case "easiest":
return byDifficulty(a) - byDifficulty(b);
case "hardest":
return byDifficulty(b) - byDifficulty(a);
default:
return 0;
}
});
}
// Special case: when multiple departments are selected → sort by click order
if (type === "course" && filters.departments.length > 1) {
result = result.toSorted((a, b) => {
const idA = departmentProperties.find((d) => d.name === (a as Course).department)?.id;
const idB = departmentProperties.find((d) => d.name === (b as Course).department)?.id;
const idxA = idA ? filters.departments.indexOf(idA) : -1;
const idxB = idB ? filters.departments.indexOf(idB) : -1;
if (idxA === -1 && idxB === -1) return 0;
if (idxA === -1) return 1;
if (idxB === -1) return -1;
return idxA - idxB;
});
}
return result;
}, [itemsWithAvg, filters, type, sortBy]);
🤖 Prompt for AI Agents
In `@src/components/courses/ItemList.tsx` around lines 165 - 181, The sort
dropdown isn't applied because sorting is not performed inside the memo that
produces filteredItems and sortBy is not included in that memo's dependency
list; update the useMemo that returns filteredItems to apply sorting based on
the sortBy value (handle cases like review_count → your actual review-count
field) before returning result, include sortBy in the dependency array, and
ensure the special-case department ordering logic (currently using
departmentProperties and Course casting) remains part of that post-filter sort
pipeline so filteredItems reflects the selected sort option.

@rajeev-sr
Copy link
Collaborator

some professors Some professors are appearing multiple times in the list in your PR.
resolve it

your updated code :
Screenshot from 2026-01-23 01-55-55

previous code :
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FOSSOVERFLOW-25] Add Search functionality on Professors page

2 participants