From 3dc56d781b6cbd5f507a80bda6905d59662a4ab5 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Tue, 13 Jan 2026 15:07:50 -0500 Subject: [PATCH 1/5] add filtering to FE and express backend --- .../components/FilterBar/FilterBar.module.css | 212 ++++++++++++++++++ .../app/components/FilterBar/FilterBar.tsx | 209 +++++++++++++++++ .../client/app/components/FilterBar/index.ts | 2 + .../app/components/MovieCard/MovieCard.tsx | 7 +- mflix/client/app/components/index.ts | 1 + mflix/client/app/lib/api.ts | 60 ++++- mflix/client/app/movie/[id]/page.tsx | 8 +- mflix/client/app/movies/page.tsx | 104 +++++++-- 8 files changed, 568 insertions(+), 35 deletions(-) create mode 100644 mflix/client/app/components/FilterBar/FilterBar.module.css create mode 100644 mflix/client/app/components/FilterBar/FilterBar.tsx create mode 100644 mflix/client/app/components/FilterBar/index.ts diff --git a/mflix/client/app/components/FilterBar/FilterBar.module.css b/mflix/client/app/components/FilterBar/FilterBar.module.css new file mode 100644 index 0000000..ee17f25 --- /dev/null +++ b/mflix/client/app/components/FilterBar/FilterBar.module.css @@ -0,0 +1,212 @@ +/** + * FilterBar Component Styles + * + * CSS Module for the movie filter bar component. + * Provides a horizontal filter bar for filtering movies by genre, year, rating, etc. + */ + +.filterBar { + background: white; + border-radius: 12px; + padding: 1.25rem 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + margin-bottom: 1.5rem; +} + +.filterHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.filterTitle { + font-size: 0.875rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.clearFiltersButton { + background: transparent; + border: 1px solid #e2e8f0; + color: #64748b; + padding: 0.375rem 0.75rem; + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.clearFiltersButton:hover { + background: #f8fafc; + border-color: #cbd5e1; + color: #475569; +} + +.filterControls { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-end; +} + +.filterGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + min-width: 140px; +} + +.filterLabel { + font-size: 0.75rem; + font-weight: 500; + color: #64748b; +} + +.filterSelect, +.filterInput { + padding: 0.5rem 0.75rem; + border: 2px solid #e2e8f0; + border-radius: 8px; + font-size: 0.875rem; + background: white; + color: #374151; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + min-width: 120px; +} + +.filterSelect:hover, +.filterInput:hover { + border-color: #cbd5e1; +} + +.filterSelect:focus, +.filterInput:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15); +} + +.ratingGroup { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ratingInput { + width: 70px; +} + +.ratingDivider { + color: #94a3b8; + font-size: 0.875rem; +} + +.applyButton { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.applyButton:hover { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); +} + +.applyButton:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.activeFilters { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid #e2e8f0; +} + +.filterChip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + background: #eff6ff; + color: #2563eb; + padding: 0.25rem 0.625rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.chipRemove { + background: none; + border: none; + color: #2563eb; + cursor: pointer; + padding: 0; + font-size: 1rem; + line-height: 1; + opacity: 0.7; +} + +.chipRemove:hover { + opacity: 1; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .filterBar { + padding: 1rem; + } + + .filterControls { + flex-direction: column; + align-items: stretch; + } + + .filterGroup { + min-width: 100%; + } + + .ratingGroup { + flex-wrap: wrap; + } + + .ratingInput { + flex: 1; + min-width: 80px; + } + + .applyButton { + width: 100%; + padding: 0.75rem 1rem; + } +} + +@media (max-width: 480px) { + .filterHeader { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + } + + .clearFiltersButton { + width: 100%; + } +} diff --git a/mflix/client/app/components/FilterBar/FilterBar.tsx b/mflix/client/app/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000..e5755bc --- /dev/null +++ b/mflix/client/app/components/FilterBar/FilterBar.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import styles from './FilterBar.module.css'; +import type { MovieFilterParams } from '@/lib/api'; + +const GENRES = [ + 'Action', 'Adventure', 'Animation', 'Biography', 'Comedy', 'Crime', + 'Documentary', 'Drama', 'Family', 'Fantasy', 'Film-Noir', 'History', + 'Horror', 'Music', 'Musical', 'Mystery', 'Romance', 'Sci-Fi', + 'Short', 'Sport', 'Thriller', 'War', 'Western' +]; + +const SORT_OPTIONS = [ + { value: 'title', label: 'Title' }, + { value: 'year', label: 'Year' }, + { value: 'imdb.rating', label: 'IMDB Rating' }, +]; + +interface FilterBarProps { + onFilterChange: (filters: MovieFilterParams) => void; + isLoading?: boolean; + initialFilters?: MovieFilterParams; +} + +export default function FilterBar({ + onFilterChange, + isLoading = false, + initialFilters = {} +}: FilterBarProps) { + const [filters, setFilters] = useState(initialFilters); + + // Sync internal state when initialFilters changes (e.g. from URL navigation) + useEffect(() => { + setFilters(initialFilters); + }, [JSON.stringify(initialFilters)]); // Use JSON.stringify for deep comparison + + const handleFilterChange = useCallback((key: keyof MovieFilterParams, value: string | number | undefined) => { + setFilters(prev => { + const newFilters = { ...prev }; + if (value === '' || value === undefined) { + delete newFilters[key]; + } else { + (newFilters as Record)[key] = value; + } + return newFilters; + }); + }, []); + + const handleApplyFilters = useCallback(() => { + onFilterChange(filters); + }, [filters, onFilterChange]); + + const handleClearFilters = useCallback(() => { + setFilters({}); + onFilterChange({}); + }, [onFilterChange]); + + const hasActiveFilters = Object.keys(filters).length > 0; + + const activeFilterChips: { key: string; label: string }[] = []; + if (filters.genre) activeFilterChips.push({ key: 'genre', label: `Genre: ${filters.genre}` }); + if (filters.year) activeFilterChips.push({ key: 'year', label: `Year: ${filters.year}` }); + if (filters.minRating !== undefined) activeFilterChips.push({ key: 'minRating', label: `Min Rating: ${filters.minRating}` }); + if (filters.maxRating !== undefined) activeFilterChips.push({ key: 'maxRating', label: `Max Rating: ${filters.maxRating}` }); + if (filters.sortBy) { + const sortLabel = SORT_OPTIONS.find(o => o.value === filters.sortBy)?.label || filters.sortBy; + activeFilterChips.push({ key: 'sort', label: `Sort: ${sortLabel} (${filters.sortOrder || 'asc'})` }); + } + + const removeFilter = (key: string) => { + if (key === 'sort') { + handleFilterChange('sortBy', undefined); + handleFilterChange('sortOrder', undefined); + } else { + handleFilterChange(key as keyof MovieFilterParams, undefined); + } + }; + + return ( +
+
+

+ Filter Movies +

+ {hasActiveFilters && ( + + )} +
+ +
+
+ + +
+ +
+ + handleFilterChange('year', e.target.value ? parseInt(e.target.value) : undefined)} + disabled={isLoading} + min={1900} + max={2030} + /> +
+ +
+ +
+ handleFilterChange('minRating', e.target.value ? parseFloat(e.target.value) : undefined)} + disabled={isLoading} + min={0} + max={10} + step={0.1} + /> + to + handleFilterChange('maxRating', e.target.value ? parseFloat(e.target.value) : undefined)} + disabled={isLoading} + min={0} + max={10} + step={0.1} + /> +
+
+ +
+ + +
+ +
+ + +
+ + +
+ + {activeFilterChips.length > 0 && ( +
+ {activeFilterChips.map(chip => ( + + {chip.label} + + + ))} +
+ )} +
+ ); +} + diff --git a/mflix/client/app/components/FilterBar/index.ts b/mflix/client/app/components/FilterBar/index.ts new file mode 100644 index 0000000..839567f --- /dev/null +++ b/mflix/client/app/components/FilterBar/index.ts @@ -0,0 +1,2 @@ +export { default as FilterBar } from './FilterBar'; + diff --git a/mflix/client/app/components/MovieCard/MovieCard.tsx b/mflix/client/app/components/MovieCard/MovieCard.tsx index 1ff2c9d..7fa702c 100644 --- a/mflix/client/app/components/MovieCard/MovieCard.tsx +++ b/mflix/client/app/components/MovieCard/MovieCard.tsx @@ -3,12 +3,13 @@ import Image from 'next/image'; import Link from 'next/link'; import movieStyles from "./MovieCard.module.css"; -import { Movie } from "../../types/movie"; -import { ROUTES } from "../../lib/constants"; +import { Movie } from "@/types/movie"; +import { ROUTES } from "@/lib/constants"; +import React from "react"; /** * Movie Card Client Component - * + * * This component handles the interactive parts of the movie card, * such as image error handling and selection checkbox. */ diff --git a/mflix/client/app/components/index.ts b/mflix/client/app/components/index.ts index 03c422b..11b6de1 100644 --- a/mflix/client/app/components/index.ts +++ b/mflix/client/app/components/index.ts @@ -7,6 +7,7 @@ export { default as EditMovieForm } from './EditMovieForm'; export { default as AddMovieForm } from './AddMovieForm'; export { default as BatchEditMovieForm } from './BatchEditMovieForm'; export { default as SearchMovieModal } from './SearchMovieModal'; +export { FilterBar } from './FilterBar'; export { Skeleton, MovieCardSkeleton, diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index c99c08f..165a4ff 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -1,4 +1,4 @@ -import { Movie, MoviesApiResponse } from '../types/movie'; +import { Movie, MoviesApiResponse } from '@/types/movie'; /** * API configuration and helper functions @@ -7,17 +7,59 @@ import { Movie, MoviesApiResponse } from '../types/movie'; const API_BASE_URL = process.env.API_URL || 'http://localhost:3001'; /** - * Fetches movies from the Express API with pagination support - * This function runs on the server during SSR + * Filter parameters for the movies endpoint + * These map to MongoDB find() query operators + */ +export interface MovieFilterParams { + genre?: string; + year?: number; + minRating?: number; + maxRating?: number; + sortBy?: 'title' | 'year' | 'imdb.rating'; + sortOrder?: 'asc' | 'desc'; +} + +/** + * Fetches movies from the backend API with pagination and filtering support. + * Demonstrates using MongoDB find() with query filters. */ export async function fetchMovies( - limit: number = 20, - skip: number = 0 + limit: number = 20, + skip: number = 0, + filters?: MovieFilterParams ): Promise<{ movies: Movie[]; hasNextPage: boolean; hasPrevPage: boolean }> { try { + // Build query parameters + const queryParams = new URLSearchParams(); + // Request one extra movie to check if there's a next page const requestLimit = Math.min(limit + 1, 100); - const response = await fetch(`${API_BASE_URL}/api/movies?limit=${requestLimit}&skip=${skip}`, { + queryParams.append('limit', requestLimit.toString()); + queryParams.append('skip', skip.toString()); + + // Add filter parameters if provided + if (filters) { + if (filters.genre) { + queryParams.append('genre', filters.genre); + } + if (filters.year !== undefined) { + queryParams.append('year', filters.year.toString()); + } + if (filters.minRating !== undefined) { + queryParams.append('minRating', filters.minRating.toString()); + } + if (filters.maxRating !== undefined) { + queryParams.append('maxRating', filters.maxRating.toString()); + } + if (filters.sortBy) { + queryParams.append('sortBy', filters.sortBy); + } + if (filters.sortOrder) { + queryParams.append('sortOrder', filters.sortOrder); + } + } + + const response = await fetch(`${API_BASE_URL}/api/movies?${queryParams}`, { next: { revalidate: 300 }, // Revalidate every 5 minutes }); @@ -26,7 +68,7 @@ export async function fetchMovies( } const result: MoviesApiResponse = await response.json(); - + if (!result.success) { throw new Error('API returned error response'); } @@ -42,12 +84,12 @@ export async function fetchMovies( }; } catch (error) { console.error('Error fetching movies:', error); - + // In development, throw the error to help with debugging if (process.env.NODE_ENV === 'development') { throw error; } - + // In production, return empty result with logged error to prevent page crash return { movies: [], diff --git a/mflix/client/app/movie/[id]/page.tsx b/mflix/client/app/movie/[id]/page.tsx index 42dade2..86395c4 100644 --- a/mflix/client/app/movie/[id]/page.tsx +++ b/mflix/client/app/movie/[id]/page.tsx @@ -4,11 +4,11 @@ import { useState, useEffect } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { fetchMovieById, updateMovie, deleteMovie } from '../../lib/api'; +import { fetchMovieById, updateMovie, deleteMovie } from '@/lib/api'; import { ActionButtons, EditMovieForm } from '../../components'; import { ErrorDisplay, LoadingSpinner } from '../../components/ui'; -import { Movie } from '../../types/movie'; -import { ROUTES } from '../../lib/constants'; +import { Movie } from '@/types/movie'; +import { ROUTES } from '@/lib/constants'; import pageStyles from './page.module.css'; interface MovieDetailsPageProps { @@ -205,7 +205,7 @@ export default function MovieDetailsPage({ params }: MovieDetailsPageProps) { {movie.poster ? (
{`${movie.title}(null); - + + // Parse filter parameters from URL for persistence + const parseFiltersFromUrl = (): MovieFilterParams => { + const filters: MovieFilterParams = {}; + + const genre = searchParams.get('genre'); + const year = searchParams.get('year'); + const minRating = searchParams.get('minRating'); + const maxRating = searchParams.get('maxRating'); + const sortBy = searchParams.get('sortBy'); + const sortOrder = searchParams.get('sortOrder'); + + if (genre) filters.genre = genre; + if (year) filters.year = parseInt(year); + if (minRating) filters.minRating = parseFloat(minRating); + if (maxRating) filters.maxRating = parseFloat(maxRating); + if (sortBy && ['title', 'year', 'imdb.rating'].includes(sortBy)) { + filters.sortBy = sortBy as MovieFilterParams['sortBy']; + } + if (sortOrder && ['asc', 'desc'].includes(sortOrder)) { + filters.sortOrder = sortOrder as 'asc' | 'desc'; + } + + return filters; + }; + + // Build URL with filter parameters + const buildUrlWithFilters = (newPage: number, newLimit: number, filters: MovieFilterParams): string => { + const params = new URLSearchParams(); + params.set('page', newPage.toString()); + params.set('limit', newLimit.toString()); + + if (filters.genre) params.set('genre', filters.genre); + if (filters.year !== undefined) params.set('year', filters.year.toString()); + if (filters.minRating !== undefined) params.set('minRating', filters.minRating.toString()); + if (filters.maxRating !== undefined) params.set('maxRating', filters.maxRating.toString()); + if (filters.sortBy) params.set('sortBy', filters.sortBy); + if (filters.sortOrder) params.set('sortOrder', filters.sortOrder); + + return `${ROUTES.movies}?${params.toString()}`; + }; + + // Get filters from URL on initial load and when URL changes + const urlFilters = parseFiltersFromUrl(); + const hasUrlFilters = Object.keys(urlFilters).length > 0; + const page = parseInt(searchParams.get('page') || '1'); const limit = Math.min( - parseInt(searchParams.get('limit') || APP_CONFIG.defaultMovieLimit.toString()), + parseInt(searchParams.get('limit') || APP_CONFIG.defaultMovieLimit.toString()), APP_CONFIG.maxMovieLimit ); const skip = (page - 1) * limit; - const loadMovies = async () => { + const loadMovies = async (filters?: MovieFilterParams) => { setIsLoading(true); setError(null); - + try { - const result = await fetchMovies(limit, skip); + const result = await fetchMovies(limit, skip, filters); setMovies(result.movies); setHasNextPage(result.hasNextPage); setHasPrevPage(result.hasPrevPage); @@ -73,13 +118,23 @@ export default function Movies() { setError('Failed to load movies. Make sure the server is running on port 3001.'); setMovies([]); } - + setIsLoading(false); }; useEffect(() => { - loadMovies(); - }, [page, limit]); + // Load movies with filters from URL when page/limit/filters change + loadMovies(hasUrlFilters ? urlFilters : undefined); + }, [searchParams]); // Re-run when any URL param changes + + // Handler for filter changes from FilterBar - updates URL + const handleFilterChange = (filters: MovieFilterParams) => { + const hasFilters = Object.keys(filters).length > 0; + + // Always reset to page 1 when filters change and update URL + const newUrl = buildUrlWithFilters(1, limit, filters); + router.push(newUrl); + }; const handleAddMovie = () => { setShowAddForm(true); @@ -462,7 +517,7 @@ export default function Movies() {

- {isSearchMode ? `Search Results` : 'Movies'} + {isSearchMode ? `Search Results` : hasUrlFilters ? 'Filtered Movies' : 'Movies'}

@@ -572,20 +627,31 @@ export default function Movies() { {/* Page Size Selector - only show for regular mode */} {!showAddForm && !showBatchEditForm && !showSearchModal && !isSearchMode && } - + + {/* Filter Bar - display when not in search mode and not showing forms */} + {!showAddForm && !showBatchEditForm && !showSearchModal && !isSearchMode && ( + + )} + {/* Movies Content */} {!showAddForm && !showBatchEditForm && !showSearchModal && ( <> {error && displayMovies.length === 0 ? ( - handleSearchSubmit(currentSearchParams!) : loadMovies} + handleSearchSubmit(currentSearchParams!) : () => loadMovies(hasUrlFilters ? urlFilters : undefined)} /> ) : displayMovies.length === 0 ? (

- {isSearchMode + {isSearchMode ? 'No movies found matching your search criteria. Try different search terms.' + : hasUrlFilters + ? 'No movies found matching your filter criteria. Try adjusting your filters.' : 'No movies found. Make sure the server is running on port 3001.' }

From 76029d8be2414a12b132ffa8e5f8bde94d178aa6 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Wed, 14 Jan 2026 08:32:34 -0500 Subject: [PATCH 2/5] Get distinct genres --- .../app/components/FilterBar/FilterBar.tsx | 28 +++++++++------- mflix/client/app/lib/api.ts | 32 +++++++++++++++++-- .../src/controllers/movieController.ts | 28 ++++++++++++++++ mflix/server/js-express/src/routes/movies.ts | 25 +++++++++++++++ 4 files changed, 100 insertions(+), 13 deletions(-) diff --git a/mflix/client/app/components/FilterBar/FilterBar.tsx b/mflix/client/app/components/FilterBar/FilterBar.tsx index e5755bc..dccb9ad 100644 --- a/mflix/client/app/components/FilterBar/FilterBar.tsx +++ b/mflix/client/app/components/FilterBar/FilterBar.tsx @@ -2,14 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import styles from './FilterBar.module.css'; -import type { MovieFilterParams } from '@/lib/api'; - -const GENRES = [ - 'Action', 'Adventure', 'Animation', 'Biography', 'Comedy', 'Crime', - 'Documentary', 'Drama', 'Family', 'Fantasy', 'Film-Noir', 'History', - 'Horror', 'Music', 'Musical', 'Mystery', 'Romance', 'Sci-Fi', - 'Short', 'Sport', 'Thriller', 'War', 'Western' -]; +import { fetchGenres, type MovieFilterParams } from '@/lib/api'; const SORT_OPTIONS = [ { value: 'title', label: 'Title' }, @@ -29,6 +22,19 @@ export default function FilterBar({ initialFilters = {} }: FilterBarProps) { const [filters, setFilters] = useState(initialFilters); + const [genres, setGenres] = useState([]); + const [isLoadingGenres, setIsLoadingGenres] = useState(true); + + // Fetch genres from the API on mount + useEffect(() => { + async function loadGenres() { + setIsLoadingGenres(true); + const fetchedGenres = await fetchGenres(); + setGenres(fetchedGenres); + setIsLoadingGenres(false); + } + loadGenres(); + }, []); // Sync internal state when initialFilters changes (e.g. from URL navigation) useEffect(() => { @@ -97,10 +103,10 @@ export default function FilterBar({ className={styles.filterSelect} value={filters.genre || ''} onChange={(e) => handleFilterChange('genre', e.target.value)} - disabled={isLoading} + disabled={isLoading || isLoadingGenres} > - - {GENRES.map(genre => ( + + {genres.map(genre => ( ))} diff --git a/mflix/client/app/lib/api.ts b/mflix/client/app/lib/api.ts index 165a4ff..7ac8976 100644 --- a/mflix/client/app/lib/api.ts +++ b/mflix/client/app/lib/api.ts @@ -20,8 +20,8 @@ export interface MovieFilterParams { } /** - * Fetches movies from the backend API with pagination and filtering support. - * Demonstrates using MongoDB find() with query filters. + * Fetches movies from the backend API with pagination and filtering support + * using MongoDB find() with query filters. */ export async function fetchMovies( limit: number = 20, @@ -99,6 +99,34 @@ export async function fetchMovies( } } +/** + * Fetches all unique genres from the backend API + * using MongoDB's distinct() operation. + */ +export async function fetchGenres(): Promise { + try { + const response = await fetch(`${API_BASE_URL}/api/movies/genres`, { + next: { revalidate: 3600 }, // Cache genres for 1 hour since they rarely change + }); + + if (!response.ok) { + throw new Error(`Failed to fetch genres: ${response.status}`); + } + + const result: { success: boolean; data: string[]; message: string } = await response.json(); + + if (!result.success) { + throw new Error('API returned error response'); + } + + return result.data; + } catch (error) { + console.error('Error fetching genres:', error); + // Return empty array on error - FilterBar will handle gracefully + return []; + } +} + /** * Fetch a single movie by ID */ diff --git a/mflix/server/js-express/src/controllers/movieController.ts b/mflix/server/js-express/src/controllers/movieController.ts index 0d107e5..6ff04ba 100644 --- a/mflix/server/js-express/src/controllers/movieController.ts +++ b/mflix/server/js-express/src/controllers/movieController.ts @@ -134,6 +134,34 @@ export async function getAllMovies(req: Request, res: Response): Promise { res.json(createSuccessResponse(movies, `Found ${movies.length} movies`)); } +/** + * GET /api/movies/genres + * + * Retrieves all unique genres from the movies collection. + * Demonstrates the distinct() operation. + * + * Returns an array of unique genre strings, sorted alphabetically. + */ +export async function getDistinctGenres( + req: Request, + res: Response +): Promise { + const moviesCollection = getCollection("movies"); + + // Use distinct() to get all unique values from the genres array field + // MongoDB automatically flattens array fields when using distinct() + const genres = await moviesCollection.distinct("genres"); + + // Filter out null/empty values and sort alphabetically + const validGenres = genres + .filter((genre): genre is string => typeof genre === "string" && genre.length > 0) + .sort((a, b) => a.localeCompare(b)); + + res.json( + createSuccessResponse(validGenres, `Found ${validGenres.length} distinct genres`) + ); +} + /** * GET /api/movies/:id * diff --git a/mflix/server/js-express/src/routes/movies.ts b/mflix/server/js-express/src/routes/movies.ts index ed2c9d0..4be4df2 100644 --- a/mflix/server/js-express/src/routes/movies.ts +++ b/mflix/server/js-express/src/routes/movies.ts @@ -118,6 +118,31 @@ const router = express.Router(); */ router.get("/", asyncHandler(movieController.getAllMovies)); +/** + * @swagger + * /api/movies/genres: + * get: + * summary: Get all distinct genres + * description: Retrieves all unique genres from the movies collection. Demonstrates the MongoDB distinct() operation. + * tags: [Movies] + * responses: + * 200: + * description: List of distinct genres + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - type: object + * properties: + * data: + * type: array + * items: + * type: string + * example: ["Action", "Adventure", "Animation", "Comedy", "Drama"] + */ +router.get("/genres", asyncHandler(movieController.getDistinctGenres)); + /** * @swagger * /api/movies/search: From 663a3c7395e489fca025a4678f519ceb03d742a0 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Wed, 14 Jan 2026 14:00:01 -0500 Subject: [PATCH 3/5] refactor: use useRef instead of stringify --- .../app/components/FilterBar/FilterBar.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/mflix/client/app/components/FilterBar/FilterBar.tsx b/mflix/client/app/components/FilterBar/FilterBar.tsx index dccb9ad..324840d 100644 --- a/mflix/client/app/components/FilterBar/FilterBar.tsx +++ b/mflix/client/app/components/FilterBar/FilterBar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import styles from './FilterBar.module.css'; import { fetchGenres, type MovieFilterParams } from '@/lib/api'; @@ -16,6 +16,21 @@ interface FilterBarProps { initialFilters?: MovieFilterParams; } +/** + * Compares two MovieFilterParams objects for equality. + * Returns true if all filter values match. + */ +function areFiltersEqual(a: MovieFilterParams, b: MovieFilterParams): boolean { + return ( + a.genre === b.genre && + a.year === b.year && + a.minRating === b.minRating && + a.maxRating === b.maxRating && + a.sortBy === b.sortBy && + a.sortOrder === b.sortOrder + ); +} + export default function FilterBar({ onFilterChange, isLoading = false, @@ -25,6 +40,9 @@ export default function FilterBar({ const [genres, setGenres] = useState([]); const [isLoadingGenres, setIsLoadingGenres] = useState(true); + // Track previous initialFilters to detect changes + const prevInitialFiltersRef = useRef(initialFilters); + // Fetch genres from the API on mount useEffect(() => { async function loadGenres() { @@ -38,8 +56,11 @@ export default function FilterBar({ // Sync internal state when initialFilters changes (e.g. from URL navigation) useEffect(() => { - setFilters(initialFilters); - }, [JSON.stringify(initialFilters)]); // Use JSON.stringify for deep comparison + if (!areFiltersEqual(prevInitialFiltersRef.current, initialFilters)) { + setFilters(initialFilters); + prevInitialFiltersRef.current = initialFilters; + } + }, [initialFilters]); const handleFilterChange = useCallback((key: keyof MovieFilterParams, value: string | number | undefined) => { setFilters(prev => { From 3be4fc52e46eb4c92b7465f06d37a9542b2c67de Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Thu, 15 Jan 2026 09:08:11 -0500 Subject: [PATCH 4/5] Add GET /api/movies/genres endpoint to Java Spring and Python FastAPI backends - Java: Added getDistinctGenres() to MovieService interface and MovieServiceImpl - Java: Added /genres endpoint to MovieControllerImpl using mongoTemplate.findDistinct() - Python: Added /genres endpoint to movies.py router using collection.distinct() - Both implementations filter null/empty values and sort alphabetically - Returns success response with count of distinct genres --- .../controller/MovieControllerImpl.java | 21 +++++++++- .../samplemflix/service/MovieService.java | 8 ++++ .../samplemflix/service/MovieServiceImpl.java | 20 +++++++++- .../python-fastapi/src/routers/movies.py | 39 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java index 5ca9ceb..d696c3e 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/controller/MovieControllerImpl.java @@ -109,7 +109,26 @@ public ResponseEntity>> getAllMovies( return ResponseEntity.ok(response); } - + + @Operation( + summary = "Get all distinct genres", + description = "Retrieve a list of all unique genre values from the movies collection. " + + "Demonstrates the distinct() operation. Returns genres sorted alphabetically." + ) + @GetMapping("/genres") + public ResponseEntity>> getDistinctGenres() { + List genres = movieService.getDistinctGenres(); + + SuccessResponse> response = SuccessResponse.>builder() + .success(true) + .message("Found " + genres.size() + " distinct genres") + .data(genres) + .timestamp(Instant.now().toString()) + .build(); + + return ResponseEntity.ok(response); + } + @Operation( summary = "Get a single movie by ID", description = "Retrieve a single movie by its MongoDB ObjectId." diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java index 8221b27..4a592cb 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieService.java @@ -21,6 +21,14 @@ public interface MovieService { List getAllMovies(MovieSearchQuery query); + /** + * Gets all distinct genre values from the movies collection. + * Demonstrates the distinct() operation. + * + * @return List of unique genre strings, sorted alphabetically + */ + List getDistinctGenres(); + Movie getMovieById(String id); Movie createMovie(CreateMovieRequest request); diff --git a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java index 138444f..d293b23 100644 --- a/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java +++ b/mflix/server/java-spring/src/main/java/com/mongodb/samplemflix/service/MovieServiceImpl.java @@ -85,7 +85,25 @@ public List getAllMovies(MovieSearchQuery query) { return mongoTemplate.find(mongoQuery, Movie.class); } - + + @Override + public List getDistinctGenres() { + // Use MongoTemplate's findDistinct to get all unique values from the genres array field + // MongoDB automatically flattens array fields when using distinct() + List genres = mongoTemplate.findDistinct( + new Query(), + Movie.Fields.GENRES, + Movie.class, + String.class + ); + + // Filter out null/empty values and sort alphabetically + return genres.stream() + .filter(genre -> genre != null && !genre.isEmpty()) + .sorted(String::compareTo) + .collect(Collectors.toList()); + } + @Override public Movie getMovieById(String id) { if (!ObjectId.isValid(id)) { diff --git a/mflix/server/python-fastapi/src/routers/movies.py b/mflix/server/python-fastapi/src/routers/movies.py index 32f04a3..bb30c32 100644 --- a/mflix/server/python-fastapi/src/routers/movies.py +++ b/mflix/server/python-fastapi/src/routers/movies.py @@ -32,6 +32,10 @@ Search movies using MongoDB Vector Search to enable semantic search capabilities over the plot field. +- GET /api/movies/genres : + Retrieve all distinct genre values from the movies collection. + Demonstrates the distinct() operation. + - GET /api/movies/{id} : Retrieve a single movie by its ID. @@ -427,6 +431,41 @@ async def vector_search_movies( detail=f"Error performing vector search: {str(e)}" ) +""" + GET /api/movies/genres + + Retrieve all distinct genre values from the movies collection. + Demonstrates the distinct() operation. + + Returns: + SuccessResponse[List[str]]: A response object containing the list of unique genres, sorted alphabetically. +""" + +@router.get("/genres", + response_model=SuccessResponse[List[str]], + status_code=200, + summary="Retrieve all distinct genres from the movies collection.") +async def get_distinct_genres(): + movies_collection = get_collection("movies") + + try: + # Use distinct() to get all unique values from the genres array field + # MongoDB automatically flattens array fields when using distinct() + genres = await movies_collection.distinct("genres") + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Database error occurred: {str(e)}" + ) + + # Filter out null/empty values and sort alphabetically + valid_genres = sorted([ + genre for genre in genres + if isinstance(genre, str) and len(genre) > 0 + ]) + + return create_success_response(valid_genres, f"Found {len(valid_genres)} distinct genres") + """ GET /api/movies/{id} Retrieve a single movie by its ID. From c2961c2c33cc5f322a00301944b74cddd9d28703 Mon Sep 17 00:00:00 2001 From: Cory Bullinger Date: Fri, 16 Jan 2026 12:19:52 -0500 Subject: [PATCH 5/5] Add tests for filtering --- .../controller/MovieControllerTest.java | 34 ++++++++ .../samplemflix/service/MovieServiceTest.java | 78 ++++++++++++++++++ .../advancedEndpoints.integration.test.ts | 47 +++++++++++ .../python-fastapi/tests/test_movie_routes.py | 79 +++++++++++++++++++ 4 files changed, 238 insertions(+) diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java index 0f63dfd..1add6ed 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/controller/MovieControllerTest.java @@ -936,4 +936,38 @@ void testDeleteMoviesBatch_EmptyFilter() throws Exception { .andExpect(jsonPath("$.success").value(true)) .andExpect(jsonPath("$.data.deletedCount").value(0)); } + + // ==================== GET DISTINCT GENRES TESTS ==================== + + @Test + @DisplayName("GET /api/movies/genres - Should return list of distinct genres") + void testGetDistinctGenres_Success() throws Exception { + // Arrange + List genres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi"); + when(movieService.getDistinctGenres()).thenReturn(genres); + + // Act & Assert + mockMvc.perform(get("/api/movies/genres")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(5))) + .andExpect(jsonPath("$.data[0]").value("Action")) + .andExpect(jsonPath("$.data[1]").value("Comedy")) + .andExpect(jsonPath("$.data[2]").value("Drama")); + } + + @Test + @DisplayName("GET /api/movies/genres - Should return empty list when no genres exist") + void testGetDistinctGenres_EmptyList() throws Exception { + // Arrange + when(movieService.getDistinctGenres()).thenReturn(Arrays.asList()); + + // Act & Assert + mockMvc.perform(get("/api/movies/genres")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data", hasSize(0))); + } } diff --git a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java index 335e763..8c0db87 100644 --- a/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java +++ b/mflix/server/java-spring/src/test/java/com/mongodb/samplemflix/service/MovieServiceTest.java @@ -801,4 +801,82 @@ void testFindSimilarMovies_MovieNotFound() { // Act & Assert assertThrows(ResourceNotFoundException.class, () -> movieService.findSimilarMovies(movieId, 10)); } + + // ==================== GET DISTINCT GENRES TESTS ==================== + + @Test + @DisplayName("Should get distinct genres successfully") + void testGetDistinctGenres_Success() { + // Arrange + List expectedGenres = Arrays.asList("Action", "Comedy", "Drama", "Horror", "Sci-Fi"); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(expectedGenres); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(5, result.size()); + assertEquals("Action", result.get(0)); + assertEquals("Comedy", result.get(1)); + verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)); + } + + @Test + @DisplayName("Should return empty list when no genres exist") + void testGetDistinctGenres_EmptyList() { + // Arrange + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(Arrays.asList()); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(0, result.size()); + verify(mongoTemplate).findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class)); + } + + @Test + @DisplayName("Should filter out null and empty genres") + void testGetDistinctGenres_FiltersNullAndEmpty() { + // Arrange + List genresWithNulls = new ArrayList<>(Arrays.asList("Action", null, "", "Drama", "Comedy")); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(genresWithNulls); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + // The service should filter out null and empty values + assertEquals(3, result.size()); + assertTrue(result.contains("Action")); + assertTrue(result.contains("Drama")); + assertTrue(result.contains("Comedy")); + assertFalse(result.contains(null)); + assertFalse(result.contains("")); + } + + @Test + @DisplayName("Should return genres sorted alphabetically") + void testGetDistinctGenres_SortedAlphabetically() { + // Arrange + List unsortedGenres = Arrays.asList("Drama", "Action", "Comedy"); + when(mongoTemplate.findDistinct(any(Query.class), eq("genres"), eq(Movie.class), eq(String.class))) + .thenReturn(unsortedGenres); + + // Act + List result = movieService.getDistinctGenres(); + + // Assert + assertNotNull(result); + assertEquals(3, result.size()); + assertEquals("Action", result.get(0)); + assertEquals("Comedy", result.get(1)); + assertEquals("Drama", result.get(2)); + } } diff --git a/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts b/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts index 71702e2..65e4553 100644 --- a/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts +++ b/mflix/server/js-express/tests/integration/advancedEndpoints.integration.test.ts @@ -455,6 +455,53 @@ describeSearch("MongoDB Search Integration Tests", () => { expect(response.body.error).toBeDefined(); }); }); + + describe("GET /api/movies/genres", () => { + test("should return list of distinct genres", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toBeDefined(); + expect(Array.isArray(response.body.data)).toBe(true); + expect(response.body.data.length).toBeGreaterThan(0); + + // Verify genres are strings + response.body.data.forEach((genre: any) => { + expect(typeof genre).toBe("string"); + expect(genre.length).toBeGreaterThan(0); + }); + }); + + test("should return genres sorted alphabetically", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + const genres = response.body.data; + + // Verify alphabetical sorting + for (let i = 0; i < genres.length - 1; i++) { + expect(genres[i].localeCompare(genres[i + 1])).toBeLessThanOrEqual(0); + } + }); + + test("should include common genres like Action, Drama, Comedy", async () => { + const response = await request(app) + .get("/api/movies/genres") + .expect(200); + + expect(response.body.success).toBe(true); + const genres = response.body.data; + + // The sample_mflix dataset should contain these common genres + expect(genres).toContain("Action"); + expect(genres).toContain("Drama"); + expect(genres).toContain("Comedy"); + }); + }); }); diff --git a/mflix/server/python-fastapi/tests/test_movie_routes.py b/mflix/server/python-fastapi/tests/test_movie_routes.py index 5256fab..5d74779 100644 --- a/mflix/server/python-fastapi/tests/test_movie_routes.py +++ b/mflix/server/python-fastapi/tests/test_movie_routes.py @@ -1091,3 +1091,82 @@ async def test_aggregate_directors_empty_results(self, mock_execute_aggregation) # Assertions assert result.success is True assert len(result.data) == 0 + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestGetDistinctGenres: + """Tests for GET /api/movies/genres endpoint.""" + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_success(self, mock_get_collection): + """Should return list of distinct genres sorted alphabetically.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = ["Drama", "Action", "Comedy", "Horror", "Sci-Fi"] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 5 + # Verify alphabetical sorting + assert result.data == ["Action", "Comedy", "Drama", "Horror", "Sci-Fi"] + mock_collection.distinct.assert_called_once_with("genres") + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_empty_list(self, mock_get_collection): + """Should return empty list when no genres exist.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = [] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 0 + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_filters_null_and_empty(self, mock_get_collection): + """Should filter out null and empty genre values.""" + # Setup mock + mock_collection = AsyncMock() + mock_collection.distinct.return_value = ["Action", None, "", "Drama", "Comedy"] + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + result = await get_distinct_genres() + + # Assertions + assert result.success is True + assert len(result.data) == 3 + assert "Action" in result.data + assert "Drama" in result.data + assert "Comedy" in result.data + assert None not in result.data + assert "" not in result.data + + @patch('src.routers.movies.get_collection') + async def test_get_distinct_genres_database_error(self, mock_get_collection): + """Should handle database errors gracefully.""" + # Setup mock to raise exception + mock_collection = AsyncMock() + mock_collection.distinct.side_effect = Exception("Database connection failed") + mock_get_collection.return_value = mock_collection + + # Call the route handler + from src.routers.movies import get_distinct_genres + with pytest.raises(HTTPException) as exc_info: + await get_distinct_genres() + + # Assertions + assert exc_info.value.status_code == 500 + assert "Database error" in str(exc_info.value.detail)