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
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/vendor
|
||||
/node_modules
|
||||
/.phpunit.cache
|
||||
.phpunit.result.cache
|
||||
composer.lock
|
||||
50
composer.json
Normal file
50
composer.json
Normal file
@@ -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
|
||||
}
|
||||
88
config/comments.php
Normal file
88
config/comments.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
||||
use Relaticle\Comments\Policies\CommentPolicy;
|
||||
|
||||
return [
|
||||
'tables' => [
|
||||
'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',
|
||||
],
|
||||
];
|
||||
0
database/factories/.gitkeep
Normal file
0
database/factories/.gitkeep
Normal file
34
database/factories/CommentFactory.php
Normal file
34
database/factories/CommentFactory.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Relaticle\Comments\Comment;
|
||||
|
||||
class CommentFactory extends Factory
|
||||
{
|
||||
protected $model = Comment::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'body' => '<p>'.fake()->paragraph().'</p>',
|
||||
'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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('comment_attachments', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
}
|
||||
};
|
||||
22
database/migrations/create_comment_mentions_table.php.stub
Normal file
22
database/migrations/create_comment_mentions_table.php.stub
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('comment_mentions', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
23
database/migrations/create_comment_reactions_table.php.stub
Normal file
23
database/migrations/create_comment_reactions_table.php.stub
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('comment_reactions', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('comment_subscriptions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
};
|
||||
27
database/migrations/create_comments_table.php.stub
Normal file
27
database/migrations/create_comments_table.php.stub
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create(config('comments.tables.comments', 'comments'), function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
7
phpstan.neon.dist
Normal file
7
phpstan.neon.dist
Normal file
@@ -0,0 +1,7 @@
|
||||
includes:
|
||||
- ./vendor/larastan/larastan/extension.neon
|
||||
|
||||
parameters:
|
||||
paths:
|
||||
- src
|
||||
level: 5
|
||||
20
phpunit.xml.dist
Normal file
20
phpunit.xml.dist
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.0/phpunit.xsd"
|
||||
backupGlobals="false"
|
||||
colors="true"
|
||||
processIsolation="false"
|
||||
stopOnFailure="false"
|
||||
bootstrap="vendor/autoload.php"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Feature">
|
||||
<directory suffix="Test.php">./tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory suffix=".php">./src</directory>
|
||||
</include>
|
||||
</source>
|
||||
</phpunit>
|
||||
9
resources/lang/en/comments.php
Normal file
9
resources/lang/en/comments.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'deleted_comment' => 'This comment was deleted.',
|
||||
'edited' => 'edited',
|
||||
'load_more' => 'Load more comments',
|
||||
'no_comments' => 'No comments yet.',
|
||||
'comment_placeholder' => 'Write a comment...',
|
||||
];
|
||||
0
resources/views/.gitkeep
Normal file
0
resources/views/.gitkeep
Normal file
3
resources/views/filament/comments-action.blade.php
Normal file
3
resources/views/filament/comments-action.blade.php
Normal file
@@ -0,0 +1,3 @@
|
||||
<div>
|
||||
<livewire:comments :model="$record" :key="'comments-'.$record->getKey()" />
|
||||
</div>
|
||||
@@ -0,0 +1,5 @@
|
||||
<x-dynamic-component :component="$getEntryWrapperView()" :entry="$entry">
|
||||
<div>
|
||||
<livewire:comments :model="$getRecord()" :key="'comments-entry-'.$getRecord()->getKey()" />
|
||||
</div>
|
||||
</x-dynamic-component>
|
||||
246
resources/views/livewire/comment-item.blade.php
Normal file
246
resources/views/livewire/comment-item.blade.php
Normal file
@@ -0,0 +1,246 @@
|
||||
<div class="flex gap-3" wire:key="comment-item-{{ $comment->id }}">
|
||||
{{-- Avatar --}}
|
||||
<div class="shrink-0">
|
||||
@if ($comment->trashed())
|
||||
<div class="h-8 w-8 rounded-full bg-gray-200 dark:bg-gray-700"></div>
|
||||
@elseif ($comment->user?->getCommentAvatarUrl())
|
||||
<img src="{{ $comment->user->getCommentAvatarUrl() }}" alt="{{ $comment->user->getCommentName() }}" class="h-8 w-8 rounded-full object-cover">
|
||||
@else
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-sm font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300">
|
||||
{{ str($comment->user?->getCommentName() ?? '?')->substr(0, 1)->upper() }}
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
{{-- Deleted placeholder --}}
|
||||
@if ($comment->trashed())
|
||||
<p class="text-sm italic text-gray-400 dark:text-gray-500">This comment has been deleted</p>
|
||||
@else
|
||||
{{-- Header: name + timestamp --}}
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $comment->user?->getCommentName() ?? 'Unknown' }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400" title="{{ $comment->created_at->format('M j, Y g:i A') }}">
|
||||
{{ $comment->created_at->diffForHumans() }}
|
||||
</span>
|
||||
@if ($comment->isEdited())
|
||||
<span class="text-xs text-gray-400 dark:text-gray-500">(edited)</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Body or edit form --}}
|
||||
@if ($isEditing)
|
||||
<form wire:submit="saveEdit" class="mt-1">
|
||||
<textarea wire:model="editBody" rows="3"
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 sm:text-sm"
|
||||
></textarea>
|
||||
@error('editBody')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="submit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
|
||||
<button type="button" wire:click="cancelEdit" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
@else
|
||||
<div class="fi-prose prose prose-sm mt-1 max-w-none text-gray-700 dark:prose-invert dark:text-gray-300">
|
||||
{!! $comment->renderBodyWithMentions() !!}
|
||||
</div>
|
||||
|
||||
{{-- Attachments --}}
|
||||
@if ($comment->attachments->isNotEmpty())
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($comment->attachments as $attachment)
|
||||
@if ($attachment->isImage())
|
||||
<a href="{{ $attachment->url() }}" target="_blank" class="block">
|
||||
<img src="{{ $attachment->url() }}" alt="{{ $attachment->original_name }}"
|
||||
class="max-h-[200px] rounded border border-gray-200 object-cover dark:border-gray-600" />
|
||||
</a>
|
||||
@else
|
||||
<a href="{{ $attachment->url() }}" target="_blank" download="{{ $attachment->original_name }}"
|
||||
class="flex items-center gap-2 rounded border border-gray-200 px-3 py-2 text-sm text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
<svg class="h-4 w-4 shrink-0" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
|
||||
</svg>
|
||||
<span class="truncate">{{ $attachment->original_name }}</span>
|
||||
<span class="shrink-0 text-xs text-gray-400 dark:text-gray-500">({{ $attachment->formattedSize() }})</span>
|
||||
</a>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Reactions --}}
|
||||
<livewire:reactions :comment="$comment" :key="'reactions-'.$comment->id" />
|
||||
@endif
|
||||
|
||||
{{-- Actions: Reply, Edit, Delete --}}
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
@auth
|
||||
@if ($comment->canReply())
|
||||
@can('reply', $comment)
|
||||
<button wire:click="startReply" type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
Reply
|
||||
</button>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
@can('update', $comment)
|
||||
<button wire:click="startEdit" type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
Edit
|
||||
</button>
|
||||
@endcan
|
||||
|
||||
@can('delete', $comment)
|
||||
<button wire:click="deleteComment"
|
||||
wire:confirm="Are you sure you want to delete this comment?"
|
||||
type="button"
|
||||
class="text-xs text-danger-600 hover:text-danger-500 dark:text-danger-400 dark:hover:text-danger-300">
|
||||
Delete
|
||||
</button>
|
||||
@endcan
|
||||
@endauth
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Reply form --}}
|
||||
@if ($isReplying)
|
||||
<form wire:submit="addReply" class="relative mt-3"
|
||||
x-data="{
|
||||
showMentions: false,
|
||||
mentionQuery: '',
|
||||
mentionResults: [],
|
||||
selectedIndex: 0,
|
||||
mentionStart: null,
|
||||
async handleInput(event) {
|
||||
const textarea = event.target;
|
||||
const value = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.substring(0, cursorPos);
|
||||
const atIndex = textBeforeCursor.lastIndexOf('@');
|
||||
if (atIndex !== -1 && (atIndex === 0 || textBeforeCursor[atIndex - 1] === ' ' || textBeforeCursor[atIndex - 1] === '\n')) {
|
||||
const query = textBeforeCursor.substring(atIndex + 1);
|
||||
if (query.length > 0 && !query.includes(' ')) {
|
||||
this.mentionStart = atIndex;
|
||||
this.mentionQuery = query;
|
||||
this.mentionResults = await $wire.searchUsers(query);
|
||||
this.showMentions = this.mentionResults.length > 0;
|
||||
this.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.showMentions = false;
|
||||
},
|
||||
handleKeydown(event) {
|
||||
if (!this.showMentions) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.mentionResults.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (this.mentionResults.length > 0) {
|
||||
event.preventDefault();
|
||||
this.selectMention(this.mentionResults[this.selectedIndex]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
this.showMentions = false;
|
||||
}
|
||||
},
|
||||
selectMention(user) {
|
||||
const textarea = this.$refs.replyInput;
|
||||
const value = textarea.value;
|
||||
const before = value.substring(0, this.mentionStart);
|
||||
const after = value.substring(textarea.selectionStart);
|
||||
const newValue = before + '@' + user.name + ' ' + after;
|
||||
$wire.set('replyBody', newValue);
|
||||
this.showMentions = false;
|
||||
this.$nextTick(() => {
|
||||
const pos = before.length + user.name.length + 2;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
}">
|
||||
<textarea x-ref="replyInput"
|
||||
wire:model="replyBody"
|
||||
@input="handleInput($event)"
|
||||
@keydown="handleKeydown($event)"
|
||||
rows="2"
|
||||
placeholder="Write a reply..."
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder-gray-400 sm:text-sm"
|
||||
></textarea>
|
||||
|
||||
{{-- Mention autocomplete dropdown --}}
|
||||
<div x-show="showMentions" x-cloak
|
||||
class="absolute z-50 mt-1 w-64 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
|
||||
<template x-for="(user, index) in mentionResults" :key="user.id">
|
||||
<button type="button"
|
||||
@click="selectMention(user)"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<template x-if="user.avatar_url">
|
||||
<img :src="user.avatar_url" class="h-6 w-6 rounded-full object-cover" />
|
||||
</template>
|
||||
<template x-if="!user.avatar_url">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"
|
||||
x-text="user.name.charAt(0).toUpperCase()"></div>
|
||||
</template>
|
||||
<span x-text="user.name" class="text-gray-900 dark:text-gray-100"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@error('replyBody')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
@if (\Relaticle\Comments\Config::areAttachmentsEnabled())
|
||||
<div class="mt-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
Attach files
|
||||
<input type="file" wire:model="replyAttachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\Config::getAttachmentAllowedTypes()) }}" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (!empty($replyAttachments))
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($replyAttachments as $index => $file)
|
||||
<div class="flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<span>{{ $file->getClientOriginalName() }}</span>
|
||||
<button type="button" wire:click="removeReplyAttachment({{ $index }})" class="text-gray-400 hover:text-danger-500 dark:text-gray-500 dark:hover:text-danger-400">×</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@error('replyAttachments.*')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
@endif
|
||||
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button type="submit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Reply</button>
|
||||
<button type="button" wire:click="cancelReply" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
@endif
|
||||
|
||||
{{-- Nested replies --}}
|
||||
@if ($comment->replies->isNotEmpty())
|
||||
<div class="mt-3 space-y-3 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
|
||||
@foreach ($comment->replies as $reply)
|
||||
<livewire:comment-item :comment="$reply" :key="'comment-'.$reply->id" />
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
193
resources/views/livewire/comments.blade.php
Normal file
193
resources/views/livewire/comments.blade.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<div class="space-y-4"
|
||||
@if (!\Relaticle\Comments\Config::isBroadcastingEnabled())
|
||||
wire:poll.{{ \Relaticle\Comments\Config::getPollingInterval() }}
|
||||
@endif
|
||||
>
|
||||
{{-- Sort toggle --}}
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Comments ({{ $this->totalCount }})
|
||||
</h3>
|
||||
@auth
|
||||
<div class="flex items-center gap-3">
|
||||
<button wire:click="toggleSubscription" type="button"
|
||||
class="flex items-center gap-1 text-xs {{ $this->isSubscribed ? 'text-primary-600 dark:text-primary-400' : 'text-gray-400 dark:text-gray-500' }} hover:text-primary-500">
|
||||
@if ($this->isSubscribed)
|
||||
{{-- Bell icon (solid) --}}
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 2a6 6 0 00-6 6c0 1.887-.454 3.665-1.257 5.234a.75.75 0 00.515 1.076 32.91 32.91 0 003.256.508 3.5 3.5 0 006.972 0 32.903 32.903 0 003.256-.508.75.75 0 00.515-1.076A11.448 11.448 0 0116 8a6 6 0 00-6-6zm0 14.5a2 2 0 01-1.95-1.557 33.146 33.146 0 003.9 0A2 2 0 0110 16.5z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Subscribed
|
||||
@else
|
||||
{{-- Bell icon (outline) --}}
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"/>
|
||||
</svg>
|
||||
Subscribe
|
||||
@endif
|
||||
</button>
|
||||
<button wire:click="toggleSort" type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
{{ $sortDirection === 'asc' ? 'Oldest first' : 'Newest first' }}
|
||||
</button>
|
||||
</div>
|
||||
@else
|
||||
<button wire:click="toggleSort" type="button"
|
||||
class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
{{ $sortDirection === 'asc' ? 'Oldest first' : 'Newest first' }}
|
||||
</button>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
{{-- Comment list --}}
|
||||
<div class="space-y-4">
|
||||
@foreach ($this->comments as $comment)
|
||||
<livewire:comment-item :comment="$comment" :key="'comment-'.$comment->id" />
|
||||
@endforeach
|
||||
</div>
|
||||
|
||||
{{-- Load more button --}}
|
||||
@if ($this->hasMore)
|
||||
<div class="text-center">
|
||||
<button wire:click="loadMore" type="button"
|
||||
class="text-sm text-primary-600 hover:text-primary-500 dark:text-primary-400">
|
||||
Load more comments
|
||||
<span wire:loading wire:target="loadMore" class="ml-1">...</span>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- New comment form - only for authorized users --}}
|
||||
@auth
|
||||
@can('create', \Relaticle\Comments\Config::getCommentModel())
|
||||
<form wire:submit="addComment" class="relative mt-4"
|
||||
x-data="{
|
||||
showMentions: false,
|
||||
mentionQuery: '',
|
||||
mentionResults: [],
|
||||
selectedIndex: 0,
|
||||
mentionStart: null,
|
||||
async handleInput(event) {
|
||||
const textarea = event.target;
|
||||
const value = textarea.value;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
const textBeforeCursor = value.substring(0, cursorPos);
|
||||
const atIndex = textBeforeCursor.lastIndexOf('@');
|
||||
if (atIndex !== -1 && (atIndex === 0 || textBeforeCursor[atIndex - 1] === ' ' || textBeforeCursor[atIndex - 1] === '\n')) {
|
||||
const query = textBeforeCursor.substring(atIndex + 1);
|
||||
if (query.length > 0 && !query.includes(' ')) {
|
||||
this.mentionStart = atIndex;
|
||||
this.mentionQuery = query;
|
||||
this.mentionResults = await $wire.searchUsers(query);
|
||||
this.showMentions = this.mentionResults.length > 0;
|
||||
this.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.showMentions = false;
|
||||
},
|
||||
handleKeydown(event) {
|
||||
if (!this.showMentions) return;
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.mentionResults.length - 1);
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
} else if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
if (this.mentionResults.length > 0) {
|
||||
event.preventDefault();
|
||||
this.selectMention(this.mentionResults[this.selectedIndex]);
|
||||
}
|
||||
} else if (event.key === 'Escape') {
|
||||
this.showMentions = false;
|
||||
}
|
||||
},
|
||||
selectMention(user) {
|
||||
const textarea = this.$refs.commentInput;
|
||||
const value = textarea.value;
|
||||
const before = value.substring(0, this.mentionStart);
|
||||
const after = value.substring(textarea.selectionStart);
|
||||
const newValue = before + '@' + user.name + ' ' + after;
|
||||
$wire.set('newComment', newValue);
|
||||
this.showMentions = false;
|
||||
this.$nextTick(() => {
|
||||
const pos = before.length + user.name.length + 2;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
}">
|
||||
<textarea
|
||||
x-ref="commentInput"
|
||||
wire:model="newComment"
|
||||
@input="handleInput($event)"
|
||||
@keydown="handleKeydown($event)"
|
||||
rows="3"
|
||||
placeholder="Write a comment..."
|
||||
class="block w-full rounded-lg border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:placeholder-gray-400 sm:text-sm"
|
||||
></textarea>
|
||||
|
||||
{{-- Mention autocomplete dropdown --}}
|
||||
<div x-show="showMentions" x-cloak
|
||||
class="absolute z-50 mt-1 w-64 rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-600 dark:bg-gray-800">
|
||||
<template x-for="(user, index) in mentionResults" :key="user.id">
|
||||
<button type="button"
|
||||
@click="selectMention(user)"
|
||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<template x-if="user.avatar_url">
|
||||
<img :src="user.avatar_url" class="h-6 w-6 rounded-full object-cover" />
|
||||
</template>
|
||||
<template x-if="!user.avatar_url">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-primary-100 text-xs font-medium text-primary-700 dark:bg-primary-800 dark:text-primary-300"
|
||||
x-text="user.name.charAt(0).toUpperCase()"></div>
|
||||
</template>
|
||||
<span x-text="user.name" class="text-gray-900 dark:text-gray-100"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@error('newComment')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
|
||||
@if (\Relaticle\Comments\Config::areAttachmentsEnabled())
|
||||
<div class="mt-2">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13" />
|
||||
</svg>
|
||||
Attach files
|
||||
<input type="file" wire:model="attachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\Config::getAttachmentAllowedTypes()) }}" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (!empty($attachments))
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
@foreach ($attachments as $index => $file)
|
||||
<div class="flex items-center gap-1 rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300">
|
||||
<span>{{ $file->getClientOriginalName() }}</span>
|
||||
<button type="button" wire:click="removeAttachment({{ $index }})" class="text-gray-400 hover:text-danger-500 dark:text-gray-500 dark:hover:text-danger-400">×</button>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@error('attachments.*')
|
||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||
@enderror
|
||||
@endif
|
||||
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:bg-primary-500 dark:hover:bg-primary-400 dark:focus:ring-offset-gray-800"
|
||||
wire:loading.attr="disabled" wire:target="addComment">
|
||||
<span wire:loading.remove wire:target="addComment">Comment</span>
|
||||
<span wire:loading wire:target="addComment">Posting...</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@endcan
|
||||
@endauth
|
||||
</div>
|
||||
38
resources/views/livewire/reactions.blade.php
Normal file
38
resources/views/livewire/reactions.blade.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1">
|
||||
{{-- Existing reactions with counts --}}
|
||||
@foreach ($this->reactionSummary as $summary)
|
||||
<button
|
||||
wire:click="toggleReaction('{{ $summary['reaction'] }}')"
|
||||
type="button"
|
||||
title="{{ implode(', ', $summary['names']) }}{{ $summary['total_reactors'] > 3 ? ' and ' . ($summary['total_reactors'] - 3) . ' more' : '' }}"
|
||||
class="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs transition
|
||||
{{ $summary['reacted_by_user']
|
||||
? 'border-primary-300 bg-primary-50 text-primary-700 dark:border-primary-600 dark:bg-primary-900/30 dark:text-primary-300'
|
||||
: 'border-gray-200 bg-gray-50 text-gray-600 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700' }}">
|
||||
<span>{{ $summary['emoji'] }}</span>
|
||||
<span>{{ $summary['count'] }}</span>
|
||||
</button>
|
||||
@endforeach
|
||||
|
||||
{{-- Add reaction button --}}
|
||||
@auth
|
||||
<div class="relative" x-data="{ open: $wire.entangle('showPicker') }">
|
||||
<button @click="open = !open" type="button"
|
||||
class="inline-flex items-center rounded-full border border-dashed border-gray-300 px-2 py-0.5 text-xs text-gray-400 hover:border-gray-400 hover:text-gray-500 dark:border-gray-600 dark:text-gray-500 dark:hover:border-gray-500 dark:hover:text-gray-400">
|
||||
+
|
||||
</button>
|
||||
|
||||
{{-- Emoji picker dropdown --}}
|
||||
<div x-show="open" x-cloak @click.outside="open = false"
|
||||
class="absolute bottom-full left-0 z-50 mb-1 flex gap-1 rounded-lg border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-600 dark:bg-gray-800">
|
||||
@foreach (\Relaticle\Comments\Config::getReactionEmojiSet() as $key => $emoji)
|
||||
<button wire:click="toggleReaction('{{ $key }}')" type="button"
|
||||
class="rounded p-1 text-base hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="{{ str_replace('_', ' ', $key) }}">
|
||||
{{ $emoji }}
|
||||
</button>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
159
src/Comment.php
Normal file
159
src/Comment.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphToMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
use Relaticle\Comments\Database\Factories\CommentFactory;
|
||||
|
||||
class Comment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
protected static function boot(): void
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::saving(function (self $comment): void {
|
||||
$comment->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<string, string> */
|
||||
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 = '<span class="comment-mention inline rounded bg-primary-50 px-1 font-medium text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">@'.$escapedName.'</span>';
|
||||
|
||||
$body = str_replace("@{$name}", $styledSpan, $body);
|
||||
$body = str_replace("@{$name}", $styledSpan, $body);
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
45
src/CommentAttachment.php
Normal file
45
src/CommentAttachment.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Number;
|
||||
|
||||
class CommentAttachment extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'comment_id',
|
||||
'file_path',
|
||||
'original_name',
|
||||
'mime_type',
|
||||
'size',
|
||||
'disk',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return 'comment_attachments';
|
||||
}
|
||||
|
||||
public function comment(): BelongsTo
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
32
src/CommentReaction.php
Normal file
32
src/CommentReaction.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
class CommentReaction extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'comment_id',
|
||||
'user_id',
|
||||
'user_type',
|
||||
'reaction',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return 'comment_reactions';
|
||||
}
|
||||
|
||||
public function comment(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Config::getCommentModel());
|
||||
}
|
||||
|
||||
public function user(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
73
src/CommentSubscription.php
Normal file
73
src/CommentSubscription.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class CommentSubscription extends Model
|
||||
{
|
||||
public const UPDATED_AT = null;
|
||||
|
||||
protected $fillable = [
|
||||
'commentable_type',
|
||||
'commentable_id',
|
||||
'user_type',
|
||||
'user_id',
|
||||
];
|
||||
|
||||
public function getTable(): string
|
||||
{
|
||||
return 'comment_subscriptions';
|
||||
}
|
||||
|
||||
public function commentable(): MorphTo
|
||||
{
|
||||
return $this->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<int, Model> */
|
||||
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();
|
||||
}
|
||||
}
|
||||
37
src/CommentsPlugin.php
Normal file
37
src/CommentsPlugin.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Filament\Contracts\Plugin;
|
||||
use Filament\Panel;
|
||||
|
||||
class CommentsPlugin implements Plugin
|
||||
{
|
||||
public function getId(): string
|
||||
{
|
||||
return CommentsServiceProvider::$name;
|
||||
}
|
||||
|
||||
public function register(Panel $panel): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(Panel $panel): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public static function make(): static
|
||||
{
|
||||
return app(static::class);
|
||||
}
|
||||
|
||||
public static function get(): static
|
||||
{
|
||||
/** @var static $plugin */
|
||||
$plugin = filament(app(static::class)->getId());
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
}
|
||||
68
src/CommentsServiceProvider.php
Normal file
68
src/CommentsServiceProvider.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
|
||||
use Relaticle\Comments\Listeners\SendUserMentionedNotification;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Livewire\Reactions;
|
||||
use Spatie\LaravelPackageTools\Package;
|
||||
use Spatie\LaravelPackageTools\PackageServiceProvider;
|
||||
|
||||
class CommentsServiceProvider extends PackageServiceProvider
|
||||
{
|
||||
public static string $name = 'comments';
|
||||
|
||||
public static string $viewNamespace = 'comments';
|
||||
|
||||
public function configurePackage(Package $package): void
|
||||
{
|
||||
$package
|
||||
->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);
|
||||
}
|
||||
}
|
||||
0
src/Concerns/.gitkeep
Normal file
0
src/Concerns/.gitkeep
Normal file
24
src/Concerns/HasComments.php
Normal file
24
src/Concerns/HasComments.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
trait HasComments
|
||||
{
|
||||
public function comments(): MorphMany
|
||||
{
|
||||
return $this->morphMany(Config::getCommentModel(), 'commentable');
|
||||
}
|
||||
|
||||
public function topLevelComments(): MorphMany
|
||||
{
|
||||
return $this->comments()->whereNull('parent_id');
|
||||
}
|
||||
|
||||
public function commentCount(): int
|
||||
{
|
||||
return $this->comments()->count();
|
||||
}
|
||||
}
|
||||
27
src/Concerns/IsCommenter.php
Normal file
27
src/Concerns/IsCommenter.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Concerns;
|
||||
|
||||
use Filament\Models\Contracts\HasAvatar;
|
||||
use Filament\Models\Contracts\HasName;
|
||||
|
||||
trait IsCommenter
|
||||
{
|
||||
public function getCommentName(): string
|
||||
{
|
||||
if ($this instanceof HasName) {
|
||||
return $this->getFilamentName();
|
||||
}
|
||||
|
||||
return $this->name ?? 'Unknown';
|
||||
}
|
||||
|
||||
public function getCommentAvatarUrl(): ?string
|
||||
{
|
||||
if ($this instanceof HasAvatar) {
|
||||
return $this->getFilamentAvatarUrl();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
157
src/Config.php
Normal file
157
src/Config.php
Normal file
@@ -0,0 +1,157 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments;
|
||||
|
||||
use App\Models\User;
|
||||
use Closure;
|
||||
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
||||
use Relaticle\Comments\Policies\CommentPolicy;
|
||||
|
||||
class Config
|
||||
{
|
||||
protected static ?Closure $resolveAuthenticatedUser = null;
|
||||
|
||||
public static function getCommentModel(): string
|
||||
{
|
||||
return config('comments.models.comment', Comment::class);
|
||||
}
|
||||
|
||||
public static function getCommenterModel(): string
|
||||
{
|
||||
return config('comments.commenter.model', User::class);
|
||||
}
|
||||
|
||||
public static function getCommentTable(): string
|
||||
{
|
||||
return config('comments.tables.comments', 'comments');
|
||||
}
|
||||
|
||||
public static function getMaxDepth(): int
|
||||
{
|
||||
return (int) config('comments.threading.max_depth', 2);
|
||||
}
|
||||
|
||||
public static function getPerPage(): int
|
||||
{
|
||||
return (int) config('comments.pagination.per_page', 10);
|
||||
}
|
||||
|
||||
/** @return array<int, array<int, string>> */
|
||||
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<string, string> */
|
||||
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<int, string> */
|
||||
public static function getAllowedReactions(): array
|
||||
{
|
||||
return array_keys(static::getReactionEmojiSet());
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
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<int, string> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
0
src/Contracts/.gitkeep
Normal file
0
src/Contracts/.gitkeep
Normal file
14
src/Contracts/Commentable.php
Normal file
14
src/Contracts/Commentable.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
|
||||
interface Commentable
|
||||
{
|
||||
public function comments(): MorphMany;
|
||||
|
||||
public function topLevelComments(): MorphMany;
|
||||
|
||||
public function commentCount(): int;
|
||||
}
|
||||
14
src/Contracts/Commenter.php
Normal file
14
src/Contracts/Commenter.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Contracts;
|
||||
|
||||
interface Commenter
|
||||
{
|
||||
public function getKey();
|
||||
|
||||
public function getMorphClass();
|
||||
|
||||
public function getCommentName(): string;
|
||||
|
||||
public function getCommentAvatarUrl(): ?string;
|
||||
}
|
||||
15
src/Contracts/MentionResolver.php
Normal file
15
src/Contracts/MentionResolver.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Contracts;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
interface MentionResolver
|
||||
{
|
||||
/** @return Collection<int, Model> */
|
||||
public function search(string $query): Collection;
|
||||
|
||||
/** @return Collection<int, Model> */
|
||||
public function resolveByNames(array $names): Collection;
|
||||
}
|
||||
51
src/Events/CommentCreated.php
Normal file
51
src/Events/CommentCreated.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentCreated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public readonly Model $commentable;
|
||||
|
||||
public function __construct(public readonly Comment $comment)
|
||||
{
|
||||
$this->commentable = $comment->commentable;
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
src/Events/CommentDeleted.php
Normal file
51
src/Events/CommentDeleted.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentDeleted implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public readonly Model $commentable;
|
||||
|
||||
public function __construct(public readonly Comment $comment)
|
||||
{
|
||||
$this->commentable = $comment->commentable;
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
50
src/Events/CommentReacted.php
Normal file
50
src/Events/CommentReacted.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentReacted implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Comment $comment,
|
||||
public readonly object $user,
|
||||
public readonly string $reaction,
|
||||
public readonly string $action,
|
||||
) {}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
51
src/Events/CommentUpdated.php
Normal file
51
src/Events/CommentUpdated.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Broadcasting\InteractsWithBroadcasting;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithBroadcasting;
|
||||
use SerializesModels;
|
||||
|
||||
public readonly Model $commentable;
|
||||
|
||||
public function __construct(public readonly Comment $comment)
|
||||
{
|
||||
$this->commentable = $comment->commentable;
|
||||
}
|
||||
|
||||
/** @return array<int, PrivateChannel> */
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
19
src/Events/UserMentioned.php
Normal file
19
src/Events/UserMentioned.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Events;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Relaticle\Comments\Comment;
|
||||
|
||||
class UserMentioned
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly Comment $comment,
|
||||
public readonly Model $mentionedUser,
|
||||
) {}
|
||||
}
|
||||
48
src/Filament/Actions/CommentsAction.php
Normal file
48
src/Filament/Actions/CommentsAction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Filament\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Relaticle\Comments\Concerns\HasComments;
|
||||
|
||||
class CommentsAction extends Action
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this
|
||||
->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';
|
||||
}
|
||||
}
|
||||
48
src/Filament/Actions/CommentsTableAction.php
Normal file
48
src/Filament/Actions/CommentsTableAction.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Filament\Actions;
|
||||
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Relaticle\Comments\Concerns\HasComments;
|
||||
|
||||
class CommentsTableAction extends Action
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this
|
||||
->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';
|
||||
}
|
||||
}
|
||||
17
src/Filament/Infolists/Components/CommentsEntry.php
Normal file
17
src/Filament/Infolists/Components/CommentsEntry.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Filament\Infolists\Components;
|
||||
|
||||
use Filament\Infolists\Components\Entry;
|
||||
|
||||
class CommentsEntry extends Entry
|
||||
{
|
||||
protected string $view = 'comments::filament.infolists.components.comments-entry';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->columnSpanFull();
|
||||
}
|
||||
}
|
||||
43
src/Listeners/SendCommentRepliedNotification.php
Normal file
43
src/Listeners/SendCommentRepliedNotification.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Listeners;
|
||||
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Notifications\CommentRepliedNotification;
|
||||
|
||||
class SendCommentRepliedNotification
|
||||
{
|
||||
public function handle(CommentCreated $event): void
|
||||
{
|
||||
if (! Config::areNotificationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment = $event->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));
|
||||
}
|
||||
}
|
||||
34
src/Listeners/SendUserMentionedNotification.php
Normal file
34
src/Listeners/SendUserMentionedNotification.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Listeners;
|
||||
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Notifications\UserMentionedNotification;
|
||||
|
||||
class SendUserMentionedNotification
|
||||
{
|
||||
public function handle(UserMentioned $event): void
|
||||
{
|
||||
if (! Config::areNotificationsEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$comment = $event->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));
|
||||
}
|
||||
}
|
||||
183
src/Livewire/CommentItem.php
Normal file
183
src/Livewire/CommentItem.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\CommentDeleted;
|
||||
use Relaticle\Comments\Events\CommentUpdated;
|
||||
use Relaticle\Comments\Mentions\MentionParser;
|
||||
|
||||
class CommentItem extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Comment $comment;
|
||||
|
||||
public bool $isEditing = false;
|
||||
|
||||
public bool $isReplying = false;
|
||||
|
||||
public string $editBody = '';
|
||||
|
||||
public string $replyBody = '';
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
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<int, array{id: int, name: string, avatar_url: ?string}> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
209
src/Livewire/Comments.php
Normal file
209
src/Livewire/Comments.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Mentions\MentionParser;
|
||||
|
||||
class Comments extends Component
|
||||
{
|
||||
use WithFileUploads;
|
||||
|
||||
public Model $model;
|
||||
|
||||
public string $newComment = '';
|
||||
|
||||
public string $sortDirection = 'asc';
|
||||
|
||||
/** @var array<int, TemporaryUploadedFile> */
|
||||
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<int, Comment> */
|
||||
#[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<string, string> */
|
||||
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<int, array{id: int, name: string, avatar_url: ?string}> */
|
||||
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');
|
||||
}
|
||||
}
|
||||
100
src/Livewire/Reactions.php
Normal file
100
src/Livewire/Reactions.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Livewire;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\CommentReacted;
|
||||
|
||||
class Reactions extends Component
|
||||
{
|
||||
public Comment $comment;
|
||||
|
||||
public bool $showPicker = false;
|
||||
|
||||
public function mount(Comment $comment): void
|
||||
{
|
||||
$this->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<int, array{reaction: string, emoji: string, count: int, names: array<int, string>, 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');
|
||||
}
|
||||
}
|
||||
32
src/Mentions/DefaultMentionResolver.php
Normal file
32
src/Mentions/DefaultMentionResolver.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Mentions;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
|
||||
class DefaultMentionResolver implements MentionResolver
|
||||
{
|
||||
/** @return Collection<int, Model> */
|
||||
public function search(string $query): Collection
|
||||
{
|
||||
$model = Config::getCommenterModel();
|
||||
|
||||
return $model::query()
|
||||
->where('name', 'like', "{$query}%")
|
||||
->limit(Config::getMentionMaxResults())
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection<int, Model> */
|
||||
public function resolveByNames(array $names): Collection
|
||||
{
|
||||
$model = Config::getCommenterModel();
|
||||
|
||||
return $model::query()
|
||||
->whereIn('name', $names)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
52
src/Mentions/MentionParser.php
Normal file
52
src/Mentions/MentionParser.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Mentions;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
|
||||
class MentionParser
|
||||
{
|
||||
public function __construct(
|
||||
protected MentionResolver $resolver,
|
||||
) {}
|
||||
|
||||
/** @return Collection<int, int> */
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
42
src/Notifications/CommentRepliedNotification.php
Normal file
42
src/Notifications/CommentRepliedNotification.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Notifications;
|
||||
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class CommentRepliedNotification extends Notification
|
||||
{
|
||||
public function __construct(public readonly Comment $comment) {}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return Config::getNotificationChannels();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
46
src/Notifications/UserMentionedNotification.php
Normal file
46
src/Notifications/UserMentionedNotification.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Notifications;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
class UserMentionedNotification extends Notification
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Comment $comment,
|
||||
public readonly Model $mentionedBy,
|
||||
) {}
|
||||
|
||||
/** @return array<int, string> */
|
||||
public function via(mixed $notifiable): array
|
||||
{
|
||||
return Config::getNotificationChannels();
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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));
|
||||
}
|
||||
}
|
||||
0
src/Policies/.gitkeep
Normal file
0
src/Policies/.gitkeep
Normal file
36
src/Policies/CommentPolicy.php
Normal file
36
src/Policies/CommentPolicy.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Policies;
|
||||
|
||||
use Illuminate\Contracts\Auth\Authenticatable;
|
||||
use Relaticle\Comments\Comment;
|
||||
|
||||
class CommentPolicy
|
||||
{
|
||||
public function viewAny(Authenticatable $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function create(Authenticatable $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function update(Authenticatable $user, Comment $comment): bool
|
||||
{
|
||||
return $user->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();
|
||||
}
|
||||
}
|
||||
0
tests/Database/Factories/.gitkeep
Normal file
0
tests/Database/Factories/.gitkeep
Normal file
19
tests/Database/Factories/PostFactory.php
Normal file
19
tests/Database/Factories/PostFactory.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Tests\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
|
||||
class PostFactory extends Factory
|
||||
{
|
||||
protected $model = Post::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'title' => fake()->sentence(),
|
||||
];
|
||||
}
|
||||
}
|
||||
21
tests/Database/Factories/UserFactory.php
Normal file
21
tests/Database/Factories/UserFactory.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Tests\Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
protected $model = User::class;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'password' => '$2y$04$x7FnGBxMRzQmMJDhKuOi6eLGOlIhWQOGl.IWxCNasFliYJXARljqe',
|
||||
];
|
||||
}
|
||||
}
|
||||
0
tests/Feature/.gitkeep
Normal file
0
tests/Feature/.gitkeep
Normal file
310
tests/Feature/AttachmentUploadTest.php
Normal file
310
tests/Feature/AttachmentUploadTest.php
Normal file
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentAttachment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('creates comment with file attachment via Livewire component', function () {
|
||||
Storage::fake('public');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 100, 100);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->set('newComment', '<p>Comment with attachment</p>')
|
||||
->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', '<p>Vacation photos</p>')
|
||||
->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', '<p>File path test</p>')
|
||||
->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' => '<p>Image comment</p>',
|
||||
]);
|
||||
|
||||
$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' => '<p>PDF comment</p>',
|
||||
]);
|
||||
|
||||
$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', '<p>Oversized file</p>')
|
||||
->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', '<p>Malicious file</p>')
|
||||
->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', '<p>Valid file</p>')
|
||||
->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', '<p>Multiple files</p>')
|
||||
->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' => '<p>Parent comment</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$file = UploadedFile::fake()->image('reply-photo.png', 80, 80);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startReply')
|
||||
->set('replyBody', '<p>Reply with attachment</p>')
|
||||
->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);
|
||||
});
|
||||
187
tests/Feature/BroadcastingTest.php
Normal file
187
tests/Feature/BroadcastingTest.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\CommentDeleted;
|
||||
use Relaticle\Comments\Events\CommentReacted;
|
||||
use Relaticle\Comments\Events\CommentUpdated;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('CommentCreated 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 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}");
|
||||
});
|
||||
197
tests/Feature/CommentAttachmentTest.php
Normal file
197
tests/Feature/CommentAttachmentTest.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentAttachment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('creates a comment attachment with all metadata fields', 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' => '<p>Test comment</p>',
|
||||
]);
|
||||
|
||||
$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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
$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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
$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();
|
||||
});
|
||||
124
tests/Feature/CommentEventsTest.php
Normal file
124
tests/Feature/CommentEventsTest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\CommentDeleted;
|
||||
use Relaticle\Comments\Events\CommentUpdated;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('fires CommentCreated event when adding a comment', function () {
|
||||
Event::fake([CommentCreated::class]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->set('newComment', '<p>New comment</p>')
|
||||
->call('addComment');
|
||||
|
||||
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) {
|
||||
return $event->comment->body === '<p>New comment</p>'
|
||||
&& $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' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startEdit')
|
||||
->set('editBody', '<p>Edited</p>')
|
||||
->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', '<p>Reply text</p>')
|
||||
->call('addReply');
|
||||
|
||||
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) {
|
||||
return $event->comment->parent_id === $comment->id
|
||||
&& $event->comment->body === '<p>Reply text</p>';
|
||||
});
|
||||
});
|
||||
|
||||
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', '<p>Payload test</p>')
|
||||
->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;
|
||||
});
|
||||
});
|
||||
262
tests/Feature/CommentItemComponentTest.php
Normal file
262
tests/Feature/CommentItemComponentTest.php
Normal file
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('allows author to start and save edit on their 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' => '<p>Original body</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startEdit')
|
||||
->assertSet('isEditing', true)
|
||||
->assertSet('editBody', '<p>Original body</p>')
|
||||
->set('editBody', '<p>Updated body</p>')
|
||||
->call('saveEdit')
|
||||
->assertSet('isEditing', false)
|
||||
->assertSet('editBody', '');
|
||||
|
||||
$comment->refresh();
|
||||
|
||||
expect($comment->body)->toBe('<p>Updated body</p>');
|
||||
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' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
expect($comment->isEdited())->toBeFalse();
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startEdit')
|
||||
->set('editBody', '<p>Changed</p>')
|
||||
->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' => '<p>Author comment</p>',
|
||||
]);
|
||||
|
||||
$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', '<p>My reply</p>')
|
||||
->call('addReply')
|
||||
->assertSet('isReplying', false)
|
||||
->assertSet('replyBody', '');
|
||||
|
||||
$reply = Comment::where('parent_id', $comment->id)->first();
|
||||
|
||||
expect($reply)->not->toBeNull();
|
||||
expect($reply->body)->toBe('<p>My reply</p>');
|
||||
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' => '<p>Some body</p>',
|
||||
]);
|
||||
|
||||
$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', '<p>Draft reply</p>')
|
||||
->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);
|
||||
});
|
||||
108
tests/Feature/CommentReactionTest.php
Normal file
108
tests/Feature/CommentReactionTest.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\QueryException;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentReaction;
|
||||
use Relaticle\Comments\Events\CommentReacted;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
$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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
$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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
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' => '<p>Test</p>',
|
||||
]);
|
||||
|
||||
$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');
|
||||
});
|
||||
107
tests/Feature/CommentSubscriptionTest.php
Normal file
107
tests/Feature/CommentSubscriptionTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('has commentable 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->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);
|
||||
});
|
||||
197
tests/Feature/CommentTest.php
Normal file
197
tests/Feature/CommentTest.php
Normal file
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('can be created with factory', 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)->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();
|
||||
});
|
||||
62
tests/Feature/CommentsActionTest.php
Normal file
62
tests/Feature/CommentsActionTest.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Filament\Actions\CommentsAction;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('can be instantiated via make', function () {
|
||||
$action = CommentsAction::make('comments');
|
||||
|
||||
expect($action)->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();
|
||||
});
|
||||
185
tests/Feature/CommentsComponentTest.php
Normal file
185
tests/Feature/CommentsComponentTest.php
Normal file
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('allows authenticated user to create a comment on a post', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->set('newComment', '<p>Hello World</p>')
|
||||
->call('addComment')
|
||||
->assertSet('newComment', '');
|
||||
|
||||
expect(Comment::count())->toBe(1);
|
||||
expect(Comment::first()->body)->toBe('<p>Hello World</p>');
|
||||
});
|
||||
|
||||
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', '<p>Test</p>')
|
||||
->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', '<p>Hello</p>')
|
||||
->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' => '<p>Older comment</p>',
|
||||
'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' => '<p>Newer comment</p>',
|
||||
'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...');
|
||||
});
|
||||
21
tests/Feature/CommentsEntryTest.php
Normal file
21
tests/Feature/CommentsEntryTest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
|
||||
|
||||
it('can be instantiated via make', function () {
|
||||
$entry = CommentsEntry::make('comments');
|
||||
|
||||
expect($entry)->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');
|
||||
});
|
||||
41
tests/Feature/CommentsTableActionTest.php
Normal file
41
tests/Feature/CommentsTableActionTest.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Filament\Actions\CommentsTableAction;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('can be instantiated via make', function () {
|
||||
$action = CommentsTableAction::make('comments');
|
||||
|
||||
expect($action)->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);
|
||||
});
|
||||
172
tests/Feature/ContentSanitizationTest.php
Normal file
172
tests/Feature/ContentSanitizationTest.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('strips script tags from comment body on create', 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' => '<p>Hello</p><script>alert(1)</script>',
|
||||
]);
|
||||
|
||||
expect($comment->body)->not->toContain('<script>');
|
||||
expect($comment->body)->toContain('<p>Hello</p>');
|
||||
});
|
||||
|
||||
it('strips event handler attributes from comment body', 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' => '<img onerror="alert(1)" src="x">',
|
||||
]);
|
||||
|
||||
expect($comment->body)->not->toContain('onerror');
|
||||
expect($comment->body)->toContain('src="x"');
|
||||
});
|
||||
|
||||
it('strips style tags from comment body', 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' => '<p>Hi</p><style>body{display:none}</style>',
|
||||
]);
|
||||
|
||||
expect($comment->body)->not->toContain('<style>');
|
||||
expect($comment->body)->toContain('<p>Hi</p>');
|
||||
});
|
||||
|
||||
it('strips iframe tags from comment body', 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' => '<p>Hi</p><iframe src="evil.com"></iframe>',
|
||||
]);
|
||||
|
||||
expect($comment->body)->not->toContain('<iframe');
|
||||
expect($comment->body)->toContain('<p>Hi</p>');
|
||||
});
|
||||
|
||||
it('preserves safe HTML formatting through sanitization', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$safeHtml = '<p>Hello <strong>bold</strong> and <em>italic</em> text</p>'
|
||||
.'<a href="https://example.com">link</a>'
|
||||
.'<ul><li>item one</li><li>item two</li></ul>'
|
||||
.'<pre><code>echo "hello";</code></pre>'
|
||||
.'<blockquote>quoted text</blockquote>'
|
||||
.'<h1>heading one</h1>'
|
||||
.'<h2>heading two</h2>';
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'body' => $safeHtml,
|
||||
]);
|
||||
|
||||
expect($comment->body)->toContain('<strong>bold</strong>');
|
||||
expect($comment->body)->toContain('<em>italic</em>');
|
||||
expect($comment->body)->toContain('<a href="https://example.com">link</a>');
|
||||
expect($comment->body)->toContain('<ul>');
|
||||
expect($comment->body)->toContain('<li>item one</li>');
|
||||
expect($comment->body)->toContain('<pre><code>');
|
||||
expect($comment->body)->toContain('<blockquote>quoted text</blockquote>');
|
||||
expect($comment->body)->toContain('<h1>heading one</h1>');
|
||||
expect($comment->body)->toContain('<h2>heading two</h2>');
|
||||
});
|
||||
|
||||
it('sanitizes comment body on update', 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' => '<p>Clean content</p>',
|
||||
]);
|
||||
|
||||
$comment->update([
|
||||
'body' => '<p>Updated</p><script>document.cookie</script>',
|
||||
]);
|
||||
|
||||
$comment->refresh();
|
||||
|
||||
expect($comment->body)->not->toContain('<script>');
|
||||
expect($comment->body)->toContain('<p>Updated</p>');
|
||||
});
|
||||
|
||||
it('strips javascript protocol from link href', 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' => '<a href="javascript:alert(1)">click me</a>',
|
||||
]);
|
||||
|
||||
expect($comment->body)->not->toContain('javascript:');
|
||||
expect($comment->body)->toContain('click me');
|
||||
});
|
||||
|
||||
it('strips onclick handler from elements', 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' => '<div onclick="alert(1)">click me</div>',
|
||||
]);
|
||||
|
||||
expect($comment->body)->not->toContain('onclick');
|
||||
expect($comment->body)->toContain('click me');
|
||||
});
|
||||
|
||||
it('sanitizes content submitted through livewire component', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->set('newComment', '<p>Hello</p><script>alert("xss")</script>')
|
||||
->call('addComment');
|
||||
|
||||
$comment = Comment::first();
|
||||
|
||||
expect($comment->body)->not->toContain('<script>');
|
||||
expect($comment->body)->toContain('<p>Hello</p>');
|
||||
});
|
||||
77
tests/Feature/HasCommentsTest.php
Normal file
77
tests/Feature/HasCommentsTest.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('provides comments relationship on commentable model', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
Comment::factory()->count(3)->create([
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'commentable_id' => $post->id,
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
expect($post->comments)->toHaveCount(3);
|
||||
expect($post->comments->first())->toBeInstanceOf(Comment::class);
|
||||
});
|
||||
|
||||
it('provides topLevelComments excluding 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,
|
||||
];
|
||||
|
||||
$topLevel = Comment::factory()->create($attrs);
|
||||
Comment::factory()->withParent($topLevel)->create($attrs);
|
||||
|
||||
expect($post->comments()->count())->toBe(2);
|
||||
expect($post->topLevelComments()->count())->toBe(1);
|
||||
expect($post->topLevelComments->first()->id)->toBe($topLevel->id);
|
||||
});
|
||||
|
||||
it('provides comment count', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
expect($post->commentCount())->toBe(0);
|
||||
|
||||
Comment::factory()->count(5)->create([
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'commentable_id' => $post->id,
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
expect($post->commentCount())->toBe(5);
|
||||
});
|
||||
|
||||
it('scopes comments to the specific commentable', function () {
|
||||
$user = User::factory()->create();
|
||||
$post1 = Post::factory()->create();
|
||||
$post2 = Post::factory()->create();
|
||||
|
||||
Comment::factory()->count(3)->create([
|
||||
'commentable_type' => $post1->getMorphClass(),
|
||||
'commentable_id' => $post1->id,
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
Comment::factory()->count(2)->create([
|
||||
'commentable_type' => $post2->getMorphClass(),
|
||||
'commentable_id' => $post2->id,
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
expect($post1->commentCount())->toBe(3);
|
||||
expect($post2->commentCount())->toBe(2);
|
||||
});
|
||||
90
tests/Feature/MentionDisplayTest.php
Normal file
90
tests/Feature/MentionDisplayTest.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('renders mention with styled span', function () {
|
||||
$user = User::factory()->create();
|
||||
$alice = User::factory()->create(['name' => 'Alice']);
|
||||
$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' => '<p>@Alice said hi</p>',
|
||||
]);
|
||||
|
||||
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]);
|
||||
|
||||
$rendered = $comment->renderBodyWithMentions();
|
||||
|
||||
expect($rendered)->toContain('comment-mention');
|
||||
expect($rendered)->toContain('@Alice</span>');
|
||||
});
|
||||
|
||||
it('renders multiple mentions with styled spans', function () {
|
||||
$user = User::factory()->create();
|
||||
$alice = User::factory()->create(['name' => 'Alice']);
|
||||
$bob = User::factory()->create(['name' => 'Bob']);
|
||||
$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' => '<p>@Alice and @Bob</p>',
|
||||
]);
|
||||
|
||||
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]);
|
||||
$comment->mentions()->attach($bob->id, ['user_type' => $bob->getMorphClass()]);
|
||||
|
||||
$rendered = $comment->renderBodyWithMentions();
|
||||
|
||||
expect($rendered)->toContain('@Alice</span>');
|
||||
expect($rendered)->toContain('@Bob</span>');
|
||||
expect($rendered)->toContain('comment-mention');
|
||||
});
|
||||
|
||||
it('does not style non-mentioned @text', 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' => '<p>@ghost is not here</p>',
|
||||
]);
|
||||
|
||||
$rendered = $comment->renderBodyWithMentions();
|
||||
|
||||
expect($rendered)->not->toContain('comment-mention');
|
||||
});
|
||||
|
||||
it('renders comment-mention class in Livewire component', function () {
|
||||
$user = User::factory()->create();
|
||||
$alice = User::factory()->create(['name' => 'Alice']);
|
||||
$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' => '<p>Hello @Alice</p>',
|
||||
]);
|
||||
|
||||
$comment->mentions()->attach($alice->id, ['user_type' => $alice->getMorphClass()]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->assertSeeHtml('comment-mention');
|
||||
});
|
||||
223
tests/Feature/MentionParserTest.php
Normal file
223
tests/Feature/MentionParserTest.php
Normal file
@@ -0,0 +1,223 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
||||
use Relaticle\Comments\Mentions\MentionParser;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('parses @username from plain text body', function () {
|
||||
User::factory()->create(['name' => 'john']);
|
||||
User::factory()->create(['name' => 'jane']);
|
||||
|
||||
$parser = app(MentionParser::class);
|
||||
$result = $parser->parse('Hello @john and @jane');
|
||||
|
||||
expect($result)->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('parses @username from HTML body', function () {
|
||||
$john = User::factory()->create(['name' => 'john']);
|
||||
|
||||
$parser = app(MentionParser::class);
|
||||
$result = $parser->parse('<p>Hello @john</p>');
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
expect($result->first())->toBe($john->id);
|
||||
});
|
||||
|
||||
it('returns empty collection when no mentions', function () {
|
||||
$parser = app(MentionParser::class);
|
||||
$result = $parser->parse('Hello world');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns empty collection for non-existent users', function () {
|
||||
$parser = app(MentionParser::class);
|
||||
$result = $parser->parse('Hello @ghostuser');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('handles duplicate @mentions', function () {
|
||||
User::factory()->create(['name' => 'john']);
|
||||
|
||||
$parser = app(MentionParser::class);
|
||||
$result = $parser->parse('@john said hi @john');
|
||||
|
||||
expect($result)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('stores mentions in comment_mentions table on create', function () {
|
||||
$user = User::factory()->create();
|
||||
$john = User::factory()->create(['name' => 'john']);
|
||||
$jane = User::factory()->create(['name' => 'jane']);
|
||||
$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' => '<p>Hello @john and @jane</p>',
|
||||
]);
|
||||
|
||||
app(MentionParser::class)->syncMentions($comment);
|
||||
|
||||
expect($comment->mentions()->count())->toBe(2);
|
||||
expect($comment->mentions->pluck('id')->sort()->values()->all())
|
||||
->toBe(collect([$john->id, $jane->id])->sort()->values()->all());
|
||||
});
|
||||
|
||||
it('dispatches UserMentioned event for each mentioned user', function () {
|
||||
Event::fake([UserMentioned::class]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$john = User::factory()->create(['name' => 'john']);
|
||||
$jane = User::factory()->create(['name' => 'jane']);
|
||||
$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' => '<p>Hello @john and @jane</p>',
|
||||
]);
|
||||
|
||||
app(MentionParser::class)->syncMentions($comment);
|
||||
|
||||
Event::assertDispatched(UserMentioned::class, 2);
|
||||
|
||||
Event::assertDispatched(UserMentioned::class, function (UserMentioned $event) use ($comment, $john) {
|
||||
return $event->comment->id === $comment->id
|
||||
&& $event->mentionedUser->id === $john->id;
|
||||
});
|
||||
|
||||
Event::assertDispatched(UserMentioned::class, function (UserMentioned $event) use ($comment, $jane) {
|
||||
return $event->comment->id === $comment->id
|
||||
&& $event->mentionedUser->id === $jane->id;
|
||||
});
|
||||
});
|
||||
|
||||
it('only dispatches UserMentioned for newly added mentions on update', function () {
|
||||
Event::fake([UserMentioned::class]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$john = User::factory()->create(['name' => 'john']);
|
||||
$jane = User::factory()->create(['name' => 'jane']);
|
||||
$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' => '<p>Hello @john</p>',
|
||||
]);
|
||||
|
||||
app(MentionParser::class)->syncMentions($comment);
|
||||
|
||||
Event::assertDispatched(UserMentioned::class, 1);
|
||||
|
||||
Event::fake([UserMentioned::class]);
|
||||
|
||||
$comment->update(['body' => '<p>Hello @john and @jane</p>']);
|
||||
app(MentionParser::class)->syncMentions($comment->fresh());
|
||||
|
||||
Event::assertDispatched(UserMentioned::class, 1);
|
||||
|
||||
Event::assertDispatched(UserMentioned::class, function (UserMentioned $event) use ($jane) {
|
||||
return $event->mentionedUser->id === $jane->id;
|
||||
});
|
||||
});
|
||||
|
||||
it('removes mentions from pivot when user removed from body', function () {
|
||||
$user = User::factory()->create();
|
||||
$john = User::factory()->create(['name' => 'john']);
|
||||
$jane = User::factory()->create(['name' => 'jane']);
|
||||
$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' => '<p>Hello @john and @jane</p>',
|
||||
]);
|
||||
|
||||
app(MentionParser::class)->syncMentions($comment);
|
||||
|
||||
expect($comment->mentions()->count())->toBe(2);
|
||||
|
||||
$comment->update(['body' => '<p>Hello @john</p>']);
|
||||
app(MentionParser::class)->syncMentions($comment->fresh());
|
||||
|
||||
$comment->refresh();
|
||||
|
||||
expect($comment->mentions()->count())->toBe(1);
|
||||
expect($comment->mentions->first()->id)->toBe($john->id);
|
||||
});
|
||||
|
||||
it('uses configured MentionResolver', function () {
|
||||
$customResolver = new class implements MentionResolver
|
||||
{
|
||||
public function search(string $query): Collection
|
||||
{
|
||||
return collect();
|
||||
}
|
||||
|
||||
public function resolveByNames(array $names): Collection
|
||||
{
|
||||
return collect();
|
||||
}
|
||||
};
|
||||
|
||||
$this->app->instance(MentionResolver::class, $customResolver);
|
||||
|
||||
$parser = app(MentionParser::class);
|
||||
$result = $parser->parse('@someuser');
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('searches users by name prefix', function () {
|
||||
User::factory()->create(['name' => 'John Doe']);
|
||||
User::factory()->create(['name' => 'Jane Doe']);
|
||||
User::factory()->create(['name' => 'Bob Smith']);
|
||||
|
||||
$resolver = app(DefaultMentionResolver::class);
|
||||
$results = $resolver->search('Jo');
|
||||
|
||||
expect($results)->toHaveCount(1);
|
||||
expect($results->first()->name)->toBe('John Doe');
|
||||
});
|
||||
|
||||
it('limits search results to configured max', function () {
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
User::factory()->create(['name' => "Test User {$i}"]);
|
||||
}
|
||||
|
||||
config(['comments.mentions.max_results' => 3]);
|
||||
|
||||
$resolver = app(DefaultMentionResolver::class);
|
||||
$results = $resolver->search('Test');
|
||||
|
||||
expect($results)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('resolves users by exact name', function () {
|
||||
$john = User::factory()->create(['name' => 'John Doe']);
|
||||
User::factory()->create(['name' => 'Jane Doe']);
|
||||
|
||||
$resolver = app(DefaultMentionResolver::class);
|
||||
$results = $resolver->resolveByNames(['John Doe']);
|
||||
|
||||
expect($results)->toHaveCount(1);
|
||||
expect($results->first()->id)->toBe($john->id);
|
||||
});
|
||||
57
tests/Feature/MentionResolverTest.php
Normal file
57
tests/Feature/MentionResolverTest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Contracts\MentionResolver;
|
||||
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('resolves the default mention resolver from the container', function () {
|
||||
$resolver = app(MentionResolver::class);
|
||||
|
||||
expect($resolver)->toBeInstanceOf(DefaultMentionResolver::class);
|
||||
});
|
||||
|
||||
it('searches users by name prefix', function () {
|
||||
User::factory()->create(['name' => 'john']);
|
||||
User::factory()->create(['name' => 'joe']);
|
||||
User::factory()->create(['name' => 'alice']);
|
||||
|
||||
$resolver = app(MentionResolver::class);
|
||||
$results = $resolver->search('jo');
|
||||
|
||||
expect($results)->toHaveCount(2)
|
||||
->each->toBeInstanceOf(User::class);
|
||||
});
|
||||
|
||||
it('limits search results to configured max', function () {
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
User::factory()->create(['name' => "john{$i}"]);
|
||||
}
|
||||
|
||||
config()->set('comments.mentions.max_results', 3);
|
||||
|
||||
$resolver = new DefaultMentionResolver;
|
||||
$results = $resolver->search('john');
|
||||
|
||||
expect($results)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('resolves users by exact names', function () {
|
||||
$john = User::factory()->create(['name' => 'john']);
|
||||
$jane = User::factory()->create(['name' => 'jane']);
|
||||
User::factory()->create(['name' => 'alice']);
|
||||
|
||||
$resolver = app(MentionResolver::class);
|
||||
$users = $resolver->resolveByNames(['john', 'jane']);
|
||||
|
||||
expect($users)->toHaveCount(2);
|
||||
expect($users->pluck('id')->all())
|
||||
->toContain($john->id)
|
||||
->toContain($jane->id);
|
||||
});
|
||||
|
||||
it('returns empty collection for unknown names', function () {
|
||||
$resolver = app(MentionResolver::class);
|
||||
$users = $resolver->resolveByNames(['nobody', 'nonexistent']);
|
||||
|
||||
expect($users)->toBeEmpty();
|
||||
});
|
||||
111
tests/Feature/MentionSearchTest.php
Normal file
111
tests/Feature/MentionSearchTest.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('returns matching users for search query', function () {
|
||||
$alice = User::factory()->create(['name' => 'Alice']);
|
||||
User::factory()->create(['name' => 'Bob']);
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($alice);
|
||||
|
||||
$component = Livewire::test(Comments::class, ['model' => $post]);
|
||||
$results = $component->instance()->searchUsers('Ali');
|
||||
|
||||
expect($results)->toHaveCount(1);
|
||||
expect($results[0])->toMatchArray([
|
||||
'id' => $alice->id,
|
||||
'name' => 'Alice',
|
||||
]);
|
||||
expect($results[0])->toHaveKey('avatar_url');
|
||||
});
|
||||
|
||||
it('returns empty array for empty query', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(Comments::class, ['model' => $post]);
|
||||
$results = $component->instance()->searchUsers('');
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns empty array for no matches', function () {
|
||||
$user = User::factory()->create(['name' => 'Alice']);
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(Comments::class, ['model' => $post]);
|
||||
$results = $component->instance()->searchUsers('zzz');
|
||||
|
||||
expect($results)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('limits search results to configured max', function () {
|
||||
$user = User::factory()->create(['name' => 'Admin']);
|
||||
$post = Post::factory()->create();
|
||||
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
User::factory()->create(['name' => "Test User {$i}"]);
|
||||
}
|
||||
|
||||
config(['comments.mentions.max_results' => 3]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(Comments::class, ['model' => $post]);
|
||||
$results = $component->instance()->searchUsers('Test');
|
||||
|
||||
expect($results)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('stores mentions when creating comment with @mention', function () {
|
||||
$user = User::factory()->create();
|
||||
$alice = User::factory()->create(['name' => 'Alice']);
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->set('newComment', '<p>Hey @Alice check this</p>')
|
||||
->call('addComment');
|
||||
|
||||
$comment = Comment::first();
|
||||
|
||||
expect($comment->mentions)->toHaveCount(1);
|
||||
expect($comment->mentions->first()->id)->toBe($alice->id);
|
||||
});
|
||||
|
||||
it('stores mentions when editing comment with @mention', function () {
|
||||
$user = User::factory()->create();
|
||||
$bob = User::factory()->create(['name' => 'Bob']);
|
||||
$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' => '<p>Original comment</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startEdit')
|
||||
->set('editBody', '<p>Updated @Bob</p>')
|
||||
->call('saveEdit');
|
||||
|
||||
$comment->refresh();
|
||||
|
||||
expect($comment->mentions)->toHaveCount(1);
|
||||
expect($comment->mentions->first()->id)->toBe($bob->id);
|
||||
});
|
||||
232
tests/Feature/NotificationIntegrationTest.php
Normal file
232
tests/Feature/NotificationIntegrationTest.php
Normal file
@@ -0,0 +1,232 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
|
||||
use Relaticle\Comments\Listeners\SendUserMentionedNotification;
|
||||
use Relaticle\Comments\Notifications\CommentRepliedNotification;
|
||||
use Relaticle\Comments\Notifications\UserMentionedNotification;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('sends CommentRepliedNotification to parent comment author when reply is created', function () {
|
||||
Notification::fake();
|
||||
|
||||
$parentAuthor = User::factory()->create();
|
||||
$replyAuthor = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $parentAuthor);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $parentAuthor->getKey(),
|
||||
'user_type' => $parentAuthor->getMorphClass(),
|
||||
'body' => '<p>Parent comment</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $replyAuthor->getKey(),
|
||||
'user_type' => $replyAuthor->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>A reply</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($reply));
|
||||
|
||||
Notification::assertSentTo($parentAuthor, CommentRepliedNotification::class);
|
||||
});
|
||||
|
||||
it('does NOT send reply notification for top-level comments', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$subscriber = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $subscriber);
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'body' => '<p>Top-level comment</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($comment));
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does NOT send reply notification to the reply author', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $user);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'body' => '<p>My comment</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>My own reply</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($reply));
|
||||
|
||||
Notification::assertNotSentTo($user, CommentRepliedNotification::class);
|
||||
});
|
||||
|
||||
it('sends UserMentionedNotification when a user is mentioned', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$mentioned = 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' => '<p>Hey @someone</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendUserMentionedNotification;
|
||||
$listener->handle(new UserMentioned($comment, $mentioned));
|
||||
|
||||
Notification::assertSentTo($mentioned, UserMentionedNotification::class);
|
||||
});
|
||||
|
||||
it('does NOT send mention notification to the comment author', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = 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' => '<p>Hey @myself</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendUserMentionedNotification;
|
||||
$listener->handle(new UserMentioned($comment, $author));
|
||||
|
||||
Notification::assertNotSentTo($author, UserMentionedNotification::class);
|
||||
});
|
||||
|
||||
it('does NOT send reply notification to unsubscribed user', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$unsubscribedUser = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $unsubscribedUser);
|
||||
CommentSubscription::unsubscribe($post, $unsubscribedUser);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $unsubscribedUser->getKey(),
|
||||
'user_type' => $unsubscribedUser->getMorphClass(),
|
||||
'body' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>Reply</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($reply));
|
||||
|
||||
Notification::assertNotSentTo($unsubscribedUser, CommentRepliedNotification::class);
|
||||
});
|
||||
|
||||
it('auto-subscribes the comment author when creating a comment', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $author))->toBeFalse();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'body' => '<p>My comment</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($comment));
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $author))->toBeTrue();
|
||||
});
|
||||
|
||||
it('suppresses all notifications when notifications are disabled via config', function () {
|
||||
Notification::fake();
|
||||
config()->set('comments.notifications.enabled', false);
|
||||
|
||||
$author = User::factory()->create();
|
||||
$subscriber = User::factory()->create();
|
||||
$mentioned = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $subscriber);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $subscriber->getKey(),
|
||||
'user_type' => $subscriber->getMorphClass(),
|
||||
'body' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>Reply</p>',
|
||||
]);
|
||||
|
||||
$replyListener = new SendCommentRepliedNotification;
|
||||
$replyListener->handle(new CommentCreated($reply));
|
||||
|
||||
$mentionListener = new SendUserMentionedNotification;
|
||||
$mentionListener->handle(new UserMentioned($reply, $mentioned));
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
343
tests/Feature/NotificationTest.php
Normal file
343
tests/Feature/NotificationTest.php
Normal file
@@ -0,0 +1,343 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\CommentCreated;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Listeners\SendCommentRepliedNotification;
|
||||
use Relaticle\Comments\Listeners\SendUserMentionedNotification;
|
||||
use Relaticle\Comments\Notifications\CommentRepliedNotification;
|
||||
use Relaticle\Comments\Notifications\UserMentionedNotification;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('returns correct via channels from config for CommentRepliedNotification', function () {
|
||||
config()->set('comments.notifications.channels', ['database', 'mail']);
|
||||
|
||||
$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' => '<p>Hello</p>',
|
||||
]);
|
||||
|
||||
$notification = new CommentRepliedNotification($comment);
|
||||
|
||||
expect($notification->via($user))->toBe(['database', 'mail']);
|
||||
});
|
||||
|
||||
it('returns toDatabase array with comment data for CommentRepliedNotification', 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' => '<p>This is a reply body</p>',
|
||||
]);
|
||||
|
||||
$notification = new CommentRepliedNotification($comment);
|
||||
$data = $notification->toDatabase($user);
|
||||
|
||||
expect($data)->toHaveKeys(['comment_id', 'commentable_type', 'commentable_id', 'commenter_name', 'body'])
|
||||
->and($data['comment_id'])->toBe($comment->id)
|
||||
->and($data['commentable_type'])->toBe($post->getMorphClass())
|
||||
->and($data['commentable_id'])->toBe($post->id)
|
||||
->and($data['commenter_name'])->toBe($user->getCommentName());
|
||||
});
|
||||
|
||||
it('returns correct via channels from config for UserMentionedNotification', function () {
|
||||
config()->set('comments.notifications.channels', ['database']);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$mentionedBy = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $mentionedBy->getKey(),
|
||||
'user_type' => $mentionedBy->getMorphClass(),
|
||||
'body' => '<p>Hey @someone</p>',
|
||||
]);
|
||||
|
||||
$notification = new UserMentionedNotification($comment, $mentionedBy);
|
||||
|
||||
expect($notification->via($user))->toBe(['database']);
|
||||
});
|
||||
|
||||
it('returns toDatabase array with mention data for UserMentionedNotification', function () {
|
||||
$mentioner = User::factory()->create();
|
||||
$mentioned = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $mentioner->getKey(),
|
||||
'user_type' => $mentioner->getMorphClass(),
|
||||
'body' => '<p>Hey @mentioned</p>',
|
||||
]);
|
||||
|
||||
$notification = new UserMentionedNotification($comment, $mentioner);
|
||||
$data = $notification->toDatabase($mentioned);
|
||||
|
||||
expect($data)->toHaveKeys(['comment_id', 'commentable_type', 'commentable_id', 'mentioner_name', 'body'])
|
||||
->and($data['comment_id'])->toBe($comment->id)
|
||||
->and($data['mentioner_name'])->toBe($mentioner->getCommentName());
|
||||
});
|
||||
|
||||
it('sends notification to subscribers when reply comment is created', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$subscriber = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $subscriber);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $subscriber->getKey(),
|
||||
'user_type' => $subscriber->getMorphClass(),
|
||||
'body' => '<p>Original comment</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>Reply to original</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($reply));
|
||||
|
||||
Notification::assertSentTo($subscriber, CommentRepliedNotification::class);
|
||||
});
|
||||
|
||||
it('does NOT send notification for top-level comments', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$subscriber = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $subscriber);
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'body' => '<p>Top-level comment</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($comment));
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
|
||||
it('does NOT notify the reply author themselves', function () {
|
||||
Notification::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $user);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'body' => '<p>My comment</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>My own reply</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($reply));
|
||||
|
||||
Notification::assertNotSentTo($user, CommentRepliedNotification::class);
|
||||
});
|
||||
|
||||
it('auto-subscribes comment author to the thread', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $author))->toBeFalse();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'body' => '<p>Comment</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($comment));
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $author))->toBeTrue();
|
||||
});
|
||||
|
||||
it('only notifies subscribed users for reply notifications', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$subscriber = User::factory()->create();
|
||||
$nonSubscriber = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $subscriber);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $subscriber->getKey(),
|
||||
'user_type' => $subscriber->getMorphClass(),
|
||||
'body' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>Reply</p>',
|
||||
]);
|
||||
|
||||
$listener = new SendCommentRepliedNotification;
|
||||
$listener->handle(new CommentCreated($reply));
|
||||
|
||||
Notification::assertSentTo($subscriber, CommentRepliedNotification::class);
|
||||
Notification::assertNotSentTo($nonSubscriber, CommentRepliedNotification::class);
|
||||
});
|
||||
|
||||
it('sends mention notification to mentioned user', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$mentioned = 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' => '<p>Hey @mentioned</p>',
|
||||
]);
|
||||
|
||||
$event = new UserMentioned($comment, $mentioned);
|
||||
$listener = new SendUserMentionedNotification;
|
||||
$listener->handle($event);
|
||||
|
||||
Notification::assertSentTo($mentioned, UserMentionedNotification::class);
|
||||
});
|
||||
|
||||
it('does NOT send mention notification to the comment author', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = 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' => '<p>Hey @myself</p>',
|
||||
]);
|
||||
|
||||
$event = new UserMentioned($comment, $author);
|
||||
$listener = new SendUserMentionedNotification;
|
||||
$listener->handle($event);
|
||||
|
||||
Notification::assertNotSentTo($author, UserMentionedNotification::class);
|
||||
});
|
||||
|
||||
it('auto-subscribes mentioned user to the thread', function () {
|
||||
Notification::fake();
|
||||
|
||||
$author = User::factory()->create();
|
||||
$mentioned = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $mentioned))->toBeFalse();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'body' => '<p>Hey @mentioned</p>',
|
||||
]);
|
||||
|
||||
$event = new UserMentioned($comment, $mentioned);
|
||||
$listener = new SendUserMentionedNotification;
|
||||
$listener->handle($event);
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $mentioned))->toBeTrue();
|
||||
});
|
||||
|
||||
it('does not send notifications when notifications are disabled', function () {
|
||||
Notification::fake();
|
||||
config()->set('comments.notifications.enabled', false);
|
||||
|
||||
$author = User::factory()->create();
|
||||
$subscriber = User::factory()->create();
|
||||
$mentioned = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $subscriber);
|
||||
|
||||
$parentComment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $subscriber->getKey(),
|
||||
'user_type' => $subscriber->getMorphClass(),
|
||||
'body' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$reply = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $author->getKey(),
|
||||
'user_type' => $author->getMorphClass(),
|
||||
'parent_id' => $parentComment->id,
|
||||
'body' => '<p>Reply</p>',
|
||||
]);
|
||||
|
||||
$replyListener = new SendCommentRepliedNotification;
|
||||
$replyListener->handle(new CommentCreated($reply));
|
||||
|
||||
$mentionEvent = new UserMentioned($reply, $mentioned);
|
||||
$mentionListener = new SendUserMentionedNotification;
|
||||
$mentionListener->handle($mentionEvent);
|
||||
|
||||
Notification::assertNothingSent();
|
||||
});
|
||||
33
tests/Feature/PollingConfigTest.php
Normal file
33
tests/Feature/PollingConfigTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
it('returns false for isBroadcastingEnabled by default', function () {
|
||||
expect(Config::isBroadcastingEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true for isBroadcastingEnabled when config overridden', function () {
|
||||
config()->set('comments.broadcasting.enabled', true);
|
||||
|
||||
expect(Config::isBroadcastingEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns comments as default broadcast channel prefix', function () {
|
||||
expect(Config::getBroadcastChannelPrefix())->toBe('comments');
|
||||
});
|
||||
|
||||
it('returns custom broadcast channel prefix when overridden', function () {
|
||||
config()->set('comments.broadcasting.channel_prefix', 'my-app-comments');
|
||||
|
||||
expect(Config::getBroadcastChannelPrefix())->toBe('my-app-comments');
|
||||
});
|
||||
|
||||
it('returns 10s as default polling interval', function () {
|
||||
expect(Config::getPollingInterval())->toBe('10s');
|
||||
});
|
||||
|
||||
it('returns custom polling interval when overridden', function () {
|
||||
config()->set('comments.polling.interval', '30s');
|
||||
|
||||
expect(Config::getPollingInterval())->toBe('30s');
|
||||
});
|
||||
334
tests/Feature/ReactionsTest.php
Normal file
334
tests/Feature/ReactionsTest.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\CommentReaction;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Events\CommentReacted;
|
||||
use Relaticle\Comments\Livewire\Reactions;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('adds a reaction when user clicks an emoji', 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(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'thumbs_up');
|
||||
|
||||
expect(CommentReaction::where([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
])->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('removes a reaction when toggling same emoji', 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(),
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'thumbs_up');
|
||||
|
||||
expect(CommentReaction::where([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
])->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('fires CommentReacted event with added action', function () {
|
||||
Event::fake([CommentReacted::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(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'thumbs_up');
|
||||
|
||||
Event::assertDispatched(CommentReacted::class, function (CommentReacted $event) use ($comment, $user) {
|
||||
return $event->comment->id === $comment->id
|
||||
&& $event->user->getKey() === $user->getKey()
|
||||
&& $event->reaction === 'thumbs_up'
|
||||
&& $event->action === 'added';
|
||||
});
|
||||
});
|
||||
|
||||
it('fires CommentReacted event with removed action', 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(),
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => 'heart',
|
||||
]);
|
||||
|
||||
Event::fake([CommentReacted::class]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'heart');
|
||||
|
||||
Event::assertDispatched(CommentReacted::class, function (CommentReacted $event) use ($comment, $user) {
|
||||
return $event->comment->id === $comment->id
|
||||
&& $event->user->getKey() === $user->getKey()
|
||||
&& $event->reaction === 'heart'
|
||||
&& $event->action === 'removed';
|
||||
});
|
||||
});
|
||||
|
||||
it('returns correct reaction summary with counts', function () {
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$user3 = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user1->getKey(),
|
||||
'user_type' => $user1->getMorphClass(),
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user1->getKey(),
|
||||
'user_type' => $user1->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user2->getKey(),
|
||||
'user_type' => $user2->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user3->getKey(),
|
||||
'user_type' => $user3->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user1->getKey(),
|
||||
'user_type' => $user1->getMorphClass(),
|
||||
'reaction' => 'heart',
|
||||
]);
|
||||
|
||||
$this->actingAs($user1);
|
||||
|
||||
$component = Livewire::test(Reactions::class, ['comment' => $comment]);
|
||||
$summary = $component->instance()->reactionSummary;
|
||||
|
||||
expect($summary)->toHaveCount(2);
|
||||
|
||||
$thumbsUp = collect($summary)->firstWhere('reaction', 'thumbs_up');
|
||||
expect($thumbsUp['count'])->toBe(3);
|
||||
expect($thumbsUp['names'])->toHaveCount(3);
|
||||
|
||||
$heart = collect($summary)->firstWhere('reaction', 'heart');
|
||||
expect($heart['count'])->toBe(1);
|
||||
});
|
||||
|
||||
it('requires authentication to react', 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(),
|
||||
]);
|
||||
|
||||
Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'thumbs_up');
|
||||
|
||||
expect(CommentReaction::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('allows multiple reaction types from same user', 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);
|
||||
|
||||
$component = Livewire::test(Reactions::class, ['comment' => $comment]);
|
||||
|
||||
$component->call('toggleReaction', 'thumbs_up');
|
||||
$component->call('toggleReaction', 'heart');
|
||||
|
||||
expect(CommentReaction::where([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
])->exists())->toBeTrue();
|
||||
|
||||
expect(CommentReaction::where([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'reaction' => 'heart',
|
||||
])->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('allows same reaction from multiple users', function () {
|
||||
$user1 = User::factory()->create();
|
||||
$user2 = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user1->getKey(),
|
||||
'user_type' => $user1->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->actingAs($user1);
|
||||
Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'thumbs_up');
|
||||
|
||||
$this->actingAs($user2);
|
||||
Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'thumbs_up');
|
||||
|
||||
expect(CommentReaction::where([
|
||||
'comment_id' => $comment->id,
|
||||
'reaction' => 'thumbs_up',
|
||||
])->count())->toBe(2);
|
||||
});
|
||||
|
||||
it('rejects invalid reaction keys', 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(Reactions::class, ['comment' => $comment])
|
||||
->call('toggleReaction', 'invalid_emoji');
|
||||
|
||||
expect(CommentReaction::count())->toBe(0);
|
||||
});
|
||||
|
||||
it('marks reacted_by_user correctly in summary', function () {
|
||||
$userA = User::factory()->create();
|
||||
$userB = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $userA->getKey(),
|
||||
'user_type' => $userA->getMorphClass(),
|
||||
]);
|
||||
|
||||
CommentReaction::create([
|
||||
'comment_id' => $comment->id,
|
||||
'user_id' => $userA->getKey(),
|
||||
'user_type' => $userA->getMorphClass(),
|
||||
'reaction' => 'thumbs_up',
|
||||
]);
|
||||
|
||||
$this->actingAs($userA);
|
||||
$summaryA = Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->instance()->reactionSummary;
|
||||
|
||||
$thumbsUpA = collect($summaryA)->firstWhere('reaction', 'thumbs_up');
|
||||
expect($thumbsUpA['reacted_by_user'])->toBeTrue();
|
||||
|
||||
$this->actingAs($userB);
|
||||
$summaryB = Livewire::test(Reactions::class, ['comment' => $comment])
|
||||
->instance()->reactionSummary;
|
||||
|
||||
$thumbsUpB = collect($summaryB)->firstWhere('reaction', 'thumbs_up');
|
||||
expect($thumbsUpB['reacted_by_user'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns configured emoji set from config', function () {
|
||||
$emojiSet = Config::getReactionEmojiSet();
|
||||
|
||||
expect($emojiSet)->toBeArray();
|
||||
expect($emojiSet)->toHaveKey('thumbs_up');
|
||||
expect($emojiSet)->toHaveKey('heart');
|
||||
expect($emojiSet)->toHaveKey('celebrate');
|
||||
expect($emojiSet)->toHaveKey('laugh');
|
||||
expect($emojiSet)->toHaveKey('thinking');
|
||||
expect($emojiSet)->toHaveKey('sad');
|
||||
});
|
||||
|
||||
it('returns allowed reaction keys from config', function () {
|
||||
$allowed = Config::getAllowedReactions();
|
||||
|
||||
expect($allowed)->toBeArray();
|
||||
expect($allowed)->toContain('thumbs_up');
|
||||
expect($allowed)->toContain('heart');
|
||||
expect($allowed)->toContain('celebrate');
|
||||
expect($allowed)->toContain('laugh');
|
||||
expect($allowed)->toContain('thinking');
|
||||
expect($allowed)->toContain('sad');
|
||||
});
|
||||
141
tests/Feature/RichEditorTest.php
Normal file
141
tests/Feature/RichEditorTest.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
use Relaticle\Comments\Livewire\CommentItem;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('creates a comment with rich HTML content preserved', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = '<p>Hello <strong>bold</strong> and <em>italic</em> world</p>';
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->set('newComment', $html)
|
||||
->call('addComment');
|
||||
|
||||
$comment = Comment::first();
|
||||
|
||||
expect($comment->body)->toContain('<strong>bold</strong>');
|
||||
expect($comment->body)->toContain('<em>italic</em>');
|
||||
});
|
||||
|
||||
it('pre-fills editBody with existing comment HTML when starting edit', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$originalHtml = '<p>Hello <strong>world</strong></p>';
|
||||
|
||||
$comment = Comment::factory()->create([
|
||||
'commentable_id' => $post->id,
|
||||
'commentable_type' => $post->getMorphClass(),
|
||||
'user_id' => $user->getKey(),
|
||||
'user_type' => $user->getMorphClass(),
|
||||
'body' => $originalHtml,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startEdit')
|
||||
->assertSet('editBody', $originalHtml);
|
||||
});
|
||||
|
||||
it('saves edited HTML content through edit form', 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' => '<p>Original</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$updatedHtml = '<p>Updated with <strong>bold</strong> and <a href="https://example.com">a link</a></p>';
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startEdit')
|
||||
->set('editBody', $updatedHtml)
|
||||
->call('saveEdit');
|
||||
|
||||
$comment->refresh();
|
||||
|
||||
expect($comment->body)->toContain('<strong>bold</strong>');
|
||||
expect($comment->body)->toContain('<a href="https://example.com">a link</a>');
|
||||
});
|
||||
|
||||
it('creates reply with rich HTML content', 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);
|
||||
|
||||
$replyHtml = '<p>Reply with <em>emphasis</em> and <code>inline code</code></p>';
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->call('startReply')
|
||||
->set('replyBody', $replyHtml)
|
||||
->call('addReply');
|
||||
|
||||
$reply = Comment::where('parent_id', $comment->id)->first();
|
||||
|
||||
expect($reply)->not->toBeNull();
|
||||
expect($reply->body)->toContain('<em>emphasis</em>');
|
||||
expect($reply->body)->toContain('<code>inline code</code>');
|
||||
});
|
||||
|
||||
it('renders comment body with fi-prose class', 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' => '<p>Styled comment</p>',
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||
->assertSeeHtml('fi-prose');
|
||||
});
|
||||
|
||||
it('returns editor toolbar configuration as nested array', function () {
|
||||
$toolbar = Config::getEditorToolbar();
|
||||
|
||||
expect($toolbar)->toBeArray();
|
||||
expect($toolbar)->not->toBeEmpty();
|
||||
expect($toolbar[0])->toBeArray();
|
||||
expect($toolbar[0])->toContain('bold');
|
||||
expect($toolbar[0])->toContain('italic');
|
||||
});
|
||||
|
||||
it('uses custom toolbar config when overridden', function () {
|
||||
config(['comments.editor.toolbar' => [
|
||||
['bold', 'italic'],
|
||||
]]);
|
||||
|
||||
$toolbar = Config::getEditorToolbar();
|
||||
|
||||
expect($toolbar)->toHaveCount(1);
|
||||
expect($toolbar[0])->toBe(['bold', 'italic']);
|
||||
});
|
||||
38
tests/Feature/ServiceProviderTest.php
Normal file
38
tests/Feature/ServiceProviderTest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Config;
|
||||
|
||||
it('registers the config file', function () {
|
||||
expect(config('comments'))->toBeArray();
|
||||
expect(config('comments.threading.max_depth'))->toBe(2);
|
||||
expect(config('comments.pagination.per_page'))->toBe(10);
|
||||
});
|
||||
|
||||
it('resolves the comment model from config', function () {
|
||||
expect(Config::getCommentModel())->toBe(Comment::class);
|
||||
});
|
||||
|
||||
it('resolves the comment table from config', function () {
|
||||
expect(Config::getCommentTable())->toBe('comments');
|
||||
});
|
||||
|
||||
it('resolves max depth from config', function () {
|
||||
expect(Config::getMaxDepth())->toBe(2);
|
||||
});
|
||||
|
||||
it('registers the morph map for comment', function () {
|
||||
$map = Relation::morphMap();
|
||||
expect($map)->toHaveKey('comment');
|
||||
expect($map['comment'])->toBe(Comment::class);
|
||||
});
|
||||
|
||||
it('creates the comments table via migration', function () {
|
||||
expect(Schema::hasTable('comments'))->toBeTrue();
|
||||
expect(Schema::hasColumns('comments', [
|
||||
'id', 'commentable_type', 'commentable_id',
|
||||
'user_type', 'user_id', 'parent_id', 'body',
|
||||
'edited_at', 'deleted_at', 'created_at', 'updated_at',
|
||||
]))->toBeTrue();
|
||||
});
|
||||
83
tests/Feature/SubscriptionToggleTest.php
Normal file
83
tests/Feature/SubscriptionToggleTest.php
Normal file
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use Livewire\Livewire;
|
||||
use Relaticle\Comments\CommentSubscription;
|
||||
use Relaticle\Comments\Livewire\Comments;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('subscribes user when toggling from unsubscribed state', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse();
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->call('toggleSubscription');
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
|
||||
});
|
||||
|
||||
it('unsubscribes user when toggling from subscribed state', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $user))->toBeTrue();
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->call('toggleSubscription');
|
||||
|
||||
expect(CommentSubscription::isSubscribed($post, $user))->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns true for isSubscribed computed when user is subscribed', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(Comments::class, ['model' => $post]);
|
||||
|
||||
expect($component->instance()->isSubscribed())->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns false for isSubscribed computed when user is not subscribed', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$component = Livewire::test(Comments::class, ['model' => $post]);
|
||||
|
||||
expect($component->instance()->isSubscribed())->toBeFalse();
|
||||
});
|
||||
|
||||
it('renders Subscribed text for subscribed user', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
CommentSubscription::subscribe($post, $user);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->assertSee('Subscribed');
|
||||
});
|
||||
|
||||
it('renders Subscribe text for unsubscribed user', function () {
|
||||
$user = User::factory()->create();
|
||||
$post = Post::factory()->create();
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
Livewire::test(Comments::class, ['model' => $post])
|
||||
->assertSee('Subscribe');
|
||||
});
|
||||
25
tests/Feature/UserMentionedEventTest.php
Normal file
25
tests/Feature/UserMentionedEventTest.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Relaticle\Comments\Comment;
|
||||
use Relaticle\Comments\Events\UserMentioned;
|
||||
use Relaticle\Comments\Tests\Models\Post;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
it('carries correct comment and mentioned user in payload', function () {
|
||||
$user = User::factory()->create();
|
||||
$mentionedUser = User::factory()->create(['name' => 'john']);
|
||||
$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' => '<p>@john</p>',
|
||||
]);
|
||||
|
||||
$event = new UserMentioned($comment, $mentionedUser);
|
||||
|
||||
expect($event->comment)->toBe($comment)
|
||||
->and($event->mentionedUser)->toBe($mentionedUser);
|
||||
});
|
||||
0
tests/Models/.gitkeep
Normal file
0
tests/Models/.gitkeep
Normal file
24
tests/Models/Post.php
Normal file
24
tests/Models/Post.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Tests\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Relaticle\Comments\Concerns\HasComments;
|
||||
use Relaticle\Comments\Contracts\Commentable;
|
||||
use Relaticle\Comments\Tests\Database\Factories\PostFactory;
|
||||
|
||||
class Post extends Model implements Commentable
|
||||
{
|
||||
use HasComments;
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'posts';
|
||||
|
||||
protected $fillable = ['title'];
|
||||
|
||||
protected static function newFactory(): PostFactory
|
||||
{
|
||||
return PostFactory::new();
|
||||
}
|
||||
}
|
||||
26
tests/Models/User.php
Normal file
26
tests/Models/User.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Tests\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Relaticle\Comments\Concerns\IsCommenter;
|
||||
use Relaticle\Comments\Contracts\Commenter;
|
||||
use Relaticle\Comments\Tests\Database\Factories\UserFactory;
|
||||
|
||||
class User extends Authenticatable implements Commenter
|
||||
{
|
||||
use HasFactory;
|
||||
use IsCommenter;
|
||||
use Notifiable;
|
||||
|
||||
protected $table = 'users';
|
||||
|
||||
protected $fillable = ['name', 'email', 'password'];
|
||||
|
||||
protected static function newFactory(): UserFactory
|
||||
{
|
||||
return UserFactory::new();
|
||||
}
|
||||
}
|
||||
6
tests/Pest.php
Normal file
6
tests/Pest.php
Normal file
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Relaticle\Comments\Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class)->in('Feature');
|
||||
133
tests/TestCase.php
Normal file
133
tests/TestCase.php
Normal file
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Relaticle\Comments\Tests;
|
||||
|
||||
use Filament\FilamentServiceProvider;
|
||||
use Filament\Support\SupportServiceProvider;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Livewire\LivewireServiceProvider;
|
||||
use Livewire\Mechanisms\DataStore;
|
||||
use Orchestra\Testbench\TestCase as Orchestra;
|
||||
use Relaticle\Comments\CommentsServiceProvider;
|
||||
use Relaticle\Comments\Tests\Models\User;
|
||||
|
||||
abstract class TestCase extends Orchestra
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->app->singleton(DataStore::class);
|
||||
}
|
||||
|
||||
/** @return array<int, class-string> */
|
||||
protected function getPackageProviders($app): array
|
||||
{
|
||||
return [
|
||||
LivewireServiceProvider::class,
|
||||
SupportServiceProvider::class,
|
||||
FilamentServiceProvider::class,
|
||||
CommentsServiceProvider::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function defineDatabaseMigrations(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email');
|
||||
$table->string('password');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('posts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create(config('comments.tables.comments', 'comments'), function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
|
||||
Schema::create('comment_mentions', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
|
||||
Schema::create('comment_reactions', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
|
||||
Schema::create('comment_attachments', function (Blueprint $table) {
|
||||
$table->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();
|
||||
});
|
||||
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('comment_subscriptions', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
|
||||
protected function getEnvironmentSetUp($app): void
|
||||
{
|
||||
$app['config']->set('app.key', 'base64:'.base64_encode(random_bytes(32)));
|
||||
$app['config']->set('database.default', 'testing');
|
||||
$app['config']->set('database.connections.testing', [
|
||||
'driver' => 'sqlite',
|
||||
'database' => ':memory:',
|
||||
'prefix' => '',
|
||||
]);
|
||||
$app['config']->set('comments.commenter.model', User::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user