Compare commits
12 Commits
v1.0.0-alp
...
v1.0.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a26396f0d | ||
|
|
2ace8bfdd4 | ||
|
|
3d745077b7 | ||
|
|
b44b4e309e | ||
|
|
ac97dcb092 | ||
|
|
6c96fb900b | ||
|
|
7f9f13b626 | ||
|
|
e173d9b4dd | ||
|
|
f119095ae5 | ||
|
|
889dc2828b | ||
|
|
82eb6a70ad | ||
|
|
2edcfa00f1 |
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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">×</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">×</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" />
|
||||
|
||||
@@ -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">×</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">×</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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user