Merge branch 'home-page-create-plan-modal'

This commit is contained in:
Yisroel Baum 2026-04-24 11:30:29 +03:00
commit ceb956739b
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
12 changed files with 794 additions and 1 deletions

View file

@ -0,0 +1,93 @@
<?php
namespace App\Plan;
use App\User\User;
use App\User\UserRepository;
use App\ValueObjects\EmailAddress;
class JsonPlanRepository implements PlanRepository
{
private string $filePath;
public function __construct(
private UserRepository $userRepository,
) {
$this->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;
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Plan;
use App\Exceptions\BadRequestException;
use App\Plan\UseCases\CreatePlan;
use App\Plan\UseCases\CreatePlanRequest;
use DomainException;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
class PlanController
{
public function createPlan(
Request $request,
Response $response,
CreatePlan $createPlanUseCase,
): Response {
$data = json_decode((string) $request->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');
}
}

View file

@ -0,0 +1,67 @@
<?php
namespace App\ScheduledNode;
class JsonScheduledNodeRepository implements ScheduledNodeRepository
{
private string $filePath;
public function __construct()
{
$this->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;
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\User;
use App\User\UseCases\CreateUserDto;
use App\ValueObjects\EmailAddress;
class JsonUserRepository implements UserRepository
{
private string $filePath;
public function __construct()
{
$this->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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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)
})
})

View file

@ -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) {

View file

@ -3,6 +3,9 @@
$files = [
'texts.json',
'nodes.json',
'users.json',
'plans.json',
'scheduledNodes.json',
];
foreach ($files as $file) {

View file

@ -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 => '<li>' + text.name + '</li>')
.map(text =>
'<li>' + text.name +
' <button class="create-plan" data-text-id="' +
text.id + '">Create plan</button></li>'
)
.join('');
}
const cancelPlanButton = createPlanModal.querySelector(
'button.cancel-plan'
);
const savePlanButton = createPlanModal.querySelector(
'button.save-plan'
);
const planNameInput = createPlanModal.querySelector('input.plan-name');
const planDateStartInput = createPlanModal.querySelector(
'input.plan-date-start'
);
const planDateEndInput = createPlanModal.querySelector(
'input.plan-date-end'
);
function openCreatePlanModal(textId) {
createPlanModal.dataset.textId = textId;
createPlanModal.hidden = false;
}
function closeCreatePlanModal() {
createPlanModal.hidden = true;
planNameInput.value = '';
planDateStartInput.value = '';
planDateEndInput.value = '';
}
textsList.addEventListener('click', (clickEvent) => {
const createPlanButton = clickEvent.target.closest(
'button.create-plan'
);
if (createPlanButton === null) {
return;
}
openCreatePlanModal(createPlanButton.dataset.textId);
});
cancelPlanButton.addEventListener('click', () => {
closeCreatePlanModal();
});
savePlanButton.addEventListener('click', async () => {
const planName = planNameInput.value;
const dateStart = planDateStartInput.value;
const dateEnd = planDateEndInput.value;
const textId = Number(createPlanModal.dataset.textId);
if (planName === '' || dateStart === '' || dateEnd === '') {
return;
}
const response = await fetch('/api/plans', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: 0,
textId: textId,
name: planName,
dateStart: dateStart,
dateEnd: dateEnd,
}),
});
if (response.ok) {
closeCreatePlanModal();
}
});
loadTexts();
});

View file

@ -0,0 +1,280 @@
<?php
namespace Tests\e2e\Controllers;
use App\Node\CreateNodeDto;
use App\Plan\PlanController;
use App\Plan\UseCases\CreatePlan;
use App\ScheduledNode\UseCases\CreateScheduledNode;
use App\Text\CreateTextDto;
use App\User\UseCases\CreateUserDto;
use App\ValueObjects\EmailAddress;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakePlanRepository;
use Tests\Fakes\FakeScheduledNodeRepository;
use Tests\Fakes\FakeTextRepository;
use Tests\Fakes\FakeUserRepository;
class PlanControllerTest extends TestCase
{
private FakePlanRepository $planRepo;
private FakeUserRepository $userRepo;
private FakeTextRepository $textRepo;
private FakeNodeRepository $nodeRepo;
private FakeScheduledNodeRepository $scheduledNodeRepo;
private CreatePlan $createPlan;
private PlanController $controller;
public function setUp(): void
{
$this->planRepo = new FakePlanRepository();
$this->userRepo = new FakeUserRepository();
$this->textRepo = new FakeTextRepository();
$this->nodeRepo = new FakeNodeRepository();
$this->scheduledNodeRepo = new FakeScheduledNodeRepository();
$this->userRepo->create(new CreateUserDto(
email: new EmailAddress('test@test.com'),
));
$text = $this->textRepo->create(new CreateTextDto('testname'));
$this->nodeRepo->create(new CreateNodeDto(
text: $text,
title: 'Root Node',
parentNode: null,
));
$createScheduledNode = new CreateScheduledNode(
scheduledNodeRepo: $this->scheduledNodeRepo,
planRepo: $this->planRepo,
);
$this->createPlan = new CreatePlan(
$this->planRepo,
$this->userRepo,
$this->textRepo,
$this->nodeRepo,
$createScheduledNode,
);
$this->controller = new PlanController();
}
private function makeRequest(array $data): ServerRequestInterface
{
$body = new StreamFactory()->createStream(json_encode($data));
return new ServerRequestFactory()
->createServerRequest('POST', 'http://localhost/api/plans')
->withHeader('Content-Type', 'application/json')
->withBody($body);
}
public function test_create_plan_returns_201_with_id_and_name(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(201, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('id', $body);
$this->assertEquals('My Plan', $body['name']);
}
public function test_create_plan_returns_400_when_user_id_missing(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_400_when_text_id_missing(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_400_when_name_missing(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_400_when_date_start_missing(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_400_when_date_end_missing(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_400_when_date_end_before_start(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-02',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(400, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_404_when_user_not_found(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 99,
'textId' => 0,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(404, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_returns_404_when_text_not_found(): void
{
$response = $this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 99,
'name' => 'My Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(404, $response->getStatusCode());
$body = json_decode($response->getBody(), true);
$this->assertArrayHasKey('error', $body);
}
public function test_create_plan_persists_plan_in_repository(): void
{
$this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'name' => 'Persistent Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$storedPlan = $this->planRepo->find(0);
$this->assertNotNull($storedPlan);
$this->assertEquals('Persistent Plan', $storedPlan->getName());
}
public function test_create_plan_schedules_nodes(): void
{
$this->controller->createPlan(
$this->makeRequest([
'userId' => 0,
'textId' => 0,
'name' => 'Scheduling Plan',
'dateStart' => '2025-01-01',
'dateEnd' => '2025-01-01',
]),
new Response(),
$this->createPlan,
);
$this->assertEquals(
1,
$this->scheduledNodeRepo->getNumberOfTimesCreateCalled()
);
$this->assertNotNull($this->scheduledNodeRepo->find(0));
}
}

View file

@ -7,6 +7,20 @@
<h1>Home</h1>
<ul id="texts-list">
</ul>
<div id="create-plan-modal" hidden>
<h2>Create plan</h2>
<label>Name
<input class="plan-name" type="text" />
</label>
<label>Start date
<input class="plan-date-start" type="date" />
</label>
<label>End date
<input class="plan-date-end" type="date" />
</label>
<button class="save-plan">Save</button>
<button class="cancel-plan">Cancel</button>
</div>
<script src="/js/home.js"></script>
</body>
</html>