diff --git a/app/Actions/Discussion/ConvertDiscussionToThreadAction.php b/app/Actions/Discussion/ConvertDiscussionToThreadAction.php new file mode 100644 index 00000000..2e16668f --- /dev/null +++ b/app/Actions/Discussion/ConvertDiscussionToThreadAction.php @@ -0,0 +1,36 @@ + $discussion->title, + 'slug' => $discussion->slug, + 'body' => $discussion->body, + 'user_id' => $discussion->user_id, + 'last_posted_at' => null, + ]); + + $discussion->replies()->update([ + 'replyable_type' => 'thread', + 'replyable_id' => $thread->id, + ]); + + $discussion->delete(); + + app(NotifyUsersOfThreadConversion::class)->execute($thread); + + return $thread; + }); + } +} diff --git a/app/Actions/Discussion/NotifyUsersOfThreadConversion.php b/app/Actions/Discussion/NotifyUsersOfThreadConversion.php new file mode 100644 index 00000000..cb8ce3bb --- /dev/null +++ b/app/Actions/Discussion/NotifyUsersOfThreadConversion.php @@ -0,0 +1,25 @@ +replies()->pluck('user_id')->unique()->toArray(); + + User::whereIn('id', $usersToNotify)->get()->each->notify(new ThreadConvertedByCreator($thread)); + + if (Auth::check() && Auth::user()->isAdmin()) { + $thread->user->notify(new ThreadConvertedByAdmin($thread)); + } + } +} diff --git a/app/Filament/Resources/ArticleResource.php b/app/Filament/Resources/ArticleResource.php index a5ea0ba5..e66a284c 100644 --- a/app/Filament/Resources/ArticleResource.php +++ b/app/Filament/Resources/ArticleResource.php @@ -25,7 +25,7 @@ final class ArticleResource extends Resource protected static ?string $navigationIcon = 'heroicon-o-newspaper'; - public static function getNavigationGroup(): ?string + public static function getNavigationGroup(): string { return __('Contenu'); } diff --git a/app/Filament/Resources/ArticleResource/Pages/ListArticles.php b/app/Filament/Resources/ArticleResource/Pages/ListArticles.php index 4341d4ed..a793cae9 100644 --- a/app/Filament/Resources/ArticleResource/Pages/ListArticles.php +++ b/app/Filament/Resources/ArticleResource/Pages/ListArticles.php @@ -13,7 +13,7 @@ final class ListArticles extends ListRecords { protected static string $resource = ArticleResource::class; - public function isTableRecordSelectable(): ?Closure + public function isTableRecordSelectable(): Closure { return fn (Article $record): bool => $record->isNotPublished(); } diff --git a/app/Filament/Resources/ChannelResource.php b/app/Filament/Resources/ChannelResource.php index 9f967ba5..4706efb7 100644 --- a/app/Filament/Resources/ChannelResource.php +++ b/app/Filament/Resources/ChannelResource.php @@ -23,7 +23,7 @@ final class ChannelResource extends Resource protected static ?string $navigationIcon = 'untitledui-git-branch'; - public static function getNavigationGroup(): ?string + public static function getNavigationGroup(): string { return __('Forum'); } diff --git a/app/Filament/Resources/DiscussionResource.php b/app/Filament/Resources/DiscussionResource.php index 99ab6fd0..3f68d24b 100644 --- a/app/Filament/Resources/DiscussionResource.php +++ b/app/Filament/Resources/DiscussionResource.php @@ -18,7 +18,7 @@ final class DiscussionResource extends Resource protected static ?string $navigationIcon = 'untitledui-message-chat-square'; - public static function getNavigationGroup(): ?string + public static function getNavigationGroup(): string { return __('Contenu'); } diff --git a/app/Filament/Resources/TagResource.php b/app/Filament/Resources/TagResource.php index a77dc3f4..e7950e84 100644 --- a/app/Filament/Resources/TagResource.php +++ b/app/Filament/Resources/TagResource.php @@ -20,7 +20,7 @@ final class TagResource extends Resource protected static ?string $navigationIcon = 'untitledui-tag-03'; - public static function getNavigationGroup(): ?string + public static function getNavigationGroup(): string { return __('Contenu'); } diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index ca29f1c8..11a77cbc 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -23,7 +23,7 @@ final class UserResource extends Resource protected static ?string $navigationIcon = 'untitledui-users-02'; - public static function getNavigationGroup(): ?string + public static function getNavigationGroup(): string { return __('Management'); } diff --git a/app/Livewire/Components/Forum/ReplyForm.php b/app/Livewire/Components/Forum/ReplyForm.php index 82731778..56814e2f 100644 --- a/app/Livewire/Components/Forum/ReplyForm.php +++ b/app/Livewire/Components/Forum/ReplyForm.php @@ -41,7 +41,7 @@ public function open(?int $replyId = null): void { $this->reply = Reply::query()->find($replyId); - $this->form->fill(['body' => $this->reply?->body ?? '']); + $this->form->fill(['body' => $this->reply->body ?? '']); $this->show = true; } diff --git a/app/Livewire/Modals/ConvertDiscussion.php b/app/Livewire/Modals/ConvertDiscussion.php new file mode 100644 index 00000000..1ed52138 --- /dev/null +++ b/app/Livewire/Modals/ConvertDiscussion.php @@ -0,0 +1,32 @@ +findOrFail($this->discussionId); + + $this->authorize('convertedToThread', $discussion); + + $thread = app(ConvertDiscussionToThreadAction::class)->execute($discussion); + + $this->redirectRoute('forum.show', $thread, navigate: true); + } + + public function render(): View + { + return view('livewire.modals.convert-discussion'); + } +} diff --git a/app/Livewire/Modals/Unsplash.php b/app/Livewire/Modals/Unsplash.php index 3fb57808..142f066a 100644 --- a/app/Livewire/Modals/Unsplash.php +++ b/app/Livewire/Modals/Unsplash.php @@ -20,6 +20,6 @@ public static function modalMaxWidth(): string public function render(): View { - return view('livewire.modals.unsplash'); + return view('livewire.modals.unsplash'); // @phpstan-ignore-line } } diff --git a/app/Mail/NewReplyEmail.php b/app/Mail/NewReplyEmail.php index 6f8cde7e..a71402f6 100644 --- a/app/Mail/NewReplyEmail.php +++ b/app/Mail/NewReplyEmail.php @@ -21,7 +21,6 @@ public function __construct( public function build(): self { - // @phpstan-ignore-next-line return $this->subject("Re: {$this->reply->replyAble->subject()}") ->markdown('emails.new_reply'); } diff --git a/app/Models/Discussion.php b/app/Models/Discussion.php index a2fded4f..2a293e42 100644 --- a/app/Models/Discussion.php +++ b/app/Models/Discussion.php @@ -68,6 +68,7 @@ final class Discussion extends Model implements ReactableInterface, ReplyInterfa ]; protected $appends = [ + // @phpstan-ignore-next-line 'count_all_replies_with_child', ]; diff --git a/app/Models/User.php b/app/Models/User.php index d2bffbb1..2be01392 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -183,7 +183,7 @@ public function getFilamentAvatarUrl(): ?string } /** - * @return array{name: string, username: string, picture: string} + * @return array{name: string, username: string, picture: string|null} */ public function profile(): array { @@ -343,6 +343,7 @@ public function hasPassword(): bool { $password = $this->getAuthPassword(); + // @phpstan-ignore-next-line return $password !== '' && $password !== null; } @@ -382,12 +383,12 @@ public function countReplies(): int public function countSolutions(): int { - return $this->replyAble()->isSolution()->count(); + return $this->replyAble()->isSolution()->count(); // @phpstan-ignore-line } public function countArticles(): int { - return $this->articles()->approved()->count(); + return $this->articles()->approved()->count(); // @phpstan-ignore-line } public function countDiscussions(): int diff --git a/app/Notifications/ThreadConvertedByAdmin.php b/app/Notifications/ThreadConvertedByAdmin.php new file mode 100644 index 00000000..dc84cda4 --- /dev/null +++ b/app/Notifications/ThreadConvertedByAdmin.php @@ -0,0 +1,41 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('pages/discussion.converted_by_admin.subject')) + ->greeting(__('pages/discussion.converted_by_admin.greeting')) + ->line(__('pages/discussion.converted_by_admin.converted_line')) + ->line(__('pages/discussion.converted_by_admin.thread_title').$this->thread->title) + ->action(__('pages/discussion.converted_by_admin.action_text'), route('forum.show', $this->thread)) + ->line(__('pages/discussion.converted_by_admin.admin_action_line')); + } +} diff --git a/app/Notifications/ThreadConvertedByCreator.php b/app/Notifications/ThreadConvertedByCreator.php new file mode 100644 index 00000000..89eb7b66 --- /dev/null +++ b/app/Notifications/ThreadConvertedByCreator.php @@ -0,0 +1,40 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage) + ->subject(__('pages/discussion.converted_by_creator')) + ->line(__('pages/discussion.converted_by_creator.converted_line')) + ->line(__('pages/discussion.converted_by_creator.thread_title').$this->thread->title) + ->action(__('pages/discussion.converted_by_creator.action_text'), route('forum.show', $this->thread)) + ->line(__('pages/discussion.converted_by_creator.thank_you_line')); + } +} diff --git a/app/Policies/DiscussionPolicy.php b/app/Policies/DiscussionPolicy.php index 274518de..7ee28b4b 100644 --- a/app/Policies/DiscussionPolicy.php +++ b/app/Policies/DiscussionPolicy.php @@ -56,4 +56,9 @@ public function report(User $user, Discussion $discussion): bool { return $user->hasVerifiedEmail() && ! $discussion->isAuthoredBy($user); } + + public function convertedToThread(User $user, Discussion $discussion): bool + { + return $discussion->isAuthoredBy($user) || $user->isAdmin(); + } } diff --git a/app/Policies/NotificationPolicy.php b/app/Policies/NotificationPolicy.php index fcea43cb..bdcd5c2b 100644 --- a/app/Policies/NotificationPolicy.php +++ b/app/Policies/NotificationPolicy.php @@ -16,6 +16,6 @@ final class NotificationPolicy public function markAsRead(User $user, DatabaseNotification $notification): bool { - return $notification->notifiable->is($user); // @phpstan-ignore-line + return $notification->notifiable->is($user); } } diff --git a/app/Spotlight/Article.php b/app/Spotlight/Article.php index e679c217..8a3adbb6 100644 --- a/app/Spotlight/Article.php +++ b/app/Spotlight/Article.php @@ -20,7 +20,7 @@ final class Article extends SpotlightCommand protected array $synonyms = []; - public function dependencies(): ?SpotlightCommandDependencies + public function dependencies(): SpotlightCommandDependencies { return SpotlightCommandDependencies::collection() ->add( diff --git a/app/Spotlight/Discussion.php b/app/Spotlight/Discussion.php index 5d1ab8f3..d5b11292 100644 --- a/app/Spotlight/Discussion.php +++ b/app/Spotlight/Discussion.php @@ -20,7 +20,7 @@ final class Discussion extends SpotlightCommand protected array $synonyms = []; - public function dependencies(): ?SpotlightCommandDependencies + public function dependencies(): SpotlightCommandDependencies { return SpotlightCommandDependencies::collection() ->add( diff --git a/app/Spotlight/Sujet.php b/app/Spotlight/Sujet.php index 2cf9aafb..9703a0f8 100644 --- a/app/Spotlight/Sujet.php +++ b/app/Spotlight/Sujet.php @@ -25,7 +25,7 @@ final class Sujet extends SpotlightCommand 'thread', ]; - public function dependencies(): ?SpotlightCommandDependencies + public function dependencies(): SpotlightCommandDependencies { return SpotlightCommandDependencies::collection() ->add( diff --git a/app/Spotlight/User.php b/app/Spotlight/User.php index 454be24b..6a12fb4f 100644 --- a/app/Spotlight/User.php +++ b/app/Spotlight/User.php @@ -20,7 +20,7 @@ final class User extends SpotlightCommand protected array $synonyms = []; - public function dependencies(): ?SpotlightCommandDependencies + public function dependencies(): SpotlightCommandDependencies { return SpotlightCommandDependencies::collection() ->add( diff --git a/app/Traits/HasReplies.php b/app/Traits/HasReplies.php index b0ae2289..68e692d3 100644 --- a/app/Traits/HasReplies.php +++ b/app/Traits/HasReplies.php @@ -54,7 +54,7 @@ public function isConversationOld(): bool $sixMonthsAgo = now()->subMonths(6); if ($reply = $this->replies()->latest()->first()) { - /** @var $reply Reply */ + /** @var Reply $reply */ return $reply->created_at->lt($sixMonthsAgo); } diff --git a/app/Traits/HasSocialite.php b/app/Traits/HasSocialite.php index 700324ac..64eea203 100644 --- a/app/Traits/HasSocialite.php +++ b/app/Traits/HasSocialite.php @@ -8,6 +8,9 @@ use Laravel\Socialite\Contracts\User; use Laravel\Socialite\Facades\Socialite; +/** + * @phpstan-ignore trait.unused + */ trait HasSocialite { /** diff --git a/app/Traits/UserResponse.php b/app/Traits/UserResponse.php index 31de80f9..e075c99f 100644 --- a/app/Traits/UserResponse.php +++ b/app/Traits/UserResponse.php @@ -8,6 +8,9 @@ use App\Http\Resources\EnterpriseResource; use App\Models\User; +/** + * @phpstan-ignore trait.unused + */ trait UserResponse { /** diff --git a/lang/en/actions.php b/lang/en/actions.php index 7d69eb4c..cf1432cc 100644 --- a/lang/en/actions.php +++ b/lang/en/actions.php @@ -12,5 +12,6 @@ 'save' => 'Save', 'ban' => 'Ban', 'unban' => 'Cancel ban', + 'confirm' => 'Confirm', ]; diff --git a/lang/en/pages/discussion.php b/lang/en/pages/discussion.php new file mode 100644 index 00000000..a263b708 --- /dev/null +++ b/lang/en/pages/discussion.php @@ -0,0 +1,41 @@ + 'Tous les sujets de discussion', + 'contributors' => [ + 'top' => 'Top Contributeurs', + 'description' => 'Les personnes qui ont lancé le plus de discussions sur le site.', + ], + 'empty' => 'Discussions sans commentaires', + 'empty_description' => 'Les discussions / sujets qui n’ont pas encore eu de commentaires. Soyez le premier à apporter votre contribution.', + 'total_answer' => 'total réponses', + 'new_discussion' => 'Nouveau discussion', + 'filter' => [ + 'recent' => 'Récent', + 'popular' => 'Populaire', + 'active' => 'Actif', + ], + 'comments_count' => 'Commentaires (:count)', + 'convert_to_thread' => 'Convert to thread', + 'confirm_conversion' => 'Confirm conversion', + 'text_confirmation' => 'Do you really want to turn this discussion into a topic?', + 'converted_by_admin' => [ + 'subject' => 'Discussion Converted to Thread by the administrator', + 'greeting' => 'Hello!', + 'converted_line' => 'An admin has converted a discussion to a thread.', + 'thread_title' => 'Thread Title: ', + 'action_text' => 'View Thread', + 'admin_action_line' => 'This action was performed by an administrator.', + ], + 'converted_by_creator' => [ + 'subject' => 'Discussion Converted to Thread', + 'converted_line' => 'A discussion you participated in has been converted to a thread.', + 'thread_title' => 'Thread Title: ', + 'action_text' => 'View Thread', + 'thank_you_line' => 'Thank you for your participation!', + ], + +]; diff --git a/lang/fr/actions.php b/lang/fr/actions.php index 7c13a7a7..3b1ab0a7 100644 --- a/lang/fr/actions.php +++ b/lang/fr/actions.php @@ -12,5 +12,6 @@ 'save' => 'Enregistrer', 'ban' => 'Bannir', 'unban' => 'Dé-bannir', + 'confirm' => 'Confirmer', ]; diff --git a/lang/fr/pages/discussion.php b/lang/fr/pages/discussion.php index cc1551b9..1e6d0f7a 100644 --- a/lang/fr/pages/discussion.php +++ b/lang/fr/pages/discussion.php @@ -19,5 +19,23 @@ 'active' => 'Actif', ], 'comments_count' => 'Commentaires (:count)', + 'convert_to_thread' => 'Convertir en sujet', + 'confirm_conversion' => 'Confirmez la conversion', + 'text_confirmation' => 'Voulez-vous vraiment transformer cette discussion en sujet de forum?', + 'converted_by_admin' => [ + 'subject' => 'Discussion convertie en sujet par l\'administrateur', + 'greeting' => 'Bonjour !', + 'converted_line' => 'Un administrateur a converti votre discussion en sujet de forum.', + 'thread_title' => 'Titre du sujet : ', + 'action_text' => 'Voir le sujet', + 'admin_action_line' => 'Cette action a été effectuée par un administrateur.', + ], + 'converted_by_creator' => [ + 'subject' => 'Discussion convertie en sujet par le createur', + 'converted_line' => 'Une discussion à laquelle vous avez participé a été convertie en fil de discussion.', + 'thread_title' => 'Titre du fil : ', + 'action_text' => 'Voir le fil', + 'thank_you_line' => 'Merci pour votre participation !', + ], ]; diff --git a/resources/views/livewire/modals/convert-discussion.blade.php b/resources/views/livewire/modals/convert-discussion.blade.php new file mode 100644 index 00000000..82340bcb --- /dev/null +++ b/resources/views/livewire/modals/convert-discussion.blade.php @@ -0,0 +1,35 @@ + + + {{ __("pages/discussion.confirm_conversion") }} + + +
+
+
+ + {{ __("pages/discussion.text_confirmation") }} + +
+
+
+ + + + + {{ __('actions.cancel') }} + + +
diff --git a/resources/views/livewire/pages/discussions/single-discussion.blade.php b/resources/views/livewire/pages/discussions/single-discussion.blade.php index e44bec21..4119d8a2 100644 --- a/resources/views/livewire/pages/discussions/single-discussion.blade.php +++ b/resources/views/livewire/pages/discussions/single-discussion.blade.php @@ -95,18 +95,27 @@ class="mx-auto mt-6 text-sm prose prose-sm prose-green max-w-none dark:prose-inv
- {{ __('Éditer') }} + {{ __('action.edit') }} · + @can('convertedToThread', $discussion) + · + + @endcan
@endcan diff --git a/tests/Feature/Actions/Discussion/ConvertDiscussionToThreadActionTest.php b/tests/Feature/Actions/Discussion/ConvertDiscussionToThreadActionTest.php new file mode 100644 index 00000000..8d052fd6 --- /dev/null +++ b/tests/Feature/Actions/Discussion/ConvertDiscussionToThreadActionTest.php @@ -0,0 +1,55 @@ +user = $this->login(); + $this->discussion = Discussion::factory()->create(['user_id' => $this->user->id]); + Role::create(['name' => 'admin']); + Notification::fake(); +}); + +describe(ConvertDiscussionToThreadAction::class, function (): void { + it('allows discussion author to convert his discussion to a forum topic', function (): void { + $replies = Reply::factory()->count(3)->create([ + 'replyable_type' => 'discussion', + 'replyable_id' => $this->discussion->id, + ]); + + $thread = app(ConvertDiscussionToThreadAction::class)->execute(discussion: $this->discussion); + + expect($thread)->toBeInstanceOf(Thread::class) + ->and(Discussion::find($this->discussion->id))->toBeNull(); + + $replies->each(function ($reply) use ($thread): void { + $updatedReply = Reply::find($reply->id); + + expect($updatedReply->replyable_type)->toBe('thread') + ->and($updatedReply->replyable_id)->toBe($thread->id); + }); + + Notification::assertCount(3); + }); + + it('allows admin users to convert any discussion to a forum topic', function (): void { + $this->user->assignRole('admin'); + + Reply::factory()->count(3)->create([ + 'replyable_type' => 'discussion', + 'replyable_id' => $this->discussion->id, + ]); + + $thread = app(ConvertDiscussionToThreadAction::class)->execute(discussion: $this->discussion); + + expect($thread)->toBeInstanceOf(Thread::class); + + Notification::assertCount(4); + }); +}); diff --git a/tests/Feature/Livewire/Modal/ConvertDiscussionTest.php b/tests/Feature/Livewire/Modal/ConvertDiscussionTest.php new file mode 100644 index 00000000..34363912 --- /dev/null +++ b/tests/Feature/Livewire/Modal/ConvertDiscussionTest.php @@ -0,0 +1,30 @@ +user = $this->login(); +}); + +describe(ConvertDiscussion::class, function (): void { + it('requires authorization for user to convert discussion', function (): void { + $user = User::factory()->create(); + $discussion = Discussion::factory()->create(['user_id' => $user->id]); + + Livewire::test(ConvertDiscussion::class) + ->set('discussionId', $discussion->id) + ->call('save') + ->assertForbidden(); + }); + + it('throws exception for non-existent discussion', function (): void { + Livewire::test(ConvertDiscussion::class) + ->set('discussionId', 9) + ->call('save'); + })->throws(Illuminate\Database\Eloquent\ModelNotFoundException::class); +});