From eb868679c82b412256697ea441c7109020448a3d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:36:29 +0300 Subject: [PATCH 1/7] test element siblings --- .../tests/Feature/ElementsEndpointTest.php | 68 ++++++++++ .../Controllers/ElementControllerTest.php | 62 +++++++++ .../Unit/Element/UseCases/GetElementTest.php | 128 ++++++++++++++++++ 3 files changed, 258 insertions(+) diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index 157f944..a914dd9 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -94,6 +94,74 @@ class ElementsEndpointTest extends TestCase 'longPdfPath' => '/assets/pdfs/baderech-long.pdf', 'youtubeUrl' => $sampleYoutubeUrl, ], + 'siblingElements' => [ + [ + 'id' => $element->getId(), + 'title' => 'Baderech HaAvodah', + 'description' => 'A structured path for growth', + ], + ], + ]); + } + + public function testReturnsSameParentSiblingElements(): void + { + $setRepository = app(SetRepository::class); + $elementRepository = app(ElementRepository::class); + $set = $this->createSet($setRepository); + $parentElement = $this->createElement( + $elementRepository, + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '', + null, + null, + null, + null, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'Avodah Foundations', + 'Foundations for steady avodah', + null, + '

Foundations rich text

', + null, + null, + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $elementRepository, + $set, + 'Daily Practice', + 'Daily practices for growth', + null, + '', + null, + null, + null, + $parentElement, + ); + + $response = $this->getJson( + "/api/elements/{$firstChildElement->getId()}" + ); + + $response->assertOk(); + $response->assertJsonPath('siblingElements', [ + [ + 'id' => $firstChildElement->getId(), + 'title' => 'Avodah Foundations', + 'description' => 'Foundations for steady avodah', + ], + [ + 'id' => $secondChildElement->getId(), + 'title' => 'Daily Practice', + 'description' => 'Daily practices for growth', + ], ]); } diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index c0ce0a6..c32ac21 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -150,6 +150,68 @@ class ElementControllerTest extends TestCase 'description' => 'Daily practices for growth', ], ], $body['childElements']); + $this->assertSame([ + [ + 'id' => $element->getId(), + 'title' => 'Baderech HaAvodah', + 'description' => 'A structured path for growth', + ], + ], $body['siblingElements']); + } + + public function testShowReturnsSameParentSiblingPayload(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement( + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '', + null, + null, + null, + null, + ); + $firstChildElement = $this->createElement( + $set, + 'Avodah Foundations', + 'Foundations for steady avodah', + null, + '

Foundations rich text

', + null, + null, + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Daily Practice', + 'Daily practices for growth', + null, + '', + null, + null, + null, + $parentElement, + ); + + $response = $this->controller->show($firstChildElement->getId()); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + $this->assertSame([ + [ + 'id' => $firstChildElement->getId(), + 'title' => 'Avodah Foundations', + 'description' => 'Foundations for steady avodah', + ], + [ + 'id' => $secondChildElement->getId(), + 'title' => 'Daily Practice', + 'description' => 'Daily practices for growth', + ], + ], $body['siblingElements']); } public function testShowReturns400WhenIdMissing(): void diff --git a/backend/tests/Unit/Element/UseCases/GetElementTest.php b/backend/tests/Unit/Element/UseCases/GetElementTest.php index d4053f5..69917bd 100644 --- a/backend/tests/Unit/Element/UseCases/GetElementTest.php +++ b/backend/tests/Unit/Element/UseCases/GetElementTest.php @@ -159,6 +159,134 @@ class GetElementTest extends TestCase ); } + public function testReturnsSiblingElementsWithSameParent(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement( + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + '/assets/pdfs/baderech.pdf', + null, + null, + ); + $firstChildElement = $this->createElement( + $set, + 'Avodah Foundations', + 'Foundations for steady avodah', + null, + '

Foundations rich text

', + '/assets/pdfs/foundations.pdf', + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Daily Practice', + 'Daily practices for growth', + null, + '

Daily practice rich text

', + null, + null, + $parentElement, + ); + $this->createElement( + $set, + 'Nested Practice', + 'Nested description', + null, + '

Nested rich text

', + null, + null, + $firstChildElement, + ); + $otherSet = $this->createSet(2, 'Daily Learning'); + $otherParentElement = $this->createElement( + $otherSet, + 'Other Parent', + 'Other parent description', + null, + '

Other parent rich text

', + null, + null, + null, + ); + $this->createElement( + $otherSet, + 'Other Child', + 'Other child description', + null, + '

Other child rich text

', + null, + null, + $otherParentElement, + ); + + $result = $this->getElement->execute(new GetElementRequest( + id: $firstChildElement->getId(), + )); + $siblingElements = $result->getSiblingElements(); + + $this->assertCount(2, $siblingElements); + $this->assertSame( + $firstChildElement->getId(), + $siblingElements[0]->getId(), + ); + $this->assertSame( + 'Avodah Foundations', + $siblingElements[0]->getTitle(), + ); + $this->assertSame( + 'Foundations for steady avodah', + $siblingElements[0]->getDescription(), + ); + $this->assertSame( + $secondChildElement->getId(), + $siblingElements[1]->getId(), + ); + $this->assertSame('Daily Practice', $siblingElements[1]->getTitle()); + $this->assertSame( + 'Daily practices for growth', + $siblingElements[1]->getDescription(), + ); + } + + public function testReturnsRootElementAsOnlySibling(): void + { + $set = $this->createSet(1, 'Baderech'); + $rootElement = $this->createElement( + $set, + 'Baderech HaAvodah', + 'A structured path for growth', + null, + '

A structured path for growth

', + '/assets/pdfs/baderech.pdf', + null, + null, + ); + $this->createElement( + $set, + 'Avodah Foundations', + 'Foundations for steady avodah', + null, + '

Foundations rich text

', + '/assets/pdfs/foundations.pdf', + null, + $rootElement, + ); + + $result = $this->getElement->execute(new GetElementRequest( + id: $rootElement->getId(), + )); + $siblingElements = $result->getSiblingElements(); + + $this->assertCount(1, $siblingElements); + $this->assertSame($rootElement->getId(), $siblingElements[0]->getId()); + $this->assertSame('Baderech HaAvodah', $siblingElements[0]->getTitle()); + } + public function testThrowsWhenIdMissing(): void { $this->expectException(BadRequestException::class); From 96774d954090f607e4dba51d8ab2c5178a7e373f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:37:33 +0300 Subject: [PATCH 2/7] add element siblings --- backend/app/Controllers/ElementController.php | 37 ++++++++++++++----- .../UseCases/GetElement/GetElement.php | 9 +++++ .../UseCases/GetElement/GetElementResult.php | 10 +++++ 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 5164e59..36528d4 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -46,18 +46,15 @@ class ElementController } $element = $result->getElement(); - $childElements = []; - foreach ($result->getChildElements() as $childElement) { - $childElements[] = [ - 'id' => $childElement->getId(), - 'title' => $childElement->getTitle(), - 'description' => $childElement->getDescription(), - ]; - } return new JsonResponse([ - 'childElements' => $childElements, + 'childElements' => $this->buildElementSummaryPayloads( + $result->getChildElements(), + ), 'element' => $this->buildElementPayload($element), + 'siblingElements' => $this->buildElementSummaryPayloads( + $result->getSiblingElements(), + ), ], 200); } @@ -253,6 +250,28 @@ class ElementController ]; } + /** + * @param Element[] $elements + * @return array + */ + private function buildElementSummaryPayloads(array $elements): array + { + $payloads = []; + foreach ($elements as $element) { + $payloads[] = [ + 'id' => $element->getId(), + 'title' => $element->getTitle(), + 'description' => $element->getDescription(), + ]; + } + + return $payloads; + } + private function storageUrl(?string $path, string $folderPrefix): ?string { if ($path === null) { diff --git a/backend/app/Element/UseCases/GetElement/GetElement.php b/backend/app/Element/UseCases/GetElement/GetElement.php index 7103e47..584d30b 100644 --- a/backend/app/Element/UseCases/GetElement/GetElement.php +++ b/backend/app/Element/UseCases/GetElement/GetElement.php @@ -27,10 +27,19 @@ class GetElement throw new NotFoundException('Element not found'); } + $parentElement = $element->getParentElement(); + if ($parentElement === null) { + $siblingElements = [$element]; + } else { + $siblingElements = $this->elementRepository + ->findByParentElement($parentElement); + } + return new GetElementResult( element: $element, childElements: $this->elementRepository ->findByParentElement($element), + siblingElements: $siblingElements, ); } } diff --git a/backend/app/Element/UseCases/GetElement/GetElementResult.php b/backend/app/Element/UseCases/GetElement/GetElementResult.php index ad5f51b..6934411 100644 --- a/backend/app/Element/UseCases/GetElement/GetElementResult.php +++ b/backend/app/Element/UseCases/GetElement/GetElementResult.php @@ -8,10 +8,12 @@ class GetElementResult { /** * @param Element[] $childElements + * @param Element[] $siblingElements */ public function __construct( private Element $element, private array $childElements, + private array $siblingElements, ) { } @@ -27,4 +29,12 @@ class GetElementResult { return $this->childElements; } + + /** + * @return Element[] + */ + public function getSiblingElements(): array + { + return $this->siblingElements; + } } From 5b33ec5f3539b91fb5b229ccfe3a7a31260a2cc3 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:38:51 +0300 Subject: [PATCH 3/7] test sibling navigation --- .../e2e/element-sibling-navigation.cy.ts | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts diff --git a/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts new file mode 100644 index 0000000..7e6f3fb --- /dev/null +++ b/frontend/rabbi_gerzi/cypress/e2e/element-sibling-navigation.cy.ts @@ -0,0 +1,75 @@ +function loginAsAdmin(): void { + cy.visit('/login') + + cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test') + cy.get('[data-cy="login-password"]').type('password123!@#') + cy.get('[data-cy="login-submit"]').click() + + cy.url().should('eq', Cypress.config().baseUrl + '/') + cy.getCookie('auth_token').should('exist') +} + +describe('element sibling navigation', () => { + it('navigates public same-parent elements with wraparound', () => { + cy.visit('/element/1') + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') + cy.get('[data-cy="element-sibling-navigation"]').should('not.exist') + + cy.visit('/element/2') + cy.contains('h1', '1. Introduction').should('be.visible') + cy.get('[data-cy="element-sibling-navigation"]').should('be.visible') + cy.get('[data-cy="element-sibling-previous"]') + .should('have.attr', 'href', '/element/7') + .click() + + cy.location('pathname').should('eq', '/element/7') + cy.contains('h1', '6. Fluid Integration').should('be.visible') + cy.get('[data-cy="element-sibling-next"]') + .should('have.attr', 'href', '/element/2') + .click() + + cy.location('pathname').should('eq', '/element/2') + cy.contains('h1', '1. Introduction').should('be.visible') + cy.get('[data-cy="element-sibling-next"]') + .should('have.attr', 'href', '/element/3') + .click() + + cy.location('pathname').should('eq', '/element/3') + cy.contains('h1', '2. Foundations').should('be.visible') + }) + + it('navigates admin same-parent elements with wraparound', () => { + loginAsAdmin() + + cy.visit('/admin/element/1') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', 'Baderech HaAvodah') + cy.get('[data-cy="admin-sibling-navigation"]').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-sibling-navigation"]').should('be.visible') + cy.get('[data-cy="admin-sibling-previous"]') + .should('have.attr', 'href', '/admin/element/7') + .click() + + cy.location('pathname').should('eq', '/admin/element/7') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', '6. Fluid Integration') + cy.get('[data-cy="admin-sibling-next"]') + .should('have.attr', 'href', '/admin/element/2') + .click() + + cy.location('pathname').should('eq', '/admin/element/2') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', '1. Introduction') + cy.get('[data-cy="admin-sibling-next"]') + .should('have.attr', 'href', '/admin/element/3') + .click() + + cy.location('pathname').should('eq', '/admin/element/3') + cy.get('[data-cy="admin-element-title"]') + .should('have.value', '2. Foundations') + }) +}) From 57c523dd13d30cde8014f88828adf085bd3426b9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:49:49 +0300 Subject: [PATCH 4/7] add sibling navigation --- .../components/ElementSiblingNavigation.vue | 143 ++++++++++++++++++ frontend/rabbi_gerzi/src/stores/elements.ts | 6 + .../src/views/AdminElementPage.vue | 9 ++ .../rabbi_gerzi/src/views/ElementPage.vue | 11 +- 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 frontend/rabbi_gerzi/src/components/ElementSiblingNavigation.vue diff --git a/frontend/rabbi_gerzi/src/components/ElementSiblingNavigation.vue b/frontend/rabbi_gerzi/src/components/ElementSiblingNavigation.vue new file mode 100644 index 0000000..f862ea9 --- /dev/null +++ b/frontend/rabbi_gerzi/src/components/ElementSiblingNavigation.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index d72683c..894ac73 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[] + siblingElements: ChildElement[] } export interface UpdateElementInput { @@ -58,6 +59,7 @@ const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update` export const useElementsStore = defineStore('elements', () => { const element = ref(null) const childElements = ref([]) + const siblingElements = ref([]) const isLoading = ref(false) const error = ref(null) const isSaving = ref(false) @@ -71,6 +73,7 @@ export const useElementsStore = defineStore('elements', () => { async function fetchElement(elementId: string): Promise { element.value = null childElements.value = [] + siblingElements.value = [] error.value = null saveError.value = null childActionError.value = null @@ -94,8 +97,10 @@ export const useElementsStore = defineStore('elements', () => { const data: ElementResponse = await response.json() element.value = data.element childElements.value = data.childElements + siblingElements.value = data.siblingElements } catch { childElements.value = [] + siblingElements.value = [] error.value = 'Network error - could not load element' } finally { isLoading.value = false @@ -388,6 +393,7 @@ export const useElementsStore = defineStore('elements', () => { return { element, childElements, + siblingElements, isLoading, error, isSaving, diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index 75b257a..1a8606c 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -2,6 +2,7 @@ import { storeToRefs } from 'pinia' import { computed, reactive, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' +import ElementSiblingNavigation from '@/components/ElementSiblingNavigation.vue' import RichTextEditor from '@/components/RichTextEditor.vue' import SiteHeader from '@/components/SiteHeader.vue' import { useElementsStore } from '@/stores/elements' @@ -23,6 +24,7 @@ const elementsStore = useElementsStore() const { element, childElements, + siblingElements, isLoading, error, isSaving, @@ -318,6 +320,13 @@ function resetInput(changeEvent: Event): void { class="admin-element-page__form" @submit.prevent="handleSubmit" > + +