diff --git a/app/Http/Controllers/Api/V1/TaskController.php b/app/Http/Controllers/Api/V1/TaskController.php index a83ac994..10106cf2 100644 --- a/app/Http/Controllers/Api/V1/TaskController.php +++ b/app/Http/Controllers/Api/V1/TaskController.php @@ -15,6 +15,7 @@ use App\Models\Task; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Carbon; class TaskController extends Controller { @@ -53,6 +54,12 @@ class TaskController extends Controller if (! $canViewAllTasks) { $query->visibleByEmployee($user); } + $doneFilter = $request->getFilterDone(); + if ($doneFilter === 'true') { + $query->whereNotNull('done_at'); + } elseif ($doneFilter === 'false') { + $query->whereNull('done_at'); + } $tasks = $query->paginate(config('app.pagination_per_page_default')); @@ -89,6 +96,9 @@ class TaskController extends Controller { $this->checkPermission($organization, 'tasks:update', $task); $task->name = $request->input('name'); + if ($request->has('is_done')) { + $task->done_at = $request->getIsDone() ? Carbon::now() : null; + } $task->save(); return new TaskResource($task); diff --git a/app/Http/Requests/V1/Task/TaskIndexRequest.php b/app/Http/Requests/V1/Task/TaskIndexRequest.php index 1da3d4b6..1b2afec4 100644 --- a/app/Http/Requests/V1/Task/TaskIndexRequest.php +++ b/app/Http/Requests/V1/Task/TaskIndexRequest.php @@ -39,6 +39,15 @@ class TaskIndexRequest extends FormRequest return $builder; }), ], + 'done' => [ + 'string', + 'in:true,false,all', + ], ]; } + + public function getFilterDone(): string + { + return $this->input('done', 'false'); + } } diff --git a/app/Http/Requests/V1/Task/TaskUpdateRequest.php b/app/Http/Requests/V1/Task/TaskUpdateRequest.php index 3bc918c5..9f79e395 100644 --- a/app/Http/Requests/V1/Task/TaskUpdateRequest.php +++ b/app/Http/Requests/V1/Task/TaskUpdateRequest.php @@ -35,6 +35,16 @@ class TaskUpdateRequest extends FormRequest return $builder->where('project_id', '=', $this->task->project_id); }))->ignore($this->task->getKey())->withCustomTranslation('validation.task_name_already_exists'), ], + 'is_done' => [ + 'boolean', + ], ]; } + + public function getIsDone(): bool + { + assert($this->has('is_done')); + + return $this->boolean('is_done'); + } } diff --git a/app/Http/Resources/V1/Task/TaskResource.php b/app/Http/Resources/V1/Task/TaskResource.php index 2a0ef0a2..c360a7e1 100644 --- a/app/Http/Resources/V1/Task/TaskResource.php +++ b/app/Http/Resources/V1/Task/TaskResource.php @@ -26,6 +26,8 @@ class TaskResource extends BaseResource 'id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, + /** @var bool $is_done Whether the task is done */ + 'is_done' => $this->resource->is_done, /** @var string $project_id ID of the project */ 'project_id' => $this->resource->project_id, /** @var string $created_at When the tag was created */ diff --git a/app/Models/Task.php b/app/Models/Task.php index c1cb1962..a322f117 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -7,6 +7,7 @@ namespace App\Models; use App\Models\Concerns\HasUuids; use Database\Factories\TaskFactory; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -19,11 +20,13 @@ use Illuminate\Support\Carbon; * @property string $name * @property string $project_id * @property string $organization_id + * @property Carbon|null $done_at * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property-read Project $project * @property-read Organization $organization * @property-read Collection $timeEntries + * @property-read bool $is_done * * @method static TaskFactory factory() */ @@ -76,4 +79,14 @@ class Task extends Model return $builder->visibleByEmployee($user); }); } + + /** + * @return Attribute + */ + public function isDone(): Attribute + { + return Attribute::make( + get: fn (mixed $value, array $attributes) => isset($attributes['done_at']), + ); + } } diff --git a/database/factories/TaskFactory.php b/database/factories/TaskFactory.php index 0921e9c4..b21e2f3c 100644 --- a/database/factories/TaskFactory.php +++ b/database/factories/TaskFactory.php @@ -25,6 +25,7 @@ class TaskFactory extends Factory 'name' => $this->faker->word(), 'project_id' => Project::factory(), 'organization_id' => Organization::factory(), + 'done_at' => null, ]; } @@ -37,6 +38,15 @@ class TaskFactory extends Factory }); } + public function isDone(): self + { + return $this->state(function (array $attributes) { + return [ + 'done_at' => $this->faker->dateTime('now', 'UTC'), + ]; + }); + } + public function forOrganization(Organization $organization): self { return $this->state(function (array $attributes) use ($organization) { diff --git a/database/migrations/2024_06_24_114433_add_done_at_to_tasks_table.php b/database/migrations/2024_06_24_114433_add_done_at_to_tasks_table.php new file mode 100644 index 00000000..3ed6849c --- /dev/null +++ b/database/migrations/2024_06_24_114433_add_done_at_to_tasks_table.php @@ -0,0 +1,30 @@ +dateTime('done_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tasks', function (Blueprint $table): void { + $table->dropColumn('done_at'); + }); + } +}; diff --git a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php index f26f0273..a8076080 100644 --- a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php @@ -9,6 +9,7 @@ use App\Models\Project; use App\Models\ProjectMember; use App\Models\Task; use App\Models\TimeEntry; +use Illuminate\Support\Carbon; use Laravel\Passport\Passport; use PHPUnit\Framework\Attributes\UsesClass; @@ -76,6 +77,85 @@ class TaskEndpointTest extends ApiEndpointTestAbstract $response->assertJsonCount(4, 'data'); } + public function test_index_endpoint_without_filter_done_returns_list_of_all_tasks_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:view', + 'tasks:view:all', + ]); + $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2); + $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(2, 'data'); + $this->assertEqualsCanonicalizing($notDoneTasks->pluck('id')->toArray(), $response->json('data.*.id')); + } + + public function test_index_endpoint_with_filter_done_true_returns_list_of_all_done_tasks_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:view', + 'tasks:view:all', + ]); + $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2); + $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'true'])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(2, 'data'); + $this->assertEqualsCanonicalizing($doneTasks->pluck('id')->toArray(), $response->json('data.*.id')); + } + + public function test_index_endpoint_with_filter_done_false_returns_list_of_all_not_done_tasks_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:view', + 'tasks:view:all', + ]); + $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2); + $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'false'])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(2, 'data'); + $this->assertEqualsCanonicalizing($notDoneTasks->pluck('id')->toArray(), $response->json('data.*.id')); + } + + public function test_index_endpoint_with_filter_done_all_returns_list_of_all_tasks_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:view', + 'tasks:view:all', + ]); + $notDoneTasks = Task::factory()->forOrganization($data->organization)->createMany(2); + $doneTasks = Task::factory()->forOrganization($data->organization)->isDone()->createMany(2); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.tasks.index', [$data->organization->getKey(), 'done' => 'all'])); + + // Assert + $response->assertStatus(200); + $response->assertJsonCount(4, 'data'); + } + public function test_index_endpoint_returns_list_of_all_tasks_with_access_of_organization_if_user_has_no_all_permission(): void { // Arrange @@ -395,6 +475,54 @@ class TaskEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_endpoint_can_set_task_to_done(): void + { + // Arrange + $now = Carbon::now(); + $this->travelTo($now); + $data = $this->createUserWithPermission([ + 'tasks:update', + ]); + $task = Task::factory()->forOrganization($data->organization)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [ + 'name' => $task->name, + 'is_done' => true, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Task::class, [ + 'id' => $task->getKey(), + 'done_at' => $now->toDateTimeString(), + ]); + } + + public function test_update_endpoint_can_set_task_to_not_done(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:update', + ]); + $task = Task::factory()->forOrganization($data->organization)->isDone()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.tasks.update', [$data->organization->getKey(), $task->getKey()]), [ + 'name' => $task->name, + 'is_done' => false, + ]); + + // Assert + $response->assertStatus(200); + $this->assertDatabaseHas(Task::class, [ + 'id' => $task->getKey(), + 'done_at' => null, + ]); + } + public function test_delete_endpoint_deletes_tasks_if_user_has_permission(): void { // Arrange diff --git a/tests/Unit/Model/TaskModelTest.php b/tests/Unit/Model/TaskModelTest.php index e86b8027..144f3718 100644 --- a/tests/Unit/Model/TaskModelTest.php +++ b/tests/Unit/Model/TaskModelTest.php @@ -90,4 +90,28 @@ class TaskModelTest extends ModelTestAbstract $taskPrivateButMember->getKey(), ], $allTasks); } + + public function test_accessor_is_done_is_true_if_done_at_is_not_null(): void + { + // Arrange + $task = Task::factory()->isDone()->create(); + + // Act + $task->refresh(); + + // Assert + $this->assertTrue($task->is_done); + } + + public function test_accessor_is_done_is_false_if_done_at_is_null(): void + { + // Arrange + $task = Task::factory()->create(); + + // Act + $task->refresh(); + + // Assert + $this->assertFalse($task->is_done); + } }