diff --git a/app/Http/Controllers/Articles/ArticlesController.php b/app/Http/Controllers/Articles/ArticlesController.php index 579c8414f..3f6d99107 100644 --- a/app/Http/Controllers/Articles/ArticlesController.php +++ b/app/Http/Controllers/Articles/ArticlesController.php @@ -24,6 +24,7 @@ public function __construct() public function index() { + return view('articles.index'); } public function show(Article $article) diff --git a/app/Http/Livewire/ShowArticles.php b/app/Http/Livewire/ShowArticles.php new file mode 100644 index 000000000..1b24dfb74 --- /dev/null +++ b/app/Http/Livewire/ShowArticles.php @@ -0,0 +1,70 @@ + ['except' => ''], + 'sortBy' => ['except' => 'recent'], + ]; + + public function mount(): void + { + $this->toggleTag(request()->query('tag', $this->tag)); + $this->sortBy(request()->query('sortBy') ?: $this->sortBy); + } + + public function render(): View + { + $articles = Article::published(); + + if ($this->tag) { + $articles->forTag($this->tag); + } + + $articles->{$this->sortBy}(); + + return view('livewire.show-articles', [ + 'articles' => $articles->paginate(10), + 'selectedTag' => $this->tag, + 'selectedSortBy' => $this->sortBy, + ]); + } + + public function toggleTag($tag): void + { + $this->tag = $this->tag !== $tag && $this->tagExists($tag) ? $tag : null; + } + + public function sortBy($sort): void + { + $this->sortBy = $this->validSort($sort) ? $sort : 'recent'; + } + + public function tagExists($tag): bool + { + return Tag::where('slug', $tag)->exists(); + } + + public function validSort($sort): bool + { + return in_array($sort, [ + 'recent', + 'popular', + 'trending', + ]); + } +} diff --git a/app/Models/Article.php b/app/Models/Article.php index faa9f339e..091ab6608 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -10,6 +10,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Str; final class Article extends Model { @@ -48,6 +49,11 @@ public function body(): string return $this->body; } + public function excerpt(int $limit = 100): string + { + return Str::limit(strip_tags(md_to_html($this->body())), $limit); + } + public function originalUrl(): ?string { return $this->original_url; @@ -103,6 +109,13 @@ public function isNotPublished(): bool return is_null($this->published_at); } + public function readTime() + { + $minutes = round(str_word_count($this->body()) / 200); + + return $minutes == 0 ? 1 : $minutes; + } + public function scopePublished(Builder $query): Builder { return $query->whereNotNull('published_at'); @@ -112,4 +125,32 @@ public function scopeNotPublished(Builder $query): Builder { return $query->whereNull('published_at'); } + + public function scopeForTag(Builder $query, string $tag): Builder + { + return $query->whereHas('tagsRelation', function ($query) use ($tag) { + $query->where('tags.slug', $tag); + }); + } + + public function scopeRecent(Builder $query): Builder + { + return $query->orderBy('published_at', 'desc'); + } + + public function scopePopular(Builder $query): Builder + { + return $query->withCount('likes') + ->orderBy('likes_count', 'desc') + ->orderBy('published_at', 'desc'); + } + + public function scopeTrending(Builder $query): Builder + { + return $query->withCount(['likes' => function ($query) { + $query->where('created_at', '>=', now()->subWeek()); + }]) + ->orderBy('likes_count', 'desc') + ->orderBy('published_at', 'desc'); + } } diff --git a/app/Models/Tag.php b/app/Models/Tag.php index 37f683165..19506cd08 100644 --- a/app/Models/Tag.php +++ b/app/Models/Tag.php @@ -5,6 +5,7 @@ use App\Helpers\HasSlug; use App\Helpers\ModelHelpers; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphToMany; final class Tag extends Model { @@ -30,4 +31,14 @@ public function name(): string { return $this->name; } + + public function slug(): string + { + return $this->slug; + } + + public function articles(): MorphToMany + { + return $this->morphedByMany(Article::class, 'taggable'); + } } diff --git a/resources/css/tags.css b/resources/css/tags.css index 2c49d479e..7af929f9b 100644 --- a/resources/css/tags.css +++ b/resources/css/tags.css @@ -6,6 +6,14 @@ ul.tags li.active { @apply bg-gray-100 rounded font-bold; } +ul.tags li.active button { + @apply font-bold; +} + +ul.tags li button:focus { + @apply outline-none; +} + .thread-info-tags a:hover { @apply no-underline; } \ No newline at end of file diff --git a/resources/views/articles/index.blade.php b/resources/views/articles/index.blade.php index c5933b6c2..1da12f003 100644 --- a/resources/views/articles/index.blade.php +++ b/resources/views/articles/index.blade.php @@ -1,24 +1,7 @@ +@title('Community Articles') + @extends('layouts.default') @section('content') -
-

