From 71e5e6d49f4cff7e9377d0214cf0a5a7f59a679e Mon Sep 17 00:00:00 2001 From: Mateo Date: Mon, 13 Apr 2026 21:34:56 +0200 Subject: [PATCH 1/2] feat: implement thread comments with possibility to reply --- app/Http/Controllers/TaskController.php | 21 ++- app/Http/Resources/TaskResource.php | 39 +++-- app/Models/TaskComment.php | 20 +++ ...0_add_parent_id_to_task_comments_table.php | 35 ++++ .../tasks/TaskCommentThreadItem.vue | 164 ++++++++++++++++++ .../js/components/tasks/TaskEditDialog.vue | 162 ++++++++--------- resources/js/types/index.d.ts | 2 + tests/Feature/TaskCommentTest.php | 83 +++++++++ vite.config.ts | 1 - 9 files changed, 436 insertions(+), 91 deletions(-) create mode 100644 database/migrations/2026_04_13_100000_add_parent_id_to_task_comments_table.php create mode 100644 resources/js/components/tasks/TaskCommentThreadItem.vue diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index 355730a..da2ad5b 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -14,6 +14,7 @@ use App\Http\Requests\TaskCreateRequest; use Illuminate\Http\RedirectResponse; use App\Http\Requests\TaskUpdateRequest; +use Illuminate\Validation\Rule; class TaskController extends Controller { @@ -29,7 +30,19 @@ public function index(Request $request): Response ->get(); $columns->each(function ($column) { - $column->setRelation('tasks', $column->tasks()->with('creator:id,name', 'assignee:id,name', 'comments.user:id,name')->orderBy('order')->paginate(10)); + $column->setRelation( + 'tasks', + $column->tasks() + ->with([ + 'creator:id,name', + 'assignee:id,name', + 'comments' => fn($query) => $query + ->whereNull('parent_id') + ->with(['user:id,name', 'replies']), + ]) + ->orderBy('order') + ->paginate(10) + ); }); $teamMembers = User::query() @@ -80,10 +93,16 @@ public function storeComment(Request $request, Task $task): RedirectResponse $validated = $request->validate([ 'body' => ['required', 'string', 'max:2000'], + 'parent_id' => [ + 'nullable', + 'integer', + Rule::exists('task_comments', 'id')->where('task_id', $task->id), + ], ]); $task->comments()->create([ 'user_id' => $request->user()->id, + 'parent_id' => $validated['parent_id'] ?? null, 'body' => $validated['body'], ]); diff --git a/app/Http/Resources/TaskResource.php b/app/Http/Resources/TaskResource.php index 00d6df6..fa1d698 100644 --- a/app/Http/Resources/TaskResource.php +++ b/app/Http/Resources/TaskResource.php @@ -7,6 +7,28 @@ class TaskResource extends JsonResource { + /** + * 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() + : [], + ]; + } + /** * Transform the resource into an array. * @@ -41,17 +63,12 @@ public function toArray(Request $request): array 'id' => $this->assignee->id, 'name' => $this->assignee->name, ]), - 'comments' => $this->whenLoaded('comments', fn() => $this->comments->map( - fn($comment) => [ - 'id' => $comment->id, - 'body' => $comment->body, - 'created_at' => $comment->created_at?->toIso8601String(), - 'user' => [ - 'id' => $comment->user->id, - 'name' => $comment->user->name, - ], - ] - )->values()), + 'comments' => $this->whenLoaded( + 'comments', + fn() => $this->comments + ->map(fn($comment) => $this->serializeComment($comment)) + ->values() + ), ]; } } diff --git a/app/Models/TaskComment.php b/app/Models/TaskComment.php index 7379274..e008acc 100644 --- a/app/Models/TaskComment.php +++ b/app/Models/TaskComment.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Model; class TaskComment extends Model @@ -19,6 +20,7 @@ class TaskComment extends Model protected $fillable = [ 'task_id', 'user_id', + 'parent_id', 'body', ]; @@ -37,4 +39,22 @@ public function user(): BelongsTo { return $this->belongsTo(User::class); } + + /** + * Get the parent comment when this is a reply. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(self::class, 'parent_id'); + } + + /** + * Get direct replies for this comment. + */ + public function replies(): HasMany + { + return $this->hasMany(self::class, 'parent_id') + ->with(['user:id,name', 'replies']) + ->orderBy('created_at'); + } } diff --git a/database/migrations/2026_04_13_100000_add_parent_id_to_task_comments_table.php b/database/migrations/2026_04_13_100000_add_parent_id_to_task_comments_table.php new file mode 100644 index 0000000..f54974c --- /dev/null +++ b/database/migrations/2026_04_13_100000_add_parent_id_to_task_comments_table.php @@ -0,0 +1,35 @@ +foreignId('parent_id') + ->nullable() + ->after('user_id') + ->constrained('task_comments') + ->cascadeOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('task_comments', function (Blueprint $table) { + if (Schema::hasColumn('task_comments', 'parent_id')) { + $table->dropConstrainedForeignId('parent_id'); + } + }); + } +}; diff --git a/resources/js/components/tasks/TaskCommentThreadItem.vue b/resources/js/components/tasks/TaskCommentThreadItem.vue new file mode 100644 index 0000000..3859151 --- /dev/null +++ b/resources/js/components/tasks/TaskCommentThreadItem.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/resources/js/components/tasks/TaskEditDialog.vue b/resources/js/components/tasks/TaskEditDialog.vue index a442633..5e1426a 100644 --- a/resources/js/components/tasks/TaskEditDialog.vue +++ b/resources/js/components/tasks/TaskEditDialog.vue @@ -1,8 +1,8 @@ @@ -292,72 +346,24 @@ watch([() => props.task, isOpen], ([task, open]) => { No comments yet. Start the discussion. -
-
- - - {{ getInitials(comment.user.name) }} - - -
-
-

- {{ comment.user.name }} -

- - {{ formatDate(comment.created_at) }} - -
-
-
- -
-
+ :comment="comment" + :active-reply-to-id="activeReplyToId" + :reply-body="replyForm.body" + :reply-error="replyForm.errors.body" + :posting-reply="replyForm.processing" + :format-date="formatDate" + :get-initials="getInitials" + @start-reply="startReply" + @cancel-reply="cancelReply" + @update-reply-body="replyForm.body = $event" + @submit-reply="submitReply" + /> - - diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index bb0c611..4a8b04d 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -91,11 +91,13 @@ export interface TeamMember { export interface TaskComment { id: number; body: string; + parent_id?: number | null; created_at: string; user: { id: number; name: string; }; + replies?: TaskComment[]; } export interface Team { diff --git a/tests/Feature/TaskCommentTest.php b/tests/Feature/TaskCommentTest.php index 5f8560c..51f8026 100644 --- a/tests/Feature/TaskCommentTest.php +++ b/tests/Feature/TaskCommentTest.php @@ -2,8 +2,10 @@ use App\Models\Column; use App\Models\Task; +use App\Models\TaskComment; use App\Models\Team; use App\Models\User; +use Inertia\Testing\AssertableInertia as Assert; beforeEach(function () { $this->team = Team::factory()->create(); @@ -45,4 +47,85 @@ ]) ->assertStatus(403); }); + + test('stores a reply to an existing task comment', function () { + $task = Task::factory()->create([ + 'team_id' => $this->team->id, + 'column_id' => $this->column->id, + ]); + + $parent = TaskComment::create([ + 'task_id' => $task->id, + 'user_id' => $this->user->id, + 'body' => 'Initial topic', + ]); + + $this->actingAs($this->user) + ->post(route('tasks.comments.store', $task), [ + 'body' => 'This is a threaded reply.', + 'parent_id' => $parent->id, + ]) + ->assertRedirect(); + + $this->assertDatabaseHas('task_comments', [ + 'task_id' => $task->id, + 'user_id' => $this->user->id, + 'parent_id' => $parent->id, + 'body' => 'This is a threaded reply.', + ]); + }); + + test('rejects replies that target comment from another task', function () { + $task = Task::factory()->create([ + 'team_id' => $this->team->id, + 'column_id' => $this->column->id, + ]); + + $otherTask = Task::factory()->create([ + 'team_id' => $this->team->id, + 'column_id' => $this->column->id, + ]); + + $foreignParent = TaskComment::create([ + 'task_id' => $otherTask->id, + 'user_id' => $this->user->id, + 'body' => 'Other task comment', + ]); + + $this->actingAs($this->user) + ->post(route('tasks.comments.store', $task), [ + 'body' => 'Invalid reply target', + 'parent_id' => $foreignParent->id, + ]) + ->assertSessionHasErrors('parent_id'); + }); + + test('includes threaded replies in tasks page payload', function () { + $task = Task::factory()->create([ + 'team_id' => $this->team->id, + 'column_id' => $this->column->id, + ]); + + $parent = TaskComment::create([ + 'task_id' => $task->id, + 'user_id' => $this->user->id, + 'body' => 'Top level comment', + ]); + + TaskComment::create([ + 'task_id' => $task->id, + 'user_id' => $this->user->id, + 'parent_id' => $parent->id, + 'body' => 'Nested reply', + ]); + + $this->actingAs($this->user) + ->get(route('tasks.index')) + ->assertInertia( + fn(Assert $page) => $page + ->component('Tasks') + ->where('columns.0.tasks.0.comments.0.body', 'Top level comment') + ->where('columns.0.tasks.0.comments.0.replies.0.body', 'Nested reply') + ); + }); }); diff --git a/vite.config.ts b/vite.config.ts index 2fe486f..5b0eda5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,7 +10,6 @@ export default defineConfig({ plugins: [ VueDevTools({ appendTo: /resources\/js\/app\.ts$/, - launchEditor: 'code', }), laravel({ input: ['resources/css/app.css', 'resources/js/app.ts'], From 845e43bd5c369046de52447106ffbf9fd8e01d81 Mon Sep 17 00:00:00 2001 From: Mateo Date: Mon, 13 Apr 2026 22:05:55 +0200 Subject: [PATCH 2/2] fix: refactor request handling in controllers & add validation requests, and task sequences --- app/Http/Controllers/ColumnController.php | 22 +++---- .../Settings/PasswordController.php | 10 +--- .../Settings/ProfileController.php | 7 +-- app/Http/Controllers/TaskController.php | 13 +---- .../Controllers/TaskSequenceController.php | 13 ++--- app/Http/Requests/ColumnStoreRequest.php | 20 +++++++ app/Http/Requests/ColumnUpdateRequest.php | 20 +++++++ .../Settings/PasswordUpdateRequest.php | 22 +++++++ .../Settings/ProfileDestroyRequest.php | 20 +++++++ app/Http/Requests/TaskCommentStoreRequest.php | 30 ++++++++++ .../Requests/TaskSequenceUpdateRequest.php | 21 +++++++ .../tasks/TaskCommentThreadItem.vue | 58 +++++++++++++++++-- 12 files changed, 211 insertions(+), 45 deletions(-) create mode 100644 app/Http/Requests/ColumnStoreRequest.php create mode 100644 app/Http/Requests/ColumnUpdateRequest.php create mode 100644 app/Http/Requests/Settings/PasswordUpdateRequest.php create mode 100644 app/Http/Requests/Settings/ProfileDestroyRequest.php create mode 100644 app/Http/Requests/TaskCommentStoreRequest.php create mode 100644 app/Http/Requests/TaskSequenceUpdateRequest.php diff --git a/app/Http/Controllers/ColumnController.php b/app/Http/Controllers/ColumnController.php index 231bee4..14bbd56 100644 --- a/app/Http/Controllers/ColumnController.php +++ b/app/Http/Controllers/ColumnController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers; +use App\Http\Requests\ColumnStoreRequest; +use App\Http\Requests\ColumnUpdateRequest; use App\Models\Column; use App\Services\ColumnService; use Illuminate\Http\Request; @@ -9,30 +11,24 @@ class ColumnController extends Controller { - public function __construct(private ColumnService $columnService) {} - - public function store(Request $request): RedirectResponse + public function __construct(private ColumnService $columnService) { - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - ]); + } - $this->columnService->createColumn($validated, $request->user()); + public function store(ColumnStoreRequest $request): RedirectResponse + { + $this->columnService->createColumn($request->validated(), $request->user()); return back(); } - public function update(Request $request, Column $column): RedirectResponse + public function update(ColumnUpdateRequest $request, Column $column): RedirectResponse { if ($column->team_id !== $request->user()->team_id) { abort(403); } - $validated = $request->validate([ - 'name' => ['required', 'string', 'max:255'], - ]); - - $this->columnService->updateColumn($column, $validated); + $this->columnService->updateColumn($column, $request->validated()); return back(); } diff --git a/app/Http/Controllers/Settings/PasswordController.php b/app/Http/Controllers/Settings/PasswordController.php index 432a71d..79d0c95 100644 --- a/app/Http/Controllers/Settings/PasswordController.php +++ b/app/Http/Controllers/Settings/PasswordController.php @@ -3,9 +3,8 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; +use App\Http\Requests\Settings\PasswordUpdateRequest; use Illuminate\Http\RedirectResponse; -use Illuminate\Http\Request; -use Illuminate\Validation\Rules\Password; use Inertia\Inertia; use Inertia\Response; @@ -22,12 +21,9 @@ public function edit(): Response /** * Update the user's password. */ - public function update(Request $request): RedirectResponse + public function update(PasswordUpdateRequest $request): RedirectResponse { - $validated = $request->validate([ - 'current_password' => ['required', 'current_password'], - 'password' => ['required', Password::defaults(), 'confirmed'], - ]); + $validated = $request->validated(); $request->user()->update([ 'password' => $validated['password'], diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 10f3d22..b9daa17 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Settings; use App\Http\Controllers\Controller; +use App\Http\Requests\Settings\ProfileDestroyRequest; use App\Http\Requests\Settings\ProfileUpdateRequest; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Http\RedirectResponse; @@ -43,11 +44,9 @@ public function update(ProfileUpdateRequest $request): RedirectResponse /** * Delete the user's profile. */ - public function destroy(Request $request): RedirectResponse + public function destroy(ProfileDestroyRequest $request): RedirectResponse { - $request->validate([ - 'password' => ['required', 'current_password'], - ]); + $request->validated(); $user = $request->user(); diff --git a/app/Http/Controllers/TaskController.php b/app/Http/Controllers/TaskController.php index da2ad5b..eaab7c6 100644 --- a/app/Http/Controllers/TaskController.php +++ b/app/Http/Controllers/TaskController.php @@ -12,9 +12,9 @@ use Illuminate\Http\Request; use App\Http\Controllers\Controller; use App\Http\Requests\TaskCreateRequest; +use App\Http\Requests\TaskCommentStoreRequest; use Illuminate\Http\RedirectResponse; use App\Http\Requests\TaskUpdateRequest; -use Illuminate\Validation\Rule; class TaskController extends Controller { @@ -85,20 +85,13 @@ public function destroy(Request $request, Task $task): RedirectResponse return back(); } - public function storeComment(Request $request, Task $task): RedirectResponse + public function storeComment(TaskCommentStoreRequest $request, Task $task): RedirectResponse { if ($task->team_id !== $request->user()->team_id) { abort(403, 'You are not authorized to comment on this task.'); } - $validated = $request->validate([ - 'body' => ['required', 'string', 'max:2000'], - 'parent_id' => [ - 'nullable', - 'integer', - Rule::exists('task_comments', 'id')->where('task_id', $task->id), - ], - ]); + $validated = $request->validated(); $task->comments()->create([ 'user_id' => $request->user()->id, diff --git a/app/Http/Controllers/TaskSequenceController.php b/app/Http/Controllers/TaskSequenceController.php index a2ab4a7..ac2aeee 100644 --- a/app/Http/Controllers/TaskSequenceController.php +++ b/app/Http/Controllers/TaskSequenceController.php @@ -2,25 +2,24 @@ namespace App\Http\Controllers; +use App\Http\Requests\TaskSequenceUpdateRequest; use App\Models\Task; use App\Services\TaskService; -use Illuminate\Http\Request; use Illuminate\Http\RedirectResponse; class TaskSequenceController extends Controller { - public function __construct(private TaskService $taskService) {} + public function __construct(private TaskService $taskService) + { + } - public function update(Request $request, Task $task): RedirectResponse + public function update(TaskSequenceUpdateRequest $request, Task $task): RedirectResponse { if ($task->team_id !== $request->user()->team_id) { abort(403); } - $validated = $request->validate([ - 'column_id' => ['required', 'exists:columns,id'], - 'order' => ['required', 'integer', 'min:0'], - ]); + $validated = $request->validated(); $this->taskService->updateSequence($task, $validated['column_id'], $validated['order']); diff --git a/app/Http/Requests/ColumnStoreRequest.php b/app/Http/Requests/ColumnStoreRequest.php new file mode 100644 index 0000000..7d12356 --- /dev/null +++ b/app/Http/Requests/ColumnStoreRequest.php @@ -0,0 +1,20 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/ColumnUpdateRequest.php b/app/Http/Requests/ColumnUpdateRequest.php new file mode 100644 index 0000000..3ad07ed --- /dev/null +++ b/app/Http/Requests/ColumnUpdateRequest.php @@ -0,0 +1,20 @@ +|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/Settings/PasswordUpdateRequest.php b/app/Http/Requests/Settings/PasswordUpdateRequest.php new file mode 100644 index 0000000..05fcc07 --- /dev/null +++ b/app/Http/Requests/Settings/PasswordUpdateRequest.php @@ -0,0 +1,22 @@ +|string> + */ + public function rules(): array + { + return [ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', Password::defaults(), 'confirmed'], + ]; + } +} diff --git a/app/Http/Requests/Settings/ProfileDestroyRequest.php b/app/Http/Requests/Settings/ProfileDestroyRequest.php new file mode 100644 index 0000000..6cddae2 --- /dev/null +++ b/app/Http/Requests/Settings/ProfileDestroyRequest.php @@ -0,0 +1,20 @@ +|string> + */ + public function rules(): array + { + return [ + 'password' => ['required', 'current_password'], + ]; + } +} diff --git a/app/Http/Requests/TaskCommentStoreRequest.php b/app/Http/Requests/TaskCommentStoreRequest.php new file mode 100644 index 0000000..4b83282 --- /dev/null +++ b/app/Http/Requests/TaskCommentStoreRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + $task = $this->route('task'); + $taskId = $task instanceof Task ? $task->id : $task; + + return [ + 'body' => ['required', 'string', 'max:2000'], + 'parent_id' => [ + 'nullable', + 'integer', + Rule::exists('task_comments', 'id')->where('task_id', $taskId), + ], + ]; + } +} diff --git a/app/Http/Requests/TaskSequenceUpdateRequest.php b/app/Http/Requests/TaskSequenceUpdateRequest.php new file mode 100644 index 0000000..dade0c2 --- /dev/null +++ b/app/Http/Requests/TaskSequenceUpdateRequest.php @@ -0,0 +1,21 @@ +|string> + */ + public function rules(): array + { + return [ + 'column_id' => ['required', 'exists:columns,id'], + 'order' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/resources/js/components/tasks/TaskCommentThreadItem.vue b/resources/js/components/tasks/TaskCommentThreadItem.vue index 3859151..c7d1287 100644 --- a/resources/js/components/tasks/TaskCommentThreadItem.vue +++ b/resources/js/components/tasks/TaskCommentThreadItem.vue @@ -4,6 +4,7 @@ import TaskRichTextEditor from '@/components/tasks/TaskRichTextEditor.vue'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import type { TaskComment } from '@/types'; +import { computed, ref } from 'vue'; defineOptions({ name: 'TaskCommentThreadItem', @@ -34,6 +35,24 @@ const emit = defineEmits<{ }>(); const isReplying = () => props.activeReplyToId === props.comment.id; +const repliesExpanded = ref(false); +const allReplies = computed(() => props.comment.replies ?? []); +const canToggleReplies = computed(() => props.depth === 0); +const visibleReplies = computed(() => { + if (!canToggleReplies.value) { + return allReplies.value; + } + + return repliesExpanded.value ? allReplies.value : []; +}); + +const showMoreReplies = () => { + repliesExpanded.value = true; +}; + +const showLessReplies = () => { + repliesExpanded.value = false; +};