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
145 changes: 145 additions & 0 deletions client/e2e-tests/filtering.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { test, expect } from '@playwright/test';

test.describe('Game Filtering Feature', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await expect(page.getByTestId('games-grid')).toBeVisible();
});

test('should display filter controls on the page', async ({ page }) => {
await test.step('Verify filter container is visible', async () => {
await expect(page.getByTestId('game-filters')).toBeVisible();
});

await test.step('Verify publisher filter dropdown is available', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
await expect(publisherFilter).toBeVisible();
await expect(page.getByLabel('Publisher')).toBeVisible();
});

await test.step('Verify category filter dropdown is available', async () => {
const categoryFilter = page.getByTestId('category-filter');
await expect(categoryFilter).toBeVisible();
await expect(page.getByLabel('Category')).toBeVisible();
});
});

test('should filter games by publisher', async ({ page }) => {
const initialGameCount = await page.getByTestId('game-card').count();

await test.step('Select a publisher from the dropdown', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
await publisherFilter.selectOption({ index: 1 });
});

await test.step('Verify games are filtered', async () => {
// Wait for games grid to be visible after filter
await expect(page.getByTestId('games-grid')).toBeVisible();
const filteredGameCount = await page.getByTestId('game-card').count();
expect(filteredGameCount).toBeLessThanOrEqual(initialGameCount);
expect(filteredGameCount).toBeGreaterThan(0);
});
});

test('should filter games by category', async ({ page }) => {
const initialGameCount = await page.getByTestId('game-card').count();

await test.step('Select a category from the dropdown', async () => {
const categoryFilter = page.getByTestId('category-filter');
await categoryFilter.selectOption({ index: 1 });
});

await test.step('Verify games are filtered', async () => {
// Wait for games grid to be visible after filter
await expect(page.getByTestId('games-grid')).toBeVisible();
const filteredGameCount = await page.getByTestId('game-card').count();
expect(filteredGameCount).toBeLessThanOrEqual(initialGameCount);
expect(filteredGameCount).toBeGreaterThan(0);
});
});

test('should filter games by both publisher and category', async ({ page }) => {
await test.step('Select a publisher from the dropdown', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
await publisherFilter.selectOption({ index: 1 });
});

await test.step('Wait for publisher filter to apply', async () => {
await expect(page.getByTestId('games-grid')).toBeVisible();
});

await test.step('Select a category from the dropdown', async () => {
const categoryFilter = page.getByTestId('category-filter');
await categoryFilter.selectOption({ index: 1 });
});

await test.step('Verify games are filtered by both criteria', async () => {
// Wait for either games grid or empty state to appear after filter
await expect(page.getByTestId('games-grid').or(page.getByText('No games match your filters'))).toBeVisible();
});
});

test('should show clear filters button when filters are active', async ({ page }) => {
await test.step('Verify clear button is not visible initially', async () => {
await expect(page.getByTestId('clear-filters-button')).not.toBeVisible();
});

await test.step('Apply a filter', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
await publisherFilter.selectOption({ index: 1 });
});

await test.step('Verify clear button appears', async () => {
await expect(page.getByTestId('clear-filters-button')).toBeVisible();
});
});

test('should clear all filters when clicking clear button', async ({ page }) => {
const initialGameCount = await page.getByTestId('game-card').count();

await test.step('Apply a filter', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
await publisherFilter.selectOption({ index: 1 });
await expect(page.getByTestId('clear-filters-button')).toBeVisible();
});

await test.step('Click clear filters button', async () => {
await page.getByTestId('clear-filters-button').click();
});

await test.step('Verify all games are shown again', async () => {
// Wait for games to reload
await expect(page.getByTestId('games-grid')).toBeVisible();
await expect(page.getByTestId('game-card')).toHaveCount(initialGameCount);
});

await test.step('Verify dropdowns are reset to default', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
const categoryFilter = page.getByTestId('category-filter');
await expect(publisherFilter).toHaveValue('');
await expect(categoryFilter).toHaveValue('');
});

