diff --git a/app/Plan/JsonPlanRepository.php b/app/Plan/JsonPlanRepository.php new file mode 100644 index 0000000..8a88838 --- /dev/null +++ b/app/Plan/JsonPlanRepository.php @@ -0,0 +1,93 @@ +filePath = __DIR__ . '/../../data/plans.json'; + } + + public function create(CreatePlanDto $dto): Plan + { + $plans = $this->readPlans(); + $id = $this->getNextId($plans); + + $plans[] = [ + 'id' => $id, + 'name' => $dto->name, + 'userId' => $dto->user->getId(), + ]; + $this->writePlans($plans); + + return new Plan( + id: $id, + name: $dto->name, + user: $dto->user, + ); + } + + public function find(int $id): ?Plan + { + $plans = $this->readPlans(); + + foreach ($plans as $data) { + if ($data['id'] === $id) { + $user = $this->userRepository->find($data['userId']); + if ($user === null) { + return null; + } + return new Plan( + id: $data['id'], + name: $data['name'], + user: $user, + ); + } + } + + return null; + } + + private function readPlans(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writePlans(array $plans): void + { + file_put_contents( + $this->filePath, + json_encode($plans, JSON_PRETTY_PRINT) + ); + } + + private function getNextId(array $plans): int + { + if (empty($plans)) { + return 0; + } + + $maxId = -1; + foreach ($plans as $plan) { + if ($plan['id'] > $maxId) { + $maxId = $plan['id']; + } + } + + return $maxId + 1; + } +} diff --git a/app/Plan/PlanController.php b/app/Plan/PlanController.php new file mode 100644 index 0000000..cb88de8 --- /dev/null +++ b/app/Plan/PlanController.php @@ -0,0 +1,56 @@ +getBody(), true) ?? []; + + $userId = isset($data['userId']) ? (int) $data['userId'] : null; + $textId = isset($data['textId']) ? (int) $data['textId'] : null; + $name = $data['name'] ?? null; + $dateStart = $data['dateStart'] ?? null; + $dateEnd = $data['dateEnd'] ?? null; + + try { + $plan = $createPlanUseCase->execute(new CreatePlanRequest( + userId: $userId, + textId: $textId, + name: $name, + dateStart: $dateStart, + dateEnd: $dateEnd, + )); + } catch (BadRequestException $exception) { + $response->getBody()->write( + json_encode(['error' => $exception->getMessage()]) + ); + return $response->withStatus(400) + ->withHeader('Content-Type', 'application/json'); + } catch (DomainException $exception) { + $response->getBody()->write( + json_encode(['error' => $exception->getMessage()]) + ); + return $response->withStatus(404) + ->withHeader('Content-Type', 'application/json'); + } + + $response->getBody()->write(json_encode([ + 'id' => $plan->getId(), + 'name' => $plan->getName(), + ])); + return $response->withStatus(201) + ->withHeader('Content-Type', 'application/json'); + } +} diff --git a/app/ScheduledNode/JsonScheduledNodeRepository.php b/app/ScheduledNode/JsonScheduledNodeRepository.php new file mode 100644 index 0000000..bee31ea --- /dev/null +++ b/app/ScheduledNode/JsonScheduledNodeRepository.php @@ -0,0 +1,67 @@ +filePath = __DIR__ . '/../../data/scheduledNodes.json'; + } + + public function create(CreateScheduledNodeDto $dto): ScheduledNode + { + $scheduledNodes = $this->readScheduledNodes(); + $id = $this->getNextId($scheduledNodes); + + $scheduledNodes[] = [ + 'id' => $id, + 'date' => $dto->date->format('Y-m-d'), + 'planId' => $dto->plan->getId(), + ]; + $this->writeScheduledNodes($scheduledNodes); + + return new ScheduledNode( + id: $id, + date: $dto->date, + plan: $dto->plan, + ); + } + + private function readScheduledNodes(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writeScheduledNodes(array $scheduledNodes): void + { + file_put_contents( + $this->filePath, + json_encode($scheduledNodes, JSON_PRETTY_PRINT) + ); + } + + private function getNextId(array $scheduledNodes): int + { + if (empty($scheduledNodes)) { + return 0; + } + + $maxId = -1; + foreach ($scheduledNodes as $scheduledNode) { + if ($scheduledNode['id'] > $maxId) { + $maxId = $scheduledNode['id']; + } + } + + return $maxId + 1; + } +} diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php new file mode 100644 index 0000000..bde8c62 --- /dev/null +++ b/app/User/JsonUserRepository.php @@ -0,0 +1,84 @@ +filePath = __DIR__ . '/../../data/users.json'; + } + + public function create(CreateUserDto $dto): User + { + $users = $this->readUsers(); + $id = $this->getNextId($users); + + $users[] = [ + 'id' => $id, + 'email' => (string) $dto->email, + ]; + $this->writeUsers($users); + + return new User( + id: $id, + email: $dto->email, + ); + } + + public function find(int $id): ?User + { + $users = $this->readUsers(); + + foreach ($users as $data) { + if ($data['id'] === $id) { + return new User( + id: $data['id'], + email: new EmailAddress($data['email']), + ); + } + } + + return null; + } + + private function readUsers(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writeUsers(array $users): void + { + file_put_contents( + $this->filePath, + json_encode($users, JSON_PRETTY_PRINT) + ); + } + + private function getNextId(array $users): int + { + if (empty($users)) { + return 0; + } + + $maxId = -1; + foreach ($users as $user) { + if ($user['id'] > $maxId) { + $maxId = $user['id']; + } + } + + return $maxId + 1; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index db0dd63..1ceeb8e 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -6,6 +6,7 @@ use DI\Bridge\Slim\Bridge; use App\View\ViewController; use App\Text\TextController; use App\Node\NodeController; +use App\Plan\PlanController; $container = require __DIR__ . '/container.php'; $app = Bridge::create($container); @@ -26,4 +27,6 @@ $app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); $app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); $app->post('/api/nodes', [NodeController::class, 'createNode']); +$app->post('/api/plans', [PlanController::class, 'createPlan']); + return $app; diff --git a/bootstrap/container.php b/bootstrap/container.php index efcfde5..fb80996 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -6,10 +6,20 @@ use App\Text\TextRepository; use App\Text\JsonTextRepository; use App\Node\NodeRepository; use App\Node\JsonNodeRepository; +use App\Plan\PlanRepository; +use App\Plan\JsonPlanRepository; +use App\User\UserRepository; +use App\User\JsonUserRepository; +use App\ScheduledNode\ScheduledNodeRepository; +use App\ScheduledNode\JsonScheduledNodeRepository; $container = new Container([ TextRepository::class => DI\autowire(JsonTextRepository::class), NodeRepository::class => DI\autowire(JsonNodeRepository::class), + PlanRepository::class => DI\autowire(JsonPlanRepository::class), + UserRepository::class => DI\autowire(JsonUserRepository::class), + ScheduledNodeRepository::class => + DI\autowire(JsonScheduledNodeRepository::class), ]); return $container; diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js new file mode 100644 index 0000000..3c9ee87 --- /dev/null +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -0,0 +1,98 @@ +describe('Create plan modal on the home page', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + cy.intercept('GET', '/api/texts').as('getTexts') + cy.visit('/home') + cy.wait('@getTexts') + }) + + afterEach(() => { + cy.exec('npm run db:wipe') + }) + + it('shows a "Create plan" button on each text', () => { + cy.get('#texts-list li').each((textItem) => { + cy.wrap(textItem).find('button.create-plan').should('exist') + }) + }) + + it('hides the create plan modal by default', () => { + cy.get('#create-plan-modal').should('not.be.visible') + }) + + it('shows the modal when clicking "Create plan"', () => { + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal').should('be.visible') + }) + + it('modal contains name, date start, date end, save, cancel', () => { + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-name').should('be.visible') + cy.get('#create-plan-modal input.plan-date-start') + .should('be.visible') + cy.get('#create-plan-modal input.plan-date-end') + .should('be.visible') + cy.get('#create-plan-modal button.save-plan').should('be.visible') + cy.get('#create-plan-modal button.cancel-plan').should('be.visible') + }) + + it('hides the modal when clicking "Cancel"', () => { + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal').should('be.visible') + cy.get('#create-plan-modal button.cancel-plan').click() + cy.get('#create-plan-modal').should('not.be.visible') + }) + + it('submits plan details to /api/plans', () => { + cy.intercept('POST', '/api/plans').as('createPlan') + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-name') + .type('My reading plan') + cy.get('#create-plan-modal input.plan-date-start') + .type('2025-01-01') + cy.get('#create-plan-modal input.plan-date-end') + .type('2025-01-31') + cy.get('#create-plan-modal button.save-plan').click() + cy.wait('@createPlan').then((createPlanRequest) => { + expect(createPlanRequest.response.statusCode).to.eq(201) + expect(createPlanRequest.request.body).to.deep.equal({ + userId: 0, + textId: 0, + name: 'My reading plan', + dateStart: '2025-01-01', + dateEnd: '2025-01-31', + }) + }) + }) + + it('closes the modal after successful submit', () => { + cy.intercept('POST', '/api/plans').as('createPlan') + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-name') + .type('Another plan') + cy.get('#create-plan-modal input.plan-date-start') + .type('2025-02-01') + cy.get('#create-plan-modal input.plan-date-end') + .type('2025-02-28') + cy.get('#create-plan-modal button.save-plan').click() + cy.wait('@createPlan') + cy.get('#create-plan-modal').should('not.be.visible') + }) + + it('does not submit if name is empty', () => { + cy.intercept('POST', '/api/plans').as('createPlan') + cy.get('#texts-list li').first() + .find('button.create-plan').click() + cy.get('#create-plan-modal input.plan-date-start') + .type('2025-01-01') + cy.get('#create-plan-modal input.plan-date-end') + .type('2025-01-31') + cy.get('#create-plan-modal button.save-plan').click() + cy.get('@createPlan.all').should('have.length', 0) + }) +}) diff --git a/data/seedDb.php b/data/seedDb.php index 3206372..922ca90 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -28,9 +28,22 @@ $nodes = [ ], ]; +$users = [ + [ + 'id' => 0, + 'email' => 'user@example.com', + ], +]; + +$plans = []; +$scheduledNodes = []; + $fileDataMap = [ 'texts.json' => $texts, 'nodes.json' => $nodes, + 'users.json' => $users, + 'plans.json' => $plans, + 'scheduledNodes.json' => $scheduledNodes, ]; foreach ($fileDataMap as $file => $data) { diff --git a/data/wipeDb.php b/data/wipeDb.php index 4183d5b..658bb2f 100644 --- a/data/wipeDb.php +++ b/data/wipeDb.php @@ -3,6 +3,9 @@ $files = [ 'texts.json', 'nodes.json', + 'users.json', + 'plans.json', + 'scheduledNodes.json', ]; foreach ($files as $file) { diff --git a/public/js/home.js b/public/js/home.js index 3e7cde8..b46a051 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -1,13 +1,85 @@ document.addEventListener('DOMContentLoaded', () => { const textsList = document.getElementById('texts-list'); + const createPlanModal = document.getElementById('create-plan-modal'); async function loadTexts() { const response = await fetch('/api/texts'); const texts = await response.json(); textsList.innerHTML = texts - .map(text => '