From 3670fdd8691eaf777992034c089629bc8186693d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 23:02:32 +0300 Subject: [PATCH 1/7] tests for bulk create nodes --- .../Node/UseCases/BulkCreateNodesTest.php | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 tests/Unit/Node/UseCases/BulkCreateNodesTest.php diff --git a/tests/Unit/Node/UseCases/BulkCreateNodesTest.php b/tests/Unit/Node/UseCases/BulkCreateNodesTest.php new file mode 100644 index 0000000..13b16d0 --- /dev/null +++ b/tests/Unit/Node/UseCases/BulkCreateNodesTest.php @@ -0,0 +1,134 @@ +textRepo = new FakeTextRepository; + $this->textRepo->create(new CreateTextDto(name: 'text')); + + $this->nodeRepo = new FakeNodeRepository; + $text = $this->textRepo->find(0); + $this->parentNode = $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Root', + parentNode: null, + )); + + $this->useCase = new BulkCreateNodes( + nodeRepo: $this->nodeRepo, + textRepo: $this->textRepo, + ); + } + + public function test_creates_correct_number_of_nodes(): void + { + $nodes = $this->useCase->execute(new BulkCreateNodesRequest( + textId: 0, + parentNodeId: $this->parentNode->getId(), + titlePrefix: 'Page', + count: 5, + )); + + $this->assertCount(5, $nodes); + } + + public function test_nodes_have_correct_titles(): void + { + $nodes = $this->useCase->execute(new BulkCreateNodesRequest( + textId: 0, + parentNodeId: $this->parentNode->getId(), + titlePrefix: 'Page', + count: 3, + )); + + $this->assertEquals('Page 1', $nodes[0]->getTitle()); + $this->assertEquals('Page 2', $nodes[1]->getTitle()); + $this->assertEquals('Page 3', $nodes[2]->getTitle()); + } + + public function test_nodes_have_correct_parent(): void + { + $nodes = $this->useCase->execute(new BulkCreateNodesRequest( + textId: 0, + parentNodeId: $this->parentNode->getId(), + titlePrefix: 'Page', + count: 3, + )); + + foreach ($nodes as $node) { + $this->assertEquals($this->parentNode->getId(), $node->getParentNode()->getId()); + } + } + + public function test_nodes_belong_to_text(): void + { + $nodes = $this->useCase->execute(new BulkCreateNodesRequest( + textId: 0, + parentNodeId: $this->parentNode->getId(), + titlePrefix: 'Page', + count: 3, + )); + + foreach ($nodes as $node) { + $this->assertEquals(0, $node->getText()->getId()); + } + } + + public function test_returns_array_of_node_instances(): void + { + $nodes = $this->useCase->execute(new BulkCreateNodesRequest( + textId: 0, + parentNodeId: $this->parentNode->getId(), + titlePrefix: 'Chapter', + count: 2, + )); + + foreach ($nodes as $node) { + $this->assertInstanceOf(Node::class, $node); + } + } + + public function test_throws_if_text_doesnt_exist(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage("Text with id: 99 doesnt exist"); + + $this->useCase->execute(new BulkCreateNodesRequest( + textId: 99, + parentNodeId: $this->parentNode->getId(), + titlePrefix: 'Page', + count: 5, + )); + } + + public function test_throws_if_parent_node_doesnt_exist(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage("Node with id: 99 doesnt exist"); + + $this->useCase->execute(new BulkCreateNodesRequest( + textId: 0, + parentNodeId: 99, + titlePrefix: 'Page', + count: 5, + )); + } +} From 612564e9fbf44d1891ca945c04b5c44c9ea62577 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 23:04:25 +0300 Subject: [PATCH 2/7] test controller --- .../BulkCreateNodesControllerTest.php | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 tests/e2e/Controllers/BulkCreateNodesControllerTest.php diff --git a/tests/e2e/Controllers/BulkCreateNodesControllerTest.php b/tests/e2e/Controllers/BulkCreateNodesControllerTest.php new file mode 100644 index 0000000..0ff3f0c --- /dev/null +++ b/tests/e2e/Controllers/BulkCreateNodesControllerTest.php @@ -0,0 +1,218 @@ +textRepo = new FakeTextRepository; + $text = $this->textRepo->create(new CreateTextDto(name: 'test text')); + + $this->nodeRepo = new FakeNodeRepository; + $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: 'Root Node', + parentNode: null, + )); + $this->useCase = new BulkCreateNodes( + $this->nodeRepo, + $this->textRepo + ); + $this->controller = new NodeController( + $this->nodeRepo, + $this->textRepo + ); + } + + private function makeRequest(array $data): ServerRequestInterface + { + $body = (new StreamFactory())->createStream(json_encode($data)); + return (new ServerRequestFactory()) + ->createServerRequest('POST', 'http://localhost/api/nodes/bulk') + ->withHeader('Content-Type', 'application/json') + ->withBody($body); + } + + public function test_bulk_create_nodes_returns_201_with_created_nodes(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'titlePrefix' => 'Page', + 'count' => 3, + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(201, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertIsArray($body); + } + + public function test_bulk_create_nodes_returns_correct_count(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'titlePrefix' => 'Page', + 'count' => 10, + ]), + new Response(), + $this->useCase, + ); + + $body = json_decode($response->getBody(), true); + $this->assertCount(10, $body); + } + + public function test_bulk_create_nodes_returns_correct_titles(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'titlePrefix' => 'Chapter', + 'count' => 3, + ]), + new Response(), + $this->useCase, + ); + + $body = json_decode($response->getBody(), true); + $this->assertEquals('Chapter 1', $body[0]['title']); + $this->assertEquals('Chapter 2', $body[1]['title']); + $this->assertEquals('Chapter 3', $body[2]['title']); + } + + public function test_bulk_create_nodes_response_includes_id_and_parent_node_id(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'titlePrefix' => 'Page', + 'count' => 2, + ]), + new Response(), + $this->useCase, + ); + + $body = json_decode($response->getBody(), true); + $this->assertArrayHasKey('id', $body[0]); + $this->assertEquals(0, $body[0]['parentNodeId']); + } + + public function test_bulk_create_nodes_returns_400_when_title_prefix_missing(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'count' => 5, + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_bulk_create_nodes_returns_400_when_count_is_zero(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'titlePrefix' => 'Page', + 'count' => 0, + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_bulk_create_nodes_returns_400_when_count_is_missing(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 0, + 'titlePrefix' => 'Page', + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_bulk_create_nodes_returns_400_when_parent_node_id_missing(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'titlePrefix' => 'Page', + 'count' => 5, + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_bulk_create_nodes_returns_404_when_text_not_found(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 99, + 'parentNodeId' => 0, + 'titlePrefix' => 'Page', + 'count' => 5, + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(404, $response->getStatusCode()); + } + + public function test_bulk_create_nodes_returns_404_when_parent_node_not_found(): void + { + $response = $this->controller->bulkCreateNodes( + $this->makeRequest([ + 'textId' => 0, + 'parentNodeId' => 99, + 'titlePrefix' => 'Page', + 'count' => 5, + ]), + new Response(), + $this->useCase, + ); + + $this->assertEquals(404, $response->getStatusCode()); + } +} From 5c2b6c9eddb416aaee547a4a736cd20cd58bc8d0 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 23:04:39 +0300 Subject: [PATCH 3/7] cypress tests for bulk add children --- cypress/e2e/adminTextBulkAdd.cy.js | 85 ++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 cypress/e2e/adminTextBulkAdd.cy.js diff --git a/cypress/e2e/adminTextBulkAdd.cy.js b/cypress/e2e/adminTextBulkAdd.cy.js new file mode 100644 index 0000000..03159a2 --- /dev/null +++ b/cypress/e2e/adminTextBulkAdd.cy.js @@ -0,0 +1,85 @@ +describe('Bulk add children on the admin text detail page', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + cy.intercept('GET', '/api/texts/0').as('getText') + cy.intercept('GET', '/api/nodes/0').as('getNodes') + cy.visit('/admin/texts/0') + cy.wait('@getText') + cy.wait('@getNodes') + }) + + afterEach(() => { + cy.exec('npm run db:wipe') + }) + + it('shows a "Bulk add children" button on each node', () => { + cy.get('#text-detail li').each(($li) => { + cy.wrap($li).find('button.bulk-add-children').should('exist') + }) + }) + + it('clicking "Bulk add children" reveals inline form inputs', () => { + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-title').should('be.visible') + cy.get('#text-detail > ul > li').first().children('input.bulk-count').should('be.visible') + cy.get('#text-detail > ul > li').first().children('button.save-bulk').should('be.visible') + }) + + it('clicking "Bulk add children" again hides the form', () => { + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-title').should('be.visible') + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-title').should('not.exist') + cy.get('#text-detail > ul > li').first().children('input.bulk-count').should('not.exist') + cy.get('#text-detail > ul > li').first().children('button.save-bulk').should('not.exist') + }) + + it('can bulk add children to the root node', () => { + cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') + cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Page') + cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('3') + cy.get('#text-detail > ul > li').first().children('button.save-bulk').click() + cy.wait('@bulkCreate').its('response.statusCode').should('eq', 201) + cy.wait('@getNodesRefresh') + cy.get('#text-detail li').should('contain', 'Page 1') + cy.get('#text-detail li').should('contain', 'Page 2') + cy.get('#text-detail li').should('contain', 'Page 3') + }) + + it('does not submit if title prefix is empty', () => { + cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('3') + cy.get('#text-detail > ul > li').first().children('button.save-bulk').click() + cy.get('@bulkCreate.all').should('have.length', 0) + }) + + it('does not submit if count is empty', () => { + cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Page') + cy.get('#text-detail > ul > li').first().children('button.save-bulk').click() + cy.get('@bulkCreate.all').should('have.length', 0) + }) + + it('bulk added nodes persist after page reload', () => { + cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') + cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') + cy.get('#text-detail > ul > li').first().children('button.bulk-add-children').click() + cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Page') + cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('3') + cy.get('#text-detail > ul > li').first().children('button.save-bulk').click() + cy.wait('@bulkCreate') + cy.wait('@getNodesRefresh') + cy.intercept('GET', '/api/texts/0').as('getTextReload') + cy.intercept('GET', '/api/nodes/0').as('getNodesReload') + cy.reload() + cy.wait('@getTextReload') + cy.wait('@getNodesReload') + cy.get('#text-detail li').should('contain', 'Page 1') + cy.get('#text-detail li').should('contain', 'Page 2') + cy.get('#text-detail li').should('contain', 'Page 3') + }) +}) From ce56e460ff76b55595b06326b72c7c334371e087 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 23:04:56 +0300 Subject: [PATCH 4/7] add use case and request --- app/Node/UseCases/BulkCreateNodes.php | 45 ++++++++++++++++++++ app/Node/UseCases/BulkCreateNodesRequest.php | 13 ++++++ 2 files changed, 58 insertions(+) create mode 100644 app/Node/UseCases/BulkCreateNodes.php create mode 100644 app/Node/UseCases/BulkCreateNodesRequest.php diff --git a/app/Node/UseCases/BulkCreateNodes.php b/app/Node/UseCases/BulkCreateNodes.php new file mode 100644 index 0000000..1ca322d --- /dev/null +++ b/app/Node/UseCases/BulkCreateNodes.php @@ -0,0 +1,45 @@ +textRepo->find($request->textId); + if ($text === null) { + throw new DomainException("Text with id: {$request->textId} doesnt exist"); + } + + $parentNode = $this->nodeRepo->find($request->parentNodeId); + if ($parentNode === null) { + throw new DomainException("Node with id: {$request->parentNodeId} doesnt exist"); + } + + $created = []; + for ($i = 1; $i <= $request->count; $i++) { + $created[] = $this->nodeRepo->create(new CreateNodeDto( + text: $text, + title: "{$request->titlePrefix} {$i}", + parentNode: $parentNode, + )); + } + + return $created; + } +} diff --git a/app/Node/UseCases/BulkCreateNodesRequest.php b/app/Node/UseCases/BulkCreateNodesRequest.php new file mode 100644 index 0000000..45b94be --- /dev/null +++ b/app/Node/UseCases/BulkCreateNodesRequest.php @@ -0,0 +1,13 @@ + Date: Sat, 18 Apr 2026 23:05:14 +0300 Subject: [PATCH 5/7] implement node controller method for bulk create nodes --- app/Node/NodeController.php | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/app/Node/NodeController.php b/app/Node/NodeController.php index 6104327..20d534f 100644 --- a/app/Node/NodeController.php +++ b/app/Node/NodeController.php @@ -2,7 +2,9 @@ namespace App\Node; +use App\Node\UseCases\BulkCreateNodesRequest; use App\Node\NodeRepository; +use App\Node\UseCases\BulkCreateNodes; use App\Node\UseCases\CreateNode; use App\Node\UseCases\CreateNodeRequest; use App\Text\TextRepository; @@ -75,4 +77,55 @@ class NodeController ])); return $response->withStatus(201)->withHeader('Content-Type', 'application/json'); } + + public function bulkCreateNodes( + Request $request, + Response $response, + BulkCreateNodes $bulkCreateNodesUseCase, + ): Response { + $data = json_decode((string) $request->getBody(), true) ?? []; + + $titlePrefix = trim($data['titlePrefix'] ?? ''); + if ($titlePrefix === '') { + $response->getBody()->write(json_encode(['error' => 'Title prefix is required'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + $count = isset($data['count']) ? (int) $data['count'] : 0; + if ($count < 1) { + $response->getBody()->write(json_encode(['error' => 'Count must be at least 1'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + if (!isset($data['parentNodeId']) || $data['parentNodeId'] === null) { + $response->getBody()->write(json_encode(['error' => 'parentNodeId is required'])); + return $response->withStatus(400)->withHeader('Content-Type', 'application/json'); + } + + $textId = (int) ($data['textId'] ?? 0); + $parentNodeId = (int) $data['parentNodeId']; + + try { + $nodes = $bulkCreateNodesUseCase->execute(new BulkCreateNodesRequest( + textId: $textId, + parentNodeId: $parentNodeId, + titlePrefix: $titlePrefix, + count: $count, + )); + } catch (DomainException $e) { + $response->getBody()->write(json_encode(['error' => $e->getMessage()])); + return $response->withStatus(404)->withHeader('Content-Type', 'application/json'); + } + + $result = array_map(function ($node) { + return [ + 'id' => $node->getId(), + 'title' => $node->getTitle(), + 'parentNodeId' => $node->getParentNode()?->getId(), + ]; + }, $nodes); + + $response->getBody()->write(json_encode(array_values($result))); + return $response->withStatus(201)->withHeader('Content-Type', 'application/json'); + } } From d9c9b4439e79c7609628b621dbc8eb8d8d8c0fde Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 23:05:32 +0300 Subject: [PATCH 6/7] add post route for bulk creation of nodes --- bootstrap/app.php | 1 + 1 file changed, 1 insertion(+) diff --git a/bootstrap/app.php b/bootstrap/app.php index cd895b3..4f98720 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,6 +22,7 @@ $app->get('/api/texts/{textId}', [TextController::class, 'getText']); $app->post('/api/texts', [TextController::class, 'createText']); $app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); +$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); $app->post('/api/nodes', [NodeController::class, 'createNode']); return $app; From 9ed42654a342a4cf7079987ec507c287aee5fec0 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 18 Apr 2026 23:06:04 +0300 Subject: [PATCH 7/7] add js for bulk creating nodes --- public/js/text.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/public/js/text.js b/public/js/text.js index fc62744..f017bd4 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -58,6 +58,12 @@ function renderTree(nodes, textId) { addBtn.addEventListener('click', () => toggleAddForm(li, node.id, textId)); li.appendChild(addBtn); + const bulkBtn = document.createElement('button'); + bulkBtn.textContent = 'Bulk add children'; + bulkBtn.className = 'bulk-add-children'; + bulkBtn.addEventListener('click', () => toggleBulkAddForm(li, node.id, textId)); + li.appendChild(bulkBtn); + if (node.children.length > 0) { li.appendChild(renderTree(node.children, textId)); } @@ -102,3 +108,48 @@ function toggleAddForm(li, parentNodeId, textId) { li.appendChild(input); li.appendChild(saveBtn); } + +function toggleBulkAddForm(li, parentNodeId, textId) { + const existing = li.querySelector('input.bulk-title'); + if (existing) { + existing.remove(); + li.querySelector('input.bulk-count').remove(); + li.querySelector('button.save-bulk').remove(); + return; + } + + const titleInput = document.createElement('input'); + titleInput.type = 'text'; + titleInput.className = 'bulk-title'; + titleInput.placeholder = 'Title prefix'; + + const countInput = document.createElement('input'); + countInput.type = 'number'; + countInput.className = 'bulk-count'; + countInput.placeholder = 'Count'; + countInput.min = '1'; + + const saveBtn = document.createElement('button'); + saveBtn.textContent = 'Save'; + saveBtn.className = 'save-bulk'; + saveBtn.addEventListener('click', () => { + const titlePrefix = titleInput.value.trim(); + const count = parseInt(countInput.value); + if (!titlePrefix || !count || count < 1) return; + + fetch('/api/nodes/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }), + }) + .then(res => { + if (!res.ok) throw new Error('Failed to bulk create nodes'); + return res.json(); + }) + .then(() => fetchAndRenderNodes(textId)); + }); + + li.appendChild(titleInput); + li.appendChild(countInput); + li.appendChild(saveBtn); +}