12 Commits

Author SHA1 Message Date
manukminasyan
6a26396f0d refactor: polish comment form layout - inline attach and comment button
Move Comment/Reply button to same row as Attach link using
justify-between flex layout. Shorten "Attach files" to "Attach".
Place Cancel on left side, action buttons on right for edit/reply forms.
Cleaner, more compact footer area.
2026-03-27 21:32:05 +04:00
manukminasyan
2ace8bfdd4 feat: make comment form sticky at bottom of slide-over
Pin the comment editor to the bottom of the slide-over panel so it's
always visible while scrolling through comments. Uses CSS sticky
positioning with border separator and background color.
2026-03-27 21:26:28 +04:00
manukminasyan
3d745077b7 fix: prevent lazy loading violation on replies relationship
Check relationLoaded('replies') before accessing $comment->replies
to avoid LazyLoadingViolationException when rendering nested comments
whose replies aren't eager loaded.
2026-03-27 21:23:18 +04:00
manukminasyan
b44b4e309e fix: avoid lazy loading parent relationship in depth calculation
Use a query-based approach instead of traversing the parent relationship
to prevent LazyLoadingViolationException when strict mode is enabled.
2026-03-27 21:20:20 +04:00
manukminasyan
ac97dcb092 fix: replace form elements with div+wire:click to prevent nested form conflicts
The CommentsAction slide-over wraps content in a Filament action form.
Nested <form> elements inside the comments Livewire templates caused the
browser to submit the outer action form instead, closing the slide-over
without storing the comment.

Replace <form wire:submit> with <div> and type="submit" buttons with
type="button" wire:click for all three forms (comment, edit, reply).
2026-03-27 21:04:34 +04:00
manukminasyan
6c96fb900b fix: update tests for RichEditor form data paths and service providers
Update all tests to use new form state paths (commentData.body,
editData.body, replyData.body) instead of removed public properties.
Remove searchUsers() tests (method replaced by MentionProvider).
Add BladeUI Icons service providers to TestCase for RichEditor views.
2026-03-27 19:20:56 +04:00
manukminasyan
7f9f13b626 fix: add missing Filament service providers to test setup
InteractsWithForms requires FormsServiceProvider, SchemasServiceProvider,
and ActionsServiceProvider to be registered in the test environment.
2026-03-27 18:46:40 +04:00
manukminasyan
e173d9b4dd refactor: replace custom textarea with Filament RichEditor and built-in mentions
Replace the custom Alpine.js textarea + mention system with Filament v5's
built-in RichEditor component and MentionProvider. This fixes Alpine scope
errors (showMentions/mentionResults not defined) that occurred during
Livewire DOM morphing inside Filament slide-over modals.

- Add InteractsWithForms + HasForms to Comments and CommentItem components
- Define commentForm(), editForm(), replyForm() with RichEditor + mentions
- Add CommentsConfig::makeMentionProvider() shared helper
- Update MentionParser to extract mention IDs from RichEditor HTML format
- Update Comment::renderBodyWithMentions() to use RichContentRenderer
- Remove all custom Alpine.js mention code from blade templates
- Backward compatible with existing plain text comments
2026-03-27 18:43:07 +04:00
manukminasyan
f119095ae5 fix: use schema() instead of modalContent() for Filament 5 compatibility
Replace deprecated modalContent() with schema([CommentsEntry::make('comments')])
in both CommentsAction and CommentsTableAction to fix comment creation in
Filament 5 modals.
2026-03-27 17:48:32 +04:00
manukminasyan
889dc2828b fix: use callout component for alpha warning 2026-03-27 16:08:55 +04:00
manukminasyan
82eb6a70ad fix: move alpha alert into hero description using correct alert component 2026-03-27 16:08:10 +04:00
manukminasyan
2edcfa00f1 fix: use inline alpha warning and aspect-video preview on docs homepage 2026-03-27 16:06:57 +04:00
22 changed files with 292 additions and 451 deletions

Binary file not shown.

View File

