commit 29fcbd8aecc03b42a8919ed1613ec2a0bfc35b57 Author: manukminasyan Date: Thu Mar 26 23:02:56 2026 +0400 feat: initial release of relaticle/comments Filament comments package with: - Polymorphic commenting on any Eloquent model - Threaded replies with configurable depth - @mentions with autocomplete and user search - Emoji reactions with toggle and who-reacted tooltips - File attachments via Livewire uploads - Reply and mention notifications via Filament notification system - Thread subscriptions for notification control - Real-time broadcasting (opt-in Echo) with polling fallback - Dark mode support - CommentsAction, CommentsTableAction, CommentsEntry for Filament integration - 204 tests, 421 assertions diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7b81ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/vendor +/node_modules +/.phpunit.cache +.phpunit.result.cache +composer.lock diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..46a5a8a --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "relaticle/comments", + "description": "A full-featured commenting system for Filament panels", + "type": "library", + "license": "MIT", + "require": { + "php": "^8.2", + "filament/filament": "^4.0|^5.0", + "filament/notifications": "^4.0|^5.0", + "filament/support": "^4.0|^5.0", + "illuminate/database": "^12.0|^13.0", + "illuminate/support": "^12.0|^13.0", + "livewire/livewire": "^3.5|^4.0", + "spatie/laravel-package-tools": "^1.93" + }, + "require-dev": { + "laravel/pint": "^1.0", + "larastan/larastan": "^3.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.0", + "pestphp/pest-plugin-livewire": "^3.0|^4.0" + }, + "autoload": { + "psr-4": { + "Relaticle\\Comments\\": "src/", + "Relaticle\\Comments\\Database\\Factories\\": "database/factories/" + } + }, + "autoload-dev": { + "psr-4": { + "Relaticle\\Comments\\Tests\\": "tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Relaticle\\Comments\\CommentsServiceProvider" + ] + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/config/comments.php b/config/comments.php new file mode 100644 index 0000000..0552125 --- /dev/null +++ b/config/comments.php @@ -0,0 +1,88 @@ + [ + 'comments' => 'comments', + ], + + 'models' => [ + 'comment' => Comment::class, + ], + + 'commenter' => [ + 'model' => User::class, + ], + + 'policy' => CommentPolicy::class, + + 'threading' => [ + 'max_depth' => 2, + ], + + 'pagination' => [ + 'per_page' => 10, + ], + + 'reactions' => [ + 'emoji_set' => [ + 'thumbs_up' => "\u{1F44D}", + 'heart' => "\u{2764}\u{FE0F}", + 'celebrate' => "\u{1F389}", + 'laugh' => "\u{1F604}", + 'thinking' => "\u{1F914}", + 'sad' => "\u{1F622}", + ], + ], + + 'mentions' => [ + 'resolver' => DefaultMentionResolver::class, + 'max_results' => 5, + ], + + 'editor' => [ + 'toolbar' => [ + ['bold', 'italic', 'strike', 'link'], + ['bulletList', 'orderedList'], + ['codeBlock'], + ], + ], + + 'notifications' => [ + 'channels' => ['database'], + 'enabled' => true, + ], + + 'subscriptions' => [ + 'auto_subscribe' => true, + ], + + 'attachments' => [ + 'enabled' => true, + 'disk' => 'public', + 'max_size' => 10240, + 'allowed_types' => [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], + ], + + 'broadcasting' => [ + 'enabled' => false, + 'channel_prefix' => 'comments', + ], + + 'polling' => [ + 'interval' => '10s', + ], +]; diff --git a/database/factories/.gitkeep b/database/factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/database/factories/CommentFactory.php b/database/factories/CommentFactory.php new file mode 100644 index 0000000..55cdeec --- /dev/null +++ b/database/factories/CommentFactory.php @@ -0,0 +1,34 @@ + */ + public function definition(): array + { + return [ + 'body' => '

'.fake()->paragraph().'

', + 'edited_at' => null, + ]; + } + + public function withParent(?Comment $parent = null): static + { + return $this->state(fn (array $attributes) => [ + 'parent_id' => $parent?->id, + ]); + } + + public function edited(): static + { + return $this->state(fn (array $attributes) => [ + 'edited_at' => now(), + ]); + } +} diff --git a/database/migrations/create_comment_attachments_table.php.stub b/database/migrations/create_comment_attachments_table.php.stub new file mode 100644 index 0000000..a04f830 --- /dev/null +++ b/database/migrations/create_comment_attachments_table.php.stub @@ -0,0 +1,24 @@ +id(); + $table->foreignId('comment_id') + ->constrained(config('comments.tables.comments', 'comments')) + ->cascadeOnDelete(); + $table->string('file_path'); + $table->string('original_name'); + $table->string('mime_type'); + $table->unsignedBigInteger('size'); + $table->string('disk'); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/create_comment_mentions_table.php.stub b/database/migrations/create_comment_mentions_table.php.stub new file mode 100644 index 0000000..f48e957 --- /dev/null +++ b/database/migrations/create_comment_mentions_table.php.stub @@ -0,0 +1,22 @@ +id(); + $table->foreignId('comment_id') + ->constrained(config('comments.tables.comments', 'comments')) + ->cascadeOnDelete(); + $table->morphs('user'); + $table->timestamps(); + + $table->unique(['comment_id', 'user_id', 'user_type']); + }); + } +}; diff --git a/database/migrations/create_comment_reactions_table.php.stub b/database/migrations/create_comment_reactions_table.php.stub new file mode 100644 index 0000000..79422c2 --- /dev/null +++ b/database/migrations/create_comment_reactions_table.php.stub @@ -0,0 +1,23 @@ +id(); + $table->foreignId('comment_id') + ->constrained(config('comments.tables.comments', 'comments')) + ->cascadeOnDelete(); + $table->morphs('user'); + $table->string('reaction'); + $table->timestamps(); + + $table->unique(['comment_id', 'user_id', 'user_type', 'reaction']); + }); + } +}; diff --git a/database/migrations/create_comment_subscriptions_table.php.stub b/database/migrations/create_comment_subscriptions_table.php.stub new file mode 100644 index 0000000..f5872b9 --- /dev/null +++ b/database/migrations/create_comment_subscriptions_table.php.stub @@ -0,0 +1,20 @@ +id(); + $table->morphs('commentable'); + $table->morphs('user'); + $table->timestamp('created_at')->nullable(); + + $table->unique(['commentable_type', 'commentable_id', 'user_type', 'user_id'], 'comment_subscriptions_unique'); + }); + } +}; diff --git a/database/migrations/create_comments_table.php.stub b/database/migrations/create_comments_table.php.stub new file mode 100644 index 0000000..80c0694 --- /dev/null +++ b/database/migrations/create_comments_table.php.stub @@ -0,0 +1,27 @@ +id(); + $table->morphs('commentable'); + $table->morphs('user'); + $table->foreignId('parent_id') + ->nullable() + ->constrained(config('comments.tables.comments', 'comments')) + ->cascadeOnDelete(); + $table->text('body'); + $table->timestamp('edited_at')->nullable(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['commentable_type', 'commentable_id', 'parent_id']); + }); + } +}; diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..ef61ab7 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,7 @@ +includes: + - ./vendor/larastan/larastan/extension.neon + +parameters: + paths: + - src + level: 5 diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..cb3257c --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,20 @@ + + + + + ./tests/Feature + + + + + ./src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..cbb9859 --- /dev/null +++ b/pint.json @@ -0,0 +1 @@ +{"preset": "laravel"} diff --git a/resources/lang/en/comments.php b/resources/lang/en/comments.php new file mode 100644 index 0000000..840703b --- /dev/null +++ b/resources/lang/en/comments.php @@ -0,0 +1,9 @@ + 'This comment was deleted.', + 'edited' => 'edited', + 'load_more' => 'Load more comments', + 'no_comments' => 'No comments yet.', + 'comment_placeholder' => 'Write a comment...', +]; diff --git a/resources/views/.gitkeep b/resources/views/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/views/filament/comments-action.blade.php b/resources/views/filament/comments-action.blade.php new file mode 100644 index 0000000..56eba08 --- /dev/null +++ b/resources/views/filament/comments-action.blade.php @@ -0,0 +1,3 @@ +
+ +
diff --git a/resources/views/filament/infolists/components/comments-entry.blade.php b/resources/views/filament/infolists/components/comments-entry.blade.php new file mode 100644 index 0000000..f96ee57 --- /dev/null +++ b/resources/views/filament/infolists/components/comments-entry.blade.php @@ -0,0 +1,5 @@ + +
+ +
+
diff --git a/resources/views/livewire/comment-item.blade.php b/resources/views/livewire/comment-item.blade.php new file mode 100644 index 0000000..2d5e06e --- /dev/null +++ b/resources/views/livewire/comment-item.blade.php @@ -0,0 +1,246 @@ +
+ {{-- Avatar --}} +
+ @if ($comment->trashed()) +
+ @elseif ($comment->user?->getCommentAvatarUrl()) + {{ $comment->user->getCommentName() }} + @else +
+ {{ str($comment->user?->getCommentName() ?? '?')->substr(0, 1)->upper() }} +
+ @endif +
+ +
+ {{-- Deleted placeholder --}} + @if ($comment->trashed()) +

This comment has been deleted

+ @else + {{-- Header: name + timestamp --}} +
+ + {{ $comment->user?->getCommentName() ?? 'Unknown' }} + + + {{ $comment->created_at->diffForHumans() }} + + @if ($comment->isEdited()) + (edited) + @endif +
+ + {{-- Body or edit form --}} + @if ($isEditing) +
+ + @error('editBody') +

{{ $message }}

+ @enderror +
+ + +
+
+ @else +
+ {!! $comment->renderBodyWithMentions() !!} +
+ + {{-- Attachments --}} + @if ($comment->attachments->isNotEmpty()) +
+ @foreach ($comment->attachments as $attachment) + @if ($attachment->isImage()) + + {{ $attachment->original_name }} + + @else + + + + + {{ $attachment->original_name }} + ({{ $attachment->formattedSize() }}) + + @endif + @endforeach +
+ @endif + + {{-- Reactions --}} + + @endif + + {{-- Actions: Reply, Edit, Delete --}} +
+ @auth + @if ($comment->canReply()) + @can('reply', $comment) + + @endcan + @endif + + @can('update', $comment) + + @endcan + + @can('delete', $comment) + + @endcan + @endauth +
+ @endif + + {{-- Reply form --}} + @if ($isReplying) +
+ + + {{-- Mention autocomplete dropdown --}} +
+ +
+ + @error('replyBody') +

{{ $message }}

+ @enderror + + @if (\Relaticle\Comments\Config::areAttachmentsEnabled()) +
+ +
+ + @if (!empty($replyAttachments)) +
+ @foreach ($replyAttachments as $index => $file) +
+ {{ $file->getClientOriginalName() }} + +
+ @endforeach +
+ @endif + + @error('replyAttachments.*') +

{{ $message }}

+ @enderror + @endif + +
+ + +
+
+ @endif + + {{-- Nested replies --}} + @if ($comment->replies->isNotEmpty()) +
+ @foreach ($comment->replies as $reply) + + @endforeach +
+ @endif +
+
diff --git a/resources/views/livewire/comments.blade.php b/resources/views/livewire/comments.blade.php new file mode 100644 index 0000000..6ea7fa7 --- /dev/null +++ b/resources/views/livewire/comments.blade.php @@ -0,0 +1,193 @@ +
+ {{-- Sort toggle --}} +
+

+ Comments ({{ $this->totalCount }}) +

+ @auth +
+ + +
+ @else + + @endauth +
+ + {{-- Comment list --}} +
+ @foreach ($this->comments as $comment) + + @endforeach +
+ + {{-- Load more button --}} + @if ($this->hasMore) +
+ +
+ @endif + + {{-- New comment form - only for authorized users --}} + @auth + @can('create', \Relaticle\Comments\Config::getCommentModel()) +
+ + + {{-- Mention autocomplete dropdown --}} +
+ +
+ + @error('newComment') +

{{ $message }}

+ @enderror + + @if (\Relaticle\Comments\Config::areAttachmentsEnabled()) +
+ +
+ + @if (!empty($attachments)) +
+ @foreach ($attachments as $index => $file) +
+ {{ $file->getClientOriginalName() }} + +
+ @endforeach +
+ @endif + + @error('attachments.*') +

{{ $message }}

+ @enderror + @endif + +
+ +
+
+ @endcan + @endauth +
diff --git a/resources/views/livewire/reactions.blade.php b/resources/views/livewire/reactions.blade.php new file mode 100644 index 0000000..49ac295 --- /dev/null +++ b/resources/views/livewire/reactions.blade.php @@ -0,0 +1,38 @@ +
+ {{-- Existing reactions with counts --}} + @foreach ($this->reactionSummary as $summary) + + @endforeach + + {{-- Add reaction button --}} + @auth +
+ + + {{-- Emoji picker dropdown --}} +
+ @foreach (\Relaticle\Comments\Config::getReactionEmojiSet() as $key => $emoji) + + @endforeach +
+
+ @endauth +
diff --git a/src/Comment.php b/src/Comment.php new file mode 100644 index 0000000..7e7f15f --- /dev/null +++ b/src/Comment.php @@ -0,0 +1,159 @@ +body = Str::sanitizeHtml($comment->body); + }); + + static::forceDeleting(function (self $comment): void { + $comment->attachments()->delete(); + $comment->reactions()->delete(); + $comment->mentions()->detach(); + }); + } + + protected $fillable = [ + 'body', + 'parent_id', + 'user_id', + 'user_type', + 'edited_at', + ]; + + public function getTable(): string + { + return Config::getCommentTable(); + } + + /** @return array */ + protected function casts(): array + { + return [ + 'edited_at' => 'datetime', + ]; + } + + protected static function newFactory(): CommentFactory + { + return CommentFactory::new(); + } + + public function commentable(): MorphTo + { + return $this->morphTo(); + } + + public function user(): MorphTo + { + return $this->morphTo(); + } + + public function parent(): BelongsTo + { + return $this->belongsTo(Config::getCommentModel(), 'parent_id'); + } + + public function replies(): HasMany + { + return $this->hasMany(Config::getCommentModel(), 'parent_id'); + } + + public function reactions(): HasMany + { + return $this->hasMany(CommentReaction::class); + } + + public function attachments(): HasMany + { + return $this->hasMany(CommentAttachment::class); + } + + public function mentions(): MorphToMany + { + return $this->morphedByMany( + Config::getCommenterModel(), + 'user', + 'comment_mentions', + 'comment_id', + 'user_id', + ); + } + + public function isReply(): bool + { + return $this->parent_id !== null; + } + + public function isTopLevel(): bool + { + return $this->parent_id === null; + } + + public function hasReplies(): bool + { + return $this->replies()->exists(); + } + + public function isEdited(): bool + { + return $this->edited_at !== null; + } + + public function canReply(): bool + { + return $this->depth() < Config::getMaxDepth(); + } + + public function depth(): int + { + $depth = 0; + $comment = $this; + + while ($comment->parent_id !== null) { + $comment = $comment->parent; + $depth++; + + if ($depth >= Config::getMaxDepth()) { + return Config::getMaxDepth(); + } + } + + return $depth; + } + + public function renderBodyWithMentions(): string + { + $body = $this->body; + $mentionNames = $this->mentions->pluck('name')->filter()->unique(); + + foreach ($mentionNames as $name) { + $escapedName = e($name); + $styledSpan = '@'.$escapedName.''; + + $body = str_replace("@{$name}", $styledSpan, $body); + $body = str_replace("@{$name}", $styledSpan, $body); + } + + return $body; + } +} diff --git a/src/CommentAttachment.php b/src/CommentAttachment.php new file mode 100644 index 0000000..b08bdfb --- /dev/null +++ b/src/CommentAttachment.php @@ -0,0 +1,45 @@ +belongsTo(Config::getCommentModel()); + } + + public function isImage(): bool + { + return str_starts_with($this->mime_type, 'image/'); + } + + public function url(): string + { + return Storage::disk($this->disk)->url($this->file_path); + } + + public function formattedSize(): string + { + return Number::fileSize($this->size); + } +} diff --git a/src/CommentReaction.php b/src/CommentReaction.php new file mode 100644 index 0000000..01624eb --- /dev/null +++ b/src/CommentReaction.php @@ -0,0 +1,32 @@ +belongsTo(Config::getCommentModel()); + } + + public function user(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/CommentSubscription.php b/src/CommentSubscription.php new file mode 100644 index 0000000..72828c1 --- /dev/null +++ b/src/CommentSubscription.php @@ -0,0 +1,73 @@ +morphTo(); + } + + public function user(): MorphTo + { + return $this->morphTo(); + } + + public static function isSubscribed(Model $commentable, Model $user): bool + { + return static::where([ + 'commentable_type' => $commentable->getMorphClass(), + 'commentable_id' => $commentable->getKey(), + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->getKey(), + ])->exists(); + } + + public static function subscribe(Model $commentable, Model $user): void + { + static::firstOrCreate([ + 'commentable_type' => $commentable->getMorphClass(), + 'commentable_id' => $commentable->getKey(), + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->getKey(), + ]); + } + + public static function unsubscribe(Model $commentable, Model $user): void + { + static::where([ + 'commentable_type' => $commentable->getMorphClass(), + 'commentable_id' => $commentable->getKey(), + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->getKey(), + ])->delete(); + } + + /** @return Collection */ + public static function subscribersFor(Model $commentable): Collection + { + return static::where([ + 'commentable_type' => $commentable->getMorphClass(), + 'commentable_id' => $commentable->getKey(), + ])->with('user')->get()->pluck('user')->filter()->values(); + } +} diff --git a/src/CommentsPlugin.php b/src/CommentsPlugin.php new file mode 100644 index 0000000..18e3b58 --- /dev/null +++ b/src/CommentsPlugin.php @@ -0,0 +1,37 @@ +getId()); + + return $plugin; + } +} diff --git a/src/CommentsServiceProvider.php b/src/CommentsServiceProvider.php new file mode 100644 index 0000000..d10b7d9 --- /dev/null +++ b/src/CommentsServiceProvider.php @@ -0,0 +1,68 @@ +name(static::$name) + ->hasConfigFile() + ->hasViews(static::$viewNamespace) + ->hasTranslations() + ->hasMigrations([ + 'create_comments_table', + 'create_comment_mentions_table', + 'create_comment_reactions_table', + 'create_comment_subscriptions_table', + 'create_comment_attachments_table', + ]); + } + + public function packageRegistered(): void + { + Relation::morphMap([ + 'comment' => Config::getCommentModel(), + ]); + + $this->app->bind( + MentionResolver::class, + fn () => new (Config::getMentionResolver()) + ); + } + + public function packageBooted(): void + { + Gate::policy( + Config::getCommentModel(), + Config::getPolicyClass(), + ); + + Event::listen(CommentCreated::class, SendCommentRepliedNotification::class); + Event::listen(UserMentioned::class, SendUserMentionedNotification::class); + + Livewire::component('comments', Comments::class); + Livewire::component('comment-item', CommentItem::class); + Livewire::component('reactions', Reactions::class); + } +} diff --git a/src/Concerns/.gitkeep b/src/Concerns/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Concerns/HasComments.php b/src/Concerns/HasComments.php new file mode 100644 index 0000000..041a3f5 --- /dev/null +++ b/src/Concerns/HasComments.php @@ -0,0 +1,24 @@ +morphMany(Config::getCommentModel(), 'commentable'); + } + + public function topLevelComments(): MorphMany + { + return $this->comments()->whereNull('parent_id'); + } + + public function commentCount(): int + { + return $this->comments()->count(); + } +} diff --git a/src/Concerns/IsCommenter.php b/src/Concerns/IsCommenter.php new file mode 100644 index 0000000..cf62707 --- /dev/null +++ b/src/Concerns/IsCommenter.php @@ -0,0 +1,27 @@ +getFilamentName(); + } + + return $this->name ?? 'Unknown'; + } + + public function getCommentAvatarUrl(): ?string + { + if ($this instanceof HasAvatar) { + return $this->getFilamentAvatarUrl(); + } + + return null; + } +} diff --git a/src/Config.php b/src/Config.php new file mode 100644 index 0000000..872de83 --- /dev/null +++ b/src/Config.php @@ -0,0 +1,157 @@ +> */ + public static function getEditorToolbar(): array + { + return (array) config('comments.editor.toolbar', [ + ['bold', 'italic', 'strike', 'link'], + ['bulletList', 'orderedList'], + ['codeBlock'], + ]); + } + + public static function getPolicyClass(): string + { + return config('comments.policy', CommentPolicy::class); + } + + public static function getMentionResolver(): string + { + return config('comments.mentions.resolver', DefaultMentionResolver::class); + } + + public static function getMentionMaxResults(): int + { + return (int) config('comments.mentions.max_results', 5); + } + + /** @return array */ + public static function getReactionEmojiSet(): array + { + return (array) config('comments.reactions.emoji_set', [ + 'thumbs_up' => "\u{1F44D}", + 'heart' => "\u{2764}\u{FE0F}", + 'celebrate' => "\u{1F389}", + 'laugh' => "\u{1F604}", + 'thinking' => "\u{1F914}", + 'sad' => "\u{1F622}", + ]); + } + + /** @return array */ + public static function getAllowedReactions(): array + { + return array_keys(static::getReactionEmojiSet()); + } + + /** @return array */ + public static function getNotificationChannels(): array + { + return (array) config('comments.notifications.channels', ['database']); + } + + public static function areNotificationsEnabled(): bool + { + return (bool) config('comments.notifications.enabled', true); + } + + public static function shouldAutoSubscribe(): bool + { + return (bool) config('comments.subscriptions.auto_subscribe', true); + } + + public static function areAttachmentsEnabled(): bool + { + return (bool) config('comments.attachments.enabled', true); + } + + public static function getAttachmentDisk(): string + { + return (string) config('comments.attachments.disk', 'public'); + } + + public static function getAttachmentMaxSize(): int + { + return (int) config('comments.attachments.max_size', 10240); + } + + /** @return array */ + public static function getAttachmentAllowedTypes(): array + { + return (array) config('comments.attachments.allowed_types', [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ]); + } + + public static function isBroadcastingEnabled(): bool + { + return (bool) config('comments.broadcasting.enabled', false); + } + + public static function getBroadcastChannelPrefix(): string + { + return (string) config('comments.broadcasting.channel_prefix', 'comments'); + } + + public static function getPollingInterval(): string + { + return (string) config('comments.polling.interval', '10s'); + } + + public static function resolveAuthenticatedUser(): ?object + { + if (static::$resolveAuthenticatedUser) { + return call_user_func(static::$resolveAuthenticatedUser); + } + + return auth()->user(); + } + + public static function resolveAuthenticatedUserUsing(Closure $callback): void + { + static::$resolveAuthenticatedUser = $callback; + } +} diff --git a/src/Contracts/.gitkeep b/src/Contracts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Contracts/Commentable.php b/src/Contracts/Commentable.php new file mode 100644 index 0000000..f571a05 --- /dev/null +++ b/src/Contracts/Commentable.php @@ -0,0 +1,14 @@ + */ + public function search(string $query): Collection; + + /** @return Collection */ + public function resolveByNames(array $names): Collection; +} diff --git a/src/Events/CommentCreated.php b/src/Events/CommentCreated.php new file mode 100644 index 0000000..d5c8d9f --- /dev/null +++ b/src/Events/CommentCreated.php @@ -0,0 +1,51 @@ +commentable = $comment->commentable; + } + + /** @return array */ + public function broadcastOn(): array + { + $prefix = Config::getBroadcastChannelPrefix(); + + return [ + new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), + ]; + } + + public function broadcastWhen(): bool + { + return Config::isBroadcastingEnabled(); + } + + /** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */ + public function broadcastWith(): array + { + return [ + 'comment_id' => $this->comment->id, + 'commentable_type' => $this->comment->commentable_type, + 'commentable_id' => $this->comment->commentable_id, + ]; + } +} diff --git a/src/Events/CommentDeleted.php b/src/Events/CommentDeleted.php new file mode 100644 index 0000000..05e87a5 --- /dev/null +++ b/src/Events/CommentDeleted.php @@ -0,0 +1,51 @@ +commentable = $comment->commentable; + } + + /** @return array */ + public function broadcastOn(): array + { + $prefix = Config::getBroadcastChannelPrefix(); + + return [ + new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), + ]; + } + + public function broadcastWhen(): bool + { + return Config::isBroadcastingEnabled(); + } + + /** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */ + public function broadcastWith(): array + { + return [ + 'comment_id' => $this->comment->id, + 'commentable_type' => $this->comment->commentable_type, + 'commentable_id' => $this->comment->commentable_id, + ]; + } +} diff --git a/src/Events/CommentReacted.php b/src/Events/CommentReacted.php new file mode 100644 index 0000000..3eaaead --- /dev/null +++ b/src/Events/CommentReacted.php @@ -0,0 +1,50 @@ + */ + public function broadcastOn(): array + { + $prefix = Config::getBroadcastChannelPrefix(); + + return [ + new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), + ]; + } + + public function broadcastWhen(): bool + { + return Config::isBroadcastingEnabled(); + } + + /** @return array{comment_id: int|string, reaction: string, action: string} */ + public function broadcastWith(): array + { + return [ + 'comment_id' => $this->comment->id, + 'reaction' => $this->reaction, + 'action' => $this->action, + ]; + } +} diff --git a/src/Events/CommentUpdated.php b/src/Events/CommentUpdated.php new file mode 100644 index 0000000..64c05ba --- /dev/null +++ b/src/Events/CommentUpdated.php @@ -0,0 +1,51 @@ +commentable = $comment->commentable; + } + + /** @return array */ + public function broadcastOn(): array + { + $prefix = Config::getBroadcastChannelPrefix(); + + return [ + new PrivateChannel("{$prefix}.{$this->comment->commentable_type}.{$this->comment->commentable_id}"), + ]; + } + + public function broadcastWhen(): bool + { + return Config::isBroadcastingEnabled(); + } + + /** @return array{comment_id: int|string, commentable_type: string, commentable_id: int|string} */ + public function broadcastWith(): array + { + return [ + 'comment_id' => $this->comment->id, + 'commentable_type' => $this->comment->commentable_type, + 'commentable_id' => $this->comment->commentable_id, + ]; + } +} diff --git a/src/Events/UserMentioned.php b/src/Events/UserMentioned.php new file mode 100644 index 0000000..d192f6d --- /dev/null +++ b/src/Events/UserMentioned.php @@ -0,0 +1,19 @@ +label(__('Comments')) + ->icon('heroicon-o-chat-bubble-left-right') + ->slideOver() + ->modalHeading(__('Comments')) + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalContent(function (): View { + return view('comments::filament.comments-action', [ + 'record' => $this->getRecord(), + ]); + }) + ->badge(function (): ?int { + $record = $this->getRecord(); + + if (! $record) { + return null; + } + + if (! in_array(HasComments::class, class_uses_recursive($record))) { + return null; + } + + $count = $record->commentCount(); + + return $count > 0 ? $count : null; + }); + } + + public static function getDefaultName(): ?string + { + return 'comments'; + } +} diff --git a/src/Filament/Actions/CommentsTableAction.php b/src/Filament/Actions/CommentsTableAction.php new file mode 100644 index 0000000..bb756d4 --- /dev/null +++ b/src/Filament/Actions/CommentsTableAction.php @@ -0,0 +1,48 @@ +label(__('Comments')) + ->icon('heroicon-o-chat-bubble-left-right') + ->slideOver() + ->modalHeading(__('Comments')) + ->modalSubmitAction(false) + ->modalCancelAction(false) + ->modalContent(function (): View { + return view('comments::filament.comments-action', [ + 'record' => $this->getRecord(), + ]); + }) + ->badge(function (): ?int { + $record = $this->getRecord(); + + if (! $record) { + return null; + } + + if (! in_array(HasComments::class, class_uses_recursive($record))) { + return null; + } + + $count = $record->commentCount(); + + return $count > 0 ? $count : null; + }); + } + + public static function getDefaultName(): ?string + { + return 'comments'; + } +} diff --git a/src/Filament/Infolists/Components/CommentsEntry.php b/src/Filament/Infolists/Components/CommentsEntry.php new file mode 100644 index 0000000..fd0f0ae --- /dev/null +++ b/src/Filament/Infolists/Components/CommentsEntry.php @@ -0,0 +1,17 @@ +columnSpanFull(); + } +} diff --git a/src/Listeners/SendCommentRepliedNotification.php b/src/Listeners/SendCommentRepliedNotification.php new file mode 100644 index 0000000..312a63f --- /dev/null +++ b/src/Listeners/SendCommentRepliedNotification.php @@ -0,0 +1,43 @@ +comment; + $commentable = $event->commentable; + + if (Config::shouldAutoSubscribe()) { + CommentSubscription::subscribe($commentable, $comment->user); + } + + if (! $comment->isReply()) { + return; + } + + $subscribers = CommentSubscription::subscribersFor($commentable); + + $recipients = $subscribers->filter(function ($user) use ($comment) { + return ! ($user->getMorphClass() === $comment->user->getMorphClass() + && $user->getKey() === $comment->user->getKey()); + }); + + if ($recipients->isEmpty()) { + return; + } + + Notification::send($recipients, new CommentRepliedNotification($comment)); + } +} diff --git a/src/Listeners/SendUserMentionedNotification.php b/src/Listeners/SendUserMentionedNotification.php new file mode 100644 index 0000000..44b214b --- /dev/null +++ b/src/Listeners/SendUserMentionedNotification.php @@ -0,0 +1,34 @@ +comment; + $mentionedUser = $event->mentionedUser; + + if (Config::shouldAutoSubscribe()) { + CommentSubscription::subscribe($comment->commentable, $mentionedUser); + } + + $isSelf = $mentionedUser->getMorphClass() === $comment->user->getMorphClass() + && $mentionedUser->getKey() === $comment->user->getKey(); + + if ($isSelf) { + return; + } + + $mentionedUser->notify(new UserMentionedNotification($comment, $comment->user)); + } +} diff --git a/src/Livewire/CommentItem.php b/src/Livewire/CommentItem.php new file mode 100644 index 0000000..036f505 --- /dev/null +++ b/src/Livewire/CommentItem.php @@ -0,0 +1,183 @@ + */ + public array $replyAttachments = []; + + public function mount(Comment $comment): void + { + $this->comment = $comment; + } + + public function startEdit(): void + { + $this->authorize('update', $this->comment); + + $this->isEditing = true; + $this->editBody = $this->comment->body; + } + + public function cancelEdit(): void + { + $this->isEditing = false; + $this->editBody = ''; + } + + public function saveEdit(): void + { + $this->authorize('update', $this->comment); + + $this->validate([ + 'editBody' => ['required', 'string', 'min:1'], + ]); + + $this->comment->update([ + 'body' => $this->editBody, + 'edited_at' => now(), + ]); + + event(new CommentUpdated($this->comment->fresh())); + + app(MentionParser::class)->syncMentions($this->comment->fresh()); + + $this->dispatch('commentUpdated'); + + $this->isEditing = false; + $this->editBody = ''; + } + + public function deleteComment(): void + { + $this->authorize('delete', $this->comment); + + $this->comment->delete(); + + event(new CommentDeleted($this->comment)); + + $this->dispatch('commentDeleted'); + } + + public function startReply(): void + { + if (! $this->comment->canReply()) { + return; + } + + $this->isReplying = true; + } + + public function cancelReply(): void + { + $this->isReplying = false; + $this->replyBody = ''; + $this->replyAttachments = []; + } + + public function addReply(): void + { + $this->authorize('reply', $this->comment); + + $rules = ['replyBody' => ['required', 'string', 'min:1']]; + + if (Config::areAttachmentsEnabled()) { + $maxSize = Config::getAttachmentMaxSize(); + $allowedTypes = implode(',', Config::getAttachmentAllowedTypes()); + $rules['replyAttachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"]; + } + + $this->validate($rules); + + $user = Config::resolveAuthenticatedUser(); + + $reply = $this->comment->commentable->comments()->create([ + 'body' => $this->replyBody, + 'parent_id' => $this->comment->id, + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + if (Config::areAttachmentsEnabled() && ! empty($this->replyAttachments)) { + $disk = Config::getAttachmentDisk(); + + foreach ($this->replyAttachments as $file) { + $path = $file->store("comments/attachments/{$reply->id}", $disk); + + $reply->attachments()->create([ + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'disk' => $disk, + ]); + } + } + + event(new CommentCreated($reply)); + + app(MentionParser::class)->syncMentions($reply); + + $this->dispatch('commentUpdated'); + + $this->isReplying = false; + $this->replyBody = ''; + $this->replyAttachments = []; + } + + public function removeReplyAttachment(int $index): void + { + $attachments = $this->replyAttachments; + unset($attachments[$index]); + $this->replyAttachments = array_values($attachments); + } + + /** @return array */ + public function searchUsers(string $query): array + { + if (mb_strlen($query) < 1) { + return []; + } + + $resolver = app(MentionResolver::class); + + return $resolver->search($query) + ->map(fn ($user) => [ + 'id' => $user->getKey(), + 'name' => $user->getCommentName(), + 'avatar_url' => $user->getCommentAvatarUrl(), + ]) + ->values() + ->all(); + } + + public function render(): View + { + return view('comments::livewire.comment-item'); + } +} diff --git a/src/Livewire/Comments.php b/src/Livewire/Comments.php new file mode 100644 index 0000000..74efc20 --- /dev/null +++ b/src/Livewire/Comments.php @@ -0,0 +1,209 @@ + */ + public array $attachments = []; + + public int $perPage = 10; + + public int $loadedCount = 10; + + public function mount(Model $model): void + { + $this->model = $model; + $this->perPage = Config::getPerPage(); + $this->loadedCount = $this->perPage; + } + + /** @return Collection */ + #[Computed] + public function comments(): Collection + { + return $this->model + ->topLevelComments() + ->with(['user', 'mentions', 'attachments', 'reactions.user', 'replies.user', 'replies.mentions', 'replies.attachments', 'replies.reactions.user']) + ->orderBy('created_at', $this->sortDirection) + ->take($this->loadedCount) + ->get(); + } + + #[Computed] + public function totalCount(): int + { + return $this->model->topLevelComments()->count(); + } + + #[Computed] + public function hasMore(): bool + { + return $this->totalCount > $this->loadedCount; + } + + #[Computed] + public function isSubscribed(): bool + { + $user = Config::resolveAuthenticatedUser(); + + if (! $user) { + return false; + } + + return CommentSubscription::isSubscribed($this->model, $user); + } + + public function toggleSubscription(): void + { + $user = Config::resolveAuthenticatedUser(); + + if (! $user) { + return; + } + + if ($this->isSubscribed) { + CommentSubscription::unsubscribe($this->model, $user); + } else { + CommentSubscription::subscribe($this->model, $user); + } + + unset($this->isSubscribed); + } + + public function addComment(): void + { + $rules = ['newComment' => ['required', 'string', 'min:1']]; + + if (Config::areAttachmentsEnabled()) { + $maxSize = Config::getAttachmentMaxSize(); + $allowedTypes = implode(',', Config::getAttachmentAllowedTypes()); + $rules['attachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"]; + } + + $this->validate($rules); + + $this->authorize('create', Config::getCommentModel()); + + $user = Config::resolveAuthenticatedUser(); + + $comment = $this->model->comments()->create([ + 'body' => $this->newComment, + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + if (Config::areAttachmentsEnabled() && ! empty($this->attachments)) { + $disk = Config::getAttachmentDisk(); + + foreach ($this->attachments as $file) { + $path = $file->store("comments/attachments/{$comment->id}", $disk); + + $comment->attachments()->create([ + 'file_path' => $path, + 'original_name' => $file->getClientOriginalName(), + 'mime_type' => $file->getMimeType(), + 'size' => $file->getSize(), + 'disk' => $disk, + ]); + } + } + + event(new CommentCreated($comment)); + + app(MentionParser::class)->syncMentions($comment); + + $this->reset('newComment', 'attachments'); + } + + public function removeAttachment(int $index): void + { + $attachments = $this->attachments; + unset($attachments[$index]); + $this->attachments = array_values($attachments); + } + + public function loadMore(): void + { + $this->loadedCount += $this->perPage; + } + + public function toggleSort(): void + { + $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc'; + } + + /** @return array */ + public function getListeners(): array + { + $listeners = [ + 'commentDeleted' => 'refreshComments', + 'commentUpdated' => 'refreshComments', + ]; + + if (Config::isBroadcastingEnabled()) { + $prefix = Config::getBroadcastChannelPrefix(); + $type = $this->model->getMorphClass(); + $id = $this->model->getKey(); + $channel = "echo-private:{$prefix}.{$type}.{$id}"; + + $listeners["{$channel},CommentCreated"] = 'refreshComments'; + $listeners["{$channel},CommentUpdated"] = 'refreshComments'; + $listeners["{$channel},CommentDeleted"] = 'refreshComments'; + $listeners["{$channel},CommentReacted"] = 'refreshComments'; + } + + return $listeners; + } + + public function refreshComments(): void + { + unset($this->comments, $this->totalCount, $this->hasMore); + } + + /** @return array */ + public function searchUsers(string $query): array + { + if (mb_strlen($query) < 1) { + return []; + } + + $resolver = app(MentionResolver::class); + + return $resolver->search($query) + ->map(fn ($user) => [ + 'id' => $user->getKey(), + 'name' => $user->getCommentName(), + 'avatar_url' => $user->getCommentAvatarUrl(), + ]) + ->values() + ->all(); + } + + public function render(): View + { + return view('comments::livewire.comments'); + } +} diff --git a/src/Livewire/Reactions.php b/src/Livewire/Reactions.php new file mode 100644 index 0000000..ff10b62 --- /dev/null +++ b/src/Livewire/Reactions.php @@ -0,0 +1,100 @@ +comment = $comment; + } + + public function toggleReaction(string $reaction): void + { + $user = Config::resolveAuthenticatedUser(); + + if (! $user) { + return; + } + + if (! in_array($reaction, Config::getAllowedReactions())) { + return; + } + + $existing = $this->comment->reactions() + ->where('user_id', $user->getKey()) + ->where('user_type', $user->getMorphClass()) + ->where('reaction', $reaction) + ->first(); + + if ($existing) { + $existing->delete(); + + event(new CommentReacted($this->comment, $user, $reaction, 'removed')); + } else { + $this->comment->reactions()->create([ + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'reaction' => $reaction, + ]); + + event(new CommentReacted($this->comment, $user, $reaction, 'added')); + } + + unset($this->reactionSummary); + + $this->showPicker = false; + } + + public function togglePicker(): void + { + $this->showPicker = ! $this->showPicker; + } + + /** @return array, total_reactors: int, reacted_by_user: bool}> */ + #[Computed] + public function reactionSummary(): array + { + $user = Config::resolveAuthenticatedUser(); + $userId = $user?->getKey(); + $userType = $user?->getMorphClass(); + + $reactions = $this->comment->reactions()->with('user')->get(); + + $emojiSet = Config::getReactionEmojiSet(); + + return $reactions + ->groupBy('reaction') + ->map(function ($group, $key) use ($emojiSet, $userId, $userType) { + return [ + 'reaction' => $key, + 'emoji' => $emojiSet[$key] ?? $key, + 'count' => $group->count(), + 'names' => $group->pluck('user.name')->filter()->take(3)->values()->all(), + 'total_reactors' => $group->count(), + 'reacted_by_user' => $group->contains( + fn ($r) => $r->user_id == $userId && $r->user_type === $userType + ), + ]; + }) + ->sortByDesc('count') + ->values() + ->all(); + } + + public function render(): View + { + return view('comments::livewire.reactions'); + } +} diff --git a/src/Mentions/DefaultMentionResolver.php b/src/Mentions/DefaultMentionResolver.php new file mode 100644 index 0000000..dc3a119 --- /dev/null +++ b/src/Mentions/DefaultMentionResolver.php @@ -0,0 +1,32 @@ + */ + public function search(string $query): Collection + { + $model = Config::getCommenterModel(); + + return $model::query() + ->where('name', 'like', "{$query}%") + ->limit(Config::getMentionMaxResults()) + ->get(); + } + + /** @return Collection */ + public function resolveByNames(array $names): Collection + { + $model = Config::getCommenterModel(); + + return $model::query() + ->whereIn('name', $names) + ->get(); + } +} diff --git a/src/Mentions/MentionParser.php b/src/Mentions/MentionParser.php new file mode 100644 index 0000000..aa1dc07 --- /dev/null +++ b/src/Mentions/MentionParser.php @@ -0,0 +1,52 @@ + */ + public function parse(string $body): Collection + { + $text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8'); + + preg_match_all('/(?<=@)[\w]+/', $text, $matches); + + $names = array_unique($matches[0] ?? []); + + if (empty($names)) { + return collect(); + } + + return $this->resolver->resolveByNames($names)->pluck('id'); + } + + public function syncMentions(Comment $comment): void + { + $newMentionIds = $this->parse($comment->body); + $existingMentionIds = $comment->mentions()->pluck('comment_mentions.user_id'); + + $addedIds = $newMentionIds->diff($existingMentionIds); + + $comment->mentions()->sync($newMentionIds->all()); + + $commenterModel = Config::getCommenterModel(); + + $addedIds->each(function ($userId) use ($comment, $commenterModel) { + $mentionedUser = $commenterModel::find($userId); + + if ($mentionedUser) { + UserMentioned::dispatch($comment, $mentionedUser); + } + }); + } +} diff --git a/src/Notifications/CommentRepliedNotification.php b/src/Notifications/CommentRepliedNotification.php new file mode 100644 index 0000000..af31b01 --- /dev/null +++ b/src/Notifications/CommentRepliedNotification.php @@ -0,0 +1,42 @@ + */ + public function via(mixed $notifiable): array + { + return Config::getNotificationChannels(); + } + + /** @return array */ + public function toDatabase(mixed $notifiable): array + { + return [ + 'comment_id' => $this->comment->id, + 'commentable_type' => $this->comment->commentable_type, + 'commentable_id' => $this->comment->commentable_id, + 'commenter_name' => $this->comment->user->getCommentName(), + 'body' => Str::limit(strip_tags($this->comment->body), 100), + ]; + } + + public function toMail(mixed $notifiable): MailMessage + { + $commenterName = $this->comment->user->getCommentName(); + + return (new MailMessage) + ->subject('New reply to your comment') + ->line("{$commenterName} replied to your comment:") + ->line(Str::limit(strip_tags($this->comment->body), 200)); + } +} diff --git a/src/Notifications/UserMentionedNotification.php b/src/Notifications/UserMentionedNotification.php new file mode 100644 index 0000000..49dc18b --- /dev/null +++ b/src/Notifications/UserMentionedNotification.php @@ -0,0 +1,46 @@ + */ + public function via(mixed $notifiable): array + { + return Config::getNotificationChannels(); + } + + /** @return array */ + public function toDatabase(mixed $notifiable): array + { + return [ + 'comment_id' => $this->comment->id, + 'commentable_type' => $this->comment->commentable_type, + 'commentable_id' => $this->comment->commentable_id, + 'mentioner_name' => $this->mentionedBy->getCommentName(), + 'body' => Str::limit(strip_tags($this->comment->body), 100), + ]; + } + + public function toMail(mixed $notifiable): MailMessage + { + $mentionerName = $this->mentionedBy->getCommentName(); + + return (new MailMessage) + ->subject('You were mentioned in a comment') + ->line("{$mentionerName} mentioned you in a comment:") + ->line(Str::limit(strip_tags($this->comment->body), 200)); + } +} diff --git a/src/Policies/.gitkeep b/src/Policies/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Policies/CommentPolicy.php b/src/Policies/CommentPolicy.php new file mode 100644 index 0000000..e3532a0 --- /dev/null +++ b/src/Policies/CommentPolicy.php @@ -0,0 +1,36 @@ +getKey() === $comment->user_id + && $user->getMorphClass() === $comment->user_type; + } + + public function delete(Authenticatable $user, Comment $comment): bool + { + return $user->getKey() === $comment->user_id + && $user->getMorphClass() === $comment->user_type; + } + + public function reply(Authenticatable $user, Comment $comment): bool + { + return $comment->canReply(); + } +} diff --git a/tests/Database/Factories/.gitkeep b/tests/Database/Factories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Database/Factories/PostFactory.php b/tests/Database/Factories/PostFactory.php new file mode 100644 index 0000000..383b6d0 --- /dev/null +++ b/tests/Database/Factories/PostFactory.php @@ -0,0 +1,19 @@ + */ + public function definition(): array + { + return [ + 'title' => fake()->sentence(), + ]; + } +} diff --git a/tests/Database/Factories/UserFactory.php b/tests/Database/Factories/UserFactory.php new file mode 100644 index 0000000..2c6db81 --- /dev/null +++ b/tests/Database/Factories/UserFactory.php @@ -0,0 +1,21 @@ + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'password' => '$2y$04$x7FnGBxMRzQmMJDhKuOi6eLGOlIhWQOGl.IWxCNasFliYJXARljqe', + ]; + } +} diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Feature/AttachmentUploadTest.php b/tests/Feature/AttachmentUploadTest.php new file mode 100644 index 0000000..eec83cb --- /dev/null +++ b/tests/Feature/AttachmentUploadTest.php @@ -0,0 +1,310 @@ +create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $file = UploadedFile::fake()->image('photo.jpg', 100, 100); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Comment with attachment

') + ->set('attachments', [$file]) + ->call('addComment') + ->assertSet('newComment', '') + ->assertSet('attachments', []); + + expect(Comment::count())->toBe(1); + expect(CommentAttachment::count())->toBe(1); +}); + +it('stores attachment with correct metadata', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $file = UploadedFile::fake()->image('vacation.jpg', 200, 200)->size(512); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Vacation photos

') + ->set('attachments', [$file]) + ->call('addComment'); + + $attachment = CommentAttachment::first(); + $comment = Comment::first(); + + expect($attachment->original_name)->toBe('vacation.jpg') + ->and($attachment->mime_type)->toBe('image/jpeg') + ->and($attachment->size)->toBeGreaterThan(0) + ->and($attachment->disk)->toBe('public') + ->and($attachment->comment_id)->toBe($comment->id) + ->and($attachment->file_path)->toStartWith("comments/attachments/{$comment->id}/"); +}); + +it('stores file on configured disk at comments/attachments/{comment_id}/ path', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $file = UploadedFile::fake()->image('test.png', 50, 50); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

File path test

') + ->set('attachments', [$file]) + ->call('addComment'); + + $attachment = CommentAttachment::first(); + + Storage::disk('public')->assertExists($attachment->file_path); + expect($attachment->file_path)->toContain("comments/attachments/{$attachment->comment_id}/"); +}); + +it('displays image attachment thumbnail in comment item view', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Image comment

', + ]); + + $file = UploadedFile::fake()->image('photo.jpg', 100, 100); + $path = $file->store("comments/attachments/{$comment->id}", 'public'); + + CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => $path, + 'original_name' => 'photo.jpg', + 'mime_type' => 'image/jpeg', + 'size' => $file->getSize(), + 'disk' => 'public', + ]); + + $comment->load('attachments'); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->assertSeeHtml('max-h-[200px]') + ->assertSeeHtml('photo.jpg'); +}); + +it('displays non-image attachment as download link', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

PDF comment

', + ]); + + $file = UploadedFile::fake()->create('document.pdf', 2048, 'application/pdf'); + $path = $file->store("comments/attachments/{$comment->id}", 'public'); + + CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => $path, + 'original_name' => 'document.pdf', + 'mime_type' => 'application/pdf', + 'size' => $file->getSize(), + 'disk' => 'public', + ]); + + $comment->load('attachments'); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->assertSeeHtml('document.pdf') + ->assertSeeHtml('download="document.pdf"'); +}); + +it('rejects file exceeding max size', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $oversizedFile = UploadedFile::fake()->create('big.pdf', Config::getAttachmentMaxSize() + 1, 'application/pdf'); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Oversized file

') + ->set('attachments', [$oversizedFile]) + ->call('addComment') + ->assertHasErrors('attachments.0'); + + expect(Comment::count())->toBe(0); + expect(CommentAttachment::count())->toBe(0); +}); + +it('rejects disallowed file type', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload'); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Malicious file

') + ->set('attachments', [$exeFile]) + ->call('addComment') + ->assertHasErrors('attachments.0'); + + expect(Comment::count())->toBe(0); + expect(CommentAttachment::count())->toBe(0); +}); + +it('accepts allowed file types', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Valid file

') + ->set('attachments', [$imageFile]) + ->call('addComment') + ->assertHasNoErrors('attachments.0'); + + expect(Comment::count())->toBe(1); + expect(CommentAttachment::count())->toBe(1); +}); + +it('hides upload UI when attachments disabled', function () { + config(['comments.attachments.enabled' => false]); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->assertDontSeeHtml('Attach files'); +}); + +it('shows upload UI when attachments enabled', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->assertSeeHtml('Attach files'); +}); + +it('creates comment with multiple file attachments', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $file1 = UploadedFile::fake()->image('photo1.jpg', 100, 100); + $file2 = UploadedFile::fake()->create('notes.pdf', 512, 'application/pdf'); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Multiple files

') + ->set('attachments', [$file1, $file2]) + ->call('addComment'); + + expect(Comment::count())->toBe(1); + expect(CommentAttachment::count())->toBe(2); + + $attachments = CommentAttachment::all(); + expect($attachments->pluck('original_name')->toArray()) + ->toContain('photo1.jpg') + ->toContain('notes.pdf'); +}); + +it('creates reply with file attachment via CommentItem component', function () { + Storage::fake('public'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Parent comment

', + ]); + + $this->actingAs($user); + + $file = UploadedFile::fake()->image('reply-photo.png', 80, 80); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startReply') + ->set('replyBody', '

Reply with attachment

') + ->set('replyAttachments', [$file]) + ->call('addReply') + ->assertSet('isReplying', false) + ->assertSet('replyBody', '') + ->assertSet('replyAttachments', []); + + $reply = Comment::where('parent_id', $comment->id)->first(); + + expect($reply)->not->toBeNull(); + expect($reply->attachments)->toHaveCount(1); + expect($reply->attachments->first()->original_name)->toBe('reply-photo.png'); +}); + +it('removes attachment from pending list before submission', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + $file1 = UploadedFile::fake()->image('photo1.jpg', 50, 50); + $file2 = UploadedFile::fake()->image('photo2.jpg', 50, 50); + + $component = Livewire::test(Comments::class, ['model' => $post]) + ->set('attachments', [$file1, $file2]); + + expect($component->get('attachments'))->toHaveCount(2); + + $component->call('removeAttachment', 0); + + expect($component->get('attachments'))->toHaveCount(1); +}); diff --git a/tests/Feature/BroadcastingTest.php b/tests/Feature/BroadcastingTest.php new file mode 100644 index 0000000..1f0d502 --- /dev/null +++ b/tests/Feature/BroadcastingTest.php @@ -0,0 +1,187 @@ +create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentCreated($comment); + + expect($event)->toBeInstanceOf(ShouldBroadcast::class); +}); + +it('CommentUpdated event implements ShouldBroadcast', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentUpdated($comment); + + expect($event)->toBeInstanceOf(ShouldBroadcast::class); +}); + +it('CommentDeleted event implements ShouldBroadcast', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentDeleted($comment); + + expect($event)->toBeInstanceOf(ShouldBroadcast::class); +}); + +it('CommentReacted event implements ShouldBroadcast', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentReacted($comment, $user, 'thumbs_up', 'added'); + + expect($event)->toBeInstanceOf(ShouldBroadcast::class); +}); + +it('broadcastOn returns PrivateChannel with correct channel name', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentCreated($comment); + $channels = $event->broadcastOn(); + + expect($channels)->toBeArray() + ->and($channels[0])->toBeInstanceOf(PrivateChannel::class) + ->and($channels[0]->name)->toBe("private-comments.{$post->getMorphClass()}.{$post->id}"); +}); + +it('broadcastWhen returns false when broadcasting is disabled', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentCreated($comment); + + expect($event->broadcastWhen())->toBeFalse(); +}); + +it('broadcastWhen returns true when broadcasting is enabled', function () { + config()->set('comments.broadcasting.enabled', true); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentCreated($comment); + + expect($event->broadcastWhen())->toBeTrue(); +}); + +it('broadcastWith returns array with comment_id for CommentCreated', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentCreated($comment); + $data = $event->broadcastWith(); + + expect($data)->toBeArray() + ->toHaveKey('comment_id', $comment->id) + ->toHaveKey('commentable_type', $post->getMorphClass()) + ->toHaveKey('commentable_id', $post->id); +}); + +it('broadcastWith returns array with comment_id, reaction, and action for CommentReacted', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentReacted($comment, $user, 'thumbs_up', 'added'); + $data = $event->broadcastWith(); + + expect($data)->toBeArray() + ->toHaveKey('comment_id', $comment->id) + ->toHaveKey('reaction', 'thumbs_up') + ->toHaveKey('action', 'added'); +}); + +it('uses custom channel prefix from config in broadcastOn', function () { + config()->set('comments.broadcasting.channel_prefix', 'custom-prefix'); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $event = new CommentCreated($comment); + $channels = $event->broadcastOn(); + + expect($channels[0]->name)->toBe("private-custom-prefix.{$post->getMorphClass()}.{$post->id}"); +}); diff --git a/tests/Feature/CommentAttachmentTest.php b/tests/Feature/CommentAttachmentTest.php new file mode 100644 index 0000000..8ab7014 --- /dev/null +++ b/tests/Feature/CommentAttachmentTest.php @@ -0,0 +1,197 @@ +create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test comment

', + ]); + + $attachment = CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => 'comments/attachments/1/photo.jpg', + 'original_name' => 'photo.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 2048, + 'disk' => 'public', + ]); + + expect($attachment)->toBeInstanceOf(CommentAttachment::class) + ->and($attachment->file_path)->toBe('comments/attachments/1/photo.jpg') + ->and($attachment->original_name)->toBe('photo.jpg') + ->and($attachment->mime_type)->toBe('image/jpeg') + ->and($attachment->size)->toBe(2048) + ->and($attachment->disk)->toBe('public'); +}); + +it('belongs to a comment via comment() relationship', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + $attachment = CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => 'comments/attachments/1/test.png', + 'original_name' => 'test.png', + 'mime_type' => 'image/png', + 'size' => 1024, + 'disk' => 'public', + ]); + + expect($attachment->comment)->toBeInstanceOf(Comment::class) + ->and($attachment->comment->id)->toBe($comment->id); +}); + +it('has attachments() hasMany relationship on Comment', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => 'comments/attachments/1/file1.png', + 'original_name' => 'file1.png', + 'mime_type' => 'image/png', + 'size' => 2048, + 'disk' => 'public', + ]); + + CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => 'comments/attachments/1/file2.pdf', + 'original_name' => 'file2.pdf', + 'mime_type' => 'application/pdf', + 'size' => 5120, + 'disk' => 'public', + ]); + + expect($comment->attachments)->toHaveCount(2) + ->and($comment->attachments->first())->toBeInstanceOf(CommentAttachment::class); +}); + +it('cascade deletes attachments when comment is force deleted', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => 'comments/attachments/1/photo.jpg', + 'original_name' => 'photo.jpg', + 'mime_type' => 'image/jpeg', + 'size' => 1024, + 'disk' => 'public', + ]); + + expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(1); + + $comment->forceDelete(); + + expect(CommentAttachment::where('comment_id', $comment->id)->count())->toBe(0); +}); + +it('correctly identifies image and non-image mime types via isImage()', function (string $mimeType, bool $expected) { + $attachment = new CommentAttachment(['mime_type' => $mimeType]); + + expect($attachment->isImage())->toBe($expected); +})->with([ + 'image/jpeg is image' => ['image/jpeg', true], + 'image/png is image' => ['image/png', true], + 'image/gif is image' => ['image/gif', true], + 'image/webp is image' => ['image/webp', true], + 'application/pdf is not image' => ['application/pdf', false], + 'text/plain is not image' => ['text/plain', false], +]); + +it('formats bytes into human-readable size via formattedSize()', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + $attachment = CommentAttachment::create([ + 'comment_id' => $comment->id, + 'file_path' => 'comments/attachments/1/file.pdf', + 'original_name' => 'file.pdf', + 'mime_type' => 'application/pdf', + 'size' => 1024, + 'disk' => 'public', + ]); + + expect($attachment->formattedSize())->toContain('KB'); +}); + +it('returns default attachment disk as public', function () { + expect(Config::getAttachmentDisk())->toBe('public'); +}); + +it('returns default attachment max size as 10240', function () { + expect(Config::getAttachmentMaxSize())->toBe(10240); +}); + +it('returns default allowed attachment types', function () { + $allowedTypes = Config::getAttachmentAllowedTypes(); + + expect($allowedTypes)->toBeArray() + ->toContain('image/jpeg') + ->toContain('image/png') + ->toContain('application/pdf'); +}); + +it('respects custom config overrides for attachment settings', function () { + config(['comments.attachments.disk' => 's3']); + config(['comments.attachments.max_size' => 5120]); + config(['comments.attachments.allowed_types' => ['image/png']]); + + expect(Config::getAttachmentDisk())->toBe('s3') + ->and(Config::getAttachmentMaxSize())->toBe(5120) + ->and(Config::getAttachmentAllowedTypes())->toBe(['image/png']); +}); + +it('reports attachments as enabled by default', function () { + expect(Config::areAttachmentsEnabled())->toBeTrue(); +}); + +it('respects disabled attachments config', function () { + config(['comments.attachments.enabled' => false]); + + expect(Config::areAttachmentsEnabled())->toBeFalse(); +}); diff --git a/tests/Feature/CommentEventsTest.php b/tests/Feature/CommentEventsTest.php new file mode 100644 index 0000000..132933f --- /dev/null +++ b/tests/Feature/CommentEventsTest.php @@ -0,0 +1,124 @@ +create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

