From 3a9116dc3fb52d88995dd99cbf2107b9a8cee387 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 22 Jun 2026 10:01:31 +0300 Subject: [PATCH 1/2] test parent navigation --- .../tests/Feature/ElementsEndpointTest.php | 6 ++ .../Controllers/ElementControllerTest.php | 6 ++ .../e2e/element-sibling-navigation.cy.ts | 57 +++++++++++++++++++ 3 files changed, 69 insertions(+) diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 329e932..3f21172 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -94,6 +94,7 @@ class ElementsEndpointTest extends TestCase 'longPdfPath' => '/assets/pdfs/baderech-long.pdf', 'youtubeUrl' => $sampleYoutubeUrl, ], + 'parentElement' => null, 'siblingElements' => [ [ 'id' => $element->getId(), @@ -151,6 +152,11 @@ class ElementsEndpointTest extends TestCase ); $response->assertOk(); + $response->assertJsonPath('parentElement', [ + 'id' => $parentElement->getId(), + 'title' => 'Baderech HaAvodah', + 'description' => 'A structured path for growth', + ]); $response->assertJsonPath('siblingElements', [ [ 'id' => $firstChildElement->getId(), diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 7704306..1ebcb9c 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -140,6 +140,7 @@ class ElementControllerTest extends TestCase 'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s', $body['element']['youtubeUrl'], ); + $this->assertNull($body['parentElement']); $this->assertSame([ [ 'id' => $firstChildElement->getId(), @@ -202,6 +203,11 @@ class ElementControllerTest extends TestCase $this->assertEquals(200, $response->getStatusCode()); $body = json_decode($response->getContent(), true); + $this->assertSame([ + 'id' => $parentElement->getId(), + 'title' => 'Baderech HaAvodah', + 'description' => 'A structured path for growth', + ], $body['parentElement']); $this->assertSame([ [ 'id' => $firstChildElement->getId(), diff --git a/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts index 8c52491..57a89ca 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts @@ -10,6 +10,63 @@ function loginAsAdmin(): void { } describe('element sibling navigation', () => { + it('navigates public parent elements when one exists', () => { + cy.visit('/element/1') + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') + cy.get('[data-cy="element-parent-link"]').should('not.exist') + + cy.visit('/element/2') + cy.contains('h1', '1. Introduction').should('be.visible') + cy.get('[data-cy="element-parent-link"]').as('parentLink') + cy.get('@parentLink') + .should('be.visible') + .and('contain.text', 'Parent: Baderech HaAvodah') + cy.get('@parentLink').should('have.attr', 'href', '/element/1') + cy.get('@parentLink').then((parentLinkElements) => { + cy.get('h1').then((headingElements) => { + expect( + parentLinkElements[0].compareDocumentPosition(headingElements[0]) + & Node.DOCUMENT_POSITION_FOLLOWING, + ).to.not.equal(0) + }) + }) + cy.get('@parentLink').click() + + cy.location('pathname').should('eq', '/element/1') + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') + }) + + it('navigates admin parent elements when one exists', () => { + loginAsAdmin() + + cy.visit('/admin/element/1') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', 'Baderech HaAvodah') + cy.get('[data-cy="admin-parent-link"]').should('not.exist') + + cy.visit('/admin/element/2') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', '1. Introduction') + cy.get('[data-cy="admin-parent-link"]').as('parentLink') + cy.get('@parentLink') + .should('be.visible') + .and('contain.text', 'Parent: Baderech HaAvodah') + cy.get('@parentLink').should('have.attr', 'href', '/admin/element/1') + cy.get('@parentLink').then((parentLinkElements) => { + cy.get('.admin-element-page__heading').then((headingElements) => { + expect( + parentLinkElements[0].compareDocumentPosition(headingElements[0]) + & Node.DOCUMENT_POSITION_FOLLOWING, + ).to.not.equal(0) + }) + }) + cy.get('@parentLink').click() + + cy.location('pathname').should('eq', '/admin/element/1') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', 'Baderech HaAvodah') + }) + it('navigates public same-parent elements with wraparound', () => { cy.visit('/element/1') cy.contains('h1', 'Baderech HaAvodah').should('be.visible') From efc424183a3d059b92998ebdbf715bfbba297e57 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 22 Jun 2026 10:04:58 +0300 Subject: [PATCH 2/2] add parent navigation --- backend/app/Controllers/ElementController.php | 32 +++++++++-- .../src/components/ElementParentLink.vue | 57 +++++++++++++++++++ frontend/rabbi_gerzi/src/stores/elements.ts | 6 ++ .../src/views/AdminElementPage.vue | 10 +++- .../rabbi_gerzi/src/views/ElementPage.vue | 28 ++++++++- 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 frontend/rabbi_gerzi/src/components/ElementParentLink.vue diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 0101dea..8665880 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -55,6 +55,9 @@ class ElementController $result->getChildElements(), ), 'element' => $this->buildElementPayload($element), + 'parentElement' => $this->buildElementSummaryPayload( + $element->getParentElement(), + ), 'siblingElements' => $this->buildElementSummaryPayloads( $result->getSiblingElements(), ), @@ -328,16 +331,35 @@ class ElementController { $payloads = []; foreach ($elements as $element) { - $payloads[] = [ - 'id' => $element->getId(), - 'title' => $element->getTitle(), - 'description' => $element->getDescription(), - ]; + $payload = $this->buildElementSummaryPayload($element); + if ($payload !== null) { + $payloads[] = $payload; + } } return $payloads; } + /** + * @return array{ + * id: int, + * title: string, + * description: string, + * }|null + */ + private function buildElementSummaryPayload(?Element $element): ?array + { + if ($element === null) { + return null; + } + + return [ + 'id' => $element->getId(), + 'title' => $element->getTitle(), + 'description' => $element->getDescription(), + ]; + } + private function storageUrl(?string $path, string $folderPrefix): ?string { if ($path === null) { diff --git a/frontend/rabbi_gerzi/src/components/ElementParentLink.vue b/frontend/rabbi_gerzi/src/components/ElementParentLink.vue new file mode 100644 index 0000000..f105b68 --- /dev/null +++ b/frontend/rabbi_gerzi/src/components/ElementParentLink.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index 074ba04..870eb2e 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -20,6 +20,7 @@ type ElementFileType = 'iconImage' | 'shortPdf' | 'longPdf' interface ElementResponse { element: Element childElements: ChildElement[] + parentElement: ChildElement | null siblingElements: ChildElement[] } @@ -63,6 +64,7 @@ const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update` export const useElementsStore = defineStore('elements', () => { const element = ref(null) const childElements = ref([]) + const parentElement = ref(null) const siblingElements = ref([]) const isLoading = ref(false) const error = ref(null) @@ -77,6 +79,7 @@ export const useElementsStore = defineStore('elements', () => { async function fetchElement(elementId: string): Promise { element.value = null childElements.value = [] + parentElement.value = null siblingElements.value = [] error.value = null saveError.value = null @@ -101,9 +104,11 @@ export const useElementsStore = defineStore('elements', () => { const data: ElementResponse = await response.json() element.value = data.element childElements.value = data.childElements + parentElement.value = data.parentElement siblingElements.value = data.siblingElements } catch { childElements.value = [] + parentElement.value = null siblingElements.value = [] error.value = 'Network error - could not load element' } finally { @@ -451,6 +456,7 @@ export const useElementsStore = defineStore('elements', () => { return { element, childElements, + parentElement, siblingElements, isLoading, error, diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index 2c66cfc..3d529ba 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -6,6 +6,7 @@ import { ChevronUp as ChevronUpIcon, } from 'lucide-vue-next' import { useRoute, useRouter } from 'vue-router' +import ElementParentLink from '@/components/ElementParentLink.vue' import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue' import RichTextEditor from '@/components/RichTextEditor.vue' import SiteHeader from '@/components/SiteHeader.vue' @@ -30,6 +31,7 @@ const elementsStore = useElementsStore() const { element, childElements, + parentElement, siblingElements, isLoading, error, @@ -352,6 +354,12 @@ function resetInput(changeEvent: Event): void {
+

Edit Element

@@ -730,7 +738,7 @@ function resetInput(changeEvent: Event): void { } .admin-element-page__heading { - margin: 0; + margin: 0.35rem 0 0; color: #2c2c2c; font-family: var(--font-serif); font-size: 2rem; diff --git a/frontend/rabbi_gerzi/src/views/ElementPage.vue b/frontend/rabbi_gerzi/src/views/ElementPage.vue index fbf4dee..c2ed707 100644 --- a/frontend/rabbi_gerzi/src/views/ElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/ElementPage.vue @@ -2,6 +2,7 @@ import { storeToRefs } from 'pinia' import { computed, watch } from 'vue' import { useRoute } from 'vue-router' +import ElementParentLink from '@/components/ElementParentLink.vue' import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue' import SiteHeader from '@/components/SiteHeader.vue' import { useElementsStore } from '@/stores/elements' @@ -10,8 +11,14 @@ type TimestampPart = string | undefined const route = useRoute() const elementsStore = useElementsStore() -const { element, childElements, siblingElements, isLoading, error } = - storeToRefs(elementsStore) +const { + element, + childElements, + parentElement, + siblingElements, + isLoading, + error, +} = storeToRefs(elementsStore) const youtubeIframeAllow = [ 'accelerometer', @@ -221,6 +228,17 @@ function isShortYoutubeHost(hostname: string): boolean { data-cy="element-icon" /> +
+ +
+

{{ element.title }}

@@ -335,6 +353,12 @@ function isShortYoutubeHost(hostname: string): boolean { object-fit: contain; } +.element-page__parent-navigation { + display: flex; + justify-content: center; + margin: 0 0 0.5rem; +} + .element-page__heading { margin: 0; color: #2c2c2c;