Merge branch 'feature/fetch-element-title'

This commit is contained in:
Yisroel Baum 2026-05-26 20:01:58 +03:00
commit d950fe55dd
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
11 changed files with 386 additions and 3 deletions

View file

@ -0,0 +1,40 @@
<?php
namespace App\Controllers;
use App\Element\UseCases\GetElement\GetElement;
use App\Element\UseCases\GetElement\GetElementRequest;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use Illuminate\Http\JsonResponse;
class ElementController
{
public function __construct(private GetElement $getElement)
{
}
public function show(?int $id): JsonResponse
{
try {
$element = $this->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);
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Element\UseCases\GetElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class GetElement
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(GetElementRequest $request): Element
{
if ($request->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;
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace App\Element\UseCases\GetElement;
class GetElementRequest
{
public function __construct(public ?int $id)
{
}
}

View file

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use DomainException;
class NotFoundException extends DomainException
{
}

View file

@ -1,6 +1,7 @@
<?php
use App\Controllers\AuthController;
use App\Controllers\ElementController;
use App\Controllers\SetController;
use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route;
@ -10,3 +11,4 @@ Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me'])
->middleware(AuthMiddleware::class);
Route::get('/sets', [SetController::class, 'index']);
Route::get('/elements/{id}', [ElementController::class, 'show']);

View file

@ -0,0 +1,51 @@
<?php
namespace Tests\Feature;
use App\Element\CreateElementDto;
use App\Element\ElementRepository;
use App\Set\CreateSetDto;
use App\Set\SetRepository;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ElementsEndpointTest extends TestCase
{
use RefreshDatabase;
public function testReturnsElementTitle(): void
{
$setRepository = app(SetRepository::class);
$elementRepository = app(ElementRepository::class);
$set = $setRepository->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',
]);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace Tests\Unit\Controllers;
use App\Controllers\ElementController;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\GetElement\GetElement;
use App\Set\Set as DomainSet;
use Tests\Fakes\FakeElementRepository;
use Tests\TestCase;
class ElementControllerTest extends TestCase
{
private ElementController $controller;
private FakeElementRepository $elementRepo;
protected function setUp(): void
{
$this->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,
));
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Tests\Unit\Element\UseCases;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\GetElement\GetElement;
use App\Element\UseCases\GetElement\GetElementRequest;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Set\Set as DomainSet;
use Tests\Fakes\FakeElementRepository;
use Tests\TestCase;
class GetElementTest extends TestCase
{
private FakeElementRepository $elementRepo;
private GetElement $getElement;
protected function setUp(): void
{
$this->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,
));
}
}

View file

@ -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')
})
})

View file

@ -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<Element | null>(null)
const isLoading = ref(false)
const error = ref<string | null>(null)
async function fetchElement(elementId: string): Promise<void> {
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 }
})

View file

@ -1,9 +1,13 @@
<script setup lang="ts">
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import { useElementsStore } from '@/stores/elements'
const route = useRoute()
const elementsStore = useElementsStore()
const { element, isLoading, error } = storeToRefs(elementsStore)
const elementId = computed(() => {
const routeElementId = route.params.id
@ -14,13 +18,35 @@ const elementId = computed(() => {
return routeElementId
})
watch(
elementId,
(currentElementId) => {
if (typeof currentElementId !== 'string' || currentElementId === '') {
return
}
void elementsStore.fetchElement(currentElementId)
},
{ immediate: true },
)
</script>
<template>
<div class="element-page">
<SiteHeader />
<main class="element-page__main" data-cy="element-page">
<h1 class="element-page__heading">Element {{ elementId }}</h1>
<p v-if="isLoading" class="element-page__status">Loading element...</p>
<p
v-else-if="error"
class="element-page__status element-page__status--error"
data-cy="element-error"
>
{{ error }}
</p>
<h1 v-else-if="element" class="element-page__heading">
{{ element.title }}
</h1>
</main>
</div>
</template>
@ -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;