{{ $article->title() }}

-
- @include('forum.threads.info.avatar', ['user' => $article->author(), 'size' => 50]) -
- - {{ $article->author()->name() }} - - - {{ $article->createdAt()->format('j M, Y') }} - {{ $article->readTime() }} min read -
-
-
-
+ @endsection \ No newline at end of file diff --git a/resources/views/livewire/show-articles.blade.php b/resources/views/livewire/show-articles.blade.php new file mode 100644 index 000000000..1d428f6d2 --- /dev/null +++ b/resources/views/livewire/show-articles.blade.php @@ -0,0 +1,96 @@ +
+
+
+ Loading... +
+ + @foreach($articles as $article) +
+
+ @foreach($article->tags() as $tag) + + @endforeach +
+ +

+ {{ $article->title() }} +

+

+ {{ $article->excerpt() }} +

+
+ +
+
+
+ + {{ $article->author()->name }} + +
+
+

+ + {{ $article->author()->name() }} + +

+
+ + + · + + + {{ $article->readTime() }} min read + +
+
+
+
+ + + + {{ $article->likesCount() }} +
+
+
+ @endforeach + + {{ $articles->links() }} + +
+ +
+ + + + + + +
    +
  • + +
  • + + @foreach (App\Models\Tag::whereHas('articles')->orderBy('name')->get() as $tag) +
  • + +
  • + @endforeach +
+
+
diff --git a/tests/Feature/ArticleTest.php b/tests/Feature/ArticleTest.php index 08ecf631b..0fc849555 100644 --- a/tests/Feature/ArticleTest.php +++ b/tests/Feature/ArticleTest.php @@ -2,10 +2,12 @@ namespace Tests\Feature; +use App\Http\Livewire\ShowArticles; use App\Models\Article; use App\Models\Series; use App\Models\Tag; use Illuminate\Foundation\Testing\DatabaseMigrations; +use Livewire\Livewire; class ArticleTest extends BrowserKitTestCase { @@ -372,4 +374,37 @@ public function draft_articles_cannot_be_viewed_by_logged_in_users() $this->get('/articles/my-first-article') ->assertResponseStatus(404); } + + /** @test */ + public function sort_parameters_are_set_correctly() + { + Livewire::test(ShowArticles::class) + ->assertSet('sortBy', 'recent') + ->call('sortBy', 'popular') + ->assertSet('sortBy', 'popular') + ->call('sortBy', 'trending') + ->assertSet('sortBy', 'trending') + ->call('sortBy', 'recent') + ->assertSet('sortBy', 'recent'); + } + + /** @test */ + public function tags_can_be_toggled() + { + $tag = factory(Tag::class)->create(); + + Livewire::test(ShowArticles::class) + ->call('toggleTag', $tag->slug) + ->assertSet('tag', $tag->slug) + ->call('toggleTag', $tag->slug) + ->assertSet('tag', null); + } + + /** @test */ + public function invalid_sort_parameter_defaults_to_recent() + { + Livewire::test(ShowArticles::class) + ->call('sortBy', 'something-invalid') + ->assertSet('sortBy', 'recent'); + } } diff --git a/tests/Integration/Models/ArticleTest.php b/tests/Integration/Models/ArticleTest.php new file mode 100644 index 000000000..a51b10636 --- /dev/null +++ b/tests/Integration/Models/ArticleTest.php @@ -0,0 +1,59 @@ +create(); + $articles = factory(Article::class, 3)->create(); + + // Like the second article twice. + $articles[1]->likedBy($users[0]); + $articles[1]->likedBy($users[1]); + + // Like the first article once. + $articles[0]->likedBy($users[0]); + + $popularArticles = Article::popular()->get(); + + $this->assertEquals($articles[1]->title, $popularArticles[0]->title); + $this->assertEquals($articles[0]->title, $popularArticles[1]->title); + $this->assertEquals($articles[2]->title, $popularArticles[2]->title); + } + + /** @test */ + public function we_can_get_trending_articles() + { + $users = factory(User::class, 3)->create(); + $articles = factory(Article::class, 3)->create(); + + // Like the first article by two users. + $articles[0]->likedBy($users[0]); + $articles[0]->likedBy($users[1]); + + // Update the like timestamp outside of the trending window. + $articles[0]->likes()->update(['created_at' => now()->subWeeks(2)]); + + // Like the remaining articles once, but inside the trending window. + $articles[1]->likedBy($users[0]); + $articles[2]->likedBy($users[0]); + + $trendingArticles = Article::trending()->get(); + + // The first article has more likes, but outside the trending window + // so should be returned last. + $this->assertEquals($articles[1]->title, $trendingArticles[0]->title); + $this->assertEquals($articles[2]->title, $trendingArticles[1]->title); + $this->assertEquals($articles[0]->title, $trendingArticles[2]->title); + } +}