diff --git a/app/Node/JsonNodeRepository.php b/app/Node/JsonNodeRepository.php new file mode 100644 index 0000000..a466bc0 --- /dev/null +++ b/app/Node/JsonNodeRepository.php @@ -0,0 +1,129 @@ +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; + } +} diff --git a/app/Node/NodeController.php b/app/Node/NodeController.php new file mode 100644 index 0000000..a02f575 --- /dev/null +++ b/app/Node/NodeController.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/app/Text/TextController.php b/app/Text/TextController.php index 500986e..c608018 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -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, diff --git a/app/Text/UseCases/CreateText.php b/app/Text/UseCases/CreateText.php index 062497c..bf4715b 100644 --- a/app/Text/UseCases/CreateText.php +++ b/app/Text/UseCases/CreateText.php @@ -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; } } diff --git a/app/View/ViewController.php b/app/View/ViewController.php index 0ee00ea..580c53e 100644 --- a/app/View/ViewController.php +++ b/app/View/ViewController.php @@ -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; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index ddc3dcd..bfe8142 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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; diff --git a/bootstrap/container.php b/bootstrap/container.php index 09a0d9a..efcfde5 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -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; diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 5dca670..e2a3b4e 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -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) + }) }) diff --git a/data/seedDb.php b/data/seedDb.php index d8d3698..28bc820 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -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) { diff --git a/data/wipeDb.php b/data/wipeDb.php index b180c35..2d71990 100644 --- a/data/wipeDb.php +++ b/data/wipeDb.php @@ -2,6 +2,7 @@ $files = [ 'texts.json', + 'nodes.json', ]; foreach ($files as $file) { diff --git a/public/js/text.js b/public/js/text.js new file mode 100644 index 0000000..985ca15 --- /dev/null +++ b/public/js/text.js @@ -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; +} diff --git a/public/js/texts.js b/public/js/texts.js index 9421c20..1937749 100644 --- a/public/js/texts.js +++ b/public/js/texts.js @@ -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 => '