diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php new file mode 100644 index 0000000..62a6385 --- /dev/null +++ b/backend/app/Controllers/ElementController.php @@ -0,0 +1,40 @@ +getElement->execute( + new GetElementRequest(id: $id) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'element' => [ + 'id' => $element->getId(), + 'title' => $element->getTitle(), + ], + ], 200); + } +} diff --git a/backend/app/Element/UseCases/GetElement/GetElement.php b/backend/app/Element/UseCases/GetElement/GetElement.php new file mode 100644 index 0000000..2dc555e --- /dev/null +++ b/backend/app/Element/UseCases/GetElement/GetElement.php @@ -0,0 +1,33 @@ +id === null) { + throw new BadRequestException('id is required'); + } + + $element = $this->elementRepository->find($request->id); + if ($element === null) { + throw new NotFoundException('Element not found'); + } + + return $element; + } +} diff --git a/backend/app/Element/UseCases/GetElement/GetElementRequest.php b/backend/app/Element/UseCases/GetElement/GetElementRequest.php new file mode 100644 index 0000000..0e636b6 --- /dev/null +++ b/backend/app/Element/UseCases/GetElement/GetElementRequest.php @@ -0,0 +1,10 @@ +middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); +Route::get('/elements/{id}', [ElementController::class, 'show']); diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php new file mode 100644 index 0000000..be69aec --- /dev/null +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -0,0 +1,51 @@ +create(new CreateSetDto( + name: 'Baderech HaAvodah', + description: 'A structured path for growth', + iconImageUrl: '/assets/baderech-haavodah-icon.png', + )); + $element = $elementRepository->create(new CreateElementDto( + set: $set, + title: 'Baderech HaAvodah', + parentElement: null, + )); + + $response = $this->getJson("/api/elements/{$element->getId()}"); + + $response->assertOk(); + $response->assertExactJson([ + 'element' => [ + 'id' => $element->getId(), + 'title' => 'Baderech HaAvodah', + ], + ]); + } + + public function testReturns404WhenElementDoesNotExist(): void + { + $response = $this->getJson('/api/elements/999'); + + $response->assertNotFound(); + $response->assertExactJson([ + 'error' => 'Element not found', + ]); + } +} diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php new file mode 100644 index 0000000..93dddfc --- /dev/null +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -0,0 +1,75 @@ +elementRepo = new FakeElementRepository(); + $getElement = new GetElement($this->elementRepo); + $this->controller = new ElementController($getElement); + } + + public function testShowReturnsElementPayload(): void + { + $element = $this->createElement('Baderech HaAvodah'); + + $response = $this->controller->show($element->getId()); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getContent(), true); + $this->assertSame($element->getId(), $body['element']['id']); + $this->assertSame('Baderech HaAvodah', $body['element']['title']); + } + + public function testShowReturns400WhenIdMissing(): void + { + $response = $this->controller->show(null); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertSame( + ['error' => 'id is required'], + json_decode($response->getContent(), true), + ); + } + + public function testShowReturns404WhenElementDoesNotExist(): void + { + $response = $this->controller->show(999); + + $this->assertEquals(404, $response->getStatusCode()); + $this->assertSame( + ['error' => 'Element not found'], + json_decode($response->getContent(), true), + ); + } + + private function createElement(string $title): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $title, + parentElement: null, + )); + } +} diff --git a/backend/tests/Unit/Element/UseCases/GetElementTest.php b/backend/tests/Unit/Element/UseCases/GetElementTest.php new file mode 100644 index 0000000..a301af6 --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/GetElementTest.php @@ -0,0 +1,71 @@ +elementRepo = new FakeElementRepository(); + $this->getElement = new GetElement($this->elementRepo); + } + + public function testReturnsElementWhenFound(): void + { + $element = $this->createElement('Baderech HaAvodah'); + + $foundElement = $this->getElement->execute(new GetElementRequest( + id: $element->getId(), + )); + + $this->assertInstanceOf(Element::class, $foundElement); + $this->assertSame($element->getId(), $foundElement->getId()); + $this->assertSame('Baderech HaAvodah', $foundElement->getTitle()); + } + + public function testThrowsWhenIdMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('id is required'); + + $this->getElement->execute(new GetElementRequest(id: null)); + } + + public function testThrowsWhenElementDoesNotExist(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Element not found'); + + $this->getElement->execute(new GetElementRequest(id: 999)); + } + + private function createElement(string $title): Element + { + $set = new DomainSet( + id: 1, + name: 'Baderech', + description: 'Baderech description', + iconImageUrl: '/assets/baderech-icon.png', + ); + + return $this->elementRepo->create(new CreateElementDto( + set: $set, + title: $title, + parentElement: null, + )); + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index 2a093a0..69fe661 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -40,6 +40,6 @@ describe('media page sets', () => { cy.get('@baderechCard').click() cy.location('pathname').should('eq', '/element/1') cy.get('[data-cy="element-page"]').should('be.visible') - cy.contains('h1', 'Element 1').should('be.visible') + cy.contains('h1', 'Baderech HaAvodah').should('be.visible') }) }) diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts new file mode 100644 index 0000000..0624751 --- /dev/null +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -0,0 +1,50 @@ +import { ref } from 'vue' +import { defineStore } from 'pinia' + +export interface Element { + id: number + title: string +} + +interface ElementResponse { + element: Element +} + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string + +export const useElementsStore = defineStore('elements', () => { + const element = ref(null) + const isLoading = ref(false) + const error = ref(null) + + async function fetchElement(elementId: string): Promise { + element.value = null + error.value = null + isLoading.value = true + + try { + const encodedElementId = encodeURIComponent(elementId) + const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}` + const response = await fetch(elementUrl) + + if (response.status === 404) { + error.value = 'Element not found' + return + } + + if (!response.ok) { + error.value = 'Could not load element' + return + } + + const data: ElementResponse = await response.json() + element.value = data.element + } catch { + error.value = 'Network error - could not load element' + } finally { + isLoading.value = false + } + } + + return { element, isLoading, error, fetchElement } +}) diff --git a/frontend/rabbi_gerzi/src/views/ElementPage.vue b/frontend/rabbi_gerzi/src/views/ElementPage.vue index 22191ce..c222cef 100644 --- a/frontend/rabbi_gerzi/src/views/ElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/ElementPage.vue @@ -1,9 +1,13 @@ @@ -46,6 +72,22 @@ const elementId = computed(() => { text-align: center; } +.element-page__status { + max-width: 48rem; + margin: 0 auto; + padding: 1rem 1.25rem; + color: var(--color-text-muted); + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: 8px; + font-size: 0.95rem; +} + +.element-page__status--error { + color: #7c2d2d; + border-color: #e5b8b8; +} + @media (max-width: 768px) { .element-page__main { padding: 3rem 1rem;