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/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')
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 @@
+
+
+
+
+
+ Parent: {{ parentElement.title }}
+
+
+
+
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 {
@@ -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;