Compare commits

..

14 commits

42 changed files with 2170 additions and 35 deletions

View file

@ -20,6 +20,9 @@ intentionally omitted here - update this section as entities land.
- Do not write unit tests for concrete repository implementations
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
Use cases are tested with fake repositories.
- Do not write reflection-only contract tests for repository interface
method names, parameter counts, parameter names, or type hints. PHP
interfaces already enforce those signatures; test behavior instead.
- Repository methods that find records by a foreign key should accept
the related entity, not a raw id (e.g. `findBySet(Set $set)`, not
`findBySetId(int $setId)`).

View file

@ -2,16 +2,22 @@
namespace App\Controllers;
use App\Element\Element;
use App\Element\UseCases\GetElement\GetElement;
use App\Element\UseCases\GetElement\GetElementRequest;
use App\Element\UseCases\UpdateElement\UpdateElement;
use App\Element\UseCases\UpdateElement\UpdateElementRequest;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ElementController
{
public function __construct(private GetElement $getElement)
{
public function __construct(
private GetElement $getElement,
private UpdateElement $updateElement,
) {
}
public function show(?int $id): JsonResponse
@ -42,7 +48,66 @@ class ElementController
return new JsonResponse([
'childElements' => $childElements,
'element' => [
'element' => $this->buildElementPayload($element),
], 200);
}
public function update(?int $id, Request $request): JsonResponse
{
try {
$element = $this->updateElement->execute(
new UpdateElementRequest(
id: $id,
title: $this->stringInput($request, 'title'),
description: $this->stringInput($request, 'description'),
iconImageUrl: $this->stringInput(
$request,
'iconImageUrl',
),
richText: $this->stringInput($request, 'richText'),
pdfPath: $this->stringInput($request, 'pdfPath'),
youtubeUrl: $this->stringInput($request, 'youtubeUrl'),
)
);
} catch (BadRequestException $exception) {
return new JsonResponse([
'error' => $exception->getMessage(),
], 400);
} catch (NotFoundException $exception) {
return new JsonResponse([
'error' => $exception->getMessage(),
], 404);
}
return new JsonResponse([
'element' => $this->buildElementPayload($element),
], 200);
}
private function stringInput(Request $request, string $key): ?string
{
$value = $request->input($key);
if (! is_string($value)) {
return null;
}
return $value;
}
/**
* @return array{
* id: int,
* title: string,
* description: string,
* iconImageUrl: string|null,
* richText: string,
* pdfPath: string|null,
* youtubeUrl: string|null,
* }
*/
private function buildElementPayload(Element $element): array
{
return [
'id' => $element->getId(),
'title' => $element->getTitle(),
'description' => $element->getDescription(),
@ -50,7 +115,6 @@ class ElementController
'richText' => $element->getRichText(),
'pdfPath' => $element->getPdfPath(),
'youtubeUrl' => $element->getYoutubeUrl(),
],
], 200);
];
}
}

View file

