From bac29ba73789091de681f504eceac98133256f59 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 4 May 2026 09:29:51 +0300 Subject: [PATCH 1/8] test horizontal node layout --- cypress/e2e/adminTextLayout.cy.js | 41 +++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 cypress/e2e/adminTextLayout.cy.js diff --git a/cypress/e2e/adminTextLayout.cy.js b/cypress/e2e/adminTextLayout.cy.js new file mode 100644 index 0000000..f13b97c --- /dev/null +++ b/cypress/e2e/adminTextLayout.cy.js @@ -0,0 +1,41 @@ +describe('The admin text detail page horizontal layout', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + cy.loginAsAdmin() + 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('renders child list to the right of the parent title', () => { + cy.get('#text-detail > ul > li').first().as('rootLi') + cy.get('@rootLi').children('span').first().as('rootTitle') + cy.get('@rootLi').children('ul').first().as('childList') + + cy.get('@childList').should('be.visible') + + cy.get('@rootTitle').then(($title) => { + const titleRect = $title[0].getBoundingClientRect() + cy.get('@childList').then(($list) => { + const listRect = $list[0].getBoundingClientRect() + expect(listRect.left).to.be.greaterThan(titleRect.right) + }) + }) + }) + + it('stacks sibling child nodes vertically within the right column', () => { + cy.get('#text-detail > ul > li > ul > li').then(($siblings) => { + expect($siblings.length).to.be.greaterThan(1) + const firstRect = $siblings[0].getBoundingClientRect() + const secondRect = $siblings[1].getBoundingClientRect() + expect(secondRect.top).to.be.greaterThan(firstRect.top) + expect(Math.abs(secondRect.left - firstRect.left)).to.be.lessThan(2) + }) + }) +}) From c493bef13d28f7584f985311fd911979faec50e2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 4 May 2026 09:32:15 +0300 Subject: [PATCH 2/8] seed sibling node in layout test --- cypress/e2e/adminTextLayout.cy.js | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/cypress/e2e/adminTextLayout.cy.js b/cypress/e2e/adminTextLayout.cy.js index f13b97c..a5e75cc 100644 --- a/cypress/e2e/adminTextLayout.cy.js +++ b/cypress/e2e/adminTextLayout.cy.js @@ -30,12 +30,31 @@ describe('The admin text detail page horizontal layout', () => { }) it('stacks sibling child nodes vertically within the right column', () => { + 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('Sibling One{enter}') + cy.wait('@createNode') + cy.wait('@getNodesRefresh') + + cy.get('#text-detail > ul > li > ul > li').should( + 'have.length.greaterThan', + 1 + ) + cy.get('#text-detail > ul > li > ul > li').then(($siblings) => { - expect($siblings.length).to.be.greaterThan(1) const firstRect = $siblings[0].getBoundingClientRect() - const secondRect = $siblings[1].getBoundingClientRect() - expect(secondRect.top).to.be.greaterThan(firstRect.top) - expect(Math.abs(secondRect.left - firstRect.left)).to.be.lessThan(2) + const lastRect = $siblings[$siblings.length - 1] + .getBoundingClientRect() + expect(lastRect.top).to.be.greaterThan(firstRect.top) + expect(Math.abs(lastRect.left - firstRect.left)).to.be.lessThan(2) }) }) }) From e0be4bad11ff17a67436478139310c6204776a79 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 4 May 2026 09:36:10 +0300 Subject: [PATCH 3/8] lay out child nodes to right of parent --- public/css/app.css | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/public/css/app.css b/public/css/app.css index 767dddd..555a36e 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -444,6 +444,12 @@ form { elements added. */ +.node-tree ul { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + .node-tree > ul { border-left: 2px solid var(--color-border); padding-left: var(--space-4); @@ -451,25 +457,24 @@ form { .node-tree ul ul { border-left: 1px solid var(--color-border); - margin-top: var(--space-2); - margin-left: var(--space-2); + margin-left: var(--space-3); padding-left: var(--space-4); } .node-tree li { display: flex; - flex-wrap: wrap; - align-items: center; + flex-wrap: nowrap; + align-items: flex-start; gap: var(--space-2); padding: var(--space-2) 0; } .node-tree li > ul { - flex-basis: 100%; - margin-top: var(--space-2); + flex-shrink: 0; } .node-tree li > span { + flex-shrink: 0; font-weight: 500; } From c73cbe2f1a323f9a2227ae3ee4f322ac73243665 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 4 May 2026 09:45:36 +0300 Subject: [PATCH 4/8] hide node action buttons until title clicked --- public/css/app.css | 16 ++++++++++++++++ public/js/text.js | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/public/css/app.css b/public/css/app.css index 555a36e..e58a175 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -476,6 +476,22 @@ form { .node-tree li > span { flex-shrink: 0; font-weight: 500; + cursor: pointer; +} + +.node-tree li > span:hover, +.node-tree li.is-active > span { + color: var(--color-primary); +} + +.node-tree li > button.add-child, +.node-tree li > button.bulk-add-children { + display: none; +} + +.node-tree li.is-active > button.add-child, +.node-tree li.is-active > button.bulk-add-children { + display: inline-block; } .node-tree li > button { diff --git a/public/js/text.js b/public/js/text.js index 7566869..9dab122 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -1,4 +1,5 @@ const expandedNodeIds = new Set(); +let activeNodeId = null; document.addEventListener('DOMContentLoaded', () => { const textId = window.location.pathname.split('/').pop(); @@ -75,8 +76,29 @@ function renderTree(nodes, textId, depth = 0) { const titleSpan = document.createElement('span'); titleSpan.textContent = node.title; + titleSpan.addEventListener('click', () => { + if (activeNodeId === node.id) { + activeNodeId = null; + li.classList.remove('is-active'); + closeAllAddForms(); + return; + } + const previousActive = document.querySelector( + '#text-detail li.is-active' + ); + if (previousActive) { + previousActive.classList.remove('is-active'); + } + closeAllAddForms(); + li.classList.add('is-active'); + activeNodeId = node.id; + }); li.appendChild(titleSpan); + if (node.id === activeNodeId) { + li.classList.add('is-active'); + } + const addBtn = document.createElement('button'); addBtn.textContent = 'Add child'; addBtn.className = 'add-child'; From 793b0149b889a4171e4bc62176e36d0af16a3353 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 4 May 2026 10:03:54 +0300 Subject: [PATCH 5/8] activate node before clicking action buttons in tests --- cypress/e2e/adminText.cy.js | 20 ++++++++++++++++---- cypress/e2e/adminTextBulkAdd.cy.js | 24 ++++++++++++++++-------- cypress/e2e/adminTextLayout.cy.js | 1 + cypress/e2e/userText.cy.js | 2 +- cypress/support/commands.js | 5 +++++ 5 files changed, 39 insertions(+), 13 deletions(-) diff --git a/cypress/e2e/adminText.cy.js b/cypress/e2e/adminText.cy.js index 4e0893c..83415c2 100644 --- a/cypress/e2e/adminText.cy.js +++ b/cypress/e2e/adminText.cy.js @@ -32,7 +32,8 @@ describe('The admin text detail page', () => { }) 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().activateNode() + .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') }) @@ -41,7 +42,8 @@ describe('The admin text detail page', () => { 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().activateNode() + .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() @@ -55,7 +57,8 @@ describe('The admin text detail page', () => { 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().activateNode() + .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() @@ -69,6 +72,9 @@ describe('The admin text detail page', () => { cy.intercept('POST', '/api/nodes').as('createNode') cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') + cy.get('#text-detail > ul > li > ul > li') + .first() + .activateNode() cy.get('#text-detail > ul > li > ul > li') .first() .find('button.toggle-children') @@ -107,6 +113,7 @@ describe('The admin text detail page', () => { cy.get('#text-detail > ul > li') .first() + .activateNode() .children('button.add-child') .click() cy.get('#text-detail > ul > li') @@ -123,6 +130,7 @@ describe('The admin text detail page', () => { it('opening add-child on another node closes the first one', () => { cy.get('#text-detail > ul > li') .first() + .activateNode() .children('button.add-child') .click() cy.get('#text-detail > ul > li') @@ -132,6 +140,7 @@ describe('The admin text detail page', () => { cy.get('#text-detail > ul > li > ul > li') .first() + .activateNode() .children('button.add-child') .click() @@ -152,6 +161,7 @@ describe('The admin text detail page', () => { it('opening bulk-add closes an open add-child form', () => { cy.get('#text-detail > ul > li') .first() + .activateNode() .children('button.add-child') .click() cy.get('#text-detail > ul > li') @@ -161,6 +171,7 @@ describe('The admin text detail page', () => { cy.get('#text-detail > ul > li > ul > li') .first() + .activateNode() .children('button.bulk-add-children') .click() @@ -178,7 +189,8 @@ describe('The admin text detail page', () => { 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().activateNode() + .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() diff --git a/cypress/e2e/adminTextBulkAdd.cy.js b/cypress/e2e/adminTextBulkAdd.cy.js index 47d3c7f..39b06d3 100644 --- a/cypress/e2e/adminTextBulkAdd.cy.js +++ b/cypress/e2e/adminTextBulkAdd.cy.js @@ -20,14 +20,16 @@ describe('Bulk add children on the admin text detail page', () => { }) 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().activateNode() + .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().activateNode() + .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') @@ -38,7 +40,8 @@ describe('Bulk add children on the admin text detail page', () => { 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().activateNode() + .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() @@ -51,7 +54,8 @@ describe('Bulk add children on the admin text detail page', () => { 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().activateNode() + .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) @@ -59,7 +63,8 @@ describe('Bulk add children on the admin text detail page', () => { 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().activateNode() + .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) @@ -68,7 +73,8 @@ describe('Bulk add children on the admin text detail page', () => { it('pressing Enter in the bulk-count input submits', () => { 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().activateNode() + .children('button.bulk-add-children').click() cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Enter') cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('2{enter}') cy.wait('@bulkCreate').its('response.statusCode').should('eq', 201) @@ -80,7 +86,8 @@ describe('Bulk add children on the admin text detail page', () => { it('pressing Enter in the bulk-title input submits', () => { 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().activateNode() + .children('button.bulk-add-children').click() cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('2') cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Title{enter}') cy.wait('@bulkCreate').its('response.statusCode').should('eq', 201) @@ -92,7 +99,8 @@ describe('Bulk add children on the admin text detail page', () => { 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().activateNode() + .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() diff --git a/cypress/e2e/adminTextLayout.cy.js b/cypress/e2e/adminTextLayout.cy.js index a5e75cc..6d741a1 100644 --- a/cypress/e2e/adminTextLayout.cy.js +++ b/cypress/e2e/adminTextLayout.cy.js @@ -35,6 +35,7 @@ describe('The admin text detail page horizontal layout', () => { cy.get('#text-detail > ul > li') .first() + .activateNode() .children('button.add-child') .click() cy.get('#text-detail > ul > li') diff --git a/cypress/e2e/userText.cy.js b/cypress/e2e/userText.cy.js index 2318f07..7c3e379 100644 --- a/cypress/e2e/userText.cy.js +++ b/cypress/e2e/userText.cy.js @@ -30,7 +30,7 @@ describe('The user text detail page', () => { cy.visit('/texts/0') cy.wait('@getNodes') - cy.get('#text-detail > ul > li').first() + cy.get('#text-detail > ul > li').first().activateNode() .children('button.add-child').click() cy.get('#text-detail > ul > li').first() .children('input.child-title').type('My new child') diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 1c1d028..af58025 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -17,3 +17,8 @@ Cypress.Commands.add('loginAsUser', () => { Cypress.Commands.add('loginAsSecondUser', () => { cy.login('user2@example.com', 'password2') }) + +Cypress.Commands.add('activateNode', { prevSubject: 'element' }, ($li) => { + cy.wrap($li).children('span').first().click() + return cy.wrap($li) +}) From 7e33103bc953e897d36ecf9ffba49e781c071734 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 4 May 2026 10:06:06 +0300 Subject: [PATCH 6/8] expand only one sibling at a time --- public/js/text.js | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/public/js/text.js b/public/js/text.js index 9dab122..aca2210 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -1,4 +1,4 @@ -const expandedNodeIds = new Set(); +const expandedChildByParent = new Map(); let activeNodeId = null; document.addEventListener('DOMContentLoaded', () => { @@ -102,7 +102,10 @@ function renderTree(nodes, textId, depth = 0) { const addBtn = document.createElement('button'); addBtn.textContent = 'Add child'; addBtn.className = 'add-child'; - addBtn.addEventListener('click', () => toggleAddForm(li, node.id, textId)); + addBtn.addEventListener( + 'click', + () => toggleAddForm(li, node.id, node.parentNodeId, textId) + ); li.appendChild(addBtn); const bulkBtn = document.createElement('button'); @@ -110,31 +113,32 @@ function renderTree(nodes, textId, depth = 0) { bulkBtn.className = 'bulk-add-children'; bulkBtn.addEventListener( 'click', - () => toggleBulkAddForm(li, node.id, textId) + () => toggleBulkAddForm(li, node.id, node.parentNodeId, textId) ); li.appendChild(bulkBtn); if (node.children.length > 0) { const childUl = renderTree(node.children, textId, depth + 1); const childrenVisible = - expandedNodeIds.has(node.id) || depth === 0; - if (childrenVisible) { - expandedNodeIds.add(node.id); - } + depth === 0 || + expandedChildByParent.get(node.parentNodeId) === node.id; childUl.style.display = childrenVisible ? '' : 'none'; const toggleBtn = document.createElement('button'); toggleBtn.className = 'toggle-children'; toggleBtn.textContent = childrenVisible ? '▼' : '▶'; toggleBtn.addEventListener('click', () => { - const isHidden = childUl.style.display === 'none'; - childUl.style.display = isHidden ? '' : 'none'; - toggleBtn.textContent = isHidden ? '▼' : '▶'; - if (isHidden) { - expandedNodeIds.add(node.id); - } else { - expandedNodeIds.delete(node.id); + if (depth === 0) { + return; } + const expandedSibling = + expandedChildByParent.get(node.parentNodeId); + if (expandedSibling === node.id) { + expandedChildByParent.delete(node.parentNodeId); + } else { + expandedChildByParent.set(node.parentNodeId, node.id); + } + fetchAndRenderNodes(textId); }); li.appendChild(toggleBtn); @@ -161,7 +165,7 @@ function closeAllAddForms() { }); } -function toggleAddForm(li, parentNodeId, textId) { +function toggleAddForm(li, parentNodeId, grandparentId, textId) { const existing = li.querySelector('input.child-title'); if (existing) { existing.remove(); @@ -184,7 +188,7 @@ function toggleAddForm(li, parentNodeId, textId) { const title = input.value.trim(); if (!title) return; - expandedNodeIds.add(parentNodeId); + expandedChildByParent.set(grandparentId, parentNodeId); fetch('/api/nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -211,7 +215,7 @@ function toggleAddForm(li, parentNodeId, textId) { input.focus(); } -function toggleBulkAddForm(li, parentNodeId, textId) { +function toggleBulkAddForm(li, parentNodeId, grandparentId, textId) { const existing = li.querySelector('input.bulk-title'); if (existing) { existing.remove(); @@ -242,7 +246,7 @@ function toggleBulkAddForm(li, parentNodeId, textId) { const count = parseInt(countInput.value); if (!titlePrefix || !count || count < 1) return; - expandedNodeIds.add(parentNodeId); + expandedChildByParent.set(grandparentId, parentNodeId); fetch('/api/nodes/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, From 27f5638ebc3efc34d22e5b8f5f844ae20c17d59c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 11 May 2026 19:01:16 +0300 Subject: [PATCH 7/8] empty path leads to home view --- bootstrap/app.php | 1 + 1 file changed, 1 insertion(+) diff --git a/bootstrap/app.php b/bootstrap/app.php index 1e4d6cf..5e80c73 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -27,6 +27,7 @@ $app->post('/api/auth/register', [AuthController::class, 'register']); // Authenticated routes (any logged-in user) $app->group('', function (RouteCollectorProxy $group) { + $group->get('/', [ViewController::class, 'home']); $group->get('/home', [ViewController::class, 'home']); $group->get('/today', [ViewController::class, 'today']); $group->get('/texts', [ViewController::class, 'userTexts']); From e4136f0e372ebca7924b3733a5764de66224e4a7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 11 May 2026 19:11:25 +0300 Subject: [PATCH 8/8] keep children flush to top of page --- public/css/app.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/css/app.css b/public/css/app.css index e58a175..9e3c2cf 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -466,7 +466,7 @@ form { flex-wrap: nowrap; align-items: flex-start; gap: var(--space-2); - padding: var(--space-2) 0; + padding: 0; } .node-tree li > ul {