await test.step('Verify clear button is hidden', async () => {
await expect(page.getByTestId('clear-filters-button')).not.toBeVisible();
});
});

test('should have accessible filter controls', async ({ page }) => {
await test.step('Verify publisher filter has accessible label', async () => {
const publisherLabel = page.getByLabel('Publisher');
await expect(publisherLabel).toBeVisible();
});

await test.step('Verify category filter has accessible label', async () => {
const categoryLabel = page.getByLabel('Category');
await expect(categoryLabel).toBeVisible();
});

await test.step('Verify filters can be keyboard navigated', async () => {
const publisherFilter = page.getByTestId('publisher-filter');
await publisherFilter.focus();
await expect(publisherFilter).toBeFocused();
});
});
});
95 changes: 95 additions & 0 deletions client/src/components/GameFilters.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<script lang="ts">
interface Publisher {
id: number;
name: string;
}

interface Category {
id: number;
name: string;
}

let {
publishers = [],
categories = [],
selectedPublisherId = $bindable<number | null>(null),
selectedCategoryId = $bindable<number | null>(null),
onFilterChange
}: {
publishers: Publisher[];
categories: Category[];
selectedPublisherId?: number | null;
selectedCategoryId?: number | null;
onFilterChange: () => void;
} = $props();

function handlePublisherChange(event: Event) {
const target = event.target as HTMLSelectElement;
selectedPublisherId = target.value ? parseInt(target.value) : null;
onFilterChange();
}

function handleCategoryChange(event: Event) {
const target = event.target as HTMLSelectElement;
selectedCategoryId = target.value ? parseInt(target.value) : null;
onFilterChange();
}

function clearFilters() {
selectedPublisherId = null;
selectedCategoryId = null;
onFilterChange();
}

let hasActiveFilters = $derived(selectedPublisherId !== null || selectedCategoryId !== null);
</script>