New comment

') + ->call('addComment'); + + Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) { + return $event->comment->body === '

New comment

' + && $event->commentable->id === $post->id; + }); +}); + +it('fires CommentUpdated event when editing a comment', function () { + Event::fake([CommentUpdated::class]); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Original

', + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startEdit') + ->set('editBody', '

Edited

') + ->call('saveEdit'); + + Event::assertDispatched(CommentUpdated::class, function (CommentUpdated $event) use ($comment) { + return $event->comment->id === $comment->id; + }); +}); + +it('fires CommentDeleted event when deleting a comment', function () { + Event::fake([CommentDeleted::class]); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('deleteComment'); + + Event::assertDispatched(CommentDeleted::class, function (CommentDeleted $event) use ($comment) { + return $event->comment->id === $comment->id; + }); +}); + +it('fires CommentCreated event when adding a reply', function () { + Event::fake([CommentCreated::class]); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startReply') + ->set('replyBody', '

Reply text

') + ->call('addReply'); + + Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) { + return $event->comment->parent_id === $comment->id + && $event->comment->body === '

Reply text

'; + }); +}); + +it('carries correct comment and commentable in event payload', function () { + Event::fake([CommentCreated::class]); + + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Payload test

') + ->call('addComment'); + + Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) { + return $event->comment instanceof Comment + && $event->commentable->id === $post->id + && $event->comment->user_id === $user->id; + }); +}); diff --git a/tests/Feature/CommentItemComponentTest.php b/tests/Feature/CommentItemComponentTest.php new file mode 100644 index 0000000..6e62cc3 --- /dev/null +++ b/tests/Feature/CommentItemComponentTest.php @@ -0,0 +1,262 @@ +create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Original body

', + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startEdit') + ->assertSet('isEditing', true) + ->assertSet('editBody', '

Original body

') + ->set('editBody', '

Updated body

') + ->call('saveEdit') + ->assertSet('isEditing', false) + ->assertSet('editBody', ''); + + $comment->refresh(); + + expect($comment->body)->toBe('

Updated body

'); + expect($comment->isEdited())->toBeTrue(); +}); + +it('marks edited comment with edited indicator', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Original

', + ]); + + $this->actingAs($user); + + expect($comment->isEdited())->toBeFalse(); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startEdit') + ->set('editBody', '

Changed

') + ->call('saveEdit'); + + $comment->refresh(); + + expect($comment->isEdited())->toBeTrue(); + expect($comment->edited_at)->not->toBeNull(); +}); + +it('prevents non-author from editing a comment', function () { + $author = User::factory()->create(); + $otherUser = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $author->getKey(), + 'user_type' => $author->getMorphClass(), + 'body' => '

Author comment

', + ]); + + $this->actingAs($otherUser); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startEdit') + ->assertForbidden(); +}); + +it('allows author to delete their own comment', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('deleteComment'); + + expect(Comment::find($comment->id))->toBeNull(); + expect(Comment::withTrashed()->find($comment->id)->trashed())->toBeTrue(); +}); + +it('preserves replies when parent comment is deleted', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + $attrs = [ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]; + + $parent = Comment::factory()->create($attrs); + $reply = Comment::factory()->withParent($parent)->create($attrs); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $parent]) + ->call('deleteComment'); + + expect(Comment::withTrashed()->find($parent->id)->trashed())->toBeTrue(); + expect(Comment::find($reply->id))->not->toBeNull(); + expect(Comment::find($reply->id)->trashed())->toBeFalse(); +}); + +it('prevents non-author from deleting a comment', function () { + $author = User::factory()->create(); + $otherUser = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $author->getKey(), + 'user_type' => $author->getMorphClass(), + ]); + + $this->actingAs($otherUser); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('deleteComment') + ->assertForbidden(); +}); + +it('allows user to reply to a comment', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startReply') + ->assertSet('isReplying', true) + ->set('replyBody', '

My reply

') + ->call('addReply') + ->assertSet('isReplying', false) + ->assertSet('replyBody', ''); + + $reply = Comment::where('parent_id', $comment->id)->first(); + + expect($reply)->not->toBeNull(); + expect($reply->body)->toBe('

My reply

'); + expect($reply->user_id)->toBe($user->id); + expect($reply->commentable_id)->toBe($post->id); +}); + +it('respects max depth for replies', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + $attrs = [ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]; + + config(['comments.threading.max_depth' => 1]); + + $level0 = Comment::factory()->create($attrs); + $level1 = Comment::factory()->withParent($level0)->create($attrs); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $level1]) + ->call('startReply') + ->assertSet('isReplying', false); +}); + +it('resets state when cancelling edit', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Some body

', + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startEdit') + ->assertSet('isEditing', true) + ->call('cancelEdit') + ->assertSet('isEditing', false) + ->assertSet('editBody', ''); +}); + +it('resets state when cancelling reply', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(CommentItem::class, ['comment' => $comment]) + ->call('startReply') + ->assertSet('isReplying', true) + ->set('replyBody', '

Draft reply

') + ->call('cancelReply') + ->assertSet('isReplying', false) + ->assertSet('replyBody', ''); +}); + +it('loads all replies within a thread eagerly', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + $attrs = [ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]; + + $parent = Comment::factory()->create($attrs); + Comment::factory()->count(3)->withParent($parent)->create($attrs); + + $parentWithReplies = Comment::with('replies.user')->find($parent->id); + + $this->actingAs($user); + + $component = Livewire::test(CommentItem::class, ['comment' => $parentWithReplies]); + + expect($component->instance()->comment->replies)->toHaveCount(3); +}); diff --git a/tests/Feature/CommentReactionTest.php b/tests/Feature/CommentReactionTest.php new file mode 100644 index 0000000..b30be20 --- /dev/null +++ b/tests/Feature/CommentReactionTest.php @@ -0,0 +1,108 @@ +create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + $reaction = CommentReaction::create([ + 'comment_id' => $comment->id, + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'reaction' => 'thumbs_up', + ]); + + expect($reaction->comment)->toBeInstanceOf(Comment::class) + ->and($reaction->comment->id)->toBe($comment->id); +}); + +it('belongs to a user via polymorphic user() relationship', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + $reaction = CommentReaction::create([ + 'comment_id' => $comment->id, + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'reaction' => 'heart', + ]); + + expect($reaction->user)->toBeInstanceOf(User::class) + ->and($reaction->user->id)->toBe($user->id); +}); + +it('prevents duplicate reactions with unique constraint', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + CommentReaction::create([ + 'comment_id' => $comment->id, + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'reaction' => 'thumbs_up', + ]); + + expect(fn () => CommentReaction::create([ + 'comment_id' => $comment->id, + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'reaction' => 'thumbs_up', + ]))->toThrow(QueryException::class); +}); + +it('carries comment, user, reaction key, and action in CommentReacted event', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Test

', + ]); + + $event = new CommentReacted( + comment: $comment, + user: $user, + reaction: 'heart', + action: 'added', + ); + + expect($event->comment)->toBeInstanceOf(Comment::class) + ->and($event->comment->id)->toBe($comment->id) + ->and($event->user)->toBeInstanceOf(User::class) + ->and($event->user->id)->toBe($user->id) + ->and($event->reaction)->toBe('heart') + ->and($event->action)->toBe('added'); +}); diff --git a/tests/Feature/CommentSubscriptionTest.php b/tests/Feature/CommentSubscriptionTest.php new file mode 100644 index 0000000..6172728 --- /dev/null +++ b/tests/Feature/CommentSubscriptionTest.php @@ -0,0 +1,107 @@ +create(); + $post = Post::factory()->create(); + + $subscription = CommentSubscription::create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + expect($subscription->commentable)->toBeInstanceOf(Post::class) + ->and($subscription->commentable->id)->toBe($post->id); +}); + +it('has user morphTo relationship', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $subscription = CommentSubscription::create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + expect($subscription->user)->toBeInstanceOf(User::class) + ->and($subscription->user->id)->toBe($user->id); +}); + +it('returns database as default notification channel', function () { + expect(Config::getNotificationChannels())->toBe(['database']); +}); + +it('returns custom channels when configured', function () { + config()->set('comments.notifications.channels', ['database', 'mail']); + + expect(Config::getNotificationChannels())->toBe(['database', 'mail']); +}); + +it('returns true for shouldAutoSubscribe by default', function () { + expect(Config::shouldAutoSubscribe())->toBeTrue(); +}); + +it('returns false for shouldAutoSubscribe when configured', function () { + config()->set('comments.subscriptions.auto_subscribe', false); + + expect(Config::shouldAutoSubscribe())->toBeFalse(); +}); + +it('checks if user is subscribed to a commentable via isSubscribed()', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse(); + + CommentSubscription::create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue(); +}); + +it('creates subscription via subscribe() static method', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + CommentSubscription::subscribe($post, $user); + + expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue(); +}); + +it('removes subscription via unsubscribe() static method', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + CommentSubscription::subscribe($post, $user); + CommentSubscription::unsubscribe($post, $user); + + expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse(); +}); + +it('is idempotent when subscribing twice', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + CommentSubscription::subscribe($post, $user); + CommentSubscription::subscribe($post, $user); + + expect(CommentSubscription::where([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ])->count())->toBe(1); +}); diff --git a/tests/Feature/CommentTest.php b/tests/Feature/CommentTest.php new file mode 100644 index 0000000..9be0664 --- /dev/null +++ b/tests/Feature/CommentTest.php @@ -0,0 +1,197 @@ +create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($comment)->toBeInstanceOf(Comment::class); + expect($comment->body)->toBeString(); + expect($comment->commentable_id)->toBe($post->id); + expect($comment->user_id)->toBe($user->id); +}); + +it('belongs to a commentable model via morphTo', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($comment->commentable)->toBeInstanceOf(Post::class); + expect($comment->commentable->id)->toBe($post->id); +}); + +it('belongs to a user via morphTo', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($comment->user)->toBeInstanceOf(User::class); + expect($comment->user->id)->toBe($user->id); +}); + +it('supports threading with parent and replies', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $parent = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + $reply = Comment::factory()->withParent($parent)->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($reply->parent->id)->toBe($parent->id); + expect($parent->replies)->toHaveCount(1); + expect($parent->replies->first()->id)->toBe($reply->id); +}); + +it('identifies top-level vs reply comments', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $topLevel = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + $reply = Comment::factory()->withParent($topLevel)->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($topLevel->isTopLevel())->toBeTrue(); + expect($topLevel->isReply())->toBeFalse(); + expect($reply->isReply())->toBeTrue(); + expect($reply->isTopLevel())->toBeFalse(); +}); + +it('calculates depth correctly', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + $attrs = [ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]; + + $level0 = Comment::factory()->create($attrs); + $level1 = Comment::factory()->withParent($level0)->create($attrs); + $level2 = Comment::factory()->withParent($level1)->create($attrs); + + expect($level0->depth())->toBe(0); + expect($level1->depth())->toBe(1); + expect($level2->depth())->toBe(2); +}); + +it('checks canReply based on max depth', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + $attrs = [ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]; + + $level0 = Comment::factory()->create($attrs); + $level1 = Comment::factory()->withParent($level0)->create($attrs); + $level2 = Comment::factory()->withParent($level1)->create($attrs); + + expect($level0->canReply())->toBeTrue(); + expect($level1->canReply())->toBeTrue(); + expect($level2->canReply())->toBeFalse(); +}); + +it('supports soft deletes', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + $comment->delete(); + + expect(Comment::find($comment->id))->toBeNull(); + expect(Comment::withTrashed()->find($comment->id))->not->toBeNull(); + expect(Comment::withTrashed()->find($comment->id)->trashed())->toBeTrue(); +}); + +it('tracks edited state', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($comment->isEdited())->toBeFalse(); + + $edited = Comment::factory()->edited()->create([ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]); + + expect($edited->isEdited())->toBeTrue(); + expect($edited->edited_at)->toBeInstanceOf(Carbon::class); +}); + +it('detects when it has replies', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + $attrs = [ + 'commentable_type' => $post->getMorphClass(), + 'commentable_id' => $post->id, + 'user_type' => $user->getMorphClass(), + 'user_id' => $user->id, + ]; + + $parent = Comment::factory()->create($attrs); + expect($parent->hasReplies())->toBeFalse(); + + Comment::factory()->withParent($parent)->create($attrs); + expect($parent->hasReplies())->toBeTrue(); +}); diff --git a/tests/Feature/CommentsActionTest.php b/tests/Feature/CommentsActionTest.php new file mode 100644 index 0000000..8421e14 --- /dev/null +++ b/tests/Feature/CommentsActionTest.php @@ -0,0 +1,62 @@ +toBeInstanceOf(CommentsAction::class); +}); + +it('has the correct default name', function () { + $action = CommentsAction::make('comments'); + + expect($action->getName())->toBe('comments'); +}); + +it('configures as a slide-over', function () { + $action = CommentsAction::make('comments'); + + expect($action->isModalSlideOver())->toBeTrue(); +}); + +it('has a chat bubble icon', function () { + $action = CommentsAction::make('comments'); + + expect($action->getIcon())->toBe('heroicon-o-chat-bubble-left-right'); +}); + +it('has modal content configured', function () { + $action = CommentsAction::make('comments'); + + expect($action->hasModalContent())->toBeTrue(); +}); + +it('shows badge with comment count when comments exist', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + Comment::factory()->count(3)->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $action = CommentsAction::make('comments'); + $action->record($post); + + expect($action->getBadge())->toBe(3); +}); + +it('returns null badge when no comments exist', function () { + $post = Post::factory()->create(); + + $action = CommentsAction::make('comments'); + $action->record($post); + + expect($action->getBadge())->toBeNull(); +}); diff --git a/tests/Feature/CommentsComponentTest.php b/tests/Feature/CommentsComponentTest.php new file mode 100644 index 0000000..68ccc49 --- /dev/null +++ b/tests/Feature/CommentsComponentTest.php @@ -0,0 +1,185 @@ +create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Hello World

') + ->call('addComment') + ->assertSet('newComment', ''); + + expect(Comment::count())->toBe(1); + expect(Comment::first()->body)->toBe('

Hello World

'); +}); + +it('associates new comment with the authenticated user', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Test

') + ->call('addComment'); + + $comment = Comment::first(); + + expect($comment->user_id)->toBe($user->id); + expect($comment->user_type)->toBe($user->getMorphClass()); + expect($comment->commentable_id)->toBe($post->id); + expect($comment->commentable_type)->toBe($post->getMorphClass()); +}); + +it('requires authentication to create a comment', function () { + $post = Post::factory()->create(); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '

Hello

') + ->call('addComment') + ->assertForbidden(); +}); + +it('validates that comment body is not empty', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->set('newComment', '') + ->call('addComment') + ->assertHasErrors('newComment'); + + expect(Comment::count())->toBe(0); +}); + +it('paginates top-level comments with load more', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + config(['comments.pagination.per_page' => 5]); + + Comment::factory()->count(12)->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + $component = Livewire::test(Comments::class, ['model' => $post]); + + expect($component->get('loadedCount'))->toBe(5); + + $component->call('loadMore'); + + expect($component->get('loadedCount'))->toBe(10); + + $component->call('loadMore'); + + expect($component->get('loadedCount'))->toBe(15); +}); + +it('hides load more button when all comments are loaded', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + config(['comments.pagination.per_page' => 10]); + + Comment::factory()->count(5)->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->assertDontSee('Load more comments'); +}); + +it('toggles sort direction between asc and desc', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->assertSet('sortDirection', 'asc') + ->call('toggleSort') + ->assertSet('sortDirection', 'desc') + ->call('toggleSort') + ->assertSet('sortDirection', 'asc'); +}); + +it('returns comments in correct sort order via computed property', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $older = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Older comment

', + 'created_at' => now()->subHour(), + ]); + + $newer = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Newer comment

', + 'created_at' => now(), + ]); + + $this->actingAs($user); + + $component = Livewire::test(Comments::class, ['model' => $post]); + + $comments = $component->instance()->comments(); + expect($comments->first()->id)->toBe($older->id); + expect($comments->last()->id)->toBe($newer->id); + + $component->call('toggleSort'); + + $comments = $component->instance()->comments(); + expect($comments->first()->id)->toBe($newer->id); + expect($comments->last()->id)->toBe($older->id); +}); + +it('displays total comment count', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + Comment::factory()->count(3)->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $this->actingAs($user); + + Livewire::test(Comments::class, ['model' => $post]) + ->assertSee('Comments (3)'); +}); + +it('hides comment form for guests', function () { + $post = Post::factory()->create(); + + Livewire::test(Comments::class, ['model' => $post]) + ->assertDontSee('Write a comment...'); +}); diff --git a/tests/Feature/CommentsEntryTest.php b/tests/Feature/CommentsEntryTest.php new file mode 100644 index 0000000..e87d3c6 --- /dev/null +++ b/tests/Feature/CommentsEntryTest.php @@ -0,0 +1,21 @@ +toBeInstanceOf(CommentsEntry::class); +}); + +it('has the correct view path', function () { + $entry = CommentsEntry::make('comments'); + + expect($entry->getView())->toBe('comments::filament.infolists.components.comments-entry'); +}); + +it('defaults to full column span', function () { + $entry = CommentsEntry::make('comments'); + + expect($entry->getColumnSpan('default'))->toBe('full'); +}); diff --git a/tests/Feature/CommentsTableActionTest.php b/tests/Feature/CommentsTableActionTest.php new file mode 100644 index 0000000..f8c0584 --- /dev/null +++ b/tests/Feature/CommentsTableActionTest.php @@ -0,0 +1,41 @@ +toBeInstanceOf(CommentsTableAction::class); +}); + +it('configures as a slide-over', function () { + $action = CommentsTableAction::make('comments'); + + expect($action->isModalSlideOver())->toBeTrue(); +}); + +it('has modal content configured', function () { + $action = CommentsTableAction::make('comments'); + + expect($action->hasModalContent())->toBeTrue(); +}); + +it('shows badge with comment count for the record', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + Comment::factory()->count(5)->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + ]); + + $action = CommentsTableAction::make('comments'); + $action->record($post); + + expect($action->getBadge())->toBe(5); +}); diff --git a/tests/Feature/ContentSanitizationTest.php b/tests/Feature/ContentSanitizationTest.php new file mode 100644 index 0000000..6aae804 --- /dev/null +++ b/tests/Feature/ContentSanitizationTest.php @@ -0,0 +1,172 @@ +create(); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'user_id' => $user->getKey(), + 'user_type' => $user->getMorphClass(), + 'body' => '

Hello

', + ]); + + expect($comment->body)->not->toContain('', + ]); + + $comment->refresh(); + + expect($comment->body)->not->toContain('') + ->call('addComment'); + + $comment = Comment::first(); + + expect($comment->body)->not->toContain('