From 66a6a83a4873f42043de11d8745504d2ae5c9b43 Mon Sep 17 00:00:00 2001 From: owlinstack Date: Fri, 10 Jul 2026 15:02:28 +0200 Subject: [PATCH 1/4] feat: add is_pinned column to articles, support filtering, and validate maximum 10 pins in Filament --- app/Filament/Resources/ArticleResource.php | 21 +++++++++ .../Controllers/Api/V1/ArticleController.php | 2 + app/Http/Requests/V1/ListArticlesRequest.php | 17 ++++++- app/Models/Article.php | 3 ++ app/Services/ArticleService.php | 2 + ...141658_add_is_pinned_to_articles_table.php | 28 ++++++++++++ tests/Feature/Api/V1/ArticleApiTest.php | 44 +++++++++++++++++++ tests/Unit/Services/ArticleServiceTest.php | 43 ++++++++++++++++++ 8 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_07_10_141658_add_is_pinned_to_articles_table.php diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index 947d2b9..793fa05 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -117,6 +117,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([ + fn () => function (string $attribute, $value, \Closure $fail) { + if ($value === true) { + // Utiliser request()->route('record') s'il s'agit d'un string (ULID) + $recordId = request()->route('record'); + $count = \App\Models\Article::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 +216,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/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..d32e7a1 --- /dev/null +++ b/database/migrations/2026_07_10_141658_add_is_pinned_to_articles_table.php @@ -0,0 +1,28 @@ +boolean('is_pinned')->default(false)->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('articles', function (Blueprint $table) { + $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/Unit/Services/ArticleServiceTest.php b/tests/Unit/Services/ArticleServiceTest.php index 3398155..564764b 100644 --- a/tests/Unit/Services/ArticleServiceTest.php +++ b/tests/Unit/Services/ArticleServiceTest.php @@ -492,4 +492,47 @@ 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); + $this->assertEquals('article-epingle', $pinned->first()->slug); + + // Test 2: isPinned = false + $notPinned = $this->articleService->listPublished(isPinned: false); + $this->assertCount(1, $notPinned); + $this->assertEquals('article-non-epingle', $notPinned->first()->slug); + + // Test 3: isPinned = null (all) + $all = $this->articleService->listPublished(isPinned: null); + $this->assertCount(2, $all); + } } From ba9c8c254ed04c6673b94b300d5766a9377180c1 Mon Sep 17 00:00:00 2001 From: owlinstack Date: Fri, 10 Jul 2026 15:07:34 +0200 Subject: [PATCH 2/4] feat: expose is_pinned in ArticleResource and update test snapshots --- app/Http/Resources/V1/ArticleResource.php | 1 + ...ticleSnapshotTest__test_article_show_matches_snapshot__1.json | 1 + ...icleSnapshotTest__test_articles_list_matches_snapshot__1.json | 1 + 3 files changed, 3 insertions(+) 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/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 From 78f61aaf613d2e75b8f089447b42228c4bdc3148 Mon Sep 17 00:00:00 2001 From: owlinstack Date: Fri, 10 Jul 2026 15:46:14 +0200 Subject: [PATCH 3/4] refactor: resolve Intelephense warnings by using query builder and simplifying imports in ArticleResource --- app/Filament/Resources/ArticleResource.php | 8 ++++---- tests/Unit/Services/ArticleServiceTest.php | 18 +++++++++++------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index 793fa05..3ceb38a 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -121,11 +121,11 @@ public static function form(Schema $schema): Schema ->label('Épingler l\'article') ->helperText('Affiche l\'article en tête de page d\'accueil (maximum 10).') ->rules([ - fn () => function (string $attribute, $value, \Closure $fail) { + function (string $attribute, $value, \Closure $fail) { if ($value === true) { - // Utiliser request()->route('record') s'il s'agit d'un string (ULID) - $recordId = request()->route('record'); - $count = \App\Models\Article::where('is_pinned', true) + $record = request()->route('record'); + $recordId = $record instanceof \Illuminate\Database\Eloquent\Model ? $record->getKey() : $record; + $count = Article::query()->where('is_pinned', true) ->when($recordId, fn ($query) => $query->where('id', '!=', $recordId)) ->count(); if ($count >= 10) { diff --git a/tests/Unit/Services/ArticleServiceTest.php b/tests/Unit/Services/ArticleServiceTest.php index 564764b..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'); } @@ -523,16 +523,20 @@ public function test_list_published_can_filter_by_pinned_status(): void // Test 1: isPinned = true $pinned = $this->articleService->listPublished(isPinned: true); - $this->assertCount(1, $pinned); - $this->assertEquals('article-epingle', $pinned->first()->slug); + $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); - $this->assertEquals('article-non-epingle', $notPinned->first()->slug); + $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); + $this->assertCount(2, $all->items()); } } From b48228108f8f1218170488ee088ac7424b602ca0 Mon Sep 17 00:00:00 2001 From: owlinstack Date: Fri, 10 Jul 2026 16:22:24 +0200 Subject: [PATCH 4/4] style: run Laravel Pint to format ArticleResource and migration according to project style guidelines --- app/Filament/Resources/ArticleResource.php | 7 ++++--- .../2026_07_10_141658_add_is_pinned_to_articles_table.php | 6 ++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index 3ceb38a..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. @@ -121,10 +122,10 @@ public static function form(Schema $schema): Schema ->label('Épingler l\'article') ->helperText('Affiche l\'article en tête de page d\'accueil (maximum 10).') ->rules([ - function (string $attribute, $value, \Closure $fail) { + function (string $attribute, $value, \Closure $fail): void { if ($value === true) { $record = request()->route('record'); - $recordId = $record instanceof \Illuminate\Database\Eloquent\Model ? $record->getKey() : $record; + $recordId = $record instanceof Model ? $record->getKey() : $record; $count = Article::query()->where('is_pinned', true) ->when($recordId, fn ($query) => $query->where('id', '!=', $recordId)) ->count(); @@ -132,7 +133,7 @@ function (string $attribute, $value, \Closure $fail) { $fail('Vous ne pouvez pas épingler plus de 10 articles.'); } } - } + }, ]), Forms\Components\DateTimePicker::make('published_at') ->label('Date de publication'), 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 index d32e7a1..f0a658d 100644 --- 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 @@ -1,5 +1,7 @@ boolean('is_pinned')->default(false)->index(); }); } @@ -21,7 +23,7 @@ public function up(): void */ public function down(): void { - Schema::table('articles', function (Blueprint $table) { + Schema::table('articles', function (Blueprint $table): void { $table->dropColumn('is_pinned'); }); }