Merge branch 'batch-creating-nodes'

This commit is contained in:
Yisroel Baum 2026-04-18 23:06:46 +03:00
commit ce689da99a
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
8 changed files with 600 additions and 0 deletions

View file

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

View file

@ -0,0 +1,45 @@
<?php
namespace App\Node\UseCases;
use App\Node\CreateNodeDto;
use App\Node\Node;
use App\Node\NodeRepository;
use App\Text\TextRepository;
use DomainException;
class BulkCreateNodes
{
public function __construct(
private NodeRepository $nodeRepo,
private TextRepository $textRepo,
) {}
/**
* @return Node[]
* @throws DomainException
*/
public function execute(BulkCreateNodesRequest $request): array
{
$text = $this->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;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Node\UseCases;
class BulkCreateNodesRequest
{
public function __construct(
public int $textId,
public int $parentNodeId,
public string $titlePrefix,
public int $count,
) {}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,134 @@
<?php
namespace Tests\Unit\Node\UseCases;
use App\Node\CreateNodeDto;
use App\Node\Node;
use App\Node\UseCases\BulkCreateNodes;
use App\Node\UseCases\BulkCreateNodesRequest;
use App\Text\CreateTextDto;
use DomainException;
use PHPUnit\Framework\TestCase;
use Tests\Fakes\FakeNodeRepository;
use Tests\Fakes\FakeTextRepository;
class BulkCreateNodesTest extends TestCase
{
private FakeTextRepository $textRepo;
private FakeNodeRepository $nodeRepo;
private BulkCreateNodes $useCase;
private Node $parentNode;
public function setUp(): void
{
$this->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,
));
}
}

View file

@ -0,0 +1,218 @@
<?php
namespace Tests\e2e\Controllers;
use App\Node\CreateNodeDto;
use App\Node\NodeController;
use App\Node\UseCases\BulkCreateNodes;
use App\Text\CreateTextDto;
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\FakeTextRepository;
class BulkCreateNodesControllerTest extends TestCase
{
private FakeTextRepository $textRepo;
private FakeNodeRepository $nodeRepo;
private BulkCreateNodes $useCase;
private NodeController $controller;
public function setUp(): void
{
$this->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());
}
}