Skip to content
Merged
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
22 changes: 22 additions & 0 deletions app/Filament/Resources/ArticleResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;

/**
* Ressource Filament de gestion des articles du blog.
Expand Down Expand Up @@ -117,6 +118,23 @@ public static function form(Schema $schema): Schema
->suffix('min'),
Forms\Components\Toggle::make('featured')
->label('Article mis en avant'),
Forms\Components\Toggle::make('is_pinned')
->label('Épingler l\'article')
->helperText('Affiche l\'article en tête de page d\'accueil (maximum 10).')
->rules([
function (string $attribute, $value, \Closure $fail): void {
if ($value === true) {
$record = request()->route('record');
$recordId = $record instanceof Model ? $record->getKey() : $record;
$count = Article::query()->where('is_pinned', true)
->when($recordId, fn ($query) => $query->where('id', '!=', $recordId))
->count();
if ($count >= 10) {
$fail('Vous ne pouvez pas épingler plus de 10 articles.');
}
}
},
]),
Forms\Components\DateTimePicker::make('published_at')
->label('Date de publication'),
]),
Expand Down Expand Up @@ -199,6 +217,10 @@ public static function table(Table $table): Table
}),
Tables\Columns\IconColumn::make('featured')
->boolean(),
Tables\Columns\IconColumn::make('is_pinned')
->label('Épinglé')
->boolean()
->sortable(),
Tables\Columns\TextColumn::make('published_at')
->date('d/m/Y')
->sortable(),
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/Api/V1/ArticleController.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,14 @@ public function index(ListArticlesRequest $request): ArticleCollection
$tag = $validated['tag'] ?? null;
$page = $validated['page'] ?? 1;
$pageSize = $validated['pageSize'] ?? 10;
$isPinned = $validated['is_pinned'] ?? null;

$paginated = $this->articleService->listPublished(
category: $category,
tag: $tag,
page: $page,
pageSize: $pageSize,
isPinned: $isPinned,
);

return new ArticleCollection($paginated);
Expand Down
17 changes: 15 additions & 2 deletions app/Http/Requests/V1/ListArticlesRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ public function authorize(): bool
return true;
}

protected function prepareForValidation(): void
{
if ($this->has('is_pinned')) {
$this->merge([
'is_pinned' => filter_var($this->query('is_pinned'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE),
]);
}
}

/**
* Get the validation rules that apply to the request.
*
Expand All @@ -28,6 +37,7 @@ public function rules(): array
'tag' => ['nullable', 'string', 'max:255'],
'page' => ['nullable', 'integer', 'min:1'],
'pageSize' => ['nullable', 'integer', 'min:1', 'max:100'],
'is_pinned' => ['nullable', 'boolean'],
];
}

Expand All @@ -36,7 +46,7 @@ public function rules(): array
*
* @param array<int, string>|string|null $key
* @param mixed $default
* @return array{category?: string|null, tag?: string|null, page?: int|null, pageSize?: int|null}
* @return array{category?: string|null, tag?: string|null, page?: int|null, pageSize?: int|null, is_pinned?: bool|null}
*/
public function validated($key = null, $default = null): array
{
Expand All @@ -52,8 +62,11 @@ public function validated($key = null, $default = null): array
if (isset($validated['pageSize'])) {
$validated['pageSize'] = \is_scalar($validated['pageSize']) ? (int) $validated['pageSize'] : null;
}
if (isset($validated['is_pinned'])) {
$validated['is_pinned'] = filter_var($validated['is_pinned'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
}

/** @var array{category?: string|null, tag?: string|null, page?: int|null, pageSize?: int|null} $validated */
/** @var array{category?: string|null, tag?: string|null, page?: int|null, pageSize?: int|null, is_pinned?: bool|null} $validated */
return $validated;
}
}
1 change: 1 addition & 0 deletions app/Http/Resources/V1/ArticleResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ public function toArray(Request $request): array
'publishedAt' => ($this->published_at ?? $this->created_at)?->toDateString(),
'readingTime' => $this->reading_time,
'featured' => $this->featured,
'is_pinned' => $this->is_pinned,
'codeFile' => $this->codeFile ? new CodeFileResource($this->codeFile) : null,
'codeFolder' => $this->codeFolder ? new CodeFolderResource($this->codeFolder) : null,
'codeProject' => ($this->codeProject && $this->codeProject->is_published) ? new CodeProjectResource($this->codeProject) : null,
Expand Down
3 changes: 3 additions & 0 deletions app/Models/Article.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
* @property ArticleStatus $status
* @property int $reading_time
* @property bool $featured
* @property bool $is_pinned
* @property Carbon|null $published_at
* @property string|null $code_file_id
* @property string|null $code_folder_id
Expand All @@ -49,6 +50,7 @@ final class Article extends Model
'status',
'reading_time',
'featured',
'is_pinned',
'published_at',
'code_file_id',
'code_folder_id',
Expand All @@ -58,6 +60,7 @@ final class Article extends Model
protected $casts = [
'status' => ArticleStatus::class,
'featured' => 'boolean',
'is_pinned' => 'boolean',
'published_at' => 'datetime',
'reading_time' => 'integer',
];
Expand Down
2 changes: 2 additions & 0 deletions app/Services/ArticleService.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,12 @@ public function listPublished(
?string $tag = null,
int $page = 1,
int $pageSize = 10,
?bool $isPinned = null,
): LengthAwarePaginator {
return Article::query()
->where('status', ArticleStatus::Published)
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))
->when($isPinned !== null, fn ($q) => $q->where('is_pinned', $isPinned))
->when($category, fn ($q, $cat) => $q->whereRelation('categories', 'slug', $cat))
->when($tag, fn ($q, $t) => $q->whereRelation('tags', fn (Builder $query) => $query->where('name', $t)->where('is_active', true)))
->with([
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

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('articles', function (Blueprint $table): void {
$table->boolean('is_pinned')->default(false)->index();
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('articles', function (Blueprint $table): void {
$table->dropColumn('is_pinned');
});
}
};
44 changes: 44 additions & 0 deletions tests/Feature/Api/V1/ArticleApiTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -386,4 +386,48 @@ public function test_get_non_existent_article_returns_404(): void
$response->assertStatus(404)
->assertJsonPath('message', 'Article not found');
}

public function test_can_filter_articles_by_pinned_status(): void
{
$pinned = Article::create([
'title' => 'Article Épinglé',
'slug' => 'article-epingle',
'excerpt' => 'Intro',
'content' => 'Corps',
'status' => ArticleStatus::Published,
'reading_time' => 3,
'published_at' => now()->subDay(),
'is_pinned' => true,
]);
$pinned->categories()->sync([$this->category->id]);

$normal = Article::create([
'title' => 'Article Normal',
'slug' => 'article-normal',
'excerpt' => 'Intro',
'content' => 'Corps',
'status' => ArticleStatus::Published,
'reading_time' => 3,
'published_at' => now()->subDay(),
'is_pinned' => false,
]);
$normal->categories()->sync([$this->category->id]);

// 1. Get pinned only
$response = $this->getJson('/api/v1/articles?is_pinned=true');
$response->assertStatus(200)
->assertJsonCount(1, 'articles')
->assertJsonPath('articles.0.slug', 'article-epingle');

// 2. Get non-pinned only
$response = $this->getJson('/api/v1/articles?is_pinned=false');
$response->assertStatus(200)
->assertJsonCount(1, 'articles')
->assertJsonPath('articles.0.slug', 'article-normal');

// 3. Get all
$response = $this->getJson('/api/v1/articles');
$response->assertStatus(200)
->assertJsonCount(2, 'articles');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"publishedAt": "2026-06-25",
"readingTime": 3,
"featured": false,
"is_pinned": false,
"codeFile": null,
"codeFolder": null,
"codeProject": null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"publishedAt": "2026-06-25",
"readingTime": 5,
"featured": false,
"is_pinned": false,
"codeFile": null,
"codeFolder": null,
"codeProject": null
Expand Down
51 changes: 49 additions & 2 deletions tests/Unit/Services/ArticleServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -455,7 +455,7 @@ public function test_list_published_eager_loads_relations_without_n_plus_one():
$results2 = $this->articleService->listPublished();
foreach ($results2->items() as $article) {
foreach ($article->categories as $category) {
$category->label;
$this->assertNotEmpty($category->label);
}
$article->tags->pluck('name');
}
Expand All @@ -482,7 +482,7 @@ public function test_list_published_eager_loads_relations_without_n_plus_one():
$results7 = $this->articleService->listPublished();
foreach ($results7->items() as $article) {
foreach ($article->categories as $category) {
$category->label;
$this->assertNotEmpty($category->label);
}
$article->tags->pluck('name');
}
Expand All @@ -492,4 +492,51 @@ public function test_list_published_eager_loads_relations_without_n_plus_one():
// Le nombre de requêtes doit être identique s'il y a eager loading (évite les requêtes N+1)
$this->assertEquals($queriesForTwo, $queriesForSeven);
}

public function test_list_published_can_filter_by_pinned_status(): void
{
// 1. Article publié et épinglé
$art1 = Article::create([
'title' => 'Article Épinglé',
'slug' => 'article-epingle',
'excerpt' => 'Intro',
'content' => 'Corps',
'status' => ArticleStatus::Published,
'reading_time' => 3,
'published_at' => now()->subDay(),
'is_pinned' => true,
]);
$art1->categories()->sync([$this->catBackend->id]);

// 2. Article publié mais NON épinglé
$art2 = Article::create([
'title' => 'Article Non Épinglé',
'slug' => 'article-non-epingle',
'excerpt' => 'Intro',
'content' => 'Corps',
'status' => ArticleStatus::Published,
'reading_time' => 3,
'published_at' => now()->subDay(),
'is_pinned' => false,
]);
$art2->categories()->sync([$this->catBackend->id]);

// Test 1: isPinned = true
$pinned = $this->articleService->listPublished(isPinned: true);
$this->assertCount(1, $pinned->items());
$firstPinned = $pinned->items()[0];
$this->assertInstanceOf(Article::class, $firstPinned);
$this->assertEquals('article-epingle', $firstPinned->slug);

// Test 2: isPinned = false
$notPinned = $this->articleService->listPublished(isPinned: false);
$this->assertCount(1, $notPinned->items());
$firstNotPinned = $notPinned->items()[0];
$this->assertInstanceOf(Article::class, $firstNotPinned);
$this->assertEquals('article-non-epingle', $firstNotPinned->slug);

// Test 3: isPinned = null (all)
$all = $this->articleService->listPublished(isPinned: null);
$this->assertCount(2, $all->items());
}
}
Loading