Merge branch 'add-nodes-to-root-node'
This commit is contained in:
commit
687283b9db
6 changed files with 351 additions and 27 deletions
|
|
@ -3,8 +3,12 @@
|
||||||
namespace App\Node;
|
namespace App\Node;
|
||||||
|
|
||||||
use App\Node\NodeRepository;
|
use App\Node\NodeRepository;
|
||||||
|
use App\Node\UseCases\CreateNode;
|
||||||
|
use App\Node\UseCases\CreateNodeRequest;
|
||||||
use App\Text\TextRepository;
|
use App\Text\TextRepository;
|
||||||
|
use DomainException;
|
||||||
use Psr\Http\Message\ResponseInterface as Response;
|
use Psr\Http\Message\ResponseInterface as Response;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
class NodeController
|
class NodeController
|
||||||
{
|
{
|
||||||
|
|
@ -34,4 +38,41 @@ class NodeController
|
||||||
$response->getBody()->write(json_encode(array_values($data)));
|
$response->getBody()->write(json_encode(array_values($data)));
|
||||||
return $response->withHeader('Content-Type', 'application/json');
|
return $response->withHeader('Content-Type', 'application/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function createNode(
|
||||||
|
Request $request,
|
||||||
|
Response $response,
|
||||||
|
CreateNode $createNodeUseCase,
|
||||||
|
): Response {
|
||||||
|
$data = json_decode((string) $request->getBody(), true) ?? [];
|
||||||
|
$title = $data['title'] ?? '';
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$response->getBody()->write(json_encode(['error' => 'Title is required']));
|
||||||
|
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
$textId = (int) ($data['textId'] ?? 0);
|
||||||
|
$parentNodeId = isset($data['parentNodeId']) && $data['parentNodeId'] !== null
|
||||||
|
? (int) $data['parentNodeId']
|
||||||
|
: null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$node = $createNodeUseCase->execute(new CreateNodeRequest(
|
||||||
|
textId: $textId,
|
||||||
|
title: $title,
|
||||||
|
parentNodeId: $parentNodeId,
|
||||||
|
));
|
||||||
|
} catch (DomainException $e) {
|
||||||
|
$response->getBody()->write(json_encode(['error' => $e->getMessage()]));
|
||||||
|
return $response->withStatus(404)->withHeader('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->getBody()->write(json_encode([
|
||||||
|
'id' => $node->getId(),
|
||||||
|
'title' => $node->getTitle(),
|
||||||
|
'parentNodeId' => $node->getParentNode()?->getId(),
|
||||||
|
]));
|
||||||
|
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,5 +22,6 @@ $app->get('/api/texts/{textId}', [TextController::class, 'getText']);
|
||||||
$app->post('/api/texts', [TextController::class, 'createText']);
|
$app->post('/api/texts', [TextController::class, 'createText']);
|
||||||
|
|
||||||
$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']);
|
$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']);
|
||||||
|
$app->post('/api/nodes', [NodeController::class, 'createNode']);
|
||||||
|
|
||||||
return $app;
|
return $app;
|
||||||
|
|
|
||||||
|
|
@ -26,23 +26,6 @@ describe('The admin page', () => {
|
||||||
cy.contains('Test Text')
|
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/nodes/0').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/nodes/1').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', () => {
|
it('navigates to a specific texts page', () => {
|
||||||
cy.visit('/admin/texts')
|
cy.visit('/admin/texts')
|
||||||
cy.get('#newTextName').type('My New Text')
|
cy.get('#newTextName').type('My New Text')
|
||||||
|
|
|
||||||
86
cypress/e2e/adminText.cy.js
Normal file
86
cypress/e2e/adminText.cy.js
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
describe('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 the text name as a heading', () => {
|
||||||
|
cy.get('h1').should('contain', 'test text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows the root node', () => {
|
||||||
|
cy.get('#text-detail li').first().should('contain', 'Chapter 1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows a child node under the root node', () => {
|
||||||
|
cy.get('#text-detail > ul > li > ul > li').should('contain', 'Section 1.1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an "Add child" button on each node', () => {
|
||||||
|
cy.get('#text-detail li').each(($li) => {
|
||||||
|
cy.wrap($li).find('button.add-child').should('exist')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clicking "Add child" reveals an inline form', () => {
|
||||||
|
cy.get('#text-detail li').first().children('button.add-child').click()
|
||||||
|
cy.get('#text-detail li').first().children('input.child-title').should('be.visible')
|
||||||
|
cy.get('#text-detail li').first().children('button.save-child').should('be.visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can add a child to the root node', () => {
|
||||||
|
cy.intercept('POST', '/api/nodes').as('createNode')
|
||||||
|
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh')
|
||||||
|
|
||||||
|
cy.get('#text-detail > ul > li').first().children('button.add-child').click()
|
||||||
|
cy.get('#text-detail > ul > li').first().children('input.child-title').type('New Child Node')
|
||||||
|
cy.get('#text-detail > ul > li').first().children('button.save-child').click()
|
||||||
|
|
||||||
|
cy.wait('@createNode').its('response.statusCode').should('eq', 201)
|
||||||
|
cy.wait('@getNodesRefresh')
|
||||||
|
|
||||||
|
cy.get('#text-detail li').should('contain', 'New Child Node')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can add a child to a child node', () => {
|
||||||
|
cy.intercept('POST', '/api/nodes').as('createNode')
|
||||||
|
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh')
|
||||||
|
|
||||||
|
cy.get('#text-detail > ul > li > ul > li').first().children('button.add-child').click()
|
||||||
|
cy.get('#text-detail > ul > li > ul > li').first().children('input.child-title').type('Nested Child Node')
|
||||||
|
cy.get('#text-detail > ul > li > ul > li').first().children('button.save-child').click()
|
||||||
|
|
||||||
|
cy.wait('@createNode').its('response.statusCode').should('eq', 201)
|
||||||
|
cy.wait('@getNodesRefresh')
|
||||||
|
|
||||||
|
cy.get('#text-detail li').should('contain', 'Nested Child Node')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('newly added child persists after page reload', () => {
|
||||||
|
cy.intercept('POST', '/api/nodes').as('createNode')
|
||||||
|
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh')
|
||||||
|
|
||||||
|
cy.get('#text-detail > ul > li').first().children('button.add-child').click()
|
||||||
|
cy.get('#text-detail > ul > li').first().children('input.child-title').type('Persistent Child')
|
||||||
|
cy.get('#text-detail > ul > li').first().children('button.save-child').click()
|
||||||
|
|
||||||
|
cy.wait('@createNode')
|
||||||
|
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', 'Persistent Child')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -8,16 +8,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||||
h1.textContent = text.name;
|
h1.textContent = text.name;
|
||||||
document.getElementById('text-detail').appendChild(h1);
|
document.getElementById('text-detail').appendChild(h1);
|
||||||
|
|
||||||
return fetch('/api/nodes/' + textId );
|
return fetchAndRenderNodes(textId);
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(nodes => {
|
|
||||||
const tree = buildTree(nodes);
|
|
||||||
const ul = renderTree(tree);
|
|
||||||
document.getElementById('text-detail').appendChild(ul);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function fetchAndRenderNodes(textId) {
|
||||||
|
return fetch('/api/nodes/' + textId)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(nodes => {
|
||||||
|
const existing = document.querySelector('#text-detail > ul');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const tree = buildTree(nodes);
|
||||||
|
const ul = renderTree(tree, textId);
|
||||||
|
document.getElementById('text-detail').appendChild(ul);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildTree(nodes) {
|
function buildTree(nodes) {
|
||||||
const map = {};
|
const map = {};
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
|
|
@ -36,15 +43,62 @@ function buildTree(nodes) {
|
||||||
return roots;
|
return roots;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTree(nodes) {
|
function renderTree(nodes, textId) {
|
||||||
const ul = document.createElement('ul');
|
const ul = document.createElement('ul');
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.textContent = node.title;
|
|
||||||
|
const titleSpan = document.createElement('span');
|
||||||
|
titleSpan.textContent = node.title;
|
||||||
|
li.appendChild(titleSpan);
|
||||||
|
|
||||||
|
const addBtn = document.createElement('button');
|
||||||
|
addBtn.textContent = 'Add child';
|
||||||
|
addBtn.className = 'add-child';
|
||||||
|
addBtn.addEventListener('click', () => toggleAddForm(li, node.id, textId));
|
||||||
|
li.appendChild(addBtn);
|
||||||
|
|
||||||
if (node.children.length > 0) {
|
if (node.children.length > 0) {
|
||||||
li.appendChild(renderTree(node.children));
|
li.appendChild(renderTree(node.children, textId));
|
||||||
}
|
}
|
||||||
|
|
||||||
ul.appendChild(li);
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
return ul;
|
return ul;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleAddForm(li, parentNodeId, textId) {
|
||||||
|
const existing = li.querySelector('input.child-title');
|
||||||
|
if (existing) {
|
||||||
|
existing.remove();
|
||||||
|
li.querySelector('button.save-child').remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.className = 'child-title';
|
||||||
|
input.placeholder = 'Node title';
|
||||||
|
|
||||||
|
const saveBtn = document.createElement('button');
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
saveBtn.className = 'save-child';
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const title = input.value.trim();
|
||||||
|
if (!title) return;
|
||||||
|
|
||||||
|
fetch('/api/nodes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }),
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error('Failed to create node');
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(() => fetchAndRenderNodes(textId));
|
||||||
|
});
|
||||||
|
|
||||||
|
li.appendChild(input);
|
||||||
|
li.appendChild(saveBtn);
|
||||||
|
}
|
||||||
|
|
|
||||||
159
tests/e2e/Controllers/NodeControllerTest.php
Normal file
159
tests/e2e/Controllers/NodeControllerTest.php
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\e2e\Controllers;
|
||||||
|
|
||||||
|
use App\Node\CreateNodeDto;
|
||||||
|
use App\Node\NodeController;
|
||||||
|
use App\Node\UseCases\CreateNode;
|
||||||
|
use App\Text\CreateTextDto;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Slim\Psr7\Factory\ServerRequestFactory;
|
||||||
|
use Slim\Psr7\Factory\StreamFactory;
|
||||||
|
use Slim\Psr7\Response;
|
||||||
|
use Tests\Fakes\FakeNodeRepository;
|
||||||
|
use Tests\Fakes\FakeTextRepository;
|
||||||
|
|
||||||
|
class NodeControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private FakeTextRepository $textRepo;
|
||||||
|
private FakeNodeRepository $nodeRepo;
|
||||||
|
private NodeController $controller;
|
||||||
|
|
||||||
|
public function setUp(): void
|
||||||
|
{
|
||||||
|
$this->textRepo = new FakeTextRepository;
|
||||||
|
$this->textRepo->create(new CreateTextDto(name: 'test text'));
|
||||||
|
|
||||||
|
$this->nodeRepo = new FakeNodeRepository;
|
||||||
|
|
||||||
|
$this->controller = new NodeController($this->nodeRepo, $this->textRepo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_nodes_of_text_returns_flat_array(): void
|
||||||
|
{
|
||||||
|
$text = $this->textRepo->find(0);
|
||||||
|
$this->nodeRepo->create(new CreateNodeDto(
|
||||||
|
text: $text,
|
||||||
|
title: 'Root Node',
|
||||||
|
parentNode: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$response = $this->controller->getNodesOfText(new Response(), 0);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$this->assertEquals(
|
||||||
|
json_encode([
|
||||||
|
['id' => 0, 'title' => 'Root Node', 'parentNodeId' => null],
|
||||||
|
]),
|
||||||
|
$response->getBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_nodes_of_text_returns_empty_array_when_no_nodes(): void
|
||||||
|
{
|
||||||
|
$response = $this->controller->getNodesOfText(new Response(), 0);
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->getStatusCode());
|
||||||
|
$this->assertEquals(json_encode([]), $response->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_nodes_of_text_returns_404_for_unknown_text(): void
|
||||||
|
{
|
||||||
|
$response = $this->controller->getNodesOfText(new Response(), 99);
|
||||||
|
|
||||||
|
$this->assertEquals(404, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_nodes_includes_parent_node_id(): void
|
||||||
|
{
|
||||||
|
$text = $this->textRepo->find(0);
|
||||||
|
$rootNode = $this->nodeRepo->create(new CreateNodeDto(
|
||||||
|
text: $text,
|
||||||
|
title: 'Root Node',
|
||||||
|
parentNode: null,
|
||||||
|
));
|
||||||
|
$this->nodeRepo->create(new CreateNodeDto(
|
||||||
|
text: $text,
|
||||||
|
title: 'Child Node',
|
||||||
|
parentNode: $rootNode,
|
||||||
|
));
|
||||||
|
|
||||||
|
$response = $this->controller->getNodesOfText(new Response(), 0);
|
||||||
|
$body = json_decode($response->getBody(), true);
|
||||||
|
|
||||||
|
$this->assertEquals(0, $body[1]['parentNodeId']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_node_returns_created_node(): void
|
||||||
|
{
|
||||||
|
$text = $this->textRepo->find(0);
|
||||||
|
$rootNode = $this->nodeRepo->create(new CreateNodeDto(
|
||||||
|
text: $text,
|
||||||
|
title: 'Root Node',
|
||||||
|
parentNode: null,
|
||||||
|
));
|
||||||
|
|
||||||
|
$body = (new StreamFactory())->createStream(json_encode([
|
||||||
|
'textId' => 0,
|
||||||
|
'title' => 'Child Node',
|
||||||
|
'parentNodeId' => $rootNode->getId(),
|
||||||
|
]));
|
||||||
|
$request = (new ServerRequestFactory())
|
||||||
|
->createServerRequest('POST', 'http://localhost/api/nodes')
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withBody($body);
|
||||||
|
|
||||||
|
$response = $this->controller->createNode(
|
||||||
|
$request,
|
||||||
|
new Response(),
|
||||||
|
new CreateNode($this->nodeRepo, $this->textRepo),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(201, $response->getStatusCode());
|
||||||
|
$body = json_decode($response->getBody(), true);
|
||||||
|
$this->assertEquals('Child Node', $body['title']);
|
||||||
|
$this->assertEquals($rootNode->getId(), $body['parentNodeId']);
|
||||||
|
$this->assertArrayHasKey('id', $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_node_returns_400_when_title_missing(): void
|
||||||
|
{
|
||||||
|
$body = (new StreamFactory())->createStream(json_encode([
|
||||||
|
'textId' => 0,
|
||||||
|
'parentNodeId' => null,
|
||||||
|
]));
|
||||||
|
$request = (new ServerRequestFactory())
|
||||||
|
->createServerRequest('POST', 'http://localhost/api/nodes')
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withBody($body);
|
||||||
|
|
||||||
|
$response = $this->controller->createNode(
|
||||||
|
$request,
|
||||||
|
new Response(),
|
||||||
|
new CreateNode($this->nodeRepo, $this->textRepo),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(400, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_create_node_returns_404_when_text_not_found(): void
|
||||||
|
{
|
||||||
|
$body = (new StreamFactory())->createStream(json_encode([
|
||||||
|
'textId' => 99,
|
||||||
|
'title' => 'Some Node',
|
||||||
|
'parentNodeId' => null,
|
||||||
|
]));
|
||||||
|
$request = (new ServerRequestFactory())
|
||||||
|
->createServerRequest('POST', 'http://localhost/api/nodes')
|
||||||
|
->withHeader('Content-Type', 'application/json')
|
||||||
|
->withBody($body);
|
||||||
|
|
||||||
|
$response = $this->controller->createNode(
|
||||||
|
$request,
|
||||||
|
new Response(),
|
||||||
|
new CreateNode($this->nodeRepo, $this->textRepo),
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals(404, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue