Compare commits
11 commits
2e87add96c
...
d950fe55dd
| Author | SHA1 | Date | |
|---|---|---|---|
| d950fe55dd | |||
| 28ea873f38 | |||
| aa77ccad81 | |||
| 46f5e6138e | |||
| ca4d2dad3b | |||
| ba6bd357e2 | |||
| 8029a9e157 | |||
| 5e1ec0dd73 | |||
| 1b05d4f1f5 | |||
| 90de724f63 | |||
| f9c92f3206 |
11 changed files with 386 additions and 3 deletions
40
backend/app/Controllers/ElementController.php
Normal file
40
backend/app/Controllers/ElementController.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
backend/app/Element/UseCases/GetElement/GetElement.php
Normal file
33
backend/app/Element/UseCases/GetElement/GetElement.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Element\UseCases\GetElement;
|
||||||
|
|
||||||
|
class GetElementRequest
|
||||||
|
{
|
||||||
|
public function __construct(public ?int $id)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
9
backend/app/Exceptions/NotFoundException.php
Normal file
9
backend/app/Exceptions/NotFoundException.php
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
|
class NotFoundException extends DomainException
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Controllers\AuthController;
|
use App\Controllers\AuthController;
|
||||||
|
use App\Controllers\ElementController;
|
||||||
use App\Controllers\SetController;
|
use App\Controllers\SetController;
|
||||||
use App\Http\Middleware\AuthMiddleware;
|
use App\Http\Middleware\AuthMiddleware;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
@ -10,3 +11,4 @@ Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
Route::get('/me', [AuthController::class, 'me'])
|
Route::get('/me', [AuthController::class, 'me'])
|
||||||
->middleware(AuthMiddleware::class);
|
->middleware(AuthMiddleware::class);
|
||||||
Route::get('/sets', [SetController::class, 'index']);
|
Route::get('/sets', [SetController::class, 'index']);
|
||||||
|
Route::get('/elements/{id}', [ElementController::class, 'show']);
|
||||||
|
|
|
||||||
51
backend/tests/Feature/ElementsEndpointTest.php
Normal file
51
backend/tests/Feature/ElementsEndpointTest.php
Normal 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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
backend/tests/Unit/Controllers/ElementControllerTest.php
Normal file
75
backend/tests/Unit/Controllers/ElementControllerTest.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
71
backend/tests/Unit/Element/UseCases/GetElementTest.php
Normal file
71
backend/tests/Unit/Element/UseCases/GetElementTest.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,6 @@ describe('media page sets', () => {
|
||||||
cy.get('@baderechCard').click()
|
cy.get('@baderechCard').click()
|
||||||
cy.location('pathname').should('eq', '/element/1')
|
cy.location('pathname').should('eq', '/element/1')
|
||||||
cy.get('[data-cy="element-page"]').should('be.visible')
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
50
frontend/rabbi_gerzi/src/stores/elements.ts
Normal file
50
frontend/rabbi_gerzi/src/stores/elements.ts
Normal 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 }
|
||||||
|
})
|
||||||
|
|
@ -1,9 +1,13 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import SiteHeader from '@/components/SiteHeader.vue'
|
import SiteHeader from '@/components/SiteHeader.vue'
|
||||||
|
import { useElementsStore } from '@/stores/elements'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const elementsStore = useElementsStore()
|
||||||
|
const { element, isLoading, error } = storeToRefs(elementsStore)
|
||||||
|
|
||||||
const elementId = computed(() => {
|
const elementId = computed(() => {
|
||||||
const routeElementId = route.params.id
|
const routeElementId = route.params.id
|
||||||
|
|
@ -14,13 +18,35 @@ const elementId = computed(() => {
|
||||||
|
|
||||||
return routeElementId
|
return routeElementId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
elementId,
|
||||||
|
(currentElementId) => {
|
||||||
|
if (typeof currentElementId !== 'string' || currentElementId === '') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void elementsStore.fetchElement(currentElementId)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="element-page">
|
<div class="element-page">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main class="element-page__main" data-cy="element-page">
|
<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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -46,6 +72,22 @@ const elementId = computed(() => {
|
||||||
text-align: center;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.element-page__main {
|
.element-page__main {
|
||||||
padding: 3rem 1rem;
|
padding: 3rem 1rem;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue