From 96c998eb5151a4055e232e0d05b397633394ff7e Mon Sep 17 00:00:00 2001 From: Arthur Monney Date: Sat, 21 Dec 2024 00:47:04 +0100 Subject: [PATCH] feat: [LAR-132] create update article action and add locale field on thread, article and discussion form --- app/Actions/Article/CreateArticleAction.php | 23 ++++---- app/Actions/Article/UpdateArticleAction.php | 40 +++++++++++++ ...{CreateArticleData.php => ArticleData.php} | 9 +-- .../CannotUpdateApprovedArticle.php | 7 +++ .../Components/Slideovers/ArticleForm.php | 44 +++++++++----- .../Components/Slideovers/DiscussionForm.php | 13 ++-- .../Components/Slideovers/ThreadForm.php | 15 +++-- app/Models/Article.php | 18 +++--- app/Models/Discussion.php | 2 + app/Models/Thread.php | 2 + database/factories/ArticleFactory.php | 22 +++++++ database/factories/TagFactory.php | 10 ++++ lang/en/global.php | 1 + lang/en/notifications.php | 1 + lang/en/validation.php | 3 +- lang/fr/global.php | 1 + lang/fr/notifications.php | 1 + lang/fr/validation.php | 1 + phpunit.xml | 1 + .../Article/CreateArticleActionTest.php | 5 +- .../Article/UpdateArticleActionTest.php | 59 +++++++++++++++++++ .../Commands/PostArticleToTwitterTest.php | 13 ++-- .../Components/Slideovers/ArticleFormTest.php | 15 ++--- 23 files changed, 240 insertions(+), 66 deletions(-) create mode 100644 app/Actions/Article/UpdateArticleAction.php rename app/Data/{CreateArticleData.php => ArticleData.php} (53%) create mode 100644 app/Exceptions/CannotUpdateApprovedArticle.php create mode 100644 tests/Feature/Actions/Article/UpdateArticleActionTest.php diff --git a/app/Actions/Article/CreateArticleAction.php b/app/Actions/Article/CreateArticleAction.php index 043f81b0..e2457168 100644 --- a/app/Actions/Article/CreateArticleAction.php +++ b/app/Actions/Article/CreateArticleAction.php @@ -4,15 +4,14 @@ namespace App\Actions\Article; -use App\Data\CreateArticleData; +use App\Data\ArticleData; use App\Models\Article; -use App\Models\User; use Carbon\Carbon; use Illuminate\Support\Facades\Auth; final class CreateArticleAction { - public function execute(CreateArticleData $articleData): Article + public function execute(ArticleData $articleData): Article { if ($articleData->published_at) { $articleData->published_at = new Carbon( @@ -21,20 +20,22 @@ public function execute(CreateArticleData $articleData): Article ); } - /** @var User $author */ - $author = Auth::user(); + if ($articleData->submitted_at) { + $articleData->submitted_at = new Carbon( + time: $articleData->submitted_at, + timezone: config('app.timezone') + ); + } - /** @var Article $article */ - $article = Article::query()->create([ + // @phpstan-ignore-next-line + return Article::query()->create([ 'title' => $articleData->title, 'slug' => $articleData->slug, 'body' => $articleData->body, 'published_at' => $articleData->published_at, - 'submitted_at' => $articleData->is_draft ? null : now(), + 'submitted_at' => $articleData->submitted_at, 'canonical_url' => $articleData->canonical_url, - 'user_id' => $author->id, + 'user_id' => Auth::id(), ]); - - return $article; } } diff --git a/app/Actions/Article/UpdateArticleAction.php b/app/Actions/Article/UpdateArticleAction.php new file mode 100644 index 00000000..ddb508de --- /dev/null +++ b/app/Actions/Article/UpdateArticleAction.php @@ -0,0 +1,40 @@ +isApproved()) { + throw new CannotUpdateApprovedArticle(__('notifications.exceptions.approved_article')); + } + + if ($articleData->published_at) { + $articleData->published_at = new Carbon( + time: $articleData->published_at, + timezone: config('app.timezone') + ); + } + + if ($articleData->submitted_at) { + $articleData->submitted_at = new Carbon( + time: $articleData->submitted_at, + timezone: config('app.timezone') + ); + } + + $article->update($articleData->toArray()); + + $article->refresh(); + + return $article; + } +} diff --git a/app/Data/CreateArticleData.php b/app/Data/ArticleData.php similarity index 53% rename from app/Data/CreateArticleData.php rename to app/Data/ArticleData.php index 1bcc8658..fca6e491 100644 --- a/app/Data/CreateArticleData.php +++ b/app/Data/ArticleData.php @@ -7,14 +7,15 @@ use Carbon\Carbon; use Spatie\LaravelData\Data; -final class CreateArticleData extends Data +final class ArticleData extends Data { public function __construct( public string $title, public string $slug, public string $body, - public string $canonical_url, - public ?Carbon $published_at, - public bool $is_draft = false, + public string $locale, + public ?string $canonical_url = null, + public ?Carbon $published_at = null, + public ?Carbon $submitted_at = null, ) {} } diff --git a/app/Exceptions/CannotUpdateApprovedArticle.php b/app/Exceptions/CannotUpdateApprovedArticle.php new file mode 100644 index 00000000..32f2b8b9 --- /dev/null +++ b/app/Exceptions/CannotUpdateApprovedArticle.php @@ -0,0 +1,7 @@ +form->fill(array_merge($this->article->toArray(), [ 'is_draft' => ! $this->article->published_at, // @phpstan-ignore-line 'published_at' => $this->article->published_at, // @phpstan-ignore-line + 'locale' => $this->article->locale ?? app()->getLocale(), ])); } @@ -114,6 +116,11 @@ public function form(Form $form): Form ->required() ->minItems(1) ->maxItems(3), + Forms\Components\ToggleButtons::make('locale') + ->label(__('validation.attributes.locale')) + ->options(['en' => 'En', 'fr' => 'Fr']) + ->helperText(__('global.locale_help')) + ->grouped(), ]) ->columnSpan(1), Forms\Components\Group::make() @@ -163,34 +170,37 @@ public function save(): void $this->validate(); - $validated = $this->form->getState(); + $state = $this->form->getState(); + + $publishedFields = [ + 'published_at' => data_get($state, 'published_at') + ? new Carbon(data_get($state, 'published_at')) + : null, + 'submitted_at' => data_get($state, 'is_draft') ? null : now(), + ]; if ($this->article?->id) { - $this->article->update(array_merge($validated, [ - 'submitted_at' => $validated['is_draft'] ? null : now(), - ])); - $this->form->model($this->article)->saveRelationships(); - $this->article->fresh(); + $article = app(UpdateArticleAction::class)->execute( + articleData: ArticleData::from(array_merge($state, $publishedFields)), + article: $this->article + ); Notification::make() ->title( - $this->article->submitted_at + $article->submitted_at ? __('notifications.article.submitted') : __('notifications.article.updated'), ) ->success() ->send(); } else { - $article = app(CreateArticleAction::class)->execute(CreateArticleData::from(array_merge($validated, [ - 'published_at' => array_key_exists('published_at', $validated) - ? new Carbon($validated['published_at']) - : null, - ]))); - $this->form->model($article)->saveRelationships(); + $article = app(CreateArticleAction::class)->execute( + ArticleData::from(array_merge($state, $publishedFields)) + ); Notification::make() ->title( - $validated['is_draft'] === false + data_get($state, 'is_draft') === false ? __('notifications.article.submitted') : __('notifications.article.created'), ) @@ -198,7 +208,9 @@ public function save(): void ->send(); } - $this->redirect(route('articles.show', ['article' => $article ?? $this->article]), navigate: true); + $this->form->model($article)->saveRelationships(); + + $this->redirect(route('articles.show', ['article' => $article]), navigate: true); } public function render(): View diff --git a/app/Livewire/Components/Slideovers/DiscussionForm.php b/app/Livewire/Components/Slideovers/DiscussionForm.php index 14163ca7..c464176c 100644 --- a/app/Livewire/Components/Slideovers/DiscussionForm.php +++ b/app/Livewire/Components/Slideovers/DiscussionForm.php @@ -39,10 +39,10 @@ public function mount(?int $discussionId = null): void ? Discussion::query()->findOrFail($discussionId) : new Discussion; - $this->form->fill(array_merge( - $this->discussion->toArray(), - ['user_id' => $this->discussion->user_id ?? Auth::id()] - )); + $this->form->fill(array_merge($this->discussion->toArray(), [ + 'user_id' => $this->discussion->user_id ?? Auth::id(), + 'locale' => $this->discussion->locale ?? app()->getLocale(), + ])); } public function form(Form $form): Form @@ -71,6 +71,11 @@ public function form(Form $form): Form ->minItems(1) ->maxItems(3) ->preload(), + Forms\Components\ToggleButtons::make('locale') + ->label(__('validation.attributes.locale')) + ->options(['en' => 'En', 'fr' => 'Fr']) + ->helperText(__('global.locale_help')) + ->grouped(), Forms\Components\MarkdownEditor::make('body') ->toolbarButtons([ 'blockquote', diff --git a/app/Livewire/Components/Slideovers/ThreadForm.php b/app/Livewire/Components/Slideovers/ThreadForm.php index 9b1073bb..9a418bb6 100644 --- a/app/Livewire/Components/Slideovers/ThreadForm.php +++ b/app/Livewire/Components/Slideovers/ThreadForm.php @@ -40,10 +40,10 @@ public function mount(?int $threadId = null): void ? Thread::query()->findOrFail($threadId) : new Thread; - $this->form->fill(array_merge( - $this->thread->toArray(), - ['user_id' => $this->thread->user_id ?? Auth::id()] - )); + $this->form->fill(array_merge($this->thread->toArray(), [ + 'user_id' => $this->thread->user_id ?? Auth::id(), + 'locale' => $this->thread->locale ?? app()->getLocale(), + ])); } public static function panelMaxWidth(): string @@ -79,10 +79,15 @@ public function form(Form $form): Form Forms\Components\Select::make('channels') ->multiple() ->relationship(titleAttribute: 'name') - ->searchable() + ->preload() ->required() ->minItems(1) ->maxItems(3), + Forms\Components\ToggleButtons::make('locale') + ->label(__('validation.attributes.locale')) + ->options(['en' => 'En', 'fr' => 'Fr']) + ->helperText(__('global.locale_help')) + ->grouped(), Forms\Components\MarkdownEditor::make('body') ->fileAttachmentsDisk('public') ->toolbarButtons([ diff --git a/app/Models/Article.php b/app/Models/Article.php index cecbbbcb..1fb33d69 100644 --- a/app/Models/Article.php +++ b/app/Models/Article.php @@ -31,15 +31,16 @@ * @property string | null $canonical_url * @property int | null $tweet_id * @property int $user_id + * @property string | null $locale * @property-read User $user - * @property \Illuminate\Support\Carbon | null $published_at - * @property \Illuminate\Support\Carbon | null $submitted_at - * @property \Illuminate\Support\Carbon | null $approved_at - * @property \Illuminate\Support\Carbon | null $shared_at - * @property \Illuminate\Support\Carbon | null $declined_at - * @property \Illuminate\Support\Carbon | null $sponsored_at - * @property \Illuminate\Support\Carbon $created_at - * @property \Illuminate\Support\Carbon $updated_at + * @property \Carbon\Carbon | null $published_at + * @property \Carbon\Carbon | null $submitted_at + * @property \Carbon\Carbon | null $approved_at + * @property \Carbon\Carbon | null $shared_at + * @property \Carbon\Carbon | null $declined_at + * @property \Carbon\Carbon | null $sponsored_at + * @property \Carbon\Carbon $created_at + * @property \Carbon\Carbon $updated_at * @property \Illuminate\Database\Eloquent\Collection | Tag[] $tags */ final class Article extends Model implements HasMedia, ReactableInterface, Viewable @@ -69,6 +70,7 @@ final class Article extends Model implements HasMedia, ReactableInterface, Viewa 'shared_at', 'sponsored_at', 'published_at', + 'locale', ]; protected $casts = [ diff --git a/app/Models/Discussion.php b/app/Models/Discussion.php index fe2390c3..e057ad73 100644 --- a/app/Models/Discussion.php +++ b/app/Models/Discussion.php @@ -34,6 +34,7 @@ * @property string $body * @property bool $locked * @property bool $is_pinned + * @property string | null $locale * @property int $user_id * @property-read int $count_all_replies_with_child * @property Carbon $created_at @@ -62,6 +63,7 @@ final class Discussion extends Model implements ReactableInterface, ReplyInterfa 'user_id', 'is_pinned', 'locked', + 'locale', ]; protected $casts = [ diff --git a/app/Models/Thread.php b/app/Models/Thread.php index 156327be..6e5573cc 100644 --- a/app/Models/Thread.php +++ b/app/Models/Thread.php @@ -43,6 +43,7 @@ * @property int $user_id * @property int $solution_reply_id * @property bool $locked + * @property string | null $locale * @property Carbon | null $last_posted_at * @property Carbon $created_at * @property Carbon $updated_at @@ -73,6 +74,7 @@ final class Thread extends Model implements Feedable, ReactableInterface, ReplyI 'body', 'slug', 'user_id', + 'locale', ]; protected $casts = [ diff --git a/database/factories/ArticleFactory.php b/database/factories/ArticleFactory.php index 35f60bbd..94da615f 100644 --- a/database/factories/ArticleFactory.php +++ b/database/factories/ArticleFactory.php @@ -19,6 +19,28 @@ public function definition(): array 'title' => $this->faker->sentence(), 'body' => $this->faker->paragraphs(3, true), 'slug' => $this->faker->unique()->slug(), + 'locale' => $this->faker->randomElement(['en', 'fr']), ]; } + + public function approved(): self + { + return $this->state(function (array $attributes): array { + return [ + 'published_at' => now()->addDays(2), + 'submitted_at' => now(), + 'approved_at' => now(), + ]; + }); + } + + public function submitted(): self + { + return $this->state(function (array $attributes): array { + return [ + 'submitted_at' => now(), + 'published_at' => now()->addDay(), + ]; + }); + } } diff --git a/database/factories/TagFactory.php b/database/factories/TagFactory.php index fec88225..6be68373 100644 --- a/database/factories/TagFactory.php +++ b/database/factories/TagFactory.php @@ -19,4 +19,14 @@ public function definition(): array 'concerns' => ['post', 'discussion', 'tutorial'], ]; } + + public function article(): self + { + return $this->state(['concerns' => ['post', 'tutorial']]); + } + + public function discussion(): self + { + return $this->state(['concerns' => ['discussion']]); + } } diff --git a/lang/en/global.php b/lang/en/global.php index d8209dc0..d4f1e92f 100644 --- a/lang/en/global.php +++ b/lang/en/global.php @@ -102,5 +102,6 @@ 'website' => 'Website', 'characters' => ':number characters', 'like' => ':count like', + 'locale_help' => 'The language in which your content will be available on the site.', ]; diff --git a/lang/en/notifications.php b/lang/en/notifications.php index 4a2a1a54..0fb1eb1f 100644 --- a/lang/en/notifications.php +++ b/lang/en/notifications.php @@ -22,6 +22,7 @@ 'exceptions' => [ 'unverified_user' => 'You are not authorized to do this. Your e-mail is not verified', 'spam_exist' => 'Spam already reported.', + 'approved_article' => 'An approved article cannot be updated.', ], 'reply' => [ diff --git a/lang/en/validation.php b/lang/en/validation.php index 7ae82085..7bd9201c 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -189,6 +189,7 @@ 'gender' => 'Gender', 'hour' => 'Hour', 'last_name' => 'Last name', + 'locale' => 'Language', 'minute' => 'Minute', 'mobile' => 'portable', 'month' => 'Month', @@ -196,7 +197,7 @@ 'password' => 'Password', 'password_confirmation' => 'Password Confirmation', 'phone' => 'Phone number', - 'second' => 'Scond', + 'second' => 'Second', 'sex' => 'Sex', 'size' => 'Size', 'time' => 'Hour', diff --git a/lang/fr/global.php b/lang/fr/global.php index 6caea354..b975b54b 100644 --- a/lang/fr/global.php +++ b/lang/fr/global.php @@ -102,5 +102,6 @@ 'website' => 'Site internet', 'characters' => ':number caractères', 'like' => ':count j\'aime', + 'locale_help' => 'La langue dans laquelle votre contenu sera accessible sur le site.', ]; diff --git a/lang/fr/notifications.php b/lang/fr/notifications.php index 853214f1..501a24fe 100644 --- a/lang/fr/notifications.php +++ b/lang/fr/notifications.php @@ -22,6 +22,7 @@ 'exceptions' => [ 'unverified_user' => "Vous n'êtes pas autorisé à effectuer cette. Votre e-mail n'est pas vérifiée", 'spam_exist' => 'Le spam a déjà été signalé.', + 'approved_article' => 'Un article approuvé ne peut pas être modifié.', ], 'reply' => [ diff --git a/lang/fr/validation.php b/lang/fr/validation.php index cacb0fa8..c01de5f5 100644 --- a/lang/fr/validation.php +++ b/lang/fr/validation.php @@ -182,6 +182,7 @@ 'gender' => 'Genre', 'hour' => 'Heure', 'last_name' => 'Nom', + 'locale' => 'Langue', 'minute' => 'Minute', 'mobile' => 'Portable', 'month' => 'Mois', diff --git a/phpunit.xml b/phpunit.xml index c2a617c7..741701fc 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -15,6 +15,7 @@ + diff --git a/tests/Feature/Actions/Article/CreateArticleActionTest.php b/tests/Feature/Actions/Article/CreateArticleActionTest.php index 29603d96..cdb10d14 100644 --- a/tests/Feature/Actions/Article/CreateArticleActionTest.php +++ b/tests/Feature/Actions/Article/CreateArticleActionTest.php @@ -3,7 +3,7 @@ declare(strict_types=1); use App\Actions\Article\CreateArticleAction; -use App\Data\CreateArticleData; +use App\Data\ArticleData; use App\Models\Article; use App\Models\Tag; @@ -15,12 +15,13 @@ describe(CreateArticleAction::class, function (): void { it('return the created article', function (): void { - $article = app(CreateArticleAction::class)->execute(CreateArticleData::from([ + $article = app(CreateArticleAction::class)->execute(ArticleData::from([ 'title' => 'Article title', 'slug' => 'Article slug', 'published_at' => now(), 'canonical_url' => 'Article canonical_url', 'body' => 'Article body', + 'locale' => 'fr', ])); expect($article) diff --git a/tests/Feature/Actions/Article/UpdateArticleActionTest.php b/tests/Feature/Actions/Article/UpdateArticleActionTest.php new file mode 100644 index 00000000..1119a14e --- /dev/null +++ b/tests/Feature/Actions/Article/UpdateArticleActionTest.php @@ -0,0 +1,59 @@ +user = $this->login(); + + TestTime::freeze('Y-m-d H:i:s', '2024-12-20 00:00:01'); +}); + +describe(UpdateArticleAction::class, function (): void { + it('should update article', function (): void { + /** @var Article $article */ + $article = Article::factory()->create([ + 'user_id' => $this->user->id, + ]); + + $updatedArticle = app(UpdateArticleAction::class)->execute( + articleData: ArticleData::from(array_merge( + $article->toArray(), + [ + 'title' => 'Update Article title', + 'published_at' => now(), + 'canonical_url' => 'Article canonical_url', + ] + )), + article: $article + ); + + $article->refresh(); + + expect($updatedArticle->title) + ->toBe('Update Article title') + ->and($article)->toBe($updatedArticle); + }); + + it('should not update an approved article', function (): void { + /** @var Article $article */ + $article = Article::factory()->approved()->create(['user_id' => $this->user->id]); + + app(UpdateArticleAction::class)->execute( + articleData: ArticleData::from(array_merge( + $article->toArray(), + [ + 'title' => 'Update Article title', + 'published_at' => now()->addDay(), + 'canonical_url' => 'Article canonical_url', + ] + )), + article: $article + ); + })->skip()->expectException(CannotUpdateApprovedArticle::class); +}); diff --git a/tests/Feature/Commands/PostArticleToTwitterTest.php b/tests/Feature/Commands/PostArticleToTwitterTest.php index 9a36aba9..11378f19 100644 --- a/tests/Feature/Commands/PostArticleToTwitterTest.php +++ b/tests/Feature/Commands/PostArticleToTwitterTest.php @@ -7,17 +7,18 @@ use Illuminate\Support\Facades\Notification; use Spatie\TestTime\TestTime; -beforeEach( - fn () => Notification::fake(), - TestTime::freeze('Y-m-d H:i:s', '2021-05-01 00:00:01') -); +beforeEach(function (): void { + Notification::fake(); + + TestTime::freeze('Y-m-d H:i:s', '2021-05-01 00:00:01'); +}); describe(PostArticleToTwitter::class, function (): void { it('shares one article on Twitter every 4 hours when the artisan command runs', function (): void { Article::factory()->createMany([ ['submitted_at' => now()], - ['submitted_at' => now(), 'approved_at' => now(), 'published_at' => now()], - ['submitted_at' => now(), 'approved_at' => now(), 'published_at' => now()->addHours(1)], + ['submitted_at' => now(), 'approved_at' => now(), 'published_at' => now()], + ['submitted_at' => now(), 'approved_at' => now(), 'published_at' => now()->addHour()], ['submitted_at' => now(), 'declined_at' => now()], ]); diff --git a/tests/Feature/Livewire/Components/Slideovers/ArticleFormTest.php b/tests/Feature/Livewire/Components/Slideovers/ArticleFormTest.php index 2f3d3cec..975a9c2e 100644 --- a/tests/Feature/Livewire/Components/Slideovers/ArticleFormTest.php +++ b/tests/Feature/Livewire/Components/Slideovers/ArticleFormTest.php @@ -3,14 +3,11 @@ declare(strict_types=1); use App\Livewire\Components\Slideovers\ArticleForm; -use Illuminate\Support\Facades\Notification; use Livewire\Livewire; -beforeEach(function (): void { - Notification::fake(); -}); - -it('return redirect to unauthenticated user', function (): void { - Livewire::test(ArticleForm::class) - ->assertStatus(302); -})->group('articles'); +describe(ArticleForm::class, function (): void { + it('return redirect to unauthenticated user', function (): void { + Livewire::test(ArticleForm::class) + ->assertStatus(302); + })->group('articles'); +})->group('article');