<div class="bg-slate-800 rounded-xl p-4 mb-6 border border-slate-700" data-testid="game-filters">
<div class="flex flex-wrap gap-4 items-end">
<div class="flex-1 min-w-[200px]">
<label for="publisher-filter" class="block text-sm font-medium text-slate-300 mb-2">
Publisher
</label>
<select
id="publisher-filter"
class="w-full bg-slate-700 text-slate-100 rounded-lg px-3 py-2 border border-slate-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={selectedPublisherId ?? ''}
onchange={handlePublisherChange}
data-testid="publisher-filter"
>
<option value="">All Publishers</option>
{#each publishers as publisher (publisher.id)}
<option value={publisher.id}>{publisher.name}</option>
{/each}
</select>
</div>

<div class="flex-1 min-w-[200px]">
<label for="category-filter" class="block text-sm font-medium text-slate-300 mb-2">
Category
</label>
<select
id="category-filter"
class="w-full bg-slate-700 text-slate-100 rounded-lg px-3 py-2 border border-slate-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={selectedCategoryId ?? ''}
onchange={handleCategoryChange}
data-testid="category-filter"
>
<option value="">All Categories</option>
{#each categories as category (category.id)}
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>

{#if hasActiveFilters}
<button
onclick={clearFilters}
class="px-4 py-2 bg-slate-600 hover:bg-slate-500 text-slate-100 rounded-lg transition-colors duration-200 focus:ring-2 focus:ring-blue-500"
data-testid="clear-filters-button"
>
Clear Filters
</button>
{/if}
</div>
</div>
56 changes: 54 additions & 2 deletions client/src/components/GameList.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import GameCard from "./GameCard.svelte";
import GameFilters from "./GameFilters.svelte";
import LoadingSkeleton from "./LoadingSkeleton.svelte";
import ErrorMessage from "./ErrorMessage.svelte";
import EmptyState from "./EmptyState.svelte";
Expand All @@ -13,14 +14,38 @@
category_name?: string;
}

interface Publisher {
id: number;
name: string;
}

interface Category {
id: number;
name: string;
}

let { games = $bindable([]) }: { games?: Game[] } = $props();
let loading = $state(true);
let error = $state<string | null>(null);
let publishers = $state<Publisher[]>([]);
let categories = $state<Category[]>([]);
let selectedPublisherId = $state<number | null>(null);
let selectedCategoryId = $state<number | null>(null);

const fetchGames = async () => {
loading = true;
try {
const response = await fetch('/api/games');
// Build URL with filter parameters
const params = new URLSearchParams();
if (selectedPublisherId !== null) {
params.append('publisher_id', selectedPublisherId.toString());
}
if (selectedCategoryId !== null) {
params.append('category_id', selectedCategoryId.toString());
}

const url = params.toString() ? `/api/games?${params}` : '/api/games';
const response = await fetch(url);
if(response.ok) {
games = await response.json();
} else {
Expand All @@ -33,20 +58,47 @@
}
};

const fetchFilterOptions = async () => {
try {
const [publishersRes, categoriesRes] = await Promise.all([
fetch('/api/publishers'),
fetch('/api/categories')
]);

if (publishersRes.ok) {
publishers = await publishersRes.json();
}
if (categoriesRes.ok) {
categories = await categoriesRes.json();
}
} catch (err) {
console.error('Failed to fetch filter options:', err);
}
};

onMount(() => {
fetchFilterOptions();
fetchGames();
});
</script>

<div>
<h2 class="text-2xl font-medium mb-6 text-slate-100">Featured Games</h2>

<GameFilters
{publishers}
{categories}
bind:selectedPublisherId
bind:selectedCategoryId
onFilterChange={fetchGames}
/>

{#if loading}
<LoadingSkeleton count={6} />
{:else if error}
<ErrorMessage error={error} />
{:else if games.length === 0}
<EmptyState message="No games available at the moment." />
<EmptyState message="No games match your filters." />
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6" data-testid="games-grid">
{#each games as game (game.id)}
Expand Down
30 changes: 26 additions & 4 deletions server/routes/games.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from flask import jsonify, Response, Blueprint
from flask import jsonify, request, Response, Blueprint
from models import db, Game, Publisher, Category
from sqlalchemy.orm import Query

Expand All @@ -18,14 +18,36 @@ def get_games_base_query() -> Query:

@games_bp.route('/api/games', methods=['GET'])
def get_games() -> Response:
# Use the base query for all games
games_query = get_games_base_query().all()
"""Get all games with optional filtering by publisher_id and category_id."""
query = get_games_base_query()

# Convert the results using the model's to_dict method
# Apply publisher filter if provided
publisher_id = request.args.get('publisher_id', type=int)
if publisher_id is not None:
query = query.filter(Game.publisher_id == publisher_id)

# Apply category filter if provided
category_id = request.args.get('category_id', type=int)
if category_id is not None:
query = query.filter(Game.category_id == category_id)

games_query = query.all()
games_list = [game.to_dict() for game in games_query]

return jsonify(games_list)

@games_bp.route('/api/publishers', methods=['GET'])
def get_publishers() -> Response:
"""Get all publishers for filter dropdown."""
publishers = db.session.query(Publisher).order_by(Publisher.name).all()
return jsonify([publisher.to_dict() for publisher in publishers])

@games_bp.route('/api/categories', methods=['GET'])
def get_categories() -> Response:
"""Get all categories for filter dropdown."""
categories = db.session.query(Category).order_by(Category.name).all()
return jsonify([category.to_dict() for category in categories])

@games_bp.route('/api/games/<int:id>', methods=['GET'])
def get_game(id: int) -> tuple[Response, int] | Response:
# Use the base query and add filter for specific game
Expand Down
Loading
Loading