@@ -14,6 +14,10 @@ A full-featured commenting system for Filament panels with threaded replies, @me
Drop-in integration with any Filament resource.
:::callout{icon="i-lucide-triangle-alert" color="amber"}
**Alpha Software** — Breaking changes may occur between releases. Not recommended for production use.
:::
#links
:::u-button
---
@@ -37,12 +41,10 @@ Drop-in integration with any Filament resource.
:::
::
::callout{icon="i-lucide-triangle-alert" color="amber"}
**Alpha Software** -- This package is currently in alpha. The API is not stable and breaking changes may occur between releases without prior notice. Do not use in production unless you are prepared to handle upgrades manually.
::
<div class="max-w-5xl mx-auto mt-8">
<img src="/preview.png" alt="Comments - threaded discussions in Filament" class="rounded-lg shadow-lg w-full" />
<div class="text-center max-w-5xl mx-auto">
<div class="aspect-video rounded-lg shadow-lg overflow-hidden">
<img src="/preview.png" alt="Comments - threaded discussions in Filament" class="w-full h-full object-cover object-top" />
</div>
</div>
::u-page-section

View File

@@ -32,20 +32,13 @@
{{-- Body or edit form --}}
@if ($isEditing)
<form wire:submit="saveEdit" class="mt-1">
<x-filament::input.wrapper>
<textarea wire:model="editBody" rows="3"
class="block w-full border-none bg-transparent px-3 py-1.5 text-sm leading-6 text-gray-950 outline-none transition duration-75 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
></textarea>
</x-filament::input.wrapper>
@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 class="mt-1">
{{ $this->editForm }}
<div class="mt-2 flex items-center justify-between">
<button type="button" wire:click="cancelEdit" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
<button type="button" wire:click="saveEdit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
</div>
</form>
</div>
@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() !!}
@@ -111,135 +104,44 @@
{{-- 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);
});
}
}">
<x-filament::input.wrapper>
<textarea x-ref="replyInput"
wire:model="replyBody"
@input="handleInput($event)"
@keydown="handleKeydown($event)"
rows="2"
placeholder="Write a reply..."
class="block w-full border-none bg-transparent px-3 py-1.5 text-sm leading-6 text-gray-950 outline-none transition duration-75 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
></textarea>
</x-filament::input.wrapper>
<div class="mt-3">
{{ $this->replyForm }}
{{-- 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\CommentsConfig::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\CommentsConfig::getAttachmentAllowedTypes()) }}" />
</label>
@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">&times;</button>
</div>
@endforeach
</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">&times;</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 class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-3">
@if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
<label class="flex cursor-pointer items-center gap-1.5 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
<input type="file" wire:model="replyAttachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\CommentsConfig::getAttachmentAllowedTypes()) }}" />
</label>
@endif
<button type="button" wire:click="cancelReply" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
</div>
<button type="button" wire:click="addReply" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Reply</button>
</div>
</form>
</div>
@endif
{{-- Nested replies --}}
@if ($comment->replies->isNotEmpty())
@if ($comment->relationLoaded('replies') && $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" />

View File

@@ -57,139 +57,48 @@
</div>
@endif
{{-- New comment form - only for authorized users --}}
{{-- New comment form - sticky at bottom of slide-over --}}
@auth
@can('create', \Relaticle\Comments\CommentsConfig::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);
});
}
}">
<x-filament::input.wrapper>
<textarea
x-ref="commentInput"
wire:model="newComment"
@input="handleInput($event)"
@keydown="handleKeydown($event)"
rows="3"
placeholder="Write a comment..."
class="block w-full border-none bg-transparent px-3 py-1.5 text-sm leading-6 text-gray-950 outline-none transition duration-75 placeholder:text-gray-400 focus:ring-0 dark:text-white dark:placeholder:text-gray-500"
></textarea>
</x-filament::input.wrapper>
<div class="sticky bottom-0 z-10 -mx-4 -mb-4 border-t border-gray-200 bg-white px-4 pb-4 pt-3 dark:border-gray-700 dark:bg-gray-900">
{{ $this->commentForm }}
{{-- 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\CommentsConfig::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\CommentsConfig::getAttachmentAllowedTypes()) }}" />
</label>
@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">&times;</button>
</div>
@endforeach
</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">&times;</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"
<div class="mt-2 flex items-center justify-between">
@if (\Relaticle\Comments\CommentsConfig::areAttachmentsEnabled())
<label class="flex cursor-pointer items-center gap-1.5 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
<input type="file" wire:model="attachments" multiple class="hidden" accept="{{ implode(',', \Relaticle\Comments\CommentsConfig::getAttachmentAllowedTypes()) }}" />
</label>
@else
<div></div>
@endif
<button type="button" wire:click="addComment"
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>
</div>
@endcan
@endauth
</div>