@ -29,31 +29,61 @@ class Element
return $this->title;
}
public function setTitle(string $title): void
{
$this->title = $title;
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): void
{
$this->description = $description;
}
public function getIconImageUrl(): ?string
{
return $this->iconImageUrl;
}
public function setIconImageUrl(?string $iconImageUrl): void
{
$this->iconImageUrl = $iconImageUrl;
}
public function getRichText(): string
{
return $this->richText;
}
public function setRichText(string $richText): void
{
$this->richText = $richText;
}
public function getPdfPath(): ?string
{
return $this->pdfPath;
}
public function setPdfPath(?string $pdfPath): void
{
$this->pdfPath = $pdfPath;
}
public function getYoutubeUrl(): ?string
{
return $this->youtubeUrl;
}
public function setYoutubeUrl(?string $youtubeUrl): void
{
$this->youtubeUrl = $youtubeUrl;
}
public function getSet(): Set
{
return $this->set;

View file

@ -8,6 +8,8 @@ interface ElementRepository
{
public function create(CreateElementDto $dto): Element;
public function update(Element $element): Element;
public function find(int $id): ?Element;
public function findRootBySet(DomainSet $set): ?Element;

View file

@ -38,6 +38,28 @@ class EloquentElementRepository implements ElementRepository
);
}
public function update(Element $element): Element
{
$model = ElementModel::find($element->getId());
if ($model === null) {
throw new DomainException(
"Element with id: {$element->getId()} doesnt exist"
);
}
$model->set_id = $element->getSet()->getId();
$model->title = $element->getTitle();
$model->description = $element->getDescription();
$model->icon_image_url = $element->getIconImageUrl();
$model->rich_text = $element->getRichText();
$model->pdf_path = $element->getPdfPath();
$model->youtube_url = $element->getYoutubeUrl();
$model->parent_element_id = $element->getParentElement()?->getId();
$model->save();
return $this->toDomain($model);
}
public function find(int $id): ?Element
{
$model = ElementModel::find($id);

View file

@ -0,0 +1,39 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdateDescription
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateDescriptionRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
if ($request->description === null) {
throw new BadRequestException('description is required');
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
$element->setDescription($request->description);
return $this->elementRepository->update($element);
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateDescriptionRequest
{
public function __construct(
public ?int $id,
public ?string $description,
) {
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdateElement
{
public function __construct(
private UpdateTitle $updateTitle,
private UpdateDescription $updateDescription,
private UpdateIconImageUrl $updateIconImageUrl,
private UpdateRichText $updateRichText,
private UpdatePdfPath $updatePdfPath,
private UpdateYoutubeUrl $updateYoutubeUrl,
private ElementRepository $elementRepository,
) {
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateElementRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
$hasNoFields = $request->title === null
&& $request->description === null
&& $request->iconImageUrl === null
&& $request->richText === null
&& $request->pdfPath === null
&& $request->youtubeUrl === null;
if ($hasNoFields) {
throw new BadRequestException('nothing to update');
}
if ($request->title !== null) {
$this->updateTitle->execute(new UpdateTitleRequest(
id: $request->id,
title: $request->title,
));
}
if ($request->description !== null) {
$this->updateDescription->execute(new UpdateDescriptionRequest(
id: $request->id,
description: $request->description,
));
}
if ($request->iconImageUrl !== null) {
$this->updateIconImageUrl->execute(
new UpdateIconImageUrlRequest(
id: $request->id,
iconImageUrl: $request->iconImageUrl,
)
);
}
if ($request->richText !== null) {
$this->updateRichText->execute(new UpdateRichTextRequest(
id: $request->id,
richText: $request->richText,
));
}
if ($request->pdfPath !== null) {
$this->updatePdfPath->execute(new UpdatePdfPathRequest(
id: $request->id,
pdfPath: $request->pdfPath,
));
}
if ($request->youtubeUrl !== null) {
$this->updateYoutubeUrl->execute(new UpdateYoutubeUrlRequest(
id: $request->id,
youtubeUrl: $request->youtubeUrl,
));
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
return $element;
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateElementRequest
{
public function __construct(
public ?int $id,
public ?string $title,
public ?string $description,
public ?string $iconImageUrl,
public ?string $richText,
public ?string $pdfPath,
public ?string $youtubeUrl,
) {
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdateIconImageUrl
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateIconImageUrlRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
if ($request->iconImageUrl === null) {
throw new BadRequestException('iconImageUrl is required');
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
$element->setIconImageUrl($this->nullableString(
$request->iconImageUrl,
));
return $this->elementRepository->update($element);
}
private function nullableString(string $value): ?string
{
if ($value === '') {
return null;
}
return $value;
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateIconImageUrlRequest
{
public function __construct(
public ?int $id,
public ?string $iconImageUrl,
) {
}
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdatePdfPath
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdatePdfPathRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
if ($request->pdfPath === null) {
throw new BadRequestException('pdfPath is required');
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
$element->setPdfPath($this->nullableString($request->pdfPath));
return $this->elementRepository->update($element);
}
private function nullableString(string $value): ?string
{
if ($value === '') {
return null;
}
return $value;
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdatePdfPathRequest
{
public function __construct(
public ?int $id,
public ?string $pdfPath,
) {
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdateRichText
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateRichTextRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
if ($request->richText === null) {
throw new BadRequestException('richText is required');
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
$element->setRichText($request->richText);
return $this->elementRepository->update($element);
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateRichTextRequest
{
public function __construct(
public ?int $id,
public ?string $richText,
) {
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdateTitle
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateTitleRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
if ($request->title === null || trim($request->title) === '') {
throw new BadRequestException('title is required');
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
$element->setTitle(trim($request->title));
return $this->elementRepository->update($element);
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateTitleRequest
{
public function __construct(
public ?int $id,
public ?string $title,
) {
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Element\UseCases\UpdateElement;
use App\Element\Element;
use App\Element\ElementRepository;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
class UpdateYoutubeUrl
{
public function __construct(private ElementRepository $elementRepository)
{
}
/**
* @throws BadRequestException
* @throws NotFoundException
*/
public function execute(UpdateYoutubeUrlRequest $request): Element
{
if ($request->id === null) {
throw new BadRequestException('id is required');
}
if ($request->youtubeUrl === null) {
throw new BadRequestException('youtubeUrl is required');
}
$element = $this->elementRepository->find($request->id);
if ($element === null) {
throw new NotFoundException('Element not found');
}
$element->setYoutubeUrl($this->nullableString(
$request->youtubeUrl,
));
return $this->elementRepository->update($element);
}
private function nullableString(string $value): ?string
{
if ($value === '') {
return null;
}
return $value;
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Element\UseCases\UpdateElement;
class UpdateYoutubeUrlRequest
{
public function __construct(
public ?int $id,
public ?string $youtubeUrl,
) {
}
}

View file

@ -13,7 +13,7 @@ $allowedOrigins = array_values(array_filter(array_map(
return [
'paths' => ['api/*'],
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS'],
'allowed_origins' => $allowedOrigins,
'allowed_origins_patterns' => [],
'allowed_headers' => [

View file

@ -12,3 +12,5 @@ Route::get('/me', [AuthController::class, 'me'])
->middleware(AuthMiddleware::class);
Route::get('/sets', [SetController::class, 'index']);
Route::get('/elements/{id}', [ElementController::class, 'show']);
Route::patch('/elements/{id}', [ElementController::class, 'update'])
->middleware(AuthMiddleware::class);

View file

@ -33,6 +33,14 @@ class FakeElementRepository implements ElementRepository
return $element;
}
public function update(Element $element): Element
{
$updatedElement = $this->cloneElement($element);
$this->elementsById[$updatedElement->getId()] = $updatedElement;
return $this->cloneElement($updatedElement);
}
public function find(int $id): ?Element
{
if (! isset($this->elementsById[$id])) {

View file

@ -21,4 +21,23 @@ class CorsTest extends TestCase
);
$response->assertHeader('Access-Control-Allow-Credentials', 'true');
}
public function testAllowsPatchPreflight(): void
{
$response = $this->withHeaders([
'Origin' => 'http://localhost:5173',
'Access-Control-Request-Method' => 'PATCH',
'Access-Control-Request-Headers' => 'content-type',
])->options('/api/elements/1');
$response->assertNoContent();
$response->assertHeader(
'Access-Control-Allow-Origin',
'http://localhost:5173'
);
$this->assertStringContainsString(
'PATCH',
$response->headers->get('Access-Control-Allow-Methods')
);
}
}

View file

@ -2,10 +2,17 @@
namespace Tests\Feature;
use App\Auth\CreateSessionDto;
use App\Auth\SessionRepository;
use App\Element\CreateElementDto;
use App\Element\ElementRepository;
use App\Set\CreateSetDto;
use App\Set\SetRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto;
use App\User\UserRepository;
use DateTimeImmutable;
use DateTimeZone;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -92,4 +99,116 @@ class ElementsEndpointTest extends TestCase
'error' => 'Element not found',
]);
}
public function testUpdateRequiresAuthentication(): 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',
description: 'A structured path for growth',
iconImageUrl: '/assets/baderech-haavodah-icon.png',
richText: '<p>A structured path for growth</p>',
pdfPath: '/assets/pdfs/baderech.pdf',
youtubeUrl: null,
parentElement: null,
));
$response = $this->patchJson("/api/elements/{$element->getId()}", [
'title' => 'Updated title',
'description' => 'Updated description',
'iconImageUrl' => '/assets/updated-icon.png',
'richText' => '<p>Updated rich text</p>',
'pdfPath' => '/assets/pdfs/updated.pdf',
'youtubeUrl' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
]);
$response->assertUnauthorized();
$response->assertExactJson([
'error' => 'unauthenticated',
]);
}
public function testAuthenticatedUpdateReturnsElementPayload(): void
{
$sampleYoutubeUrl = 'https://www.youtube.com/watch?v='
. 'dQw4w9WgXcQ';
$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',
description: 'A structured path for growth',
iconImageUrl: '/assets/baderech-haavodah-icon.png',
richText: '<p>A structured path for growth</p>',
pdfPath: '/assets/pdfs/baderech.pdf',
youtubeUrl: null,
parentElement: null,
));
$this->createSession('valid-token');
$response = $this->withCredentials()
->withUnencryptedCookie('auth_token', 'valid-token')
->patchJson("/api/elements/{$element->getId()}", [
'title' => 'Updated title',
'description' => 'Updated description',
'iconImageUrl' => '/assets/updated-icon.png',
'richText' => '<p>Updated rich text</p>',
'pdfPath' => '/assets/pdfs/updated.pdf',
'youtubeUrl' => $sampleYoutubeUrl,
]);
$response->assertOk();
$response->assertExactJson([
'element' => [
'id' => $element->getId(),
'title' => 'Updated title',
'description' => 'Updated description',
'iconImageUrl' => '/assets/updated-icon.png',
'richText' => '<p>Updated rich text</p>',
'pdfPath' => '/assets/pdfs/updated.pdf',
'youtubeUrl' => $sampleYoutubeUrl,
],
]);
$updatedElement = $elementRepository->find($element->getId());
$this->assertSame('Updated title', $updatedElement->getTitle());
$this->assertSame(
'<p>Updated rich text</p>',
$updatedElement->getRichText(),
);
}
private function createSession(string $token): void
{
$userRepository = app(UserRepository::class);
$sessionRepository = app(SessionRepository::class);
$user = $userRepository->create(new CreateUserDto(
email: new EmailAddress('admin@example.com'),
passwordHash: 'password-hash',
));
$now = new DateTimeImmutable(
'2026-05-01T00:00:00',
new DateTimeZone('UTC'),
);
$sessionRepository->create(new CreateSessionDto(
token: $token,
user: $user,
createdAt: $now,
expiresAt: new DateTimeImmutable(
'2099-01-01T00:00:00',
new DateTimeZone('UTC'),
),
));
}
}

View file

@ -6,6 +6,13 @@ use App\Controllers\ElementController;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\GetElement\GetElement;
use App\Element\UseCases\UpdateElement\UpdateDescription;
use App\Element\UseCases\UpdateElement\UpdateElement;
use App\Element\UseCases\UpdateElement\UpdateIconImageUrl;
use App\Element\UseCases\UpdateElement\UpdatePdfPath;
use App\Element\UseCases\UpdateElement\UpdateRichText;
use App\Element\UseCases\UpdateElement\UpdateTitle;
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl;
use App\Set\Set as DomainSet;
use Tests\Fakes\FakeElementRepository;
use Tests\TestCase;
@ -20,7 +27,16 @@ class ElementControllerTest extends TestCase
{
$this->elementRepo = new FakeElementRepository();
$getElement = new GetElement($this->elementRepo);
$this->controller = new ElementController($getElement);
$updateElement = new UpdateElement(
new UpdateTitle($this->elementRepo),
new UpdateDescription($this->elementRepo),
new UpdateIconImageUrl($this->elementRepo),
new UpdateRichText($this->elementRepo),
new UpdatePdfPath($this->elementRepo),
new UpdateYoutubeUrl($this->elementRepo),
$this->elementRepo,
);
$this->controller = new ElementController($getElement, $updateElement);
}
public function testShowReturnsElementPayload(): void

View file

@ -68,4 +68,39 @@ class ElementTest extends TestCase
$this->assertNull($rootElement->getYoutubeUrl());
$this->assertNull($rootElement->getParentElement());
}
public function testUpdatesEditableFields(): void
{
$set = new DomainSet(
id: 1,
name: 'Daily learning',
description: 'Daily learning description',
iconImageUrl: '/assets/daily-learning-icon.svg',
);
$element = new Element(
id: 1,
title: 'Original',
description: 'Original description',
iconImageUrl: '/assets/original.svg',
richText: '<p>Original</p>',
pdfPath: '/assets/pdfs/original.pdf',
youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU',
set: $set,
parentElement: null,
);
$element->setTitle('Updated');
$element->setDescription('Updated description');
$element->setIconImageUrl(null);
$element->setRichText('<p>Updated</p>');
$element->setPdfPath(null);
$element->setYoutubeUrl(null);
$this->assertSame('Updated', $element->getTitle());
$this->assertSame('Updated description', $element->getDescription());
$this->assertNull($element->getIconImageUrl());
$this->assertSame('<p>Updated</p>', $element->getRichText());
$this->assertNull($element->getPdfPath());
$this->assertNull($element->getYoutubeUrl());
}
}

View file

@ -0,0 +1,175 @@
<?php
namespace Tests\Unit\Element\UseCases;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\UpdateElement\UpdateDescription;
use App\Element\UseCases\UpdateElement\UpdateDescriptionRequest;
use App\Element\UseCases\UpdateElement\UpdateIconImageUrl;
use App\Element\UseCases\UpdateElement\UpdateIconImageUrlRequest;
use App\Element\UseCases\UpdateElement\UpdatePdfPath;
use App\Element\UseCases\UpdateElement\UpdatePdfPathRequest;
use App\Element\UseCases\UpdateElement\UpdateRichText;
use App\Element\UseCases\UpdateElement\UpdateRichTextRequest;
use App\Element\UseCases\UpdateElement\UpdateTitle;
use App\Element\UseCases\UpdateElement\UpdateTitleRequest;
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl;
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrlRequest;
use App\Exceptions\BadRequestException;
use App\Set\Set as DomainSet;
use Tests\Fakes\FakeElementRepository;
use Tests\TestCase;
class UpdateElementFieldsTest extends TestCase
{
private FakeElementRepository $elementRepo;
protected function setUp(): void
{
$this->elementRepo = new FakeElementRepository();
}
public function testUpdateTitleUpdatesOnlyTitle(): void
{
$element = $this->createFilledElement();
$updateTitle = new UpdateTitle($this->elementRepo);
$updatedElement = $updateTitle->execute(new UpdateTitleRequest(
id: $element->getId(),
title: 'Updated title',
));
$this->assertSame('Updated title', $updatedElement->getTitle());
$this->assertSame(
$element->getDescription(),
$updatedElement->getDescription(),
);
$this->assertSame(
$element->getRichText(),
$updatedElement->getRichText(),
);
}
public function testUpdateTitleRejectsBlankTitle(): void
{
$this->createFilledElement();
$updateTitle = new UpdateTitle($this->elementRepo);
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('title is required');
$updateTitle->execute(new UpdateTitleRequest(id: 1, title: ''));
}
public function testUpdateDescriptionUpdatesOnlyDescription(): void
{
$element = $this->createFilledElement();
$updateDescription = new UpdateDescription($this->elementRepo);
$updatedElement = $updateDescription->execute(
new UpdateDescriptionRequest(
id: $element->getId(),
description: 'Updated description',
)
);
$this->assertSame(
'Updated description',
$updatedElement->getDescription(),
);
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
$this->assertSame(
$element->getYoutubeUrl(),
$updatedElement->getYoutubeUrl(),
);
}
public function testUpdateIconImageUrlClearsEmptyString(): void
{
$element = $this->createFilledElement();
$updateIconImageUrl = new UpdateIconImageUrl($this->elementRepo);
$updatedElement = $updateIconImageUrl->execute(
new UpdateIconImageUrlRequest(
id: $element->getId(),
iconImageUrl: '',
)
);
$this->assertNull($updatedElement->getIconImageUrl());
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
}
public function testUpdateRichTextUpdatesOnlyRichText(): void
{
$element = $this->createFilledElement();
$updateRichText = new UpdateRichText($this->elementRepo);
$updatedElement = $updateRichText->execute(
new UpdateRichTextRequest(
id: $element->getId(),
richText: '<p>Updated rich text</p>',
)
);
$this->assertSame(
'<p>Updated rich text</p>',
$updatedElement->getRichText(),
);
$this->assertSame(
$element->getDescription(),
$updatedElement->getDescription(),
);
}
public function testUpdatePdfPathClearsEmptyString(): void
{
$element = $this->createFilledElement();
$updatePdfPath = new UpdatePdfPath($this->elementRepo);
$updatedElement = $updatePdfPath->execute(
new UpdatePdfPathRequest(id: $element->getId(), pdfPath: '')
);
$this->assertNull($updatedElement->getPdfPath());
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
}
public function testUpdateYoutubeUrlClearsEmptyString(): void
{
$element = $this->createFilledElement();
$updateYoutubeUrl = new UpdateYoutubeUrl($this->elementRepo);
$updatedElement = $updateYoutubeUrl->execute(
new UpdateYoutubeUrlRequest(
id: $element->getId(),
youtubeUrl: '',
)
);
$this->assertNull($updatedElement->getYoutubeUrl());
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
}
private function createFilledElement(): 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: 'Original title',
description: 'Original description',
iconImageUrl: '/assets/original-icon.png',
richText: '<p>Original rich text</p>',
pdfPath: '/assets/pdfs/original.pdf',
youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU',
parentElement: null,
));
}
}

View file

@ -0,0 +1,270 @@
<?php
namespace Tests\Unit\Element\UseCases;
use App\Element\CreateElementDto;
use App\Element\Element;
use App\Element\UseCases\UpdateElement\UpdateDescription;
use App\Element\UseCases\UpdateElement\UpdateIconImageUrl;
use App\Element\UseCases\UpdateElement\UpdateElement;
use App\Element\UseCases\UpdateElement\UpdateElementRequest;
use App\Element\UseCases\UpdateElement\UpdatePdfPath;
use App\Element\UseCases\UpdateElement\UpdateRichText;
use App\Element\UseCases\UpdateElement\UpdateTitle;
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl;
use App\Exceptions\BadRequestException;
use App\Exceptions\NotFoundException;
use App\Set\Set as DomainSet;
use Tests\Fakes\FakeElementRepository;
use Tests\TestCase;
class UpdateElementTest extends TestCase
{
private FakeElementRepository $elementRepo;
private UpdateElement $updateElement;
protected function setUp(): void
{
$this->elementRepo = new FakeElementRepository();
$this->updateElement = new UpdateElement(
new UpdateTitle($this->elementRepo),
new UpdateDescription($this->elementRepo),
new UpdateIconImageUrl($this->elementRepo),
new UpdateRichText($this->elementRepo),
new UpdatePdfPath($this->elementRepo),
new UpdateYoutubeUrl($this->elementRepo),
$this->elementRepo,
);
}
public function testUpdatesContentFields(): void
{
$set = $this->createSet(1, 'Baderech');
$element = $this->createElement(
$set,
'Original title',
'Original description',
'/assets/original-icon.png',
'<p>Original rich text</p>',
'/assets/pdfs/original.pdf',
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
null,
);
$updatedElement = $this->updateElement->execute(
new UpdateElementRequest(
id: $element->getId(),
title: 'Updated title',
description: 'Updated description',
iconImageUrl: '/assets/updated-icon.png',
richText: '<p>Updated rich text</p>',
pdfPath: '/assets/pdfs/updated.pdf',
youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
)
);
$this->assertSame($element->getId(), $updatedElement->getId());
$this->assertSame('Updated title', $updatedElement->getTitle());
$this->assertSame(
'Updated description',
$updatedElement->getDescription(),
);
$this->assertSame(
'/assets/updated-icon.png',
$updatedElement->getIconImageUrl(),
);
$this->assertSame(
'<p>Updated rich text</p>',
$updatedElement->getRichText(),
);
$this->assertSame(
'/assets/pdfs/updated.pdf',
$updatedElement->getPdfPath(),
);
$this->assertSame(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
$updatedElement->getYoutubeUrl(),
);
$persistedElement = $this->elementRepo->find($element->getId());
$this->assertInstanceOf(Element::class, $persistedElement);
$this->assertSame('Updated title', $persistedElement->getTitle());
}
public function testConvertsEmptyNullableFieldsToNull(): void
{
$set = $this->createSet(1, 'Baderech');
$element = $this->createElement(
$set,
'Original title',
'Original description',
'/assets/original-icon.png',
'<p>Original rich text</p>',
'/assets/pdfs/original.pdf',
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
null,
);
$updatedElement = $this->updateElement->execute(
new UpdateElementRequest(
id: $element->getId(),
title: 'Updated title',
description: 'Updated description',
iconImageUrl: '',
richText: '<p>Updated rich text</p>',
pdfPath: '',
youtubeUrl: '',
)
);
$this->assertNull($updatedElement->getIconImageUrl());
$this->assertNull($updatedElement->getPdfPath());
$this->assertNull($updatedElement->getYoutubeUrl());
}
public function testUpdatesOnlySuppliedFields(): void
{
$set = $this->createSet(1, 'Baderech');
$element = $this->createElement(
$set,
'Original title',
'Original description',
'/assets/original-icon.png',
'<p>Original rich text</p>',
'/assets/pdfs/original.pdf',
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
null,
);
$updatedElement = $this->updateElement->execute(
new UpdateElementRequest(
id: $element->getId(),
title: 'Updated title',
description: null,
iconImageUrl: null,
richText: null,
pdfPath: null,
youtubeUrl: null,
)
);
$this->assertSame('Updated title', $updatedElement->getTitle());
$this->assertSame(
'Original description',
$updatedElement->getDescription(),
);
$this->assertSame(
'/assets/original-icon.png',
$updatedElement->getIconImageUrl(),
);
$this->assertSame(
'<p>Original rich text</p>',
$updatedElement->getRichText(),
);
$this->assertSame(
'/assets/pdfs/original.pdf',
$updatedElement->getPdfPath(),
);
$this->assertSame(
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
$updatedElement->getYoutubeUrl(),
);
}
public function testThrowsWhenIdMissing(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('id is required');
$this->updateElement->execute(new UpdateElementRequest(
id: null,
title: 'Updated title',
description: 'Updated description',
iconImageUrl: null,
richText: '<p>Updated rich text</p>',
pdfPath: null,
youtubeUrl: null,
));
}
public function testThrowsWhenNothingProvided(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('nothing to update');
$this->updateElement->execute(new UpdateElementRequest(
id: 1,
title: null,
description: null,
iconImageUrl: null,
richText: null,
pdfPath: null,
youtubeUrl: null,
));
}
public function testThrowsWhenTitleIsBlank(): void
{
$this->expectException(BadRequestException::class);
$this->expectExceptionMessage('title is required');
$this->updateElement->execute(new UpdateElementRequest(
id: 1,
title: '',
description: 'Updated description',
iconImageUrl: null,
richText: '<p>Updated rich text</p>',
pdfPath: null,
youtubeUrl: null,
));
}
public function testThrowsWhenElementDoesNotExist(): void
{
$this->expectException(NotFoundException::class);
$this->expectExceptionMessage('Element not found');
$this->updateElement->execute(new UpdateElementRequest(
id: 999,
title: 'Updated title',
description: 'Updated description',
iconImageUrl: null,
richText: '<p>Updated rich text</p>',
pdfPath: null,
youtubeUrl: null,
));
}
private function createSet(int $id, string $name): DomainSet
{
return new DomainSet(
id: $id,
name: $name,
description: "$name description",
iconImageUrl: '/assets/baderech-icon.png',
);
}
private function createElement(
DomainSet $set,
string $title,
string $description,
?string $iconImageUrl,
string $richText,
?string $pdfPath,
?string $youtubeUrl,
?Element $parentElement,
): Element {
return $this->elementRepo->create(new CreateElementDto(
set: $set,
title: $title,
description: $description,
iconImageUrl: $iconImageUrl,
richText: $richText,
pdfPath: $pdfPath,
youtubeUrl: $youtubeUrl,
parentElement: $parentElement,
));
}
}

View file

@ -1,16 +0,0 @@
import { defineConfig } from 'cypress'
import createBundler from '@bahmutov/cypress-esbuild-preprocessor'
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
specPattern: 'cypress/e2e/**/*.cy.ts',
supportFile: 'cypress/support/e2e.ts',
fixturesFolder: false,
video: false,
screenshotOnRunFailure: false,
setupNodeEvents(onEvent) {
onEvent('file:preprocessor', createBundler())
},
},
})

View file

@ -0,0 +1,97 @@
import { execSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'cypress'
import createBundler from '@bahmutov/cypress-esbuild-preprocessor'
import { Client } from 'pg'
const configDirectory = dirname(fileURLToPath(import.meta.url))
const backendDirectory = resolve(configDirectory, '../../backend')
const socketDirectory = resolve(configDirectory, '../../.postgres')
const appDatabase = 'postgres'
const templateDatabase = 'postgres_cypress_template'
function controlClient(): Client {
return new Client({
host: socketDirectory,
database: 'template1',
user: 'postgres',
})
}
async function buildTemplate(): Promise<void> {
const setupClient = controlClient()
await setupClient.connect()
await setupClient.query(
`UPDATE pg_database SET datistemplate = false
WHERE datname = $1`,
[templateDatabase],
)
await setupClient.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = $1 AND pid <> pg_backend_pid()`,
[templateDatabase],
)
await setupClient.query(`DROP DATABASE IF EXISTS ${templateDatabase}`)
await setupClient.query(`CREATE DATABASE ${templateDatabase}`)
await setupClient.end()
const seedEnvironment = { ...process.env, DB_DATABASE: templateDatabase }
execSync('php artisan migrate:fresh --force', {
cwd: backendDirectory,
stdio: 'pipe',
env: seedEnvironment,
})
execSync('php artisan db:seed --force', {
cwd: backendDirectory,
stdio: 'pipe',
env: seedEnvironment,
})
const markClient = controlClient()
await markClient.connect()
await markClient.query(
`UPDATE pg_database SET datistemplate = true
WHERE datname = $1`,
[templateDatabase],
)
await markClient.end()
}
async function resetFromTemplate(): Promise<null> {
const client = controlClient()
await client.connect()
await client.query(
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname IN ($1, $2) AND pid <> pg_backend_pid()`,
[appDatabase, templateDatabase],
)
await client.query(`DROP DATABASE IF EXISTS ${appDatabase}`)
await client.query(
`CREATE DATABASE ${appDatabase}
TEMPLATE ${templateDatabase}`,
)
await client.end()
return null
}
export default defineConfig({
e2e: {
baseUrl: 'http://localhost:5173',
specPattern: 'cypress/e2e/**/*.cy.ts',
supportFile: 'cypress/support/e2e.ts',
fixturesFolder: false,
video: false,
screenshotOnRunFailure: false,
setupNodeEvents(onEvent) {
onEvent('before:run', async function () {
await buildTemplate()
})
onEvent('file:preprocessor', createBundler())
onEvent('task', {
'db:reset': resetFromTemplate,
})
},
},
})

View file

@ -0,0 +1,123 @@
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')
}
function fillElementForm(
title: string,
description: string,
iconImageUrl: string,
richText: string,
pdfPath: string,
youtubeUrl: string,
): void {
cy.get('[data-cy="admin-element-title"]').clear().type(title)
cy.get('[data-cy="admin-element-description"]').clear().type(description)
cy.get('[data-cy="admin-element-icon-image-url"]')
.clear()
.type(iconImageUrl)
cy.get('[data-cy="admin-element-rich-text"]').clear()
if (richText !== '') {
cy.get('[data-cy="admin-element-rich-text"]').type(richText, {
parseSpecialCharSequences: false,
})
}
cy.get('[data-cy="admin-element-pdf-path"]').clear()
if (pdfPath !== '') {
cy.get('[data-cy="admin-element-pdf-path"]').type(pdfPath)
}
cy.get('[data-cy="admin-element-youtube-url"]').clear()
if (youtubeUrl !== '') {
cy.get('[data-cy="admin-element-youtube-url"]').type(youtubeUrl)
}
}
describe('admin element editing', () => {
it('does not show the edit link to logged-out users', () => {
cy.visit('/element/1')
cy.contains('header.site-header a', 'Edit Element').should('not.exist')
})
it('shows the edit link on public element pages to logged-in users', () => {
loginAsAdmin()
cy.visit('/element/1')
cy.contains('header.site-header a', 'Edit Element')
.should('be.visible')
.and('have.attr', 'href', '/admin/element/1')
.click()
cy.location('pathname').should('eq', '/admin/element/1')
})
it('redirects logged-out admin element visits to login', () => {
cy.visit('/admin/element/1')
cy.location('pathname').should('eq', '/login')
})
it('shows the view link on admin element pages', () => {
loginAsAdmin()
cy.visit('/admin/element/1')
cy.contains('header.site-header a', 'View Element')
.should('be.visible')
.and('have.attr', 'href', '/element/1')
cy.contains('header.site-header a', 'Edit Element').should('not.exist')
cy.get('[data-cy="admin-element-title"]')
.should('have.value', 'Baderech HaAvodah')
})
it('saves element edits and restores the seed content through the UI', () => {
const originalTitle = 'Baderech HaAvodah'
const originalDescription = 'a structured path for inner and outer growth'
const originalIconImageUrl = '/assets/baderech-haavodah-icon.png'
const updatedTitle = 'Baderech HaAvodah Updated'
const updatedDescription = 'Updated admin description'
const updatedRichText = '<p>Updated admin rich text</p>'
loginAsAdmin()
cy.visit('/admin/element/1')
fillElementForm(
updatedTitle,
updatedDescription,
originalIconImageUrl,
updatedRichText,
'',
'',
)
cy.get('[data-cy="admin-element-save"]').click()
cy.get('[data-cy="admin-element-status"]')
.should('be.visible')
.and('contain.text', 'Saved')
cy.contains('header.site-header a', 'View Element').click()
cy.location('pathname').should('eq', '/element/1')
cy.contains('h1', updatedTitle).should('be.visible')
cy.get('[data-cy="element-rich-text"]')
.should('contain.text', 'Updated admin rich text')
cy.visit('/admin/element/1')
fillElementForm(
originalTitle,
originalDescription,
originalIconImageUrl,
'',
'',
'',
)
cy.get('[data-cy="admin-element-save"]').click()
cy.get('[data-cy="admin-element-status"]')
.should('be.visible')
.and('contain.text', 'Saved')
})
})

View file

@ -0,0 +1,41 @@
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('cypress database reset', () => {
it('can dirty element seed data', () => {
loginAsAdmin()
cy.visit('/admin/element/1')
cy.get('[data-cy="admin-element-title"]')
.clear()
.type('Dirty Cypress Element')
cy.get('[data-cy="admin-element-description"]')
.clear()
.type('Dirty Cypress description')
cy.get('[data-cy="admin-element-rich-text"]')
.clear()
.type('<p>Dirty Cypress rich text</p>', {
parseSpecialCharSequences: false,
})
cy.get('[data-cy="admin-element-save"]').click()
cy.get('[data-cy="admin-element-status"]')
.should('be.visible')
.and('contain.text', 'Saved')
})
it('starts the next test from seeded element data', () => {
cy.visit('/element/1')
cy.contains('h1', 'Baderech HaAvodah').should('be.visible')
cy.get('[data-cy="element-rich-text"]').should('not.exist')
})
})

View file

@ -0,0 +1,16 @@
/// <reference types="cypress" />
/* eslint-disable @typescript-eslint/no-namespace */
declare global {
namespace Cypress {
interface Chainable {
resetDb(): Chainable<null>
}
}
}
Cypress.Commands.add('resetDb', function () {
return cy.task<null>('db:reset')
})
export {}

View file

@ -1,3 +1,9 @@
// Loaded automatically before every spec file in cypress/e2e/.
// Intentionally minimal - add shared commands here when needed.
import './commands'
beforeEach(() => {
cy.resetDb()
cy.clearCookies()
cy.clearLocalStorage()
})
export {}

View file

@ -0,0 +1,12 @@
{
"extends": "../tsconfig.app.json",
"include": ["./**/*.ts"],
"compilerOptions": {
"types": ["cypress", "node"],
"isolatedModules": false,
"noUncheckedIndexedAccess": false,
"noEmit": false,
"allowImportingTsExtensions": false,
"ignoreDeprecations": "6.0"
}
}

View file

@ -16,6 +16,7 @@
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@types/pg": "^8.20.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.7.0",
@ -30,6 +31,7 @@
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"oxlint": "~1.60.0",
"pg": "^8.21.0",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
@ -2344,6 +2346,18 @@
"undici-types": "~7.16.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/sinonjs__fake-timers": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz",
@ -6495,6 +6509,103 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"dev": true,
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@ -6611,6 +6722,49 @@
"node": ">=4"
}
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -7101,6 +7255,16 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sshpk": {
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
@ -8226,6 +8390,16 @@
"node": ">=12"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View file

@ -25,6 +25,7 @@
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.12.2",
"@types/pg": "^8.20.0",
"@vitejs/plugin-vue": "^6.0.6",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.7.0",
@ -39,6 +40,7 @@
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"oxlint": "~1.60.0",
"pg": "^8.21.0",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",

View file

@ -1,9 +1,15 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { RouterLink } from 'vue-router'
import { computed, onMounted } from 'vue'
import { RouterLink, useRoute, type RouteLocationRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
interface ContextualNavAction {
label: string
to: RouteLocationRaw
}
const authStore = useAuthStore()
const route = useRoute()
const signatureImage = {
src: '/assets/signature.png',
@ -24,6 +30,42 @@ const navItems = [
{ label: 'Contact', href: '/contact', icon: '➤' },
{ label: 'Donate', href: '/donate', icon: '♡' },
]
const routeElementId = computed(() => {
const currentRouteElementId = route.params.id
if (Array.isArray(currentRouteElementId)) {
return currentRouteElementId.join('/')
}
if (typeof currentRouteElementId === 'string') {
return currentRouteElementId
}
return null
})
const contextualNavAction = computed<ContextualNavAction | null>(() => {
if (!authStore.isAuthenticated || routeElementId.value === null) {
return null
}
if (route.name === 'element') {
return {
label: 'Edit Element',
to: { name: 'admin-element', params: { id: routeElementId.value } },
}
}
if (route.name === 'admin-element') {
return {
label: 'View Element',
to: { name: 'element', params: { id: routeElementId.value } },
}
}
return null
})
</script>
<template>
@ -43,6 +85,14 @@ const navItems = [
</span>
<span>{{ item.label }}</span>
</RouterLink>
<RouterLink
v-if="contextualNavAction !== null"
:to="contextualNavAction.to"
class="site-header__nav-link"
data-cy="navbar-element-mode"
>
{{ contextualNavAction.label }}
</RouterLink>
<button
v-if="authStore.isAuthenticated"
data-cy="navbar-logout"

View file

@ -3,6 +3,8 @@ import HomePage from '@/views/HomePage.vue'
import LoginPage from '@/views/LoginPage.vue'
import MediaPage from '@/views/MediaPage.vue'
import ElementPage from '@/views/ElementPage.vue'
import AdminElementPage from '@/views/AdminElementPage.vue'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -27,7 +29,29 @@ const router = createRouter({
name: 'element',
component: ElementPage,
},
{
path: '/admin/element/:id',
name: 'admin-element',
component: AdminElementPage,
},
],
})
router.beforeEach(async (to) => {
if (to.name !== 'admin-element') {
return true
}
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
await authStore.fetchUser()
}
if (!authStore.isAuthenticated) {
return { name: 'login' }
}
return true
})
export default router

View file

@ -19,6 +19,23 @@ interface ElementResponse {
childElements: ChildElement[]
}
export interface UpdateElementInput {
title: string
description: string
iconImageUrl: string
richText: string
pdfPath: string
youtubeUrl: string
}
interface UpdateElementResponse {
element: Element
}
interface ErrorResponse {
error?: string
}
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
export const useElementsStore = defineStore('elements', () => {
@ -26,11 +43,14 @@ export const useElementsStore = defineStore('elements', () => {
const childElements = ref<ChildElement[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const isSaving = ref(false)
const saveError = ref<string | null>(null)
async function fetchElement(elementId: string): Promise<void> {
element.value = null
childElements.value = []
error.value = null
saveError.value = null
isLoading.value = true
try {
@ -59,5 +79,60 @@ export const useElementsStore = defineStore('elements', () => {
}
}
return { element, childElements, isLoading, error, fetchElement }
async function updateElement(elementId: string, input: UpdateElementInput): Promise<boolean> {
saveError.value = null
isSaving.value = true
try {
const encodedElementId = encodeURIComponent(elementId)
const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}`
const response = await fetch(elementUrl, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(input),
})
if (response.status === 401) {
saveError.value = 'Please log in again'
return false
}
if (response.status === 404) {
saveError.value = 'Element not found'
return false
}
if (response.status === 400) {
const data: ErrorResponse = await response.json()
saveError.value = data.error ?? 'Could not save element'
return false
}
if (!response.ok) {
saveError.value = 'Could not save element'
return false
}
const data: UpdateElementResponse = await response.json()
element.value = data.element
return true
} catch {
saveError.value = 'Network error - could not save element'
return false
} finally {
isSaving.value = false
}
}
return {
element,
childElements,
isLoading,
error,
isSaving,
saveError,
fetchElement,
updateElement,
}
})

View file

@ -0,0 +1,318 @@
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import { computed, reactive, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import { useElementsStore } from '@/stores/elements'
interface ElementForm {
title: string
description: string
iconImageUrl: string
richText: string
pdfPath: string
youtubeUrl: string
}
const route = useRoute()
const elementsStore = useElementsStore()
const { element, isLoading, error, isSaving, saveError } = storeToRefs(elementsStore)
const savedMessage = ref<string | null>(null)
const form = reactive<ElementForm>({
title: '',
description: '',
iconImageUrl: '',
richText: '',
pdfPath: '',
youtubeUrl: '',
})
const elementId = computed(() => {
const routeElementId = route.params.id
if (Array.isArray(routeElementId)) {
return routeElementId.join('/')
}
if (typeof routeElementId === 'string') {
return routeElementId
}
return ''
})
const statusMessage = computed(() => {
return saveError.value ?? savedMessage.value
})
watch(
elementId,
(currentElementId) => {
if (currentElementId === '') {
return
}
savedMessage.value = null
void elementsStore.fetchElement(currentElementId)
},
{ immediate: true },
)
watch(element, (currentElement) => {
if (currentElement === null) {
return
}
form.title = currentElement.title
form.description = currentElement.description
form.iconImageUrl = currentElement.iconImageUrl ?? ''
form.richText = currentElement.richText
form.pdfPath = currentElement.pdfPath ?? ''
form.youtubeUrl = currentElement.youtubeUrl ?? ''
})
async function handleSubmit(): Promise<void> {
if (elementId.value === '') {
return
}
savedMessage.value = null
const saved = await elementsStore.updateElement(elementId.value, {
title: form.title,
description: form.description,
iconImageUrl: form.iconImageUrl,
richText: form.richText,
pdfPath: form.pdfPath,
youtubeUrl: form.youtubeUrl,
})
if (saved) {
savedMessage.value = 'Saved'
}
}
</script>
<template>
<div class="admin-element-page">
<SiteHeader />
<main class="admin-element-page__main" data-cy="admin-element-page">
<header class="admin-element-page__header">
<h1 class="admin-element-page__heading">Edit Element</h1>
</header>
<p v-if="isLoading" class="admin-element-page__status">Loading element...</p>
<p
v-else-if="error"
class="admin-element-page__status admin-element-page__status--error"
data-cy="admin-element-load-error"
>
{{ error }}
</p>
<form v-else-if="element" class="admin-element-page__form" @submit.prevent="handleSubmit">
<label class="admin-element-page__field">
<span class="admin-element-page__label">Title</span>
<input
v-model="form.title"
class="admin-element-page__input"
data-cy="admin-element-title"
type="text"
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Description</span>
<textarea
v-model="form.description"
class="admin-element-page__textarea"
data-cy="admin-element-description"
rows="4"
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Icon Image URL</span>
<input
v-model="form.iconImageUrl"
class="admin-element-page__input"
data-cy="admin-element-icon-image-url"
type="text"
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">Rich Text HTML</span>
<textarea
v-model="form.richText"
class="admin-element-page__textarea admin-element-page__code"
data-cy="admin-element-rich-text"
rows="9"
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">PDF Path</span>
<input
v-model="form.pdfPath"
class="admin-element-page__input"
data-cy="admin-element-pdf-path"
type="text"
/>
</label>
<label class="admin-element-page__field">
<span class="admin-element-page__label">YouTube URL</span>
<input
v-model="form.youtubeUrl"
class="admin-element-page__input"
data-cy="admin-element-youtube-url"
type="text"
/>
</label>
<div class="admin-element-page__actions">
<button
class="admin-element-page__save"
data-cy="admin-element-save"
type="submit"
:disabled="isSaving"
>
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
<p
v-if="statusMessage !== null"
:class="['admin-element-page__status', 'admin-element-page__status--inline']"
data-cy="admin-element-status"
aria-live="polite"
>
{{ statusMessage }}
</p>
</div>
</form>
</main>
</div>
</template>
<style scoped>
.admin-element-page {
min-height: 100vh;
background: var(--color-white);
}
.admin-element-page__main {
min-height: 100vh;
padding: 4rem 2rem 5rem;
}
.admin-element-page__header,
.admin-element-page__form,
.admin-element-page__status {
max-width: 48rem;
margin-right: auto;
margin-left: auto;
}
.admin-element-page__heading {
margin: 0;
color: #2c2c2c;
font-family: var(--font-serif);
font-size: 2rem;
font-weight: 400;
line-height: 1.2;
}
.admin-element-page__form {
display: flex;
flex-direction: column;
gap: 1.1rem;
margin-top: 2rem;
}
.admin-element-page__field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.admin-element-page__label {
color: var(--color-text-muted);
font-size: 0.9rem;
font-weight: 500;
}
.admin-element-page__input,
.admin-element-page__textarea {
width: 100%;
padding: 0.7rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 4px;
color: var(--color-text);
background: var(--color-white);
font-family: var(--font-sans);
font-size: 0.96rem;
line-height: 1.5;
}
.admin-element-page__input:focus,
.admin-element-page__textarea:focus {
border-color: var(--color-slate);
outline: none;
}
.admin-element-page__textarea {
resize: vertical;
}
.admin-element-page__code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.admin-element-page__actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.5rem;
}
.admin-element-page__save {
padding: 0.7rem 1.4rem;
border: 1px solid var(--color-slate);
border-radius: 4px;
color: var(--color-white);
background: var(--color-slate);
font-family: var(--font-sans);
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
.admin-element-page__save:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.admin-element-page__status {
margin-top: 2rem;
color: var(--color-text-muted);
}
.admin-element-page__status--inline {
margin: 0;
color: var(--color-slate);
}
.admin-element-page__status--error {
color: #9f2f2f;
}
@media (max-width: 768px) {
.admin-element-page__main {
padding: 2.5rem 1rem 4rem;
}
.admin-element-page__actions {
align-items: stretch;
flex-direction: column;
}
}
</style>

View file

@ -7,5 +7,10 @@
{
"path": "./tsconfig.app.json"
}
]
],
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"ignoreDeprecations": "6.0"
}
}