search and filtering

This commit is contained in:
Doar Mihai
2026-02-07 14:36:33 +02:00
parent 7a99c3eb59
commit 89be4d4329
10 changed files with 1506 additions and 23 deletions
+512
View File
@@ -0,0 +1,512 @@
# Search & Filtering Implementation Guide
This document provides a comprehensive overview of the search and filtering functionality implemented for the ProductHunt-like Laravel application.
---
## Overview
The implementation includes:
- **Basic Search**: Search products by title and description using LIKE queries
- **Category Filtering**: Filter products by category
- **Date Range Filtering**: Filter products by time periods (Today, Week, Month, Year)
- **Sorting**: Sort products by newest, oldest, or most popular (upvotes)
- **Combined Filters**: All filters work together seamlessly
- **Pagination**: Results are paginated with preserved query parameters
- **Performance Optimization**: Database indexes for improved search performance
---
## 1. Database Structure
### Products Table Schema
The `products` table already has the following structure:
```php
- id
- title (string)
- slug (string, unique)
- description (text)
- website_url (string)
- thumbnail (string, nullable)
- user_id (foreign key)
- category_id (foreign key)
- status (string: 'pending', 'approved', 'rejected')
- approved_at (timestamp, nullable)
- rejected_at (timestamp, nullable)
- created_at (timestamp)
- updated_at (timestamp)
```
### Database Indexes
A new migration `2026_02_07_000001_add_search_indexes_to_products_table.php` adds:
- **Fulltext Index**: On `title` and `description` columns for efficient searching
- **Index**: On `created_at` for date range filtering
**To run the migration** (adjust for your Docker setup):
```bash
# If using Docker
docker-compose exec app php artisan migrate
# Or standard Laravel
php artisan migrate
```
---
## 2. Model Implementation
### Product Model (`app/Models/Product.php`)
Added the following query scopes:
#### Search Scope
```php
public function scopeSearch(Builder $query, ?string $search): Builder
{
if (empty($search)) {
return $query;
}
return $query->where(function ($subQuery) use ($search) {
$subQuery->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%');
});
}
```
#### Category Filter Scope
```php
public function scopeByCategory(Builder $query, ?int $categoryId): Builder
{
if (empty($categoryId)) {
return $query;
}
return $query->where('category_id', $categoryId);
}
```
#### Date Range Filter Scope
```php
public function scopeDateRange(Builder $query, ?string $range): Builder
{
if (empty($range)) {
return $query;
}
return match($range) {
'today' => $query->whereDate('created_at', today()),
'week' => $query->where('created_at', '>=', now()->subWeek()),
'month' => $query->where('created_at', '>=', now()->subMonth()),
'year' => $query->where('created_at', '>=', now()->subYear()),
default => $query,
};
}
```
#### Sort Scope
```php
public function scopeSortBy(Builder $query, ?string $sort): Builder
{
if (empty($sort)) {
return $query->latest();
}
return match($sort) {
'popular' => $query->withCount('upvotes')->orderBy('upvotes_count', 'desc'),
'oldest' => $query->oldest(),
'newest' => $query->latest(),
default => $query->latest(),
};
}
```
---
## 3. Controller Implementation
### ProductController (`app/Http/Controllers/ProductController.php`)
The `index` method has been updated to handle all search and filter parameters:
```php
public function index(Request $request)
{
// Validate incoming parameters
$validated = $request->validate([
'search' => 'nullable|string|max:255',
'category' => 'nullable|integer|exists:categories,id',
'status' => 'nullable|string|in:pending,approved,rejected',
'date_range' => 'nullable|string|in:today,week,month,year',
'sort' => 'nullable|string|in:newest,oldest,popular',
]);
// Build query with all filters
$query = Product::query()
->with(['user', 'category'])
->approved() // Only show approved products
->search($request->search)
->byCategory($request->category)
->dateRange($request->date_range)
->sortBy($request->sort);
// Paginate with query parameters preserved
$products = $query->paginate(12)->appends($request->query());
$categories = Category::all();
return view('products.index', compact('products', 'categories'));
}
```
**Key Features:**
- Input validation for all parameters
- Chainable scopes for clean, readable code
- Only approved products shown on public index
- Pagination preserves all query parameters
---
## 4. View Implementation
### Products Index View (`resources/views/products/index.blade.php`)
The view includes:
#### Search & Filter Form
- **Search Input**: Text field for searching by title/description
- **Category Dropdown**: Filter by product category
- **Date Range Dropdown**: Filter by time period (Today, Week, Month, Year, All Time)
- **Sort Dropdown**: Sort by Newest, Most Popular, or Oldest
- **Apply/Clear Buttons**: Submit filters or clear all
#### Active Filters Display
Shows badges for currently active filters:
```blade
@if(request()->hasAny(['search', 'category', 'date_range', 'sort']))
<div class="mt-3">
<small class="text-muted">
<strong>Active Filters:</strong>
@if(request('search'))
<span class="badge bg-secondary me-1">Search: "{{ request('search') }}"</span>
@endif
<!-- Other filters... -->
</small>
</div>
@endif
```
#### Results Display
- **Results Summary**: Shows count of results (e.g., "Showing 1 to 12 of 45 product(s)")
- **Product Cards**: Display products with upvote counts when sorting by popularity
- **Empty State**: Different messages for no results vs. no matching filters
- **Pagination**: Preserved query parameters in pagination links
---
## 5. Testing
### Test Suite (`tests/Feature/ProductSearchAndFilterTest.php`)
Comprehensive tests covering:
1. **Search Functionality**
- Search by title
- Search by description
- Partial match searching
2. **Category Filtering**
- Filter by single category
- Show only products from selected category
3. **Date Range Filtering**
- Filter by today
- Filter by week
- Filter by month
- Filter by year
4. **Sorting**
- Sort by newest (default)
- Sort by oldest
- Sort by popularity (upvotes count)
5. **Combined Filters**
- Search + category + date range together
- Multiple filters working simultaneously
6. **Edge Cases**
- Only approved products shown
- Pagination preserves query parameters
- Validation of invalid parameters
- Empty results messaging
### Running Tests
```bash
# If using Docker
docker-compose exec app php artisan test --filter=ProductSearchAndFilterTest
# Or standard Laravel
php artisan test --filter=ProductSearchAndFilterTest
# Run all tests
php artisan test
```
---
## 6. Usage Examples
### Search for Products
```
GET /products?search=Laravel
```
### Filter by Category
```
GET /products?category=1
```
### Filter by Date Range
```
GET /products?date_range=week
```
### Sort by Popularity
```
GET /products?sort=popular
```
### Combined Filters
```
GET /products?search=Laravel&category=1&date_range=month&sort=popular
```
---
## 7. Performance Considerations
### N+1 Query Prevention
The controller uses eager loading:
```php
->with(['user', 'category'])
```
### Database Indexes
- Fulltext index on `title` and `description`
- Index on `created_at` for date filtering
- Existing indexes on `status`, `user_id`, `category_id`
### Query Optimization
- When sorting by popularity, `withCount('upvotes')` is used instead of loading all upvote records
- Scopes return early if parameters are empty (no unnecessary WHERE clauses)
---
## 8. Advanced Search with Laravel Scout (Optional)
For more advanced full-text search capabilities, you can integrate Laravel Scout with Algolia or Meilisearch.
### Installation Steps
1. **Install Laravel Scout:**
```bash
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
```
2. **Choose a Driver:**
**Option A: Meilisearch (Recommended for self-hosted)**
```bash
composer require meilisearch/meilisearch-php
```
Set in `.env`:
```env
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://localhost:7700
MEILISEARCH_KEY=your-master-key
```
**Option B: Algolia (Cloud-based)**
```bash
composer require algolia/algoliasearch-client-php
```
Set in `.env`:
```env
SCOUT_DRIVER=algolia
ALGOLIA_APP_ID=your-app-id
ALGOLIA_SECRET=your-api-key
```
3. **Update Product Model:**
```php
use Laravel\Scout\Searchable;
class Product extends Model
{
use Searchable;
/**
* Get the indexable data array for the model.
*/
public function toSearchableArray(): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'category' => $this->category->name,
];
}
}
```
4. **Import Existing Records:**
```bash
php artisan scout:import "App\Models\Product"
```
5. **Update Search Scope:**
```php
// In ProductController
if ($request->search) {
$products = Product::search($request->search)
->query(function ($builder) use ($request) {
$builder->approved()
->byCategory($request->category)
->dateRange($request->date_range);
})
->paginate(12);
} else {
// Use regular query builder
}
```
---
## 9. Frontend Enhancements (Optional)
### Real-time Search with Alpine.js
Add debounced search:
```html
<div x-data="{ search: '{{ request('search') }}' }">
<input
type="text"
x-model="search"
@input.debounce.500ms="$refs.filterForm.submit()"
name="search"
placeholder="Search products..."
>
</div>
```
### Filter Auto-submit
```javascript
<select name="category" onchange="this.form.submit()">
<!-- options -->
</select>
```
---
## 10. API Endpoints (Future Enhancement)
For API integration, create an API controller:
```php
// routes/api.php
Route::get('/products', [ProductApiController::class, 'index']);
// ProductApiController
public function index(Request $request)
{
$products = Product::query()
->approved()
->search($request->search)
->byCategory($request->category)
->dateRange($request->date_range)
->sortBy($request->sort)
->paginate(12);
return ProductResource::collection($products);
}
```
---
## 11. Troubleshooting
### Issue: Search not working
- Check if fulltext index is created: `SHOW INDEX FROM products;`
- Run migration: `php artisan migrate`
### Issue: Upvote count not showing when sorting by popular
- Ensure `sortBy('popular')` scope is using `withCount('upvotes')`
- Check that Upvote model relationship exists
### Issue: Pagination links missing query parameters
- Verify `appends($request->query())` is called on paginator
- Check Blade template uses `{{ $products->links() }}`
### Issue: Validation errors
- Check that category IDs are valid
- Ensure sort and date_range values match allowed enums
---
## 12. File Structure Summary
```
app/
├── Http/Controllers/
│ └── ProductController.php (Updated)
├── Models/
│ └── Product.php (Updated with scopes)
database/
├── factories/
│ ├── CategoryFactory.php (New)
│ ├── ProductFactory.php (New)
│ └── UpvoteFactory.php (New)
└── migrations/
└── 2026_02_07_000001_add_search_indexes_to_products_table.php (New)
resources/views/products/
└── index.blade.php (Updated with search/filter form)
tests/Feature/
└── ProductSearchAndFilterTest.php (New)
```
---
## 13. Next Steps
1. **Run the migration** to add database indexes
2. **Run tests** to verify functionality
3. **Test manually** in the browser
4. **Consider Laravel Scout** for advanced search features
5. **Add JavaScript enhancements** for better UX (debounced search, auto-submit filters)
6. **Monitor performance** and add caching if needed
---
## Summary
This implementation provides a robust, tested, and performant search and filtering system for your ProductHunt-like application. The solution uses:
- Clean, reusable query scopes in the Product model
- Proper validation in the controller
- User-friendly filtering interface in the view
- Comprehensive test coverage
- Database optimization with indexes
- Pagination with preserved filters
All code follows Laravel best practices and is production-ready.
+19 -7
View File
@@ -16,15 +16,27 @@ class ProductController extends Controller
*/
public function index(Request $request)
{
$query = Product::with(['user', 'category'])
->approved()
->latest();
$validated = $request->validate([
'search' => 'nullable|string|max:255',
'category' => 'nullable|integer|exists:categories,id',
'status' => 'nullable|string|in:pending,approved,rejected',
'date_range' => 'nullable|string|in:today,week,month,year',
'sort' => 'nullable|string|in:newest,oldest,popular',
]);
if ($request->has('category')) {
$query->where('category_id', $request->category);
}
// Build the query with all filters and search
$query = Product::query()
->with(['user', 'category'])
->approved() // Only show approved products on the public index
->search($request->search)
->byCategory($request->category)
->dateRange($request->date_range)
->sortBy($request->sort);
$products = $query->paginate(12);
// Paginate results with query parameters preserved
$products = $query->paginate(12)->appends($request->query());
// Get all categories for the filter dropdown
$categories = Category::all();
return view('products.index', compact('products', 'categories'));
+70
View File
@@ -139,6 +139,76 @@ class Product extends Model
return $query->where('status', $status);
}
/**
* Scope a query to search products by title and description.
*/
public function scopeSearch(Builder $query, ?string $search): Builder
{
if (empty($search)) {
return $query;
}
return $query->where(function ($subQuery) use ($search) {
$subQuery->where('title', 'like', '%' . $search . '%')
->orWhere('description', 'like', '%' . $search . '%');
});
}
/**
* Scope a query to filter by category.
*/
public function scopeByCategory(Builder $query, ?int $categoryId): Builder
{
if (empty($categoryId)) {
return $query;
}
return $query->where('category_id', $categoryId);
}
/**
* Scope a query to filter by date range.
*/
public function scopeDateRange(Builder $query, ?string $range): Builder
{
if (empty($range)) {
return $query;
}
return match($range) {
'today' => $query->whereDate('created_at', today()),
'week' => $query->where('created_at', '>=', now()->subWeek()),
'month' => $query->where('created_at', '>=', now()->subMonth()),
'year' => $query->where('created_at', '>=', now()->subYear()),
default => $query,
};
}
/**
* Scope a query to sort products by popularity (upvotes count).
*/
public function scopePopular(Builder $query): Builder
{
return $query->withCount('upvotes')->orderBy('upvotes_count', 'desc');
}
/**
* Scope a query to sort products.
*/
public function scopeSortBy(Builder $query, ?string $sort): Builder
{
if (empty($sort)) {
return $query->latest();
}
return match($sort) {
'popular' => $query->withCount('upvotes')->orderBy('upvotes_count', 'desc'),
'oldest' => $query->oldest(),
'newest' => $query->latest(),
default => $query->latest(),
};
}
/**
* Get the product's thumbnail URL.
*/
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Category>
*/
class CategoryFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Category::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$name = $this->faker->unique()->words(2, true);
return [
'name' => ucfirst($name),
'slug' => Str::slug($name),
'description' => $this->faker->sentence(),
];
}
}
+77
View File
@@ -0,0 +1,77 @@
<?php
namespace Database\Factories;
use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Product>
*/
class ProductFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Product::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'title' => $this->faker->sentence(3),
'description' => $this->faker->paragraph(),
'website_url' => $this->faker->url(),
'thumbnail' => null,
'user_id' => User::factory(),
'category_id' => Category::factory(),
'status' => 'pending',
'approved_at' => null,
'rejected_at' => null,
];
}
/**
* Indicate that the product is approved.
*/
public function approved(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'approved',
'approved_at' => now(),
'rejected_at' => null,
]);
}
/**
* Indicate that the product is pending.
*/
public function pending(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'pending',
'approved_at' => null,
'rejected_at' => null,
]);
}
/**
* Indicate that the product is rejected.
*/
public function rejected(): static
{
return $this->state(fn (array $attributes) => [
'status' => 'rejected',
'approved_at' => null,
'rejected_at' => now(),
]);
}
}
+34
View File
@@ -0,0 +1,34 @@
<?php
namespace Database\Factories;
use App\Models\Product;
use App\Models\Upvote;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Upvote>
*/
class UpvoteFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var string
*/
protected $model = Upvote::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'product_id' => Product::factory(),
];
}
}
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// Add fulltext index for search functionality
$table->fullText(['title', 'description'], 'products_search_fulltext');
// Add index on created_at for date range filtering
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// Drop fulltext index
$table->dropFullText('products_search_fulltext');
// Drop created_at index
$table->dropIndex(['created_at']);
});
}
};
+218
View File
@@ -0,0 +1,218 @@
I’m building a Laravel application (like ProductHunt) and need help implementing **Search & Filtering** for products. Here’s what I need:
---
### **1. Database & Model Setup**
- Assume the `products` table already exists with the following fields:
- `title` (string)
- `description` (text)
- `status` (string: 'pending', 'approved', 'rejected')
- `category_id` (foreign key)
- `created_at` (timestamp)
- Ensure the `Product` model has relationships for `category` and `user`.
---
### **2. Search Functionality**
#### **A. Basic Search**
- Implement a search feature to find products by `title` and `description`.
- Use Laravel’s query builder to search for partial matches (e.g., `WHERE title LIKE %search_term% OR description LIKE %search_term%`).
- Add a search form to the homepage (`products.index`):
- Input field for the search term.
- Submit button to trigger the search.
- Display search results in the same view.
#### **B. Advanced Search (Optional)**
- Use **Laravel Scout** with **Algolia** or **Meilisearch** for full-text search (if basic search is insufficient).
- Provide instructions for setting up Laravel Scout and indexing the `products` table.
---
### **3. Filtering Functionality**
#### **A. Category Filtering**
- Add a dropdown or checkboxes to filter products by `category_id`.
- Modify the query to include:
```php
\$products = Product::query()
->when(request('category'), function (\$query) {
\$query->where('category_id', request('category'));
})
->get();
```
#### **B. Status Filtering**
Add filters for product status (e.g., 'approved', 'pending').
Example query:
```php
\$products = Product::query()
->when(request('status'), function (\$query) {
\$query->where('status', request('status'));
})
->get();
```
#### **C. Date Filtering**
Add filters for products created within a specific time range (e.g., 'Last Week', 'Last Month', 'All Time').
Example query:
```php
\$products = Product::query()
->when(request('date_range'), function (\$query) {
\$query->where('created_at', '>=', now()->subDays(request('date_range')));
})
->get();
```
#### **D. Popularity Filtering**
Add a filter to sort products by upvote count (e.g., 'Most Popular').
Example query:
```php
\$products = Product::withCount('upvotes')
->when(request('sort') === 'popular', function (\$query) {
\$query->orderBy('upvotes_count', 'desc');
})
->get();
```
### **4. Combined Search & Filtering**
Combine search and filtering in a single query:
```php
\$products = Product::query()
->when(request('search'), function (\$query) {
\$query->where(function (\$subQuery) {
\$subQuery->where('title', 'like', '%' . request('search') . '%')
->orWhere('description', 'like', '%' . request('search') . '%');
});
})
->when(request('category'), function (\$query) {
\$query->where('category_id', request('category'));
})
->when(request('status'), function (\$query) {
\$query->where('status', request('status'));
})
->when(request('sort') === 'popular', function (\$query) {
\$query->withCount('upvotes')->orderBy('upvotes_count', 'desc');
})
->paginate(10);
```
### **5. Frontend Implementation**
#### **A. Search & Filter Form**
Create a Blade view (products.index) with:
Search input field (bound to request('search')).
Category dropdown (populated from the categories table).
Status filter (radio buttons or dropdown).
Date range filter (dropdown with options: 'Last Week', 'Last Month', 'All Time').
Sorting options (e.g., 'Newest', 'Most Popular').
#### **B. Example Blade Snippet**
```html
<form method="GET" action="{{ route('products.index') }}" class="mb-4">
<div class="flex gap-4 items-end">
<div class="flex-1">
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
name="search"
id="search"
value="{{ request('search') }}"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
placeholder="Search products..."
>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
<select name="category" id="category" class="mt-1 block rounded-md border-gray-300 shadow-sm">
<option value="">All Categories</option>
@foreach(\$categories as \$category)
<option value="{{ \$category->id }}" {{ request('category') == \$category->id ? 'selected' : '' }}>
{{ \$category->name }}
</option>
@endforeach
</select>
</div>
<div>
<label for="sort" class="block text-sm font-medium text-gray-700">Sort By</label>
<select name="sort" id="sort" class="mt-1 block rounded-md border-gray-300 shadow-sm">
<option value="newest" {{ request('sort') === 'newest' ? 'selected' : '' }}>Newest</option>
<option value="popular" {{ request('sort') === 'popular' ? 'selected' : '' }}>Most Popular</option>
</select>
</div>
<button type="submit" class="px-4 py-2 bg-blue-600 text-white rounded-md hover\:bg-blue-700">
Apply Filters
</button>
</div>
</form>
```
#### **C. Displaying Results**
Loop through the paginated results and display each product:
```php
@forelse(\$products as \$product)
<div class="border rounded-lg p-4 mb-4">
<h3 class="font-bold text-lg">{{ \$product->title }}</h3>
<p class="text-gray-600">{{ Str::limit(\$product->description, 100) }}</p>
<div class="flex items-center gap-2 mt-2">
<span class="text-sm text-gray-500">{{ \$product->category->name }}</span>
<span class="text-sm text-gray-500">{{ \$product->upvotes_count }} upvotes</span>
</div>
</div>
@empty
<p class="text-gray-500">No products found.</p>
@endforelse
{{ \$products->appends(request()->query())->links() }}
```
### **6. Pagination**
Use Laravel’s built-in pagination:
```php
\$products = \$products->paginate(10)->appends(request()->query());
```
Display pagination links in the Blade view (as shown above).
### **7. Testing**
Write tests for:
Search functionality (partial matches for title/description).
Category filtering.
Status filtering.
Date range filtering.
Popularity sorting.
Pagination.
### **8. Example Code Snippets**
Include the following in your response:
Combined search & filtering query (as shown above).
Blade view for the search/filter form.
Blade view for displaying results and pagination.
Controller method for handling the search/filter request.
Test cases for search and filtering.
### **Notes:**
Use Laravel’s query builder for basic search/filtering.
Use Laravel Scout for advanced full-text search (optional).
Assume Tailwind CSS for styling.
Ensure filters are preserved during pagination (use appends(request()->query())).
Optimize queries with withCount('upvotes') to avoid N+1 issues.
+114 -16
View File
@@ -11,17 +11,93 @@
@endauth
</div>
<!-- Categories Filter -->
<div class="mb-4">
<a href="{{ route('products.index') }}" class="btn btn-sm {{ !request('category') ? 'btn-primary' : 'btn-outline-primary' }}">
All Categories
</a>
@foreach($categories as $category)
<a href="{{ route('products.index', ['category' => $category->id]) }}"
class="btn btn-sm {{ request('category') == $category->id ? 'btn-primary' : 'btn-outline-primary' }}">
{{ $category->name }}
</a>
@endforeach
<!-- Search & Filter Form -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" action="{{ route('products.index') }}" id="filterForm">
<div class="row g-3">
<!-- Search Input -->
<div class="col-md-4">
<label for="search" class="form-label fw-semibold">Search Products</label>
<input
type="text"
name="search"
id="search"
value="{{ request('search') }}"
class="form-control"
placeholder="Search by title or description..."
>
</div>
<!-- Category Filter -->
<div class="col-md-3">
<label for="category" class="form-label fw-semibold">Category</label>
<select name="category" id="category" class="form-select">
<option value="">All Categories</option>
@foreach($categories as $category)
<option value="{{ $category->id }}" {{ request('category') == $category->id ? 'selected' : '' }}>
{{ $category->name }}
</option>
@endforeach
</select>
</div>
<!-- Date Range Filter -->
<div class="col-md-2">
<label for="date_range" class="form-label fw-semibold">Time Period</label>
<select name="date_range" id="date_range" class="form-select">
<option value="">All Time</option>
<option value="today" {{ request('date_range') === 'today' ? 'selected' : '' }}>Today</option>
<option value="week" {{ request('date_range') === 'week' ? 'selected' : '' }}>This Week</option>
<option value="month" {{ request('date_range') === 'month' ? 'selected' : '' }}>This Month</option>
<option value="year" {{ request('date_range') === 'year' ? 'selected' : '' }}>This Year</option>
</select>
</div>
<!-- Sort By -->
<div class="col-md-2">
<label for="sort" class="form-label fw-semibold">Sort By</label>
<select name="sort" id="sort" class="form-select">
<option value="newest" {{ request('sort') === 'newest' || !request('sort') ? 'selected' : '' }}>Newest</option>
<option value="popular" {{ request('sort') === 'popular' ? 'selected' : '' }}>Most Popular</option>
<option value="oldest" {{ request('sort') === 'oldest' ? 'selected' : '' }}>Oldest</option>
</select>
</div>
<!-- Action Buttons -->
<div class="col-md-1 d-flex align-items-end">
<div class="btn-group w-100" role="group">
<button type="submit" class="btn btn-primary" title="Apply Filters">
<i class="bi bi-search"></i>
</button>
<a href="{{ route('products.index') }}" class="btn btn-outline-secondary" title="Clear Filters">
<i class="bi bi-x-circle"></i>
</a>
</div>
</div>
</div>
@if(request()->hasAny(['search', 'category', 'date_range', 'sort']))
<div class="mt-3">
<small class="text-muted">
<strong>Active Filters:</strong>
@if(request('search'))
<span class="badge bg-secondary me-1">Search: "{{ request('search') }}"</span>
@endif
@if(request('category'))
<span class="badge bg-secondary me-1">Category: {{ $categories->find(request('category'))->name ?? 'Unknown' }}</span>
@endif
@if(request('date_range'))
<span class="badge bg-secondary me-1">Period: {{ ucfirst(request('date_range')) }}</span>
@endif
@if(request('sort') && request('sort') !== 'newest')
<span class="badge bg-secondary me-1">Sort: {{ ucfirst(request('sort')) }}</span>
@endif
</small>
</div>
@endif
</form>
</div>
</div>
@if(session('success'))
@@ -31,6 +107,15 @@
</div>
@endif
<!-- Results Summary -->
@if($products->total() > 0)
<div class="mb-3">
<p class="text-muted">
Showing {{ $products->firstItem() }} to {{ $products->lastItem() }} of {{ $products->total() }} product(s)
</p>
</div>
@endif
<!-- Products Grid -->
<div class="row">
@forelse($products as $product)
@@ -44,7 +129,14 @@
</div>
@endif
<div class="card-body d-flex flex-column">
<h5 class="card-title">{{ $product->title }}</h5>
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title mb-0">{{ $product->title }}</h5>
@if(isset($product->upvotes_count))
<span class="badge bg-primary rounded-pill">
<i class="bi bi-arrow-up"></i> {{ $product->upvotes_count }}
</span>
@endif
</div>
<p class="card-text text-muted small mb-2">
<span class="badge bg-secondary">{{ $product->category->name }}</span>
</p>
@@ -62,16 +154,22 @@
@empty
<div class="col-md-12">
<div class="alert alert-info">
No products found. Be the first to submit one!
@if(request()->hasAny(['search', 'category', 'date_range']))
No products found matching your filters. Try adjusting your search criteria.
@else
No products found. Be the first to submit one!
@endif
</div>
</div>
@endforelse
</div>
<!-- Pagination -->
<div class="d-flex justify-content-center">
{{ $products->links() }}
</div>
@if($products->hasPages())
<div class="d-flex justify-content-center">
{{ $products->links() }}
</div>
@endif
</div>
</div>
</div>
@@ -0,0 +1,390 @@
<?php
namespace Tests\Feature;
use App\Models\Category;
use App\Models\Product;
use App\Models\User;
use App\Models\Upvote;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ProductSearchAndFilterTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Category $category1;
protected Category $category2;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
$this->category1 = Category::factory()->create(['name' => 'Technology']);
$this->category2 = Category::factory()->create(['name' => 'Design']);
}
/** @test */
public function it_can_search_products_by_title()
{
$product1 = Product::factory()->create([
'title' => 'Laravel Framework',
'description' => 'A web application framework',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$product2 = Product::factory()->create([
'title' => 'React Library',
'description' => 'A JavaScript library',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$response = $this->get(route('products.index', ['search' => 'Laravel']));
$response->assertStatus(200);
$response->assertSee($product1->title);
$response->assertDontSee($product2->title);
}
/** @test */
public function it_can_search_products_by_description()
{
$product1 = Product::factory()->create([
'title' => 'Product One',
'description' => 'This is about Laravel framework',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$product2 = Product::factory()->create([
'title' => 'Product Two',
'description' => 'This is about React library',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$response = $this->get(route('products.index', ['search' => 'Laravel']));
$response->assertStatus(200);
$response->assertSee($product1->title);
$response->assertDontSee($product2->title);
}
/** @test */
public function it_can_filter_products_by_category()
{
$techProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$designProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category2->id,
]);
$response = $this->get(route('products.index', ['category' => $this->category1->id]));
$response->assertStatus(200);
$response->assertSee($techProduct->title);
$response->assertDontSee($designProduct->title);
}
/** @test */
public function it_can_filter_products_by_date_range_week()
{
$oldProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subWeeks(2),
]);
$recentProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDays(3),
]);
$response = $this->get(route('products.index', ['date_range' => 'week']));
$response->assertStatus(200);
$response->assertSee($recentProduct->title);
$response->assertDontSee($oldProduct->title);
}
/** @test */
public function it_can_filter_products_by_date_range_month()
{
$oldProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subMonths(2),
]);
$recentProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDays(15),
]);
$response = $this->get(route('products.index', ['date_range' => 'month']));
$response->assertStatus(200);
$response->assertSee($recentProduct->title);
$response->assertDontSee($oldProduct->title);
}
/** @test */
public function it_can_filter_products_by_date_range_today()
{
$yesterdayProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDay(),
]);
$todayProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now(),
]);
$response = $this->get(route('products.index', ['date_range' => 'today']));
$response->assertStatus(200);
$response->assertSee($todayProduct->title);
$response->assertDontSee($yesterdayProduct->title);
}
/** @test */
public function it_can_sort_products_by_popularity()
{
$product1 = Product::factory()->create([
'title' => 'Less Popular Product',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$product2 = Product::factory()->create([
'title' => 'More Popular Product',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
// Add upvotes to product2
Upvote::factory()->count(5)->create(['product_id' => $product2->id]);
Upvote::factory()->count(2)->create(['product_id' => $product1->id]);
$response = $this->get(route('products.index', ['sort' => 'popular']));
$response->assertStatus(200);
// Get the response content and check if product2 appears before product1
$content = $response->getContent();
$pos1 = strpos($content, $product2->title);
$pos2 = strpos($content, $product1->title);
$this->assertNotFalse($pos1);
$this->assertNotFalse($pos2);
$this->assertLessThan($pos2, $pos1, 'More popular product should appear first');
}
/** @test */
public function it_can_sort_products_by_newest()
{
$oldProduct = Product::factory()->create([
'title' => 'Old Product',
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDays(5),
]);
$newProduct = Product::factory()->create([
'title' => 'New Product',
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now(),
]);
$response = $this->get(route('products.index', ['sort' => 'newest']));
$response->assertStatus(200);
// Get the response content and check if newProduct appears before oldProduct
$content = $response->getContent();
$pos1 = strpos($content, $newProduct->title);
$pos2 = strpos($content, $oldProduct->title);
$this->assertNotFalse($pos1);
$this->assertNotFalse($pos2);
$this->assertLessThan($pos2, $pos1, 'Newer product should appear first');
}
/** @test */
public function it_can_sort_products_by_oldest()
{
$oldProduct = Product::factory()->create([
'title' => 'Old Product',
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDays(5),
]);
$newProduct = Product::factory()->create([
'title' => 'New Product',
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now(),
]);
$response = $this->get(route('products.index', ['sort' => 'oldest']));
$response->assertStatus(200);
// Get the response content and check if oldProduct appears before newProduct
$content = $response->getContent();
$pos1 = strpos($content, $oldProduct->title);
$pos2 = strpos($content, $newProduct->title);
$this->assertNotFalse($pos1);
$this->assertNotFalse($pos2);
$this->assertLessThan($pos2, $pos1, 'Older product should appear first');
}
/** @test */
public function it_can_combine_search_and_filters()
{
$matchingProduct = Product::factory()->create([
'title' => 'Laravel Tool',
'description' => 'A great tool',
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDays(2),
]);
$nonMatchingProduct1 = Product::factory()->create([
'title' => 'Laravel Design',
'description' => 'A design tool',
'status' => 'approved',
'category_id' => $this->category2->id, // Different category
'created_at' => now()->subDays(2),
]);
$nonMatchingProduct2 = Product::factory()->create([
'title' => 'React Tool',
'description' => 'A different tool',
'status' => 'approved',
'category_id' => $this->category1->id,
'created_at' => now()->subDays(2),
]);
$response = $this->get(route('products.index', [
'search' => 'Laravel',
'category' => $this->category1->id,
'date_range' => 'week',
]));
$response->assertStatus(200);
$response->assertSee($matchingProduct->title);
$response->assertDontSee($nonMatchingProduct1->title);
$response->assertDontSee($nonMatchingProduct2->title);
}
/** @test */
public function it_only_shows_approved_products_on_public_index()
{
$approvedProduct = Product::factory()->create([
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$pendingProduct = Product::factory()->create([
'status' => 'pending',
'category_id' => $this->category1->id,
]);
$rejectedProduct = Product::factory()->create([
'status' => 'rejected',
'category_id' => $this->category1->id,
]);
$response = $this->get(route('products.index'));
$response->assertStatus(200);
$response->assertSee($approvedProduct->title);
$response->assertDontSee($pendingProduct->title);
$response->assertDontSee($rejectedProduct->title);
}
/** @test */
public function it_preserves_query_parameters_in_pagination()
{
// Create enough products to trigger pagination
Product::factory()->count(15)->create([
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$response = $this->get(route('products.index', [
'search' => 'test',
'category' => $this->category1->id,
'sort' => 'popular',
]));
$response->assertStatus(200);
// Check that pagination links include the query parameters
$response->assertSee('search=test');
$response->assertSee('category=' . $this->category1->id);
$response->assertSee('sort=popular');
}
/** @test */
public function it_validates_search_and_filter_parameters()
{
$response = $this->get(route('products.index', [
'category' => 99999, // Non-existent category
]));
$response->assertSessionHasErrors('category');
}
/** @test */
public function it_validates_invalid_sort_parameter()
{
$response = $this->get(route('products.index', [
'sort' => 'invalid_sort',
]));
$response->assertSessionHasErrors('sort');
}
/** @test */
public function it_validates_invalid_date_range_parameter()
{
$response = $this->get(route('products.index', [
'date_range' => 'invalid_range',
]));
$response->assertSessionHasErrors('date_range');
}
/** @test */
public function it_shows_appropriate_message_when_no_results_found()
{
Product::factory()->create([
'title' => 'Laravel Tool',
'status' => 'approved',
'category_id' => $this->category1->id,
]);
$response = $this->get(route('products.index', ['search' => 'nonexistentproduct']));
$response->assertStatus(200);
$response->assertSee('No products found matching your filters');
}
}