From 8d4a8bd8bf6cd527b1d13989e44c97018cd9f442 Mon Sep 17 00:00:00 2001 From: Mateo Date: Fri, 17 Jul 2026 20:38:36 +0200 Subject: [PATCH] fix: implement update, and delete operations for comments & fix bugs that caused task content deletion when creating a comment --- app/Http/Controllers/TaskController.php | 60 ++++++-- .../Requests/TaskCommentUpdateRequest.php | 20 +++ app/Http/Resources/CommentResource.php | 32 ++++ app/Http/Resources/TaskResource.php | 28 ---- app/Policies/TaskCommentPolicy.php | 25 ++++ app/Services/CommentService.php | 40 +++++ ...leteDialog.vue => ConfirmDeleteDialog.vue} | 31 ++-- .../components/tasks/ColumnDeleteDialog.vue | 44 ------ .../js/components/tasks/KanbanColumn.vue | 7 +- .../tasks/TaskActivityDiscussionPanel.vue | 4 + .../tasks/TaskCommentThreadItem.vue | 138 ++++++++++++++++-- .../js/components/tasks/TaskDeleteDialog.vue | 44 ------ .../js/components/tasks/TaskEditDialog.vue | 106 +++++++++----- resources/js/components/tasks/TaskItem.vue | 7 +- resources/js/pages/Tags.vue | 7 +- routes/tasks.php | 3 + tests/Feature/TaskCommentTest.php | 8 +- tests/Feature/TaskTest.php | 112 +++++++++++++- 18 files changed, 508 insertions(+), 208 deletions(-) create mode 100644 app/Http/Requests/TaskCommentUpdateRequest.php create mode 100644 app/Http/Resources/CommentResource.php create mode 100644 app/Policies/TaskCommentPolicy.php create mode 100644 app/Services/CommentService.php rename resources/js/components/{tags/TagDeleteDialog.vue => ConfirmDeleteDialog.vue} (56%) delete mode 100644 resources/js/components/tasks/ColumnDeleteDialog.vue delete mode 100644 resources/js/components/tasks/TaskDeleteDialog.vue diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index 9089b44..4582921 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -6,8 +6,11 @@ use App\Models\Column; use App\Models\Tag; use App\Models\User; +use App\Models\TaskComment; use App\Services\TaskService; +use App\Services\CommentService; use App\Http\Resources\ColumnResource; +use App\Http\Resources\CommentResource; use App\Http\Resources\TaskResource; use Inertia\Inertia; use Inertia\Response; @@ -15,15 +18,19 @@ use App\Http\Controllers\Controller; use App\Http\Requests\TaskCreateRequest; use App\Http\Requests\TaskCommentStoreRequest; +use App\Http\Requests\TaskCommentUpdateRequest; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use App\Http\Requests\TaskUpdateRequest; use App\Http\Requests\TaskListRequest; +use \Illuminate\Http\Response as HttpResponse; class TaskController extends Controller { - public function __construct(private TaskService $taskService) - { + public function __construct( + private TaskService $taskService, + private CommentService $commentService + ) { } public function index(TaskListRequest $request): Response @@ -68,7 +75,7 @@ public function index(TaskListRequest $request): Response ]); } - public function show(Request $request, Task $task): JsonResponse + public function show(Task $task): JsonResponse { $this->authorize('view', $task); @@ -78,15 +85,26 @@ public function show(Request $request, Task $task): JsonResponse 'assignee:id,name', 'tags:id,name,color', 'taskAttachments', - 'comments' => fn($query) => $query - ->whereNull('parent_id') - ->with(['user:id,name', 'replies']), 'events.actor:id,name', ]); return response()->json((new TaskResource($task))->resolve()); } + public function indexComments(Task $task): JsonResponse + { + $this->authorize('view', $task); + + $comments = $task->comments() + ->whereNull('parent_id') + ->with(['user:id,name', 'replies.user:id,name']) + ->get(); + + return response()->json([ + 'comments' => CommentResource::collection($comments)->resolve(), + ]); + } + public function store(TaskCreateRequest $request): RedirectResponse { $this->taskService->createTask($request->safe()->all(), $request->user()); @@ -112,18 +130,34 @@ public function destroy(Request $request, Task $task): RedirectResponse return back(); } - public function storeComment(TaskCommentStoreRequest $request, Task $task): RedirectResponse + public function storeComment(TaskCommentStoreRequest $request, Task $task): HttpResponse|JsonResponse { $this->authorize('comment', $task); $validated = $request->validated(); - $task->comments()->create([ - 'user_id' => $request->user()->id, - 'parent_id' => $validated['parent_id'] ?? null, - 'body' => $validated['body'], - ]); + $this->commentService->createComment($task, $validated, $request->user()); - return back(); + return response()->noContent(); + } + + public function updateComment(TaskCommentUpdateRequest $request, Task $task, TaskComment $comment): HttpResponse|JsonResponse + { + $this->authorize('update', $comment); + + $validated = $request->validated(); + + $this->commentService->updateComment($comment, $validated); + + return response()->noContent(); + } + + public function destroyComment(Request $request, Task $task, TaskComment $comment): HttpResponse|JsonResponse + { + $this->authorize('delete', $comment); + + $this->commentService->deleteComment($comment); + + return response()->noContent(); } } diff --git a/app/Http/Requests/TaskCommentUpdateRequest.php b/app/Http/Requests/TaskCommentUpdateRequest.php new file mode 100644 index 0000000..118904c --- /dev/null +++ b/app/Http/Requests/TaskCommentUpdateRequest.php @@ -0,0 +1,20 @@ +|string> + */ + public function rules(): array + { + return [ + 'body' => ['required', 'string', 'max:2000'], + ]; + } +} diff --git a/app/Http/Resources/CommentResource.php b/app/Http/Resources/CommentResource.php new file mode 100644 index 0000000..02c6960 --- /dev/null +++ b/app/Http/Resources/CommentResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'body' => $this->body, + 'parent_id' => $this->parent_id, + 'created_at' => $this->created_at, + 'user' => [ + 'id' => $this->user->id, + 'name' => $this->user->name, + ], + 'replies' => $this->whenLoaded( + 'replies', + fn() => CommentResource::collection($this->replies) + ), + ]; + } +} diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php index 8ebfd45..5d8adaa 100644 --- a/app/Http/Resources/TaskResource.php +++ b/app/Http/Resources/TaskResource.php @@ -9,28 +9,6 @@ class TaskResource extends JsonResource { private const INDEX_DESCRIPTION_PREVIEW_LIMIT = 100; - /** - * Serialize a comment and its nested replies. - * - * @return array - */ - private function serializeComment($comment): array - { - return [ - 'id' => $comment->id, - 'body' => $comment->body, - 'parent_id' => $comment->parent_id, - 'created_at' => $comment->created_at?->toIso8601String(), - 'user' => [ - 'id' => $comment->user->id, - 'name' => $comment->user->name, - ], - 'replies' => $comment->relationLoaded('replies') - ? $comment->replies->map(fn($reply) => $this->serializeComment($reply))->values() - : [], - ]; - } - /** * Build a short HTML preview that keeps rich text markup intact. */ @@ -165,12 +143,6 @@ public function toArray(Request $request): array 'name' => $this->column->name, 'type' => $this->column->type, ]), - 'comments' => $this->whenLoaded( - 'comments', - fn() => $this->comments - ->map(fn($comment) => $this->serializeComment($comment)) - ->values() - ), 'events' => $this->whenLoaded( 'events', fn() => $this->events->map(fn($event) => [ diff --git a/app/Policies/TaskCommentPolicy.php b/app/Policies/TaskCommentPolicy.php new file mode 100644 index 0000000..3e9e2b4 --- /dev/null +++ b/app/Policies/TaskCommentPolicy.php @@ -0,0 +1,25 @@ +id === $comment->user_id; + } + + /** + * Determine whether the user can delete the comment. + */ + public function delete(User $user, TaskComment $comment): bool + { + return $user->id === $comment->user_id; + } +} diff --git a/app/Services/CommentService.php b/app/Services/CommentService.php new file mode 100644 index 0000000..d78364a --- /dev/null +++ b/app/Services/CommentService.php @@ -0,0 +1,40 @@ +comments()->create([ + 'user_id' => $user->id, + 'parent_id' => $data['parent_id'] ?? null, + 'body' => $data['body'], + ]); + } + + /** + * Update a comment. + */ + public function updateComment(TaskComment $comment, array $data): bool + { + return $comment->update([ + 'body' => $data['body'], + ]); + } + + /** + * Delete a comment. + */ + public function deleteComment(TaskComment $comment): ?bool + { + return $comment->delete(); + } +} diff --git a/resources/js/components/tags/TagDeleteDialog.vue b/resources/js/components/ConfirmDeleteDialog.vue similarity index 56% rename from resources/js/components/tags/TagDeleteDialog.vue rename to resources/js/components/ConfirmDeleteDialog.vue index 1f19dba..8c45dc8 100644 --- a/resources/js/components/tags/TagDeleteDialog.vue +++ b/resources/js/components/ConfirmDeleteDialog.vue @@ -8,11 +8,19 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import type { Tag } from '@/types'; -defineProps<{ - tag: Tag; -}>(); +withDefaults( + defineProps<{ + title: string; + description: string; + confirmText?: string; + destructive?: boolean; + }>(), + { + confirmText: 'Delete', + destructive: true, + } +); const isOpen = defineModel('open', { default: false }); @@ -25,19 +33,18 @@ const emit = defineEmits<{ - Delete tag - - Are you sure you want to delete the tag - "{{ tag.name }}"? Tasks - using this tag will no longer be labelled with it. - + {{ title }} + {{ description }} - diff --git a/resources/js/components/tasks/ColumnDeleteDialog.vue b/resources/js/components/tasks/ColumnDeleteDialog.vue deleted file mode 100644 index 61ec04b..0000000 --- a/resources/js/components/tasks/ColumnDeleteDialog.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/resources/js/components/tasks/KanbanColumn.vue b/resources/js/components/tasks/KanbanColumn.vue index 4f65fc2..1ebd149 100644 --- a/resources/js/components/tasks/KanbanColumn.vue +++ b/resources/js/components/tasks/KanbanColumn.vue @@ -21,7 +21,7 @@ import { import { computed, ref, watch } from 'vue'; import { toast } from 'vue-sonner'; import draggable from 'vuedraggable'; -import ColumnDeleteDialog from './ColumnDeleteDialog.vue'; +import ConfirmDeleteDialog from '@/components/ConfirmDeleteDialog.vue'; import TaskItem from './TaskItem.vue'; const props = defineProps<{ @@ -285,9 +285,10 @@ watch( - diff --git a/resources/js/components/tasks/TaskActivityDiscussionPanel.vue b/resources/js/components/tasks/TaskActivityDiscussionPanel.vue index 81148b4..61462d6 100644 --- a/resources/js/components/tasks/TaskActivityDiscussionPanel.vue +++ b/resources/js/components/tasks/TaskActivityDiscussionPanel.vue @@ -35,6 +35,8 @@ const emit = defineEmits<{ cancelReply: []; updateReplyBody: [value: string]; submitReply: [commentId: number]; + updateComment: [commentId: number, body: string]; + deleteComment: [commentId: number]; }>(); const cleanedCommentBody = computed(() => { @@ -210,6 +212,8 @@ const activeTab = ref<'discussion' | 'activity'>('discussion'); @cancel-reply="emit('cancelReply')" @update-reply-body="emit('updateReplyBody', $event)" @submit-reply="emit('submitReply', $event)" + @update-comment="(commentId, body) => emit('updateComment', commentId, body)" + @delete-comment="(commentId) => emit('deleteComment', commentId)" /> diff --git a/resources/js/components/tasks/TaskCommentThreadItem.vue b/resources/js/components/tasks/TaskCommentThreadItem.vue index 9103415..1780dfb 100644 --- a/resources/js/components/tasks/TaskCommentThreadItem.vue +++ b/resources/js/components/tasks/TaskCommentThreadItem.vue @@ -1,9 +1,18 @@ - - diff --git a/resources/js/components/tasks/TaskEditDialog.vue b/resources/js/components/tasks/TaskEditDialog.vue index 33e68b3..91eaab3 100644 --- a/resources/js/components/tasks/TaskEditDialog.vue +++ b/resources/js/components/tasks/TaskEditDialog.vue @@ -13,7 +13,7 @@ import { useInitials } from '@/composables/useInitials'; import { show, update } from '@/routes/tasks'; import comments from '@/routes/tasks/comments'; import type { Tag, Task, TaskComment, TaskEvent, TeamMember } from '@/types'; -import { useForm } from '@inertiajs/vue3'; +import { useForm, useHttp } from '@inertiajs/vue3'; import { X } from 'lucide-vue-next'; import { computed, ref, watch } from 'vue'; import { toast } from 'vue-sonner'; @@ -42,14 +42,13 @@ const form = useForm({ removed_attachment_ids: [] as string[], }); -const commentForm = useForm({ - body: '', -}); +const commentForm = useHttp({ body: '' }); -const replyForm = useForm({ - body: '', - parent_id: null as number | null, -}); +const replyForm = useHttp({ body: '', parent_id: null as number | null }); + +const updateForm = useHttp({ body: '' }); + +const deleteForm = useHttp({}); const activeReplyToId = ref(null); @@ -72,38 +71,32 @@ const hydrateFormsFromTask = (task: Task) => { form.tag_ids = task.tags?.map((t) => t.id) ?? []; form.attachments = []; form.removed_attachment_ids = []; - commentsList.value = [...(task.comments ?? [])]; }; -const loadTaskDetails = async (taskId: number, showLoader = true) => { +const loadTaskDetails = async (taskId: number, showLoader = true, hydrateForm = true) => { if (showLoader) { isLoadingDetails.value = true; } try { const response = await fetch(show(taskId).url, { - headers: { - Accept: 'application/json', - }, + headers: { Accept: 'application/json' }, }); - if (!response.ok) { - throw new Error('Could not load task details'); - } + if (!response.ok) throw new Error('Could not load task details'); const task = (await response.json()) as Task; taskDetails.value = task; - form.title = task.title; - form.description = task.description || ''; - form.due_date = task.due_date - ? task.due_date.slice(0, 16) - : ''; - form.assigned_to = task.assigned_to ?? null; - form.created_by = task.created_by; - form.attachments = []; - form.removed_attachment_ids = []; - commentsList.value = [...(task.comments ?? [])]; + if (hydrateForm) { + form.title = task.title; + form.description = task.description || ''; + form.due_date = task.due_date ? task.due_date.slice(0, 16) : ''; + form.assigned_to = task.assigned_to ?? null; + form.created_by = task.created_by; + form.attachments = []; + form.removed_attachment_ids = []; + } return true; } catch { @@ -116,6 +109,21 @@ const loadTaskDetails = async (taskId: number, showLoader = true) => { } }; +const loadComments = async (taskId: number) => { + try { + const response = await fetch(comments.index(taskId).url, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) throw new Error('Could not load comments'); + + const data = (await response.json()) as { comments: TaskComment[] }; + commentsList.value = data.comments; + } catch { + toast.error('Unable to load comments'); + } +}; + const isOverdue = computed(() => { return activeTask.value?.due_date ? new Date(activeTask.value.due_date) < new Date() @@ -173,24 +181,22 @@ const submit = () => { })).post(update(activeTask.value.id).url, { preserveScroll: true, onSuccess: () => { - isOpen.value = false; toast.success('Task updated'); }, }); }; -const submitComment = () => { +const submitComment = async () => { if (!activeTask.value) return; const taskId = activeTask.value.id; const bodyText = stripRichText(commentForm.body); - if (!bodyText) return; - commentForm.post(comments.store(taskId).url, { - preserveScroll: true, + await commentForm.post(comments.store(taskId).url, { onSuccess: async () => { - await loadTaskDetails(taskId, false); + commentForm.reset('body'); + await loadComments(taskId); commentForm.reset('body'); toast.success('Comment posted'); }, @@ -211,7 +217,7 @@ const cancelReply = () => { replyForm.parent_id = null; }; -const submitReply = (commentId: number) => { +const submitReply = async (commentId: number) => { if (!activeTask.value) return; const taskId = activeTask.value.id; @@ -219,21 +225,47 @@ const submitReply = (commentId: number) => { if (!bodyText) return; replyForm.parent_id = commentId; - replyForm.post(comments.store(taskId).url, { - preserveScroll: true, + await replyForm.post(comments.store(taskId).url, { onSuccess: async () => { - await loadTaskDetails(taskId, false); + replyForm.reset('body'); + await loadComments(taskId); cancelReply(); toast.success('Reply posted'); }, }); }; +const updateComment = async (commentId: number, body: string) => { + if (!activeTask.value) return; + const taskId = activeTask.value.id; + + updateForm.body = body; + await updateForm.put(comments.update({ task: taskId, comment: commentId }).url, { + onSuccess: async () => { + await loadComments(taskId); + toast.success('Comment updated'); + }, + }); +}; + +const deleteComment = async (commentId: number) => { + if (!activeTask.value) return; + const taskId = activeTask.value.id; + + await deleteForm.delete(comments.destroy({ task: taskId, comment: commentId }).url, { + onSuccess: async () => { + await loadComments(taskId); + toast.success('Comment deleted'); + }, + }); +}; + watch([() => props.task, isOpen], ([task, open], [oldTask, oldOpen]) => { if (task && open && (!oldOpen || oldTask?.id !== task.id)) { taskDetails.value = null; hydrateFormsFromTask(task); void loadTaskDetails(task.id); + void loadComments(task.id); form.clearErrors(); commentForm.reset('body'); commentForm.clearErrors(); @@ -321,6 +353,8 @@ watch([() => props.task, isOpen], ([task, open], [oldTask, oldOpen]) => { @cancel-reply="cancelReply" @update-reply-body="replyForm.body = $event" @submit-reply="submitReply" + @update-comment="(commentId, body) => updateComment(commentId, body)" + @delete-comment="deleteComment" /> diff --git a/resources/js/components/tasks/TaskItem.vue b/resources/js/components/tasks/TaskItem.vue index ecc2714..77cd4bd 100644 --- a/resources/js/components/tasks/TaskItem.vue +++ b/resources/js/components/tasks/TaskItem.vue @@ -1,6 +1,6 @@