View File

@@ -4,6 +4,7 @@ namespace Relaticle\Comments;
use App\Models\User;
use Closure;
use Filament\Forms\Components\RichEditor\MentionProvider;
use Relaticle\Comments\Mentions\DefaultMentionResolver;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Policies\CommentPolicy;
@@ -173,4 +174,19 @@ class CommentsConfig
{
static::$resolveAuthenticatedUser = $callback;
}
public static function makeMentionProvider(): MentionProvider
{
return MentionProvider::make('@')
->getSearchResultsUsing(fn (string $search): array => static::getCommenterModel()::query()
->where('name', 'like', "%{$search}%")
->orderBy('name')
->limit(static::getMentionMaxResults())
->pluck('name', 'id')
->all())
->getLabelsUsing(fn (array $ids): array => static::getCommenterModel()::query()
->whereIn('id', $ids)
->pluck('name', 'id')
->all());
}
}

View File

@@ -3,8 +3,8 @@
namespace Relaticle\Comments\Filament\Actions;
use Filament\Actions\Action;
use Illuminate\Contracts\View\View;
use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
class CommentsAction extends Action
{
@@ -19,11 +19,9 @@ class CommentsAction extends Action
->modalHeading(__('Comments'))
->modalSubmitAction(false)
->modalCancelAction(false)
->modalContent(function (): View {
return view('comments::filament.comments-action', [
'record' => $this->getRecord(),
]);
})
->schema([
CommentsEntry::make('comments'),
])
->badge(function (): ?int {
$record = $this->getRecord();

View File

@@ -3,8 +3,8 @@
namespace Relaticle\Comments\Filament\Actions;
use Filament\Actions\Action;
use Illuminate\Contracts\View\View;
use Relaticle\Comments\Concerns\HasComments;
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
class CommentsTableAction extends Action
{
@@ -19,11 +19,9 @@ class CommentsTableAction extends Action
->modalHeading(__('Comments'))
->modalSubmitAction(false)
->modalCancelAction(false)
->modalContent(function (): View {
return view('comments::filament.comments-action', [
'record' => $this->getRecord(),
]);
})
->schema([
CommentsEntry::make('comments'),
])
->badge(function (): ?int {
$record = $this->getRecord();

View File

@@ -2,20 +2,24 @@
namespace Relaticle\Comments\Livewire;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Relaticle\Comments\CommentsConfig;
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;
use Relaticle\Comments\Models\Comment;
class CommentItem extends Component
class CommentItem extends Component implements HasForms
{
use InteractsWithForms;
use WithFileUploads;
public Comment $comment;
@@ -24,9 +28,11 @@ class CommentItem extends Component
public bool $isReplying = false;
public string $editBody = '';
/** @var array<string, mixed> */
public ?array $editData = [];
public string $replyBody = '';
/** @var array<string, mixed> */
public ?array $replyData = [];
/** @var array<int, TemporaryUploadedFile> */
public array $replyAttachments = [];
@@ -36,30 +42,60 @@ class CommentItem extends Component
$this->comment = $comment;
}
public function editForm(Schema $schema): Schema
{
return $schema
->components([
RichEditor::make('body')
->hiddenLabel()
->required()
->placeholder(__('Edit your comment...'))
->toolbarButtons(CommentsConfig::getEditorToolbar())
->mentions([
CommentsConfig::makeMentionProvider(),
]),
])
->statePath('editData');
}
public function replyForm(Schema $schema): Schema
{
return $schema
->components([
RichEditor::make('body')
->hiddenLabel()
->required()
->placeholder(__('Write a reply...'))
->toolbarButtons(CommentsConfig::getEditorToolbar())
->mentions([
CommentsConfig::makeMentionProvider(),
]),
])
->statePath('replyData');
}
public function startEdit(): void
{
$this->authorize('update', $this->comment);
$this->isEditing = true;
$this->editBody = $this->comment->body;
$this->editForm->fill(['body' => $this->comment->body]);
}
public function cancelEdit(): void
{
$this->isEditing = false;
$this->editBody = '';
$this->editForm->fill();
}
public function saveEdit(): void
{
$this->authorize('update', $this->comment);
$this->validate([
'editBody' => ['required', 'string', 'min:1'],
]);
$data = $this->editForm->getState();
$this->comment->update([
'body' => $this->editBody,
'body' => $data['body'] ?? '',
'edited_at' => now(),
]);
@@ -70,7 +106,7 @@ class CommentItem extends Component
$this->dispatch('commentUpdated');
$this->isEditing = false;
$this->editBody = '';
$this->editForm->fill();
}
public function deleteComment(): void
@@ -91,12 +127,13 @@ class CommentItem extends Component
}
$this->isReplying = true;
$this->replyForm->fill();
}
public function cancelReply(): void
{
$this->isReplying = false;
$this->replyBody = '';
$this->replyForm->fill();
$this->replyAttachments = [];
}
@@ -104,20 +141,20 @@ class CommentItem extends Component
{
$this->authorize('reply', $this->comment);
$rules = ['replyBody' => ['required', 'string', 'min:1']];
$data = $this->replyForm->getState();
if (CommentsConfig::areAttachmentsEnabled()) {
$maxSize = CommentsConfig::getAttachmentMaxSize();
$allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
$rules['replyAttachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"];
$this->validate([
'replyAttachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"],
]);
}
$this->validate($rules);
$user = CommentsConfig::resolveAuthenticatedUser();
$reply = $this->comment->commentable->comments()->create([
'body' => $this->replyBody,
'body' => $data['body'] ?? '',
'parent_id' => $this->comment->id,
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
@@ -146,7 +183,7 @@ class CommentItem extends Component
$this->dispatch('commentUpdated');
$this->isReplying = false;
$this->replyBody = '';
$this->replyForm->fill();
$this->replyAttachments = [];
}
@@ -157,25 +194,6 @@ class CommentItem extends Component
$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->getCommentDisplayName(),
'avatar_url' => $user->getCommentAvatarUrl(),
])
->values()
->all();
}
public function render(): View
{
return view('comments::livewire.comment-item');

View File

@@ -2,6 +2,10 @@
namespace Relaticle\Comments\Livewire;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Schemas\Schema;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -10,19 +14,20 @@ use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Relaticle\Comments\CommentsConfig;
use Relaticle\Comments\Contracts\MentionResolver;
use Relaticle\Comments\Events\CommentCreated;
use Relaticle\Comments\Mentions\MentionParser;
use Relaticle\Comments\Models\Comment;
use Relaticle\Comments\Models\Subscription;
class Comments extends Component
class Comments extends Component implements HasForms
{
use InteractsWithForms;
use WithFileUploads;
public Model $model;
public string $newComment = '';
/** @var array<string, mixed> */
public ?array $commentData = [];
public string $sortDirection = 'asc';
@@ -38,6 +43,23 @@ class Comments extends Component
$this->model = $model;
$this->perPage = CommentsConfig::getPerPage();
$this->loadedCount = $this->perPage;
$this->commentForm->fill();
}
public function commentForm(Schema $schema): Schema
{
return $schema
->components([
RichEditor::make('body')
->hiddenLabel()
->required()
->placeholder(__('Write a comment...'))
->toolbarButtons(CommentsConfig::getEditorToolbar())
->mentions([
CommentsConfig::makeMentionProvider(),
]),
])
->statePath('commentData');
}
/** @return Collection<int, Comment> */
@@ -95,22 +117,22 @@ class Comments extends Component
public function addComment(): void
{
$rules = ['newComment' => ['required', 'string', 'min:1']];
$data = $this->commentForm->getState();
if (CommentsConfig::areAttachmentsEnabled()) {
$maxSize = CommentsConfig::getAttachmentMaxSize();
$allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
$rules['attachments.*'] = ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"];
$this->validate([
'attachments.*' => ['nullable', 'file', "max:{$maxSize}", "mimetypes:{$allowedTypes}"],
]);
}
$this->validate($rules);
$this->authorize('create', CommentsConfig::getCommentModel());
$user = CommentsConfig::resolveAuthenticatedUser();
$comment = $this->model->comments()->create([
'body' => $this->newComment,
'body' => $data['body'] ?? '',
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
]);
@@ -135,7 +157,8 @@ class Comments extends Component
app(MentionParser::class)->syncMentions($comment);
$this->reset('newComment', 'attachments');
$this->commentForm->fill();
$this->reset('attachments');
}
public function removeAttachment(int $index): void
@@ -183,25 +206,6 @@ class Comments extends Component
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->getCommentDisplayName(),
'avatar_url' => $user->getCommentAvatarUrl(),
])
->values()
->all();
}
public function render(): View
{
return view('comments::livewire.comments');

View File

@@ -16,6 +16,32 @@ class MentionParser
/** @return Collection<int, int> */
public function parse(string $body): Collection
{
$ids = $this->parseRichEditorMentions($body);
if ($ids->isNotEmpty()) {
return $ids;
}
return $this->parsePlainTextMentions($body);
}
/** @return Collection<int, int> */
protected function parseRichEditorMentions(string $body): Collection
{
preg_match_all('/data-type=["\']mention["\'][^>]*data-id=["\'](\d+)["\']/', $body, $matches);
if (empty($matches[1])) {
preg_match_all('/data-id=["\'](\d+)["\'][^>]*data-type=["\']mention["\']/', $body, $matches);
}
$ids = array_unique(array_map('intval', $matches[1] ?? []));
return collect($ids);
}
/** @return Collection<int, int> */
protected function parsePlainTextMentions(string $body): Collection
{
$text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8');

View File

@@ -2,6 +2,8 @@
namespace Relaticle\Comments\Models;
use Filament\Forms\Components\RichEditor\MentionProvider;
use Filament\Forms\Components\RichEditor\RichContentRenderer;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -128,15 +130,12 @@ class Comment extends Model
public function depth(): int
{
$depth = 0;
$comment = $this;
$maxDepth = CommentsConfig::getMaxDepth();
$parentId = $this->parent_id;
while ($comment->parent_id !== null) {
$comment = $comment->parent;
while ($parentId !== null && $depth < $maxDepth) {
$depth++;
if ($depth >= CommentsConfig::getMaxDepth()) {
return CommentsConfig::getMaxDepth();
}
$parentId = static::where('id', $parentId)->value('parent_id');
}
return $depth;
@@ -145,6 +144,19 @@ class Comment extends Model
public function renderBodyWithMentions(): string
{
$body = $this->body;
if ($this->hasRichEditorMentions($body)) {
return RichContentRenderer::make($body)
->mentions([
MentionProvider::make('@')
->getLabelsUsing(fn (array $ids): array => CommentsConfig::getCommenterModel()::query()
->whereIn('id', $ids)
->pluck('name', 'id')
->all()),
])
->toHtml();
}
$mentionNames = $this->mentions->pluck('name')->filter()->unique();
foreach ($mentionNames as $name) {
@@ -157,4 +169,9 @@ class Comment extends Model
return $body;
}
protected function hasRichEditorMentions(string $body): bool
{
return str_contains($body, 'data-type="mention"') || str_contains($body, '<p>') || str_contains($body, '<br');
}
}

View File

@@ -22,10 +22,9 @@ it('creates comment with file attachment via Livewire component', function () {
$file = UploadedFile::fake()->image('photo.jpg', 100, 100);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Comment with attachment</p>')
->set('commentData.body', '<p>Comment with attachment</p>')
->set('attachments', [$file])
->call('addComment')
->assertSet('newComment', '')
->assertSet('attachments', []);
expect(Comment::count())->toBe(1);
@@ -43,7 +42,7 @@ it('stores attachment with correct metadata', function () {
$file = UploadedFile::fake()->image('vacation.jpg', 200, 200)->size(512);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Vacation photos</p>')
->set('commentData.body', '<p>Vacation photos</p>')
->set('attachments', [$file])
->call('addComment');
@@ -69,7 +68,7 @@ it('stores file on configured disk at comments/attachments/{comment_id}/ path',
$file = UploadedFile::fake()->image('test.png', 50, 50);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>File path test</p>')
->set('commentData.body', '<p>File path test</p>')
->set('attachments', [$file])
->call('addComment');
@@ -160,7 +159,7 @@ it('rejects file exceeding max size', function () {
$oversizedFile = UploadedFile::fake()->create('big.pdf', CommentsConfig::getAttachmentMaxSize() + 1, 'application/pdf');
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Oversized file</p>')
->set('commentData.body', '<p>Oversized file</p>')
->set('attachments', [$oversizedFile])
->call('addComment')
->assertHasErrors('attachments.0');
@@ -180,7 +179,7 @@ it('rejects disallowed file type', function () {
$exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload');
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Malicious file</p>')
->set('commentData.body', '<p>Malicious file</p>')
->set('attachments', [$exeFile])
->call('addComment')
->assertHasErrors('attachments.0');
@@ -200,7 +199,7 @@ it('accepts allowed file types', function () {
$imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Valid file</p>')
->set('commentData.body', '<p>Valid file</p>')
->set('attachments', [$imageFile])
->call('addComment')
->assertHasNoErrors('attachments.0');
@@ -218,7 +217,7 @@ it('hides upload UI when attachments disabled', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertDontSeeHtml('Attach files');
->assertDontSeeHtml('wire:model="attachments"');
});
it('shows upload UI when attachments enabled', function () {
@@ -228,7 +227,7 @@ it('shows upload UI when attachments enabled', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->assertSeeHtml('Attach files');
->assertSeeHtml('wire:model="attachments"');
});
it('creates comment with multiple file attachments', function () {
@@ -243,7 +242,7 @@ it('creates comment with multiple file attachments', function () {
$file2 = UploadedFile::fake()->create('notes.pdf', 512, 'application/pdf');
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Multiple files</p>')
->set('commentData.body', '<p>Multiple files</p>')
->set('attachments', [$file1, $file2])
->call('addComment');
@@ -276,11 +275,10 @@ it('creates reply with file attachment via CommentItem component', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', '<p>Reply with attachment</p>')
->set('replyData.body', '<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();

View File

@@ -20,7 +20,7 @@ it('fires CommentCreated event when adding a comment', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>New comment</p>')
->set('commentData.body', '<p>New comment</p>')
->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) {
@@ -47,7 +47,7 @@ it('fires CommentUpdated event when editing a comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Edited</p>')
->set('editData.body', '<p>Edited</p>')
->call('saveEdit');
Event::assertDispatched(CommentUpdated::class, function (CommentUpdated $event) use ($comment) {
@@ -95,7 +95,7 @@ it('fires CommentCreated event when adding a reply', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', '<p>Reply text</p>')
->set('replyData.body', '<p>Reply text</p>')
->call('addReply');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) {
@@ -113,7 +113,7 @@ it('carries correct comment and commentable in event payload', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Payload test</p>')
->set('commentData.body', '<p>Payload test</p>')
->call('addComment');
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) {

View File

@@ -23,11 +23,9 @@ it('allows author to start and save edit on their comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->assertSet('isEditing', true)
->assertSet('editBody', '<p>Original body</p>')
->set('editBody', '<p>Updated body</p>')
->set('editData.body', '<p>Updated body</p>')
->call('saveEdit')
->assertSet('isEditing', false)
->assertSet('editBody', '');
->assertSet('isEditing', false);
$comment->refresh();
@@ -53,7 +51,7 @@ it('marks edited comment with edited indicator', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Changed</p>')
->set('editData.body', '<p>Changed</p>')
->call('saveEdit');
$comment->refresh();
@@ -160,10 +158,9 @@ it('allows user to reply to a comment', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->assertSet('isReplying', true)
->set('replyBody', '<p>My reply</p>')
->set('replyData.body', '<p>My reply</p>')
->call('addReply')
->assertSet('isReplying', false)
->assertSet('replyBody', '');
->assertSet('isReplying', false);
$reply = Comment::where('parent_id', $comment->id)->first();
@@ -213,8 +210,7 @@ it('resets state when cancelling edit', function () {
->call('startEdit')
->assertSet('isEditing', true)
->call('cancelEdit')
->assertSet('isEditing', false)
->assertSet('editBody', '');
->assertSet('isEditing', false);
});
it('resets state when cancelling reply', function () {
@@ -233,10 +229,9 @@ it('resets state when cancelling reply', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->assertSet('isReplying', true)
->set('replyBody', '<p>Draft reply</p>')
->set('replyData.body', '<p>Draft reply</p>')
->call('cancelReply')
->assertSet('isReplying', false)
->assertSet('replyBody', '');
->assertSet('isReplying', false);
});
it('loads all replies within a thread eagerly', function () {

View File

@@ -29,10 +29,11 @@ it('has a chat bubble icon', function () {
expect($action->getIcon())->toBe('heroicon-o-chat-bubble-left-right');
});
it('has modal content configured', function () {
it('disables modal submit and cancel actions', function () {
$action = CommentsAction::make('comments');
expect($action->hasModalContent())->toBeTrue();
expect($action->getModalSubmitAction())->toBeFalsy()
->and($action->getModalCancelAction())->toBeFalsy();
});
it('shows badge with comment count when comments exist', function () {

View File

@@ -13,9 +13,8 @@ it('allows authenticated user to create a comment on a post', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello World</p>')
->call('addComment')
->assertSet('newComment', '');
->set('commentData.body', '<p>Hello World</p>')
->call('addComment');
expect(Comment::count())->toBe(1);
expect(Comment::first()->body)->toBe('<p>Hello World</p>');
@@ -28,7 +27,7 @@ it('associates new comment with the authenticated user', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Test</p>')
->set('commentData.body', '<p>Test</p>')
->call('addComment');
$comment = Comment::first();
@@ -43,7 +42,7 @@ it('requires authentication to create a comment', function () {
$post = Post::factory()->create();
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello</p>')
->set('commentData.body', '<p>Hello</p>')
->call('addComment')
->assertForbidden();
});
@@ -55,9 +54,9 @@ it('validates that comment body is not empty', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '')
->set('commentData.body', '')
->call('addComment')
->assertHasErrors('newComment');
->assertHasErrors('commentData.body');
expect(Comment::count())->toBe(0);
});

View File

@@ -17,10 +17,11 @@ it('configures as a slide-over', function () {
expect($action->isModalSlideOver())->toBeTrue();
});
it('has modal content configured', function () {
it('disables modal submit and cancel actions', function () {
$action = CommentsTableAction::make('comments');
expect($action->hasModalContent())->toBeTrue();
expect($action->getModalSubmitAction())->toBeFalsy()
->and($action->getModalCancelAction())->toBeFalsy();
});
it('shows badge with comment count for the record', function () {

View File

@@ -162,7 +162,7 @@ it('sanitizes content submitted through livewire component', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hello</p><script>alert("xss")</script>')
->set('commentData.body', '<p>Hello</p><script>alert("xss")</script>')
->call('addComment');
$comment = Comment::first();

View File

@@ -16,7 +16,7 @@ it('renders mention with styled span', function () {
'commentable_type' => $post->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>@Alice said hi</p>',
'body' => '@Alice said hi',
]);
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
@@ -38,7 +38,7 @@ it('renders multiple mentions with styled spans', function () {
'commentable_type' => $post->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>@Alice and @Bob</p>',
'body' => '@Alice and @Bob',
]);
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
@@ -60,7 +60,7 @@ it('does not style non-mentioned @text', function () {
'commentable_type' => $post->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>@ghost is not here</p>',
'body' => '@ghost is not here',
]);
$rendered = $comment->renderBodyWithMentions();
@@ -78,7 +78,7 @@ it('renders comment-mention class in Livewire component', function () {
'commentable_type' => $post->getMorphClass(),
'commenter_id' => $user->getKey(),
'commenter_type' => $user->getMorphClass(),
'body' => '<p>Hello @Alice</p>',
'body' => 'Hello @Alice',
]);
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);

View File

@@ -7,66 +7,6 @@ use Relaticle\Comments\Models\Comment;
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']);
@@ -75,7 +15,7 @@ it('stores mentions when creating comment with @mention', function () {
$this->actingAs($user);
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', '<p>Hey @Alice check this</p>')
->set('commentData.body', '<p>Hey @Alice check this</p>')
->call('addComment');
$comment = Comment::first();
@@ -101,7 +41,7 @@ it('stores mentions when editing comment with @mention', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', '<p>Updated @Bob</p>')
->set('editData.body', '<p>Updated @Bob</p>')
->call('saveEdit');
$comment->refresh();

View File

@@ -17,7 +17,7 @@ it('creates a comment with rich HTML content preserved', function () {
$html = '<p>Hello <strong>bold</strong> and <em>italic</em> world</p>';
Livewire::test(Comments::class, ['model' => $post])
->set('newComment', $html)
->set('commentData.body', $html)
->call('addComment');
$comment = Comment::first();
@@ -42,9 +42,15 @@ it('pre-fills editBody with existing comment HTML when starting edit', function
$this->actingAs($user);
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->assertSet('editBody', $originalHtml);
$component = Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit');
$editBody = $component->get('editData')['body'];
$bodyJson = json_encode($editBody);
expect($bodyJson)->toContain('Hello ');
expect($bodyJson)->toContain('world');
expect($bodyJson)->toContain('bold');
});
it('saves edited HTML content through edit form', function () {
@@ -65,13 +71,14 @@ it('saves edited HTML content through edit form', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startEdit')
->set('editBody', $updatedHtml)
->set('editData.body', $updatedHtml)
->call('saveEdit');
$comment->refresh();
expect($comment->body)->toContain('<strong>bold</strong>');
expect($comment->body)->toContain('<a href="https://example.com">a link</a>');
expect($comment->body)->toContain('href="https://example.com"');
expect($comment->body)->toContain('>a link</a>');
});
it('creates reply with rich HTML content', function () {
@@ -91,7 +98,7 @@ it('creates reply with rich HTML content', function () {
Livewire::test(CommentItem::class, ['comment' => $comment])
->call('startReply')
->set('replyBody', $replyHtml)
->set('replyData.body', $replyHtml)
->call('addReply');
$reply = Comment::where('parent_id', $comment->id)->first();

View File

@@ -2,7 +2,12 @@
namespace Relaticle\Comments\Tests;
use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
use BladeUI\Icons\BladeIconsServiceProvider;
use Filament\Actions\ActionsServiceProvider;
use Filament\FilamentServiceProvider;
use Filament\Forms\FormsServiceProvider;
use Filament\Schemas\SchemasServiceProvider;
use Filament\Support\SupportServiceProvider;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -26,7 +31,12 @@ abstract class TestCase extends Orchestra
{
return [
LivewireServiceProvider::class,
BladeIconsServiceProvider::class,
BladeHeroiconsServiceProvider::class,
SupportServiceProvider::class,
SchemasServiceProvider::class,
FormsServiceProvider::class,
ActionsServiceProvider::class,
FilamentServiceProvider::class,
CommentsServiceProvider::class,
];