Merge branch 'text-page-horizontal-layout'

This commit is contained in:
Yisroel Baum 2026-05-11 19:12:03 +03:00
commit 385699abc1
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
8 changed files with 172 additions and 38 deletions

View file

@ -27,6 +27,7 @@ $app->post('/api/auth/register', [AuthController::class, 'register']);
// Authenticated routes (any logged-in user) // Authenticated routes (any logged-in user)
$app->group('', function (RouteCollectorProxy $group) { $app->group('', function (RouteCollectorProxy $group) {
$group->get('/', [ViewController::class, 'home']);
$group->get('/home', [ViewController::class, 'home']); $group->get('/home', [ViewController::class, 'home']);
$group->get('/today', [ViewController::class, 'today']); $group->get('/today', [ViewController::class, 'today']);
$group->get('/texts', [ViewController::class, 'userTexts']); $group->get('/texts', [ViewController::class, 'userTexts']);

View file

@ -32,7 +32,8 @@ describe('The admin text detail page', () => {
}) })
it('clicking "Add child" reveals an inline form', () => { 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('input.child-title').should('be.visible')
cy.get('#text-detail li').first().children('button.save-child').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('POST', '/api/nodes').as('createNode')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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('input.child-title').type('New Child Node')
cy.get('#text-detail > ul > li').first().children('button.save-child').click() 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('POST', '/api/nodes').as('createNode')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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('input.child-title').type('Nested Child Node')
cy.get('#text-detail > ul > li > ul > li').first().children('button.save-child').click() 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('POST', '/api/nodes').as('createNode')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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') cy.get('#text-detail > ul > li > ul > li')
.first() .first()
.find('button.toggle-children') .find('button.toggle-children')
@ -107,6 +113,7 @@ describe('The admin text detail page', () => {
cy.get('#text-detail > ul > li') cy.get('#text-detail > ul > li')
.first() .first()
.activateNode()
.children('button.add-child') .children('button.add-child')
.click() .click()
cy.get('#text-detail > ul > li') 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', () => { it('opening add-child on another node closes the first one', () => {
cy.get('#text-detail > ul > li') cy.get('#text-detail > ul > li')
.first() .first()
.activateNode()
.children('button.add-child') .children('button.add-child')
.click() .click()
cy.get('#text-detail > ul > li') cy.get('#text-detail > ul > li')
@ -132,6 +140,7 @@ describe('The admin text detail page', () => {
cy.get('#text-detail > ul > li > ul > li') cy.get('#text-detail > ul > li > ul > li')
.first() .first()
.activateNode()
.children('button.add-child') .children('button.add-child')
.click() .click()
@ -152,6 +161,7 @@ describe('The admin text detail page', () => {
it('opening bulk-add closes an open add-child form', () => { it('opening bulk-add closes an open add-child form', () => {
cy.get('#text-detail > ul > li') cy.get('#text-detail > ul > li')
.first() .first()
.activateNode()
.children('button.add-child') .children('button.add-child')
.click() .click()
cy.get('#text-detail > ul > li') cy.get('#text-detail > ul > li')
@ -161,6 +171,7 @@ describe('The admin text detail page', () => {
cy.get('#text-detail > ul > li > ul > li') cy.get('#text-detail > ul > li > ul > li')
.first() .first()
.activateNode()
.children('button.bulk-add-children') .children('button.bulk-add-children')
.click() .click()
@ -178,7 +189,8 @@ describe('The admin text detail page', () => {
cy.intercept('POST', '/api/nodes').as('createNode') cy.intercept('POST', '/api/nodes').as('createNode')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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('input.child-title').type('Persistent Child')
cy.get('#text-detail > ul > li').first().children('button.save-child').click() cy.get('#text-detail > ul > li').first().children('button.save-child').click()

View file

@ -20,14 +20,16 @@ describe('Bulk add children on the admin text detail page', () => {
}) })
it('clicking "Bulk add children" reveals inline form inputs', () => { 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-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('input.bulk-count').should('be.visible')
cy.get('#text-detail > ul > li').first().children('button.save-bulk').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', () => { 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('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('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-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', () => { it('can bulk add children to the root node', () => {
cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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-title').type('Page')
cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('3') 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('#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', () => { it('does not submit if title prefix is empty', () => {
cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') 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('input.bulk-count').type('3')
cy.get('#text-detail > ul > li').first().children('button.save-bulk').click() cy.get('#text-detail > ul > li').first().children('button.save-bulk').click()
cy.get('@bulkCreate.all').should('have.length', 0) 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', () => { it('does not submit if count is empty', () => {
cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') 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('input.bulk-title').type('Page')
cy.get('#text-detail > ul > li').first().children('button.save-bulk').click() cy.get('#text-detail > ul > li').first().children('button.save-bulk').click()
cy.get('@bulkCreate.all').should('have.length', 0) 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', () => { it('pressing Enter in the bulk-count input submits', () => {
cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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-title').type('Enter')
cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('2{enter}') cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('2{enter}')
cy.wait('@bulkCreate').its('response.statusCode').should('eq', 201) 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', () => { it('pressing Enter in the bulk-title input submits', () => {
cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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-count').type('2')
cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Title{enter}') cy.get('#text-detail > ul > li').first().children('input.bulk-title').type('Title{enter}')
cy.wait('@bulkCreate').its('response.statusCode').should('eq', 201) 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', () => { it('bulk added nodes persist after page reload', () => {
cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate') cy.intercept('POST', '/api/nodes/bulk').as('bulkCreate')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh') 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-title').type('Page')
cy.get('#text-detail > ul > li').first().children('input.bulk-count').type('3') 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('#text-detail > ul > li').first().children('button.save-bulk').click()

View file

@ -0,0 +1,61 @@
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.intercept('POST', '/api/nodes').as('createNode')
cy.intercept('GET', '/api/nodes/0').as('getNodesRefresh')
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('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) => {
const firstRect = $siblings[0].getBoundingClientRect()
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)
})
})
})

View file

@ -30,7 +30,7 @@ describe('The user text detail page', () => {
cy.visit('/texts/0') cy.visit('/texts/0')
cy.wait('@getNodes') cy.wait('@getNodes')
cy.get('#text-detail > ul > li').first() cy.get('#text-detail > ul > li').first().activateNode()
.children('button.add-child').click() .children('button.add-child').click()
cy.get('#text-detail > ul > li').first() cy.get('#text-detail > ul > li').first()
.children('input.child-title').type('My new child') .children('input.child-title').type('My new child')

View file

@ -17,3 +17,8 @@ Cypress.Commands.add('loginAsUser', () => {
Cypress.Commands.add('loginAsSecondUser', () => { Cypress.Commands.add('loginAsSecondUser', () => {
cy.login('user2@example.com', 'password2') cy.login('user2@example.com', 'password2')
}) })
Cypress.Commands.add('activateNode', { prevSubject: 'element' }, ($li) => {
cy.wrap($li).children('span').first().click()
return cy.wrap($li)
})

View file

@ -444,6 +444,12 @@ form {
elements added. elements added.
*/ */
.node-tree ul {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.node-tree > ul { .node-tree > ul {
border-left: 2px solid var(--color-border); border-left: 2px solid var(--color-border);
padding-left: var(--space-4); padding-left: var(--space-4);
@ -451,26 +457,41 @@ form {
.node-tree ul ul { .node-tree ul ul {
border-left: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
margin-top: var(--space-2); margin-left: var(--space-3);
margin-left: var(--space-2);
padding-left: var(--space-4); padding-left: var(--space-4);
} }
.node-tree li { .node-tree li {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: nowrap;
align-items: center; align-items: flex-start;
gap: var(--space-2); gap: var(--space-2);
padding: var(--space-2) 0; padding: 0;
} }
.node-tree li > ul { .node-tree li > ul {
flex-basis: 100%; flex-shrink: 0;
margin-top: var(--space-2);
} }
.node-tree li > span { .node-tree li > span {
flex-shrink: 0;
font-weight: 500; 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 { .node-tree li > button {

View file

@ -1,4 +1,5 @@
const expandedNodeIds = new Set(); const expandedChildByParent = new Map();
let activeNodeId = null;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const textId = window.location.pathname.split('/').pop(); const textId = window.location.pathname.split('/').pop();
@ -75,12 +76,36 @@ function renderTree(nodes, textId, depth = 0) {
const titleSpan = document.createElement('span'); const titleSpan = document.createElement('span');
titleSpan.textContent = node.title; 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); li.appendChild(titleSpan);
if (node.id === activeNodeId) {
li.classList.add('is-active');
}
const addBtn = document.createElement('button'); const addBtn = document.createElement('button');
addBtn.textContent = 'Add child'; addBtn.textContent = 'Add child';
addBtn.className = '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); li.appendChild(addBtn);
const bulkBtn = document.createElement('button'); const bulkBtn = document.createElement('button');
@ -88,31 +113,32 @@ function renderTree(nodes, textId, depth = 0) {
bulkBtn.className = 'bulk-add-children'; bulkBtn.className = 'bulk-add-children';
bulkBtn.addEventListener( bulkBtn.addEventListener(
'click', 'click',
() => toggleBulkAddForm(li, node.id, textId) () => toggleBulkAddForm(li, node.id, node.parentNodeId, textId)
); );
li.appendChild(bulkBtn); li.appendChild(bulkBtn);
if (node.children.length > 0) { if (node.children.length > 0) {
const childUl = renderTree(node.children, textId, depth + 1); const childUl = renderTree(node.children, textId, depth + 1);
const childrenVisible = const childrenVisible =
expandedNodeIds.has(node.id) || depth === 0; depth === 0 ||
if (childrenVisible) { expandedChildByParent.get(node.parentNodeId) === node.id;
expandedNodeIds.add(node.id);
}
childUl.style.display = childrenVisible ? '' : 'none'; childUl.style.display = childrenVisible ? '' : 'none';
const toggleBtn = document.createElement('button'); const toggleBtn = document.createElement('button');
toggleBtn.className = 'toggle-children'; toggleBtn.className = 'toggle-children';
toggleBtn.textContent = childrenVisible ? '▼' : '▶'; toggleBtn.textContent = childrenVisible ? '▼' : '▶';
toggleBtn.addEventListener('click', () => { toggleBtn.addEventListener('click', () => {
const isHidden = childUl.style.display === 'none'; if (depth === 0) {
childUl.style.display = isHidden ? '' : 'none'; return;
toggleBtn.textContent = isHidden ? '▼' : '▶';
if (isHidden) {
expandedNodeIds.add(node.id);
} else {
expandedNodeIds.delete(node.id);
} }
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); li.appendChild(toggleBtn);
@ -139,7 +165,7 @@ function closeAllAddForms() {
}); });
} }
function toggleAddForm(li, parentNodeId, textId) { function toggleAddForm(li, parentNodeId, grandparentId, textId) {
const existing = li.querySelector('input.child-title'); const existing = li.querySelector('input.child-title');
if (existing) { if (existing) {
existing.remove(); existing.remove();
@ -162,7 +188,7 @@ function toggleAddForm(li, parentNodeId, textId) {
const title = input.value.trim(); const title = input.value.trim();
if (!title) return; if (!title) return;
expandedNodeIds.add(parentNodeId); expandedChildByParent.set(grandparentId, parentNodeId);
fetch('/api/nodes', { fetch('/api/nodes', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -189,7 +215,7 @@ function toggleAddForm(li, parentNodeId, textId) {
input.focus(); input.focus();
} }
function toggleBulkAddForm(li, parentNodeId, textId) { function toggleBulkAddForm(li, parentNodeId, grandparentId, textId) {
const existing = li.querySelector('input.bulk-title'); const existing = li.querySelector('input.bulk-title');
if (existing) { if (existing) {
existing.remove(); existing.remove();
@ -220,7 +246,7 @@ function toggleBulkAddForm(li, parentNodeId, textId) {
const count = parseInt(countInput.value); const count = parseInt(countInput.value);
if (!titlePrefix || !count || count < 1) return; if (!titlePrefix || !count || count < 1) return;
expandedNodeIds.add(parentNodeId); expandedChildByParent.set(grandparentId, parentNodeId);
fetch('/api/nodes/bulk', { fetch('/api/nodes/bulk', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },