From 3689945cfee5271d2edd0901b78f011fcee2678a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 15 Apr 2026 21:48:32 +0300 Subject: [PATCH 01/18] make texts into a tags --- public/js/texts.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 => '
  • ' + text.name + '
  • ').join(''); + textsList.innerHTML = texts.map(text => + '
  • ' + + text.name + + '
  • ').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(); } From 5b2d85fb9274b2d9229c0b0bde7a1a563022d52e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 15 Apr 2026 21:48:54 +0300 Subject: [PATCH 02/18] test that clicking on the a tag will bring us to a texts page --- cypress/e2e/admin.cy.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 5dca670..2c8054c 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -25,4 +25,17 @@ describe('The admin page', () => { cy.get('#submit').click() cy.contains('Test Text') }) + + 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) + }) }) From 68da48aedd5ac1de79690428bae50938d980f1d9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 09:22:55 +0300 Subject: [PATCH 03/18] add view for single text --- app/View/ViewController.php | 8 ++++++++ bootstrap/app.php | 2 ++ public/js/text.js | 9 +++++++++ views/templates/text.php | 11 +++++++++++ 4 files changed, 30 insertions(+) create mode 100644 public/js/text.js create mode 100644 views/templates/text.php 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..9022386 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -14,8 +14,10 @@ $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->post('/api/texts', [TextController::class, 'createText']); return $app; diff --git a/public/js/text.js b/public/js/text.js new file mode 100644 index 0000000..9c981d3 --- /dev/null +++ b/public/js/text.js @@ -0,0 +1,9 @@ +document.addEventListener('DOMContentLoaded', () => { + const textId = window.location.pathname.split('/').pop(); + + fetch('/api/texts/' + textId) + .then(res => res.json()) + .then(text => { + document.getElementById('text-detail').textContent = text.name; + }); +}); diff --git a/views/templates/text.php b/views/templates/text.php new file mode 100644 index 0000000..8f68729 --- /dev/null +++ b/views/templates/text.php @@ -0,0 +1,11 @@ + + + + Daily Goals - Text + + + Back to Texts +
    + + + From 4fe10214d5c38039ed03ad52f8e8cf3d4fcebeed Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 09:53:50 +0300 Subject: [PATCH 04/18] change fn to function -- style --- app/Text/TextController.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/Text/TextController.php b/app/Text/TextController.php index 500986e..a024e4f 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -18,10 +18,12 @@ 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'); From 85ab8f2bbc161f0305eb99de96afdff51033a2cf Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 09:54:21 +0300 Subject: [PATCH 05/18] add getText method to text controller --- app/Text/TextController.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/Text/TextController.php b/app/Text/TextController.php index a024e4f..c608018 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -29,6 +29,21 @@ class TextController 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, From 98f597914a22867c9e48de83a8c391ce71e3897e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 09:54:44 +0300 Subject: [PATCH 06/18] test text controller methods --- tests/e2e/Controllers/TextControllerTest.php | 83 ++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/e2e/Controllers/TextControllerTest.php diff --git a/tests/e2e/Controllers/TextControllerTest.php b/tests/e2e/Controllers/TextControllerTest.php new file mode 100644 index 0000000..5788360 --- /dev/null +++ b/tests/e2e/Controllers/TextControllerTest.php @@ -0,0 +1,83 @@ +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), + ); + $this->assertEquals( + json_encode([ + 'id' => 1, + 'name' => 'my new text', + ]), + $response->getBody() + ); + } +} From f6791b645959c2cc921c701bdf7157dab42172a4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 10:49:41 +0300 Subject: [PATCH 07/18] unit test for create text that it creates a root node as well. refactor to setUp as well --- tests/Unit/Text/UseCases/CreateTextTest.php | 37 ++++++++++++++++++--- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/tests/Unit/Text/UseCases/CreateTextTest.php b/tests/Unit/Text/UseCases/CreateTextTest.php index e4b8422..4119662 100644 --- a/tests/Unit/Text/UseCases/CreateTextTest.php +++ b/tests/Unit/Text/UseCases/CreateTextTest.php @@ -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()); + } } From 1fb5307062f002a74ce41839ce9ebbca6a428bdd Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 10:50:14 +0300 Subject: [PATCH 08/18] e2e test that created text has root node --- cypress/e2e/admin.cy.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 2c8054c..393b2b4 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -26,6 +26,16 @@ describe('The admin page', () => { cy.contains('Test Text') }) + 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') From c99e1eeeb5553df057f86152178b29a68665cf84 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 10:58:32 +0300 Subject: [PATCH 09/18] test child node of root node exists --- cypress/e2e/admin.cy.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 393b2b4..e2a3b4e 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -26,6 +26,13 @@ describe('The admin page', () => { 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') From d4f5b22034b64ed183179c44261d1bc91f2d2f73 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:00:36 +0300 Subject: [PATCH 10/18] add method to get nodes of text id in text controller --- app/Text/TextController.php | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/Text/TextController.php b/app/Text/TextController.php index c608018..8870c3b 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -5,6 +5,7 @@ namespace App\Text; use App\Text\TextRepository; use App\Text\UseCases\CreateText; use App\Text\UseCases\CreateTextRequest; +use App\Node\NodeRepository; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -12,6 +13,7 @@ class TextController { public function __construct( private TextRepository $textRepository, + private NodeRepository $nodeRepository, ) {} public function getTexts(Response $response): Response @@ -44,6 +46,28 @@ class TextController return $response->withHeader('Content-Type', 'application/json'); } + 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'); + } + public function createText( Request $request, Response $response, From 9b24fddec1f2872985156a1a0942278b76e8f262 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:01:20 +0300 Subject: [PATCH 11/18] update text controller test with node repo --- tests/e2e/Controllers/TextControllerTest.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/Controllers/TextControllerTest.php b/tests/e2e/Controllers/TextControllerTest.php index 5788360..aeee9a9 100644 --- a/tests/e2e/Controllers/TextControllerTest.php +++ b/tests/e2e/Controllers/TextControllerTest.php @@ -8,6 +8,7 @@ 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 @@ -22,7 +23,7 @@ class TextControllerTest extends TestCase $this->textRepo->create(new CreateTextDto( name: 'test text', )); - $this->controller = new TextController($this->textRepo); + $this->controller = new TextController($this->textRepo, new FakeNodeRepository); } public function test_get_one_text(): void @@ -70,7 +71,10 @@ class TextControllerTest extends TestCase $response = $this->controller->createText( $request, new Response(), - new CreateText($this->textRepo), + new CreateText( + $this->textRepo, + new FakeNodeRepository, + ), ); $this->assertEquals( json_encode([ From 6a1f44e1126e5de8a838f08494c517e62d0065e5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:01:46 +0300 Subject: [PATCH 12/18] add node data to seed and wipe scripts --- data/seedDb.php | 16 ++++++++++++++++ data/wipeDb.php | 1 + 2 files changed, 17 insertions(+) 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) { From 6ae66055cf618e7705ba0ad6bcf38b5ac080cdcb Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:02:08 +0300 Subject: [PATCH 13/18] bind node repo in container, start using autowire instead of create --- bootstrap/container.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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; From 37c519fb2a83381b39301aabb62f5bb9ec6ec843 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:02:35 +0300 Subject: [PATCH 14/18] add route for getting nodes of text --- bootstrap/app.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bootstrap/app.php b/bootstrap/app.php index 9022386..1931398 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\NodeRepository; $container = require __DIR__.'/container.php'; $app = Bridge::create($container); @@ -18,6 +19,7 @@ $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', [TextController::class, 'getNodes']); $app->post('/api/texts', [TextController::class, 'createText']); return $app; From acdfc144424a1932e8180aa42cf1c705f5513edf Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:03:10 +0300 Subject: [PATCH 15/18] create root node on text creation --- app/Text/UseCases/CreateText.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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; } } From 38d06fce43bdae066dd125e1ba90238875d980b2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:29:55 +0300 Subject: [PATCH 16/18] refactor getNodes into a new NodeController and update refs --- app/Node/NodeController.php | 37 ++++++++++++++++++++ app/Text/TextController.php | 24 ------------- bootstrap/app.php | 4 +-- tests/e2e/Controllers/TextControllerTest.php | 2 +- 4 files changed, 40 insertions(+), 27 deletions(-) create mode 100644 app/Node/NodeController.php 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 8870c3b..c608018 100644 --- a/app/Text/TextController.php +++ b/app/Text/TextController.php @@ -5,7 +5,6 @@ namespace App\Text; use App\Text\TextRepository; use App\Text\UseCases\CreateText; use App\Text\UseCases\CreateTextRequest; -use App\Node\NodeRepository; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -13,7 +12,6 @@ class TextController { public function __construct( private TextRepository $textRepository, - private NodeRepository $nodeRepository, ) {} public function getTexts(Response $response): Response @@ -46,28 +44,6 @@ class TextController return $response->withHeader('Content-Type', 'application/json'); } - 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'); - } - public function createText( Request $request, Response $response, diff --git a/bootstrap/app.php b/bootstrap/app.php index 1931398..bfe8142 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -5,7 +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\NodeRepository; +use App\Node\NodeController; $container = require __DIR__.'/container.php'; $app = Bridge::create($container); @@ -19,7 +19,7 @@ $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', [TextController::class, 'getNodes']); +$app->get('/api/texts/{textId}/nodes', [NodeController::class, 'getNodes']); $app->post('/api/texts', [TextController::class, 'createText']); return $app; diff --git a/tests/e2e/Controllers/TextControllerTest.php b/tests/e2e/Controllers/TextControllerTest.php index aeee9a9..bcf1e30 100644 --- a/tests/e2e/Controllers/TextControllerTest.php +++ b/tests/e2e/Controllers/TextControllerTest.php @@ -23,7 +23,7 @@ class TextControllerTest extends TestCase $this->textRepo->create(new CreateTextDto( name: 'test text', )); - $this->controller = new TextController($this->textRepo, new FakeNodeRepository); + $this->controller = new TextController($this->textRepo); } public function test_get_one_text(): void From a092ee88409cda26a448ba6c8bb222e6df272a4a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 11:30:17 +0300 Subject: [PATCH 17/18] json node repo --- app/Node/JsonNodeRepository.php | 129 ++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 app/Node/JsonNodeRepository.php 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; + } +} From 49140195f1d20732dc4dfe6278907686292c8cdc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 17 Apr 2026 17:52:32 +0300 Subject: [PATCH 18/18] add parsing functions for nodes of text to create indented tree --- public/js/text.js | 43 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/public/js/text.js b/public/js/text.js index 9c981d3..985ca15 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -4,6 +4,47 @@ document.addEventListener('DOMContentLoaded', () => { fetch('/api/texts/' + textId) .then(res => res.json()) .then(text => { - document.getElementById('text-detail').textContent = text.name; + 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; +}