diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index 947d2b9..44b0ce4 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -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. @@ -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'), ]), @@ -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(), diff --git a/app/Http/Controllers/Api/V1/ArticleController.php b/app/Http/Controllers/Api/V1/ArticleController.php index c28f5f4..ea269c8 100644 --- a/app/Http/Controllers/Api/V1/ArticleController.php +++ b/app/Http/Controllers/Api/V1/ArticleController.php @@ -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); diff --git a/app/Http/Requests/V1/ListArticlesRequest.php b/app/Http/Requests/V1/ListArticlesRequest.php index c7f5750..361151d 100644 --- a/app/Http/Requests/V1/ListArticlesRequest.php +++ b/app/Http/Requests/V1/ListArticlesRequest.php @@ -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. * @@ -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'], ]; } @@ -36,7 +46,7 @@ public function rules(): array * * @param array|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 { @@ -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; } } diff --git a/app/Http/Resources/V1/ArticleResource.php b/app/Http/Resources/V1/ArticleResource.php index 083c6bb..54b94da 100644 --- a/app/Http/Resources/V1/ArticleResource.php +++ b/app/Http/Resources/V1/ArticleResource.php @@ -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, diff --git a/app/Models/Article.php b/app/Models/Article.php index 5820f4c..6a1cd1e 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -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 @@ -49,6 +50,7 @@ final class Article extends Model 'status', 'reading_time', 'featured', + 'is_pinned', 'published_at', 'code_file_id', 'code_folder_id', @@ -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', ]; diff --git a/app/Services/ArticleService.php b/app/Services/ArticleService.php index d95efe9..d8ecd9a 100644 --- a/app/Services/ArticleService.php +++ b/app/Services/ArticleService.php @@ -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([ diff --git a/database/migrations/2026_07_10_141658_add_is_pinned_to_articles_table.php b/database/migrations/2026_07_10_141658_add_is_pinned_to_articles_table.php new file mode 100644 index 0000000..f0a658d --- /dev/null +++ b/database/migrations/2026_07_10_141658_add_is_pinned_to_articles_table.php @@ -0,0 +1,30 @@ +boolean('is_pinned')->default(false)->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table): void { + $table->dropColumn('is_pinned'); + }); + } +}; diff --git a/tests/Feature/Api/V1/ArticleApiTest.php b/tests/Feature/Api/V1/ArticleApiTest.php index bacb5db..6a4d25c 100644 --- a/tests/Feature/Api/V1/ArticleApiTest.php +++ b/tests/Feature/Api/V1/ArticleApiTest.php @@ -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'); + } } diff --git a/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_article_show_matches_snapshot__1.json b/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_article_show_matches_snapshot__1.json index f8c9767..08c4139 100644 --- a/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_article_show_matches_snapshot__1.json +++ b/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_article_show_matches_snapshot__1.json @@ -12,6 +12,7 @@ "publishedAt": "2026-06-25", "readingTime": 3, "featured": false, + "is_pinned": false, "codeFile": null, "codeFolder": null, "codeProject": null diff --git a/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_articles_list_matches_snapshot__1.json b/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_articles_list_matches_snapshot__1.json index 9d35df5..c127a72 100644 --- a/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_articles_list_matches_snapshot__1.json +++ b/tests/Feature/Api/V1/Snapshots/__snapshots__/ArticleSnapshotTest__test_articles_list_matches_snapshot__1.json @@ -16,6 +16,7 @@ "publishedAt": "2026-06-25", "readingTime": 5, "featured": false, + "is_pinned": false, "codeFile": null, "codeFolder": null, "codeProject": null diff --git a/tests/Unit/Services/ArticleServiceTest.php b/tests/Unit/Services/ArticleServiceTest.php index 3398155..d1330c4 100644 --- a/tests/Unit/Services/ArticleServiceTest.php +++ b/tests/Unit/Services/ArticleServiceTest.php @@ -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'); } @@ -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'); } @@ -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()); + } }