Merge branch 'individual-text-page'

This commit is contained in:
Yisroel Baum 2026-04-17 17:53:05 +03:00
commit adc72961d0
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
15 changed files with 453 additions and 12 deletions

View file

@ -0,0 +1,129 @@
<?php
namespace App\Node;
use App\Node\Node;
use App\Node\CreateNodeDto;
use App\Node\NodeRepository;
use App\Text\TextRepository;
class JsonNodeRepository implements NodeRepository
{
private string $filePath;
public function __construct(
private TextRepository $textRepository,
) {
$this->filePath = __DIR__.'/../../data/nodes.json';
}
public function create(CreateNodeDto $dto): Node
{
$nodes = $this->readNodes();
$id = $this->getNextId($nodes);
$nodes[] = [
'id' => $id,
'title' => $dto->title,
'textId' => $dto->text->getId(),
'parentNodeId' => $dto->parentNode?->getId(),
];
$this->writeNodes($nodes);
return new Node(
id: $id,
title: $dto->title,
text: $dto->text,
parentNode: $dto->parentNode,
);
}
public function find(int $id): ?Node
{
$nodes = $this->readNodes();
foreach ($nodes as $data) {
if ($data['id'] === $id) {
return $this->hydrateNode($data, $nodes);
}
}
return null;
}
/**
* @return Node[]
*/
public function findByTextId(int $id): array
{
$nodes = $this->readNodes();
$matching = array_filter(
$nodes,
fn(array $data) => $data['textId'] === $id
);
return array_values(array_map(
fn(array $data) => $this->hydrateNode($data, $nodes),
$matching
));
}
private function hydrateNode(array $data, array $allNodes): Node
{
$text = $this->textRepository->find($data['textId']);
$parentNode = null;
if ($data['parentNodeId'] !== null) {
foreach ($allNodes as $parentData) {
if ($parentData['id'] === $data['parentNodeId']) {
$parentNode = $this->hydrateNode($parentData, $allNodes);
break;
}
}
}
return new Node(
id: $data['id'],
title: $data['title'],
text: $text,
parentNode: $parentNode,
);
}
private function readNodes(): array
{
if (!file_exists($this->filePath)) {
return [];
}
$content = file_get_contents($this->filePath);
return json_decode($content, true) ?? [];
}
private function writeNodes(array $nodes): void
{
file_put_contents(
$this->filePath,
json_encode($nodes, JSON_PRETTY_PRINT)
);
}
private function getNextId(array $nodes): int
{
if (empty($nodes)) {
return 1;
}
$maxId = 0;
foreach ($nodes as $node) {
if ($node['id'] > $maxId) {
$maxId = $node['id'];
}
}
return $maxId + 1;
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Node;
use App\Node\NodeRepository;
use App\Text\TextRepository;
use Psr\Http\Message\ResponseInterface as Response;
class NodeController
{
public function __construct(
private NodeRepository $nodeRepository,
private TextRepository $textRepository,
) {}
public function getNodes(Response $response, int $textId): Response
{
$text = $this->textRepository->find($textId);
if ($text === null) {
return $response->withStatus(404);
}
$nodes = $this->nodeRepository->findByTextId($textId);
$data = array_map(function ($node) {
return [
'id' => $node->getId(),
'title' => $node->getTitle(),
'parentNodeId' => $node->getParentNode()?->getId(),
];
}, $nodes);
$response->getBody()->write(json_encode(array_values($data)));
return $response->withHeader('Content-Type', 'application/json');
}
}

View file

@ -18,15 +18,32 @@ class TextController
{
$texts = $this->textRepository->getAll();
$data = array_map(fn($text) => [
'id' => $text->getId(),
'name' => $text->getName(),
], $texts);
$data = array_map(function ($text) {
return [
'id' => $text->getId(),
'name' => $text->getName(),
];
}, $texts);
$response->getBody()->write(json_encode($data));
return $response->withHeader('Content-Type', 'application/json');
}
public function getText(Response $response, int $textId): Response
{
$text = $this->textRepository->find($textId);
if ($text === null) {
return $response->withStatus(404);
}
$response->getBody()->write(json_encode([
'id' => $text->getId(),
'name' => $text->getName(),
]));
return $response->withHeader('Content-Type', 'application/json');
}
public function createText(
Request $request,
Response $response,

View file

@ -5,17 +5,28 @@ namespace App\Text\UseCases;
use App\Text\Text;
use App\Text\CreateTextDto;
use App\Text\TextRepository;
use App\Node\NodeRepository;
use App\Node\CreateNodeDto;
class CreateText
{
public function __construct(
private TextRepository $textRepo,
private NodeRepository $nodeRepo,
) {}
public function execute(CreateTextRequest $request): Text
{
return $this->textRepo->create(new CreateTextDto(
$text = $this->textRepo->create(new CreateTextDto(
name: $request->name,
));
$this->nodeRepo->create(new CreateNodeDto(
text: $text,
title: $text->getName(),
parentNode: null,
));
return $text;
}
}

View file

@ -21,4 +21,12 @@ class ViewController
return $response;
}
public function text(Response $response): Response
{
$html = file_get_contents(__DIR__.'/../../views/templates/text.php', true);
$response->getBody()->write($html);
return $response;
}
}

View file

@ -5,6 +5,7 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use DI\Bridge\Slim\Bridge;
use App\View\ViewController;
use App\Text\TextController;
use App\Node\NodeController;
$container = require __DIR__.'/container.php';
$app = Bridge::create($container);
@ -14,8 +15,11 @@ $app->addErrorMiddleware(true, true, true);
$app->get('/admin', [ViewController::class, 'admin']);
$app->get('/admin/texts', [ViewController::class, 'texts']);
$app->get('/admin/texts/{textId}', [ViewController::class, 'text']);
$app->get('/api/texts', [TextController::class, 'getTexts']);
$app->get('/api/texts/{textId}', [TextController::class, 'getText']);
$app->get('/api/texts/{textId}/nodes', [NodeController::class, 'getNodes']);
$app->post('/api/texts', [TextController::class, 'createText']);
return $app;

View file

@ -4,9 +4,12 @@ use DI;
use DI\Container;
use App\Text\TextRepository;
use App\Text\JsonTextRepository;
use App\Node\NodeRepository;
use App\Node\JsonNodeRepository;
$container = new Container([
TextRepository::class => DI\create(JsonTextRepository::class),
TextRepository::class => DI\autowire(JsonTextRepository::class),
NodeRepository::class => DI\autowire(JsonNodeRepository::class),
]);
return $container;

View file

@ -25,4 +25,34 @@ describe('The admin page', () => {
cy.get('#submit').click()
cy.contains('Test Text')
})
it('shows one root node and child node on the seeded text page', () => {
cy.visit('/admin/texts/0')
cy.intercept('GET', '/api/texts/0/nodes').as('getNodes')
cy.wait('@getNodes')
cy.get('#text-detail > ul > li > ul > li').should('have.length', 1)
})
it('shows one root node on the text page', () => {
cy.visit('/admin/texts')
cy.get('#newTextName').type('My Node Text')
cy.get('#submit').click()
cy.intercept('GET', '/api/texts/1/nodes').as('getNodes')
cy.get('a').contains('My Node Text').click()
cy.wait('@getNodes')
cy.get('#text-detail > ul').should('have.length', 1)
})
it('navigates to a specific texts page', () => {
cy.visit('/admin/texts')
cy.get('#newTextName').type('My New Text')
cy.get('#submit').click()
cy.intercept('GET', '/admin/texts/1').as('textPage')
cy.get('a')
.contains('My New Text')
.should('have.attr', 'href', '/admin/texts/1')
.click()
cy.url().should('include', '/admin/texts/1')
cy.wait('@textPage').its('response.statusCode').should('eq', 200)
})
})

View file

@ -7,8 +7,24 @@ $texts = [
],
];
$nodes = [
[
'id' => 0,
'title' => 'Chapter 1',
'textId' => 0,
'parentNodeId' => null,
],
[
'id' => 1,
'title' => 'Section 1.1',
'textId' => 0,
'parentNodeId' => 0,
],
];
$fileDataMap = [
'texts.json' => $texts,
'nodes.json' => $nodes,
];
foreach ($fileDataMap as $file => $data) {

View file

@ -2,6 +2,7 @@
$files = [
'texts.json',
'nodes.json',
];
foreach ($files as $file) {

50
public/js/text.js Normal file
View file

@ -0,0 +1,50 @@
document.addEventListener('DOMContentLoaded', () => {
const textId = window.location.pathname.split('/').pop();
fetch('/api/texts/' + textId)
.then(res => res.json())
.then(text => {
const h1 = document.createElement('h1');
h1.textContent = text.name;
document.getElementById('text-detail').appendChild(h1);
return fetch('/api/texts/' + textId + '/nodes');
})
.then(res => res.json())
.then(nodes => {
const tree = buildTree(nodes);
const ul = renderTree(tree);
document.getElementById('text-detail').appendChild(ul);
});
});
function buildTree(nodes) {
const map = {};
nodes.forEach(node => {
map[node.id] = { ...node, children: [] };
});
const roots = [];
nodes.forEach(node => {
if (node.parentNodeId === null) {
roots.push(map[node.id]);
} else if (map[node.parentNodeId]) {
map[node.parentNodeId].children.push(map[node.id]);
}
});
return roots;
}
function renderTree(nodes) {
const ul = document.createElement('ul');
nodes.forEach(node => {
const li = document.createElement('li');
li.textContent = node.title;
if (node.children.length > 0) {
li.appendChild(renderTree(node.children));
}
ul.appendChild(li);
});
return ul;
}

View file

@ -5,7 +5,12 @@ document.addEventListener('DOMContentLoaded', () => {
async function loadTexts() {
const res = await fetch('/api/texts');
const texts = await res.json();
textsList.innerHTML = texts.map(text => '<li>' + text.name + '</li>').join('');
textsList.innerHTML = texts.map(text =>
'<li><a href=/admin/texts/'
+ text.id
+ '>'
+ text.name
+ '</a></li>').join('');
}
form.addEventListener('submit', async (e) => {
@ -18,7 +23,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (res.ok) {
const text = await res.json();
const li = document.createElement('li');
li.textContent = text.name;
const a = document.createElement('a');
a.href = '/admin/texts/' + text.id;
a.textContent = text.name;
li.appendChild(a);
textsList.appendChild(li);
form.reset();
}

View file

@ -7,19 +7,48 @@ use App\Text\TextRepository;
use App\Text\UseCases\CreateText;
use App\Text\UseCases\CreateTextRequest;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
class CreateTextTest extends TestCase
{
private FakeTextRepository $textRepo;
private FakeNodeRepository $nodeRepo;
private CreateText $useCase;
protected function setUp(): void
{
$this->textRepo = new FakeTextRepository;
$this->nodeRepo = new FakeNodeRepository;
$this->useCase = new CreateText(
$this->textRepo,
$this->nodeRepo,
);
}
public function test_create_text(): void
{
$textRepo = new FakeTextRepository;
$useCase = new CreateText($textRepo);
$text = $useCase->execute(new CreateTextRequest(
$text = $this->useCase->execute(new CreateTextRequest(
name: 'test',
));
$this->assertInstanceOf(TextRepository::class, $textRepo);
$this->assertInstanceOf(TextRepository::class, $this->textRepo);
$this->assertInstanceOf(Text::class, $text);
$this->assertEquals('test', $text->getName());
}
public function test_creates_root_node_on_text_creation(): void
{
$text = $this->useCase->execute(new CreateTextRequest(
name: 'my text',
));
$nodes = $this->nodeRepo->findByTextId($text->getId());
$this->assertCount(1, $nodes);
$rootNode = array_values($nodes)[0];
$this->assertEquals('my text', $rootNode->getTitle());
$this->assertNull($rootNode->getParentNode());
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Tests\e2e\Controllers;
use App\Text\CreateTextDto;
use App\Text\TextController;
use App\Text\UseCases\CreateText;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
class TextControllerTest extends TestCase
{
private FakeTextRepository $textRepo;
private TextController $controller;
public function setUp(): void
{
$this->textRepo = new FakeTextRepository;
$this->textRepo->create(new CreateTextDto(
name: 'test text',
));
$this->controller = new TextController($this->textRepo);
}
public function test_get_one_text(): void
{
$response = $this->controller->getText(
new Response(),
0,
);
$this->assertEquals(
json_encode([
'id' => 0,
'name' => 'test text',
]),
$response->getBody()
);
}
public function test_get_all_texts(): void
{
$this->textRepo->create(new CreateTextDto(
name: 'test text 2',
));
$response = $this->controller->getTexts(new Response());
$this->assertEquals(
json_encode([
[
'id' => 0,
'name' => 'test text',
],
[
'id' => 1,
'name' => 'test text 2',
]
]),
$response->getBody()
);
}
public function test_create_text(): void
{
$request = (new ServerRequestFactory())
->createServerRequest('POST', 'http://localhost/texts')
->withParsedBody(['name' => 'my new text']);
$response = $this->controller->createText(
$request,
new Response(),
new CreateText(
$this->textRepo,
new FakeNodeRepository,
),
);
$this->assertEquals(
json_encode([
'id' => 1,
'name' => 'my new text',
]),
$response->getBody()
);
}
}

11
views/templates/text.php Normal file
View file

@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<title>Daily Goals - Text</title>
</head>
<body>
<a href="/admin/texts" id="back">Back to Texts</a>
<div id="text-detail"></div>
<script src="/js/text.js"></script>
</body>
</html>