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.
|
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
|
#links
|
||||||
:::u-button
|
:::u-button
|
||||||
---
|
---
|
||||||
@@ -37,12 +41,10 @@ Drop-in integration with any Filament resource.
|
|||||||
:::
|
:::
|
||||||
::
|
::
|
||||||
|
|
||||||
::callout{icon="i-lucide-triangle-alert" color="amber"}
|
<div class="text-center max-w-5xl mx-auto">
|
||||||
**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="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 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>
|
</div>
|
||||||
|
|
||||||
::u-page-section
|
::u-page-section
|
||||||
|
|||||||
@@ -32,20 +32,13 @@
|
|||||||
|
|
||||||
{{-- Body or edit form --}}
|
{{-- Body or edit form --}}
|
||||||
@if ($isEditing)
|
@if ($isEditing)
|
||||||
<form wire:submit="saveEdit" class="mt-1">
|
<div class="mt-1">
|
||||||
<x-filament::input.wrapper>
|
{{ $this->editForm }}
|
||||||
<textarea wire:model="editBody" rows="3"
|
<div class="mt-2 flex items-center justify-between">
|
||||||
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"
|
<button type="button" wire:click="cancelEdit" class="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
|
||||||
></textarea>
|
<button type="button" wire:click="saveEdit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Save</button>
|
||||||
</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>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
@else
|
@else
|
||||||
<div class="fi-prose prose prose-sm mt-1 max-w-none text-gray-700 dark:prose-invert dark:text-gray-300">
|
<div class="fi-prose prose prose-sm mt-1 max-w-none text-gray-700 dark:prose-invert dark:text-gray-300">
|
||||||
{!! $comment->renderBodyWithMentions() !!}
|
{!! $comment->renderBodyWithMentions() !!}
|
||||||
@@ -111,135 +104,44 @@
|
|||||||
|
|
||||||
{{-- Reply form --}}
|
{{-- Reply form --}}
|
||||||
@if ($isReplying)
|
@if ($isReplying)
|
||||||
<form wire:submit="addReply" class="relative mt-3"
|
<div class="mt-3">
|
||||||
x-data="{
|
{{ $this->replyForm }}
|
||||||
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>
|
|
||||||
|
|
||||||
{{-- Mention autocomplete dropdown --}}
|
@if (!empty($replyAttachments))
|
||||||
<div x-show="showMentions" x-cloak
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
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">
|
@foreach ($replyAttachments as $index => $file)
|
||||||
<template x-for="(user, index) in mentionResults" :key="user.id">
|
<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">
|
||||||
<button type="button"
|
<span>{{ $file->getClientOriginalName() }}</span>
|
||||||
@click="selectMention(user)"
|
<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>
|
||||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
|
</div>
|
||||||
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">
|
@endforeach
|
||||||
<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>
|
|
||||||
</div>
|
</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.*')
|
@error('replyAttachments.*')
|
||||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||||
@enderror
|
@enderror
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-2 flex gap-2">
|
<div class="mt-2 flex items-center justify-between">
|
||||||
<button type="submit" class="text-sm font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400">Reply</button>
|
<div class="flex items-center gap-3">
|
||||||
<button type="button" wire:click="cancelReply" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400">Cancel</button>
|
@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>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- Nested replies --}}
|
{{-- 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">
|
<div class="mt-3 space-y-3 border-l-2 border-gray-200 pl-4 dark:border-gray-700">
|
||||||
@foreach ($comment->replies as $reply)
|
@foreach ($comment->replies as $reply)
|
||||||
<livewire:comment-item :comment="$reply" :key="'comment-'.$reply->id" />
|
<livewire:comment-item :comment="$reply" :key="'comment-'.$reply->id" />
|
||||||
|
|||||||
@@ -57,139 +57,48 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{-- New comment form - only for authorized users --}}
|
{{-- New comment form - sticky at bottom of slide-over --}}
|
||||||
@auth
|
@auth
|
||||||
@can('create', \Relaticle\Comments\CommentsConfig::getCommentModel())
|
@can('create', \Relaticle\Comments\CommentsConfig::getCommentModel())
|
||||||
<form wire:submit="addComment" class="relative mt-4"
|
<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">
|
||||||
x-data="{
|
{{ $this->commentForm }}
|
||||||
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>
|
|
||||||
|
|
||||||
{{-- Mention autocomplete dropdown --}}
|
@if (!empty($attachments))
|
||||||
<div x-show="showMentions" x-cloak
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
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">
|
@foreach ($attachments as $index => $file)
|
||||||
<template x-for="(user, index) in mentionResults" :key="user.id">
|
<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">
|
||||||
<button type="button"
|
<span>{{ $file->getClientOriginalName() }}</span>
|
||||||
@click="selectMention(user)"
|
<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>
|
||||||
:class="{ 'bg-primary-50 dark:bg-primary-900/20': index === selectedIndex }"
|
</div>
|
||||||
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">
|
@endforeach
|
||||||
<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>
|
|
||||||
</div>
|
</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.*')
|
@error('attachments.*')
|
||||||
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
<p class="mt-1 text-sm text-danger-600 dark:text-danger-400">{{ $message }}</p>
|
||||||
@enderror
|
@enderror
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<div class="mt-2 flex justify-end">
|
<div class="mt-2 flex items-center justify-between">
|
||||||
<button type="submit"
|
@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"
|
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">
|
wire:loading.attr="disabled" wire:target="addComment">
|
||||||
<span wire:loading.remove wire:target="addComment">Comment</span>
|
<span wire:loading.remove wire:target="addComment">Comment</span>
|
||||||
<span wire:loading wire:target="addComment">Posting...</span>
|
<span wire:loading wire:target="addComment">Posting...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
@endauth
|
@endauth
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Relaticle\Comments;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Closure;
|
use Closure;
|
||||||
|
use Filament\Forms\Components\RichEditor\MentionProvider;
|
||||||
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
use Relaticle\Comments\Mentions\DefaultMentionResolver;
|
||||||
use Relaticle\Comments\Models\Comment;
|
use Relaticle\Comments\Models\Comment;
|
||||||
use Relaticle\Comments\Policies\CommentPolicy;
|
use Relaticle\Comments\Policies\CommentPolicy;
|
||||||
@@ -173,4 +174,19 @@ class CommentsConfig
|
|||||||
{
|
{
|
||||||
static::$resolveAuthenticatedUser = $callback;
|
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;
|
namespace Relaticle\Comments\Filament\Actions;
|
||||||
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Relaticle\Comments\Concerns\HasComments;
|
use Relaticle\Comments\Concerns\HasComments;
|
||||||
|
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
|
||||||
|
|
||||||
class CommentsAction extends Action
|
class CommentsAction extends Action
|
||||||
{
|
{
|
||||||
@@ -19,11 +19,9 @@ class CommentsAction extends Action
|
|||||||
->modalHeading(__('Comments'))
|
->modalHeading(__('Comments'))
|
||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
->modalCancelAction(false)
|
->modalCancelAction(false)
|
||||||
->modalContent(function (): View {
|
->schema([
|
||||||
return view('comments::filament.comments-action', [
|
CommentsEntry::make('comments'),
|
||||||
'record' => $this->getRecord(),
|
])
|
||||||
]);
|
|
||||||
})
|
|
||||||
->badge(function (): ?int {
|
->badge(function (): ?int {
|
||||||
$record = $this->getRecord();
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
namespace Relaticle\Comments\Filament\Actions;
|
namespace Relaticle\Comments\Filament\Actions;
|
||||||
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Illuminate\Contracts\View\View;
|
|
||||||
use Relaticle\Comments\Concerns\HasComments;
|
use Relaticle\Comments\Concerns\HasComments;
|
||||||
|
use Relaticle\Comments\Filament\Infolists\Components\CommentsEntry;
|
||||||
|
|
||||||
class CommentsTableAction extends Action
|
class CommentsTableAction extends Action
|
||||||
{
|
{
|
||||||
@@ -19,11 +19,9 @@ class CommentsTableAction extends Action
|
|||||||
->modalHeading(__('Comments'))
|
->modalHeading(__('Comments'))
|
||||||
->modalSubmitAction(false)
|
->modalSubmitAction(false)
|
||||||
->modalCancelAction(false)
|
->modalCancelAction(false)
|
||||||
->modalContent(function (): View {
|
->schema([
|
||||||
return view('comments::filament.comments-action', [
|
CommentsEntry::make('comments'),
|
||||||
'record' => $this->getRecord(),
|
])
|
||||||
]);
|
|
||||||
})
|
|
||||||
->badge(function (): ?int {
|
->badge(function (): ?int {
|
||||||
$record = $this->getRecord();
|
$record = $this->getRecord();
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,24 @@
|
|||||||
|
|
||||||
namespace Relaticle\Comments\Livewire;
|
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\Contracts\View\View;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use Relaticle\Comments\CommentsConfig;
|
use Relaticle\Comments\CommentsConfig;
|
||||||
use Relaticle\Comments\Contracts\MentionResolver;
|
|
||||||
use Relaticle\Comments\Events\CommentCreated;
|
use Relaticle\Comments\Events\CommentCreated;
|
||||||
use Relaticle\Comments\Events\CommentDeleted;
|
use Relaticle\Comments\Events\CommentDeleted;
|
||||||
use Relaticle\Comments\Events\CommentUpdated;
|
use Relaticle\Comments\Events\CommentUpdated;
|
||||||
use Relaticle\Comments\Mentions\MentionParser;
|
use Relaticle\Comments\Mentions\MentionParser;
|
||||||
use Relaticle\Comments\Models\Comment;
|
use Relaticle\Comments\Models\Comment;
|
||||||
|
|
||||||
class CommentItem extends Component
|
class CommentItem extends Component implements HasForms
|
||||||
{
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public Comment $comment;
|
public Comment $comment;
|
||||||
@@ -24,9 +28,11 @@ class CommentItem extends Component
|
|||||||
|
|
||||||
public bool $isReplying = false;
|
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> */
|
/** @var array<int, TemporaryUploadedFile> */
|
||||||
public array $replyAttachments = [];
|
public array $replyAttachments = [];
|
||||||
@@ -36,30 +42,60 @@ class CommentItem extends Component
|
|||||||
$this->comment = $comment;
|
$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
|
public function startEdit(): void
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->comment);
|
$this->authorize('update', $this->comment);
|
||||||
|
|
||||||
$this->isEditing = true;
|
$this->isEditing = true;
|
||||||
$this->editBody = $this->comment->body;
|
$this->editForm->fill(['body' => $this->comment->body]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancelEdit(): void
|
public function cancelEdit(): void
|
||||||
{
|
{
|
||||||
$this->isEditing = false;
|
$this->isEditing = false;
|
||||||
$this->editBody = '';
|
$this->editForm->fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function saveEdit(): void
|
public function saveEdit(): void
|
||||||
{
|
{
|
||||||
$this->authorize('update', $this->comment);
|
$this->authorize('update', $this->comment);
|
||||||
|
|
||||||
$this->validate([
|
$data = $this->editForm->getState();
|
||||||
'editBody' => ['required', 'string', 'min:1'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->comment->update([
|
$this->comment->update([
|
||||||
'body' => $this->editBody,
|
'body' => $data['body'] ?? '',
|
||||||
'edited_at' => now(),
|
'edited_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -70,7 +106,7 @@ class CommentItem extends Component
|
|||||||
$this->dispatch('commentUpdated');
|
$this->dispatch('commentUpdated');
|
||||||
|
|
||||||
$this->isEditing = false;
|
$this->isEditing = false;
|
||||||
$this->editBody = '';
|
$this->editForm->fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteComment(): void
|
public function deleteComment(): void
|
||||||
@@ -91,12 +127,13 @@ class CommentItem extends Component
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->isReplying = true;
|
$this->isReplying = true;
|
||||||
|
$this->replyForm->fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancelReply(): void
|
public function cancelReply(): void
|
||||||
{
|
{
|
||||||
$this->isReplying = false;
|
$this->isReplying = false;
|
||||||
$this->replyBody = '';
|
$this->replyForm->fill();
|
||||||
$this->replyAttachments = [];
|
$this->replyAttachments = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,20 +141,20 @@ class CommentItem extends Component
|
|||||||
{
|
{
|
||||||
$this->authorize('reply', $this->comment);
|
$this->authorize('reply', $this->comment);
|
||||||
|
|
||||||
$rules = ['replyBody' => ['required', 'string', 'min:1']];
|
$data = $this->replyForm->getState();
|
||||||
|
|
||||||
if (CommentsConfig::areAttachmentsEnabled()) {
|
if (CommentsConfig::areAttachmentsEnabled()) {
|
||||||
$maxSize = CommentsConfig::getAttachmentMaxSize();
|
$maxSize = CommentsConfig::getAttachmentMaxSize();
|
||||||
$allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
|
$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();
|
$user = CommentsConfig::resolveAuthenticatedUser();
|
||||||
|
|
||||||
$reply = $this->comment->commentable->comments()->create([
|
$reply = $this->comment->commentable->comments()->create([
|
||||||
'body' => $this->replyBody,
|
'body' => $data['body'] ?? '',
|
||||||
'parent_id' => $this->comment->id,
|
'parent_id' => $this->comment->id,
|
||||||
'commenter_id' => $user->getKey(),
|
'commenter_id' => $user->getKey(),
|
||||||
'commenter_type' => $user->getMorphClass(),
|
'commenter_type' => $user->getMorphClass(),
|
||||||
@@ -146,7 +183,7 @@ class CommentItem extends Component
|
|||||||
$this->dispatch('commentUpdated');
|
$this->dispatch('commentUpdated');
|
||||||
|
|
||||||
$this->isReplying = false;
|
$this->isReplying = false;
|
||||||
$this->replyBody = '';
|
$this->replyForm->fill();
|
||||||
$this->replyAttachments = [];
|
$this->replyAttachments = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,25 +194,6 @@ class CommentItem extends Component
|
|||||||
$this->replyAttachments = array_values($attachments);
|
$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
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('comments::livewire.comment-item');
|
return view('comments::livewire.comment-item');
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace Relaticle\Comments\Livewire;
|
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\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -10,19 +14,20 @@ use Livewire\Component;
|
|||||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
use Livewire\WithFileUploads;
|
use Livewire\WithFileUploads;
|
||||||
use Relaticle\Comments\CommentsConfig;
|
use Relaticle\Comments\CommentsConfig;
|
||||||
use Relaticle\Comments\Contracts\MentionResolver;
|
|
||||||
use Relaticle\Comments\Events\CommentCreated;
|
use Relaticle\Comments\Events\CommentCreated;
|
||||||
use Relaticle\Comments\Mentions\MentionParser;
|
use Relaticle\Comments\Mentions\MentionParser;
|
||||||
use Relaticle\Comments\Models\Comment;
|
use Relaticle\Comments\Models\Comment;
|
||||||
use Relaticle\Comments\Models\Subscription;
|
use Relaticle\Comments\Models\Subscription;
|
||||||
|
|
||||||
class Comments extends Component
|
class Comments extends Component implements HasForms
|
||||||
{
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
use WithFileUploads;
|
use WithFileUploads;
|
||||||
|
|
||||||
public Model $model;
|
public Model $model;
|
||||||
|
|
||||||
public string $newComment = '';
|
/** @var array<string, mixed> */
|
||||||
|
public ?array $commentData = [];
|
||||||
|
|
||||||
public string $sortDirection = 'asc';
|
public string $sortDirection = 'asc';
|
||||||
|
|
||||||
@@ -38,6 +43,23 @@ class Comments extends Component
|
|||||||
$this->model = $model;
|
$this->model = $model;
|
||||||
$this->perPage = CommentsConfig::getPerPage();
|
$this->perPage = CommentsConfig::getPerPage();
|
||||||
$this->loadedCount = $this->perPage;
|
$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> */
|
/** @return Collection<int, Comment> */
|
||||||
@@ -95,22 +117,22 @@ class Comments extends Component
|
|||||||
|
|
||||||
public function addComment(): void
|
public function addComment(): void
|
||||||
{
|
{
|
||||||
$rules = ['newComment' => ['required', 'string', 'min:1']];
|
$data = $this->commentForm->getState();
|
||||||
|
|
||||||
if (CommentsConfig::areAttachmentsEnabled()) {
|
if (CommentsConfig::areAttachmentsEnabled()) {
|
||||||
$maxSize = CommentsConfig::getAttachmentMaxSize();
|
$maxSize = CommentsConfig::getAttachmentMaxSize();
|
||||||
$allowedTypes = implode(',', CommentsConfig::getAttachmentAllowedTypes());
|
$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());
|
$this->authorize('create', CommentsConfig::getCommentModel());
|
||||||
|
|
||||||
$user = CommentsConfig::resolveAuthenticatedUser();
|
$user = CommentsConfig::resolveAuthenticatedUser();
|
||||||
|
|
||||||
$comment = $this->model->comments()->create([
|
$comment = $this->model->comments()->create([
|
||||||
'body' => $this->newComment,
|
'body' => $data['body'] ?? '',
|
||||||
'commenter_id' => $user->getKey(),
|
'commenter_id' => $user->getKey(),
|
||||||
'commenter_type' => $user->getMorphClass(),
|
'commenter_type' => $user->getMorphClass(),
|
||||||
]);
|
]);
|
||||||
@@ -135,7 +157,8 @@ class Comments extends Component
|
|||||||
|
|
||||||
app(MentionParser::class)->syncMentions($comment);
|
app(MentionParser::class)->syncMentions($comment);
|
||||||
|
|
||||||
$this->reset('newComment', 'attachments');
|
$this->commentForm->fill();
|
||||||
|
$this->reset('attachments');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeAttachment(int $index): void
|
public function removeAttachment(int $index): void
|
||||||
@@ -183,25 +206,6 @@ class Comments extends Component
|
|||||||
unset($this->comments, $this->totalCount, $this->hasMore);
|
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
|
public function render(): View
|
||||||
{
|
{
|
||||||
return view('comments::livewire.comments');
|
return view('comments::livewire.comments');
|
||||||
|
|||||||
@@ -16,6 +16,32 @@ class MentionParser
|
|||||||
|
|
||||||
/** @return Collection<int, int> */
|
/** @return Collection<int, int> */
|
||||||
public function parse(string $body): Collection
|
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');
|
$text = html_entity_decode(strip_tags($body), ENT_QUOTES, 'UTF-8');
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Relaticle\Comments\Models;
|
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\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
@@ -128,15 +130,12 @@ class Comment extends Model
|
|||||||
public function depth(): int
|
public function depth(): int
|
||||||
{
|
{
|
||||||
$depth = 0;
|
$depth = 0;
|
||||||
$comment = $this;
|
$maxDepth = CommentsConfig::getMaxDepth();
|
||||||
|
$parentId = $this->parent_id;
|
||||||
|
|
||||||
while ($comment->parent_id !== null) {
|
while ($parentId !== null && $depth < $maxDepth) {
|
||||||
$comment = $comment->parent;
|
|
||||||
$depth++;
|
$depth++;
|
||||||
|
$parentId = static::where('id', $parentId)->value('parent_id');
|
||||||
if ($depth >= CommentsConfig::getMaxDepth()) {
|
|
||||||
return CommentsConfig::getMaxDepth();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $depth;
|
return $depth;
|
||||||
@@ -145,6 +144,19 @@ class Comment extends Model
|
|||||||
public function renderBodyWithMentions(): string
|
public function renderBodyWithMentions(): string
|
||||||
{
|
{
|
||||||
$body = $this->body;
|
$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();
|
$mentionNames = $this->mentions->pluck('name')->filter()->unique();
|
||||||
|
|
||||||
foreach ($mentionNames as $name) {
|
foreach ($mentionNames as $name) {
|
||||||
@@ -157,4 +169,9 @@ class Comment extends Model
|
|||||||
|
|
||||||
return $body;
|
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);
|
$file = UploadedFile::fake()->image('photo.jpg', 100, 100);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Comment with attachment</p>')
|
->set('commentData.body', '<p>Comment with attachment</p>')
|
||||||
->set('attachments', [$file])
|
->set('attachments', [$file])
|
||||||
->call('addComment')
|
->call('addComment')
|
||||||
->assertSet('newComment', '')
|
|
||||||
->assertSet('attachments', []);
|
->assertSet('attachments', []);
|
||||||
|
|
||||||
expect(Comment::count())->toBe(1);
|
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);
|
$file = UploadedFile::fake()->image('vacation.jpg', 200, 200)->size(512);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Vacation photos</p>')
|
->set('commentData.body', '<p>Vacation photos</p>')
|
||||||
->set('attachments', [$file])
|
->set('attachments', [$file])
|
||||||
->call('addComment');
|
->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);
|
$file = UploadedFile::fake()->image('test.png', 50, 50);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>File path test</p>')
|
->set('commentData.body', '<p>File path test</p>')
|
||||||
->set('attachments', [$file])
|
->set('attachments', [$file])
|
||||||
->call('addComment');
|
->call('addComment');
|
||||||
|
|
||||||
@@ -160,7 +159,7 @@ it('rejects file exceeding max size', function () {
|
|||||||
$oversizedFile = UploadedFile::fake()->create('big.pdf', CommentsConfig::getAttachmentMaxSize() + 1, 'application/pdf');
|
$oversizedFile = UploadedFile::fake()->create('big.pdf', CommentsConfig::getAttachmentMaxSize() + 1, 'application/pdf');
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Oversized file</p>')
|
->set('commentData.body', '<p>Oversized file</p>')
|
||||||
->set('attachments', [$oversizedFile])
|
->set('attachments', [$oversizedFile])
|
||||||
->call('addComment')
|
->call('addComment')
|
||||||
->assertHasErrors('attachments.0');
|
->assertHasErrors('attachments.0');
|
||||||
@@ -180,7 +179,7 @@ it('rejects disallowed file type', function () {
|
|||||||
$exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload');
|
$exeFile = UploadedFile::fake()->create('script.exe', 100, 'application/x-msdownload');
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Malicious file</p>')
|
->set('commentData.body', '<p>Malicious file</p>')
|
||||||
->set('attachments', [$exeFile])
|
->set('attachments', [$exeFile])
|
||||||
->call('addComment')
|
->call('addComment')
|
||||||
->assertHasErrors('attachments.0');
|
->assertHasErrors('attachments.0');
|
||||||
@@ -200,7 +199,7 @@ it('accepts allowed file types', function () {
|
|||||||
$imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100);
|
$imageFile = UploadedFile::fake()->image('photo.jpg', 100, 100);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Valid file</p>')
|
->set('commentData.body', '<p>Valid file</p>')
|
||||||
->set('attachments', [$imageFile])
|
->set('attachments', [$imageFile])
|
||||||
->call('addComment')
|
->call('addComment')
|
||||||
->assertHasNoErrors('attachments.0');
|
->assertHasNoErrors('attachments.0');
|
||||||
@@ -218,7 +217,7 @@ it('hides upload UI when attachments disabled', function () {
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->assertDontSeeHtml('Attach files');
|
->assertDontSeeHtml('wire:model="attachments"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows upload UI when attachments enabled', function () {
|
it('shows upload UI when attachments enabled', function () {
|
||||||
@@ -228,7 +227,7 @@ it('shows upload UI when attachments enabled', function () {
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->assertSeeHtml('Attach files');
|
->assertSeeHtml('wire:model="attachments"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates comment with multiple file attachments', function () {
|
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');
|
$file2 = UploadedFile::fake()->create('notes.pdf', 512, 'application/pdf');
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Multiple files</p>')
|
->set('commentData.body', '<p>Multiple files</p>')
|
||||||
->set('attachments', [$file1, $file2])
|
->set('attachments', [$file1, $file2])
|
||||||
->call('addComment');
|
->call('addComment');
|
||||||
|
|
||||||
@@ -276,11 +275,10 @@ it('creates reply with file attachment via CommentItem component', function () {
|
|||||||
|
|
||||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startReply')
|
->call('startReply')
|
||||||
->set('replyBody', '<p>Reply with attachment</p>')
|
->set('replyData.body', '<p>Reply with attachment</p>')
|
||||||
->set('replyAttachments', [$file])
|
->set('replyAttachments', [$file])
|
||||||
->call('addReply')
|
->call('addReply')
|
||||||
->assertSet('isReplying', false)
|
->assertSet('isReplying', false)
|
||||||
->assertSet('replyBody', '')
|
|
||||||
->assertSet('replyAttachments', []);
|
->assertSet('replyAttachments', []);
|
||||||
|
|
||||||
$reply = Comment::where('parent_id', $comment->id)->first();
|
$reply = Comment::where('parent_id', $comment->id)->first();
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ it('fires CommentCreated event when adding a comment', function () {
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>New comment</p>')
|
->set('commentData.body', '<p>New comment</p>')
|
||||||
->call('addComment');
|
->call('addComment');
|
||||||
|
|
||||||
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post) {
|
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])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startEdit')
|
->call('startEdit')
|
||||||
->set('editBody', '<p>Edited</p>')
|
->set('editData.body', '<p>Edited</p>')
|
||||||
->call('saveEdit');
|
->call('saveEdit');
|
||||||
|
|
||||||
Event::assertDispatched(CommentUpdated::class, function (CommentUpdated $event) use ($comment) {
|
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])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startReply')
|
->call('startReply')
|
||||||
->set('replyBody', '<p>Reply text</p>')
|
->set('replyData.body', '<p>Reply text</p>')
|
||||||
->call('addReply');
|
->call('addReply');
|
||||||
|
|
||||||
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($comment) {
|
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);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Payload test</p>')
|
->set('commentData.body', '<p>Payload test</p>')
|
||||||
->call('addComment');
|
->call('addComment');
|
||||||
|
|
||||||
Event::assertDispatched(CommentCreated::class, function (CommentCreated $event) use ($post, $user) {
|
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])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startEdit')
|
->call('startEdit')
|
||||||
->assertSet('isEditing', true)
|
->assertSet('isEditing', true)
|
||||||
->assertSet('editBody', '<p>Original body</p>')
|
->set('editData.body', '<p>Updated body</p>')
|
||||||
->set('editBody', '<p>Updated body</p>')
|
|
||||||
->call('saveEdit')
|
->call('saveEdit')
|
||||||
->assertSet('isEditing', false)
|
->assertSet('isEditing', false);
|
||||||
->assertSet('editBody', '');
|
|
||||||
|
|
||||||
$comment->refresh();
|
$comment->refresh();
|
||||||
|
|
||||||
@@ -53,7 +51,7 @@ it('marks edited comment with edited indicator', function () {
|
|||||||
|
|
||||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startEdit')
|
->call('startEdit')
|
||||||
->set('editBody', '<p>Changed</p>')
|
->set('editData.body', '<p>Changed</p>')
|
||||||
->call('saveEdit');
|
->call('saveEdit');
|
||||||
|
|
||||||
$comment->refresh();
|
$comment->refresh();
|
||||||
@@ -160,10 +158,9 @@ it('allows user to reply to a comment', function () {
|
|||||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startReply')
|
->call('startReply')
|
||||||
->assertSet('isReplying', true)
|
->assertSet('isReplying', true)
|
||||||
->set('replyBody', '<p>My reply</p>')
|
->set('replyData.body', '<p>My reply</p>')
|
||||||
->call('addReply')
|
->call('addReply')
|
||||||
->assertSet('isReplying', false)
|
->assertSet('isReplying', false);
|
||||||
->assertSet('replyBody', '');
|
|
||||||
|
|
||||||
$reply = Comment::where('parent_id', $comment->id)->first();
|
$reply = Comment::where('parent_id', $comment->id)->first();
|
||||||
|
|
||||||
@@ -213,8 +210,7 @@ it('resets state when cancelling edit', function () {
|
|||||||
->call('startEdit')
|
->call('startEdit')
|
||||||
->assertSet('isEditing', true)
|
->assertSet('isEditing', true)
|
||||||
->call('cancelEdit')
|
->call('cancelEdit')
|
||||||
->assertSet('isEditing', false)
|
->assertSet('isEditing', false);
|
||||||
->assertSet('editBody', '');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets state when cancelling reply', function () {
|
it('resets state when cancelling reply', function () {
|
||||||
@@ -233,10 +229,9 @@ it('resets state when cancelling reply', function () {
|
|||||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startReply')
|
->call('startReply')
|
||||||
->assertSet('isReplying', true)
|
->assertSet('isReplying', true)
|
||||||
->set('replyBody', '<p>Draft reply</p>')
|
->set('replyData.body', '<p>Draft reply</p>')
|
||||||
->call('cancelReply')
|
->call('cancelReply')
|
||||||
->assertSet('isReplying', false)
|
->assertSet('isReplying', false);
|
||||||
->assertSet('replyBody', '');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads all replies within a thread eagerly', function () {
|
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');
|
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');
|
$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 () {
|
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);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Hello World</p>')
|
->set('commentData.body', '<p>Hello World</p>')
|
||||||
->call('addComment')
|
->call('addComment');
|
||||||
->assertSet('newComment', '');
|
|
||||||
|
|
||||||
expect(Comment::count())->toBe(1);
|
expect(Comment::count())->toBe(1);
|
||||||
expect(Comment::first()->body)->toBe('<p>Hello World</p>');
|
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);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Test</p>')
|
->set('commentData.body', '<p>Test</p>')
|
||||||
->call('addComment');
|
->call('addComment');
|
||||||
|
|
||||||
$comment = Comment::first();
|
$comment = Comment::first();
|
||||||
@@ -43,7 +42,7 @@ it('requires authentication to create a comment', function () {
|
|||||||
$post = Post::factory()->create();
|
$post = Post::factory()->create();
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '<p>Hello</p>')
|
->set('commentData.body', '<p>Hello</p>')
|
||||||
->call('addComment')
|
->call('addComment')
|
||||||
->assertForbidden();
|
->assertForbidden();
|
||||||
});
|
});
|
||||||
@@ -55,9 +54,9 @@ it('validates that comment body is not empty', function () {
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', '')
|
->set('commentData.body', '')
|
||||||
->call('addComment')
|
->call('addComment')
|
||||||
->assertHasErrors('newComment');
|
->assertHasErrors('commentData.body');
|
||||||
|
|
||||||
expect(Comment::count())->toBe(0);
|
expect(Comment::count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ it('configures as a slide-over', function () {
|
|||||||
expect($action->isModalSlideOver())->toBeTrue();
|
expect($action->isModalSlideOver())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('has modal content configured', function () {
|
it('disables modal submit and cancel actions', function () {
|
||||||
$action = CommentsTableAction::make('comments');
|
$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 () {
|
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);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
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');
|
->call('addComment');
|
||||||
|
|
||||||
$comment = Comment::first();
|
$comment = Comment::first();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ it('renders mention with styled span', function () {
|
|||||||
'commentable_type' => $post->getMorphClass(),
|
'commentable_type' => $post->getMorphClass(),
|
||||||
'commenter_id' => $user->getKey(),
|
'commenter_id' => $user->getKey(),
|
||||||
'commenter_type' => $user->getMorphClass(),
|
'commenter_type' => $user->getMorphClass(),
|
||||||
'body' => '<p>@Alice said hi</p>',
|
'body' => '@Alice said hi',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
|
$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(),
|
'commentable_type' => $post->getMorphClass(),
|
||||||
'commenter_id' => $user->getKey(),
|
'commenter_id' => $user->getKey(),
|
||||||
'commenter_type' => $user->getMorphClass(),
|
'commenter_type' => $user->getMorphClass(),
|
||||||
'body' => '<p>@Alice and @Bob</p>',
|
'body' => '@Alice and @Bob',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
|
$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(),
|
'commentable_type' => $post->getMorphClass(),
|
||||||
'commenter_id' => $user->getKey(),
|
'commenter_id' => $user->getKey(),
|
||||||
'commenter_type' => $user->getMorphClass(),
|
'commenter_type' => $user->getMorphClass(),
|
||||||
'body' => '<p>@ghost is not here</p>',
|
'body' => '@ghost is not here',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$rendered = $comment->renderBodyWithMentions();
|
$rendered = $comment->renderBodyWithMentions();
|
||||||
@@ -78,7 +78,7 @@ it('renders comment-mention class in Livewire component', function () {
|
|||||||
'commentable_type' => $post->getMorphClass(),
|
'commentable_type' => $post->getMorphClass(),
|
||||||
'commenter_id' => $user->getKey(),
|
'commenter_id' => $user->getKey(),
|
||||||
'commenter_type' => $user->getMorphClass(),
|
'commenter_type' => $user->getMorphClass(),
|
||||||
'body' => '<p>Hello @Alice</p>',
|
'body' => 'Hello @Alice',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]);
|
$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\Post;
|
||||||
use Relaticle\Comments\Tests\Models\User;
|
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 () {
|
it('stores mentions when creating comment with @mention', function () {
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$alice = User::factory()->create(['name' => 'Alice']);
|
$alice = User::factory()->create(['name' => 'Alice']);
|
||||||
@@ -75,7 +15,7 @@ it('stores mentions when creating comment with @mention', function () {
|
|||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
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');
|
->call('addComment');
|
||||||
|
|
||||||
$comment = Comment::first();
|
$comment = Comment::first();
|
||||||
@@ -101,7 +41,7 @@ it('stores mentions when editing comment with @mention', function () {
|
|||||||
|
|
||||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startEdit')
|
->call('startEdit')
|
||||||
->set('editBody', '<p>Updated @Bob</p>')
|
->set('editData.body', '<p>Updated @Bob</p>')
|
||||||
->call('saveEdit');
|
->call('saveEdit');
|
||||||
|
|
||||||
$comment->refresh();
|
$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>';
|
$html = '<p>Hello <strong>bold</strong> and <em>italic</em> world</p>';
|
||||||
|
|
||||||
Livewire::test(Comments::class, ['model' => $post])
|
Livewire::test(Comments::class, ['model' => $post])
|
||||||
->set('newComment', $html)
|
->set('commentData.body', $html)
|
||||||
->call('addComment');
|
->call('addComment');
|
||||||
|
|
||||||
$comment = Comment::first();
|
$comment = Comment::first();
|
||||||
@@ -42,9 +42,15 @@ it('pre-fills editBody with existing comment HTML when starting edit', function
|
|||||||
|
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
Livewire::test(CommentItem::class, ['comment' => $comment])
|
$component = Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startEdit')
|
->call('startEdit');
|
||||||
->assertSet('editBody', $originalHtml);
|
|
||||||
|
$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 () {
|
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])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startEdit')
|
->call('startEdit')
|
||||||
->set('editBody', $updatedHtml)
|
->set('editData.body', $updatedHtml)
|
||||||
->call('saveEdit');
|
->call('saveEdit');
|
||||||
|
|
||||||
$comment->refresh();
|
$comment->refresh();
|
||||||
|
|
||||||
expect($comment->body)->toContain('<strong>bold</strong>');
|
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 () {
|
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])
|
Livewire::test(CommentItem::class, ['comment' => $comment])
|
||||||
->call('startReply')
|
->call('startReply')
|
||||||
->set('replyBody', $replyHtml)
|
->set('replyData.body', $replyHtml)
|
||||||
->call('addReply');
|
->call('addReply');
|
||||||
|
|
||||||
$reply = Comment::where('parent_id', $comment->id)->first();
|
$reply = Comment::where('parent_id', $comment->id)->first();
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
namespace Relaticle\Comments\Tests;
|
namespace Relaticle\Comments\Tests;
|
||||||
|
|
||||||
|
use BladeUI\Heroicons\BladeHeroiconsServiceProvider;
|
||||||
|
use BladeUI\Icons\BladeIconsServiceProvider;
|
||||||
|
use Filament\Actions\ActionsServiceProvider;
|
||||||
use Filament\FilamentServiceProvider;
|
use Filament\FilamentServiceProvider;
|
||||||
|
use Filament\Forms\FormsServiceProvider;
|
||||||
|
use Filament\Schemas\SchemasServiceProvider;
|
||||||
use Filament\Support\SupportServiceProvider;
|
use Filament\Support\SupportServiceProvider;
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
@@ -26,7 +31,12 @@ abstract class TestCase extends Orchestra
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
LivewireServiceProvider::class,
|
LivewireServiceProvider::class,
|
||||||
|
BladeIconsServiceProvider::class,
|
||||||
|
BladeHeroiconsServiceProvider::class,
|
||||||
SupportServiceProvider::class,
|
SupportServiceProvider::class,
|
||||||
|
SchemasServiceProvider::class,
|
||||||
|
FormsServiceProvider::class,
|
||||||
|
ActionsServiceProvider::class,
|
||||||
FilamentServiceProvider::class,
|
FilamentServiceProvider::class,
|
||||||
CommentsServiceProvider::class,
|
CommentsServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user