Compare commits
No commits in common. "77dc4173b6307e9ff6255869d69b14bcf9c6a313" and "c7659b45ee576d42be5ff650147aa0c50d7e6b08" have entirely different histories.
77dc4173b6
...
c7659b45ee
42 changed files with 35 additions and 2170 deletions
|
|
@ -20,9 +20,6 @@ intentionally omitted here - update this section as entities land.
|
||||||
- Do not write unit tests for concrete repository implementations
|
- Do not write unit tests for concrete repository implementations
|
||||||
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
|
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
|
||||||
Use cases are tested with fake repositories.
|
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
|
- Repository methods that find records by a foreign key should accept
|
||||||
the related entity, not a raw id (e.g. `findBySet(Set $set)`, not
|
the related entity, not a raw id (e.g. `findBySet(Set $set)`, not
|
||||||
`findBySetId(int $setId)`).
|
`findBySetId(int $setId)`).
|
||||||
|
|
|
||||||
|
|
@ -2,22 +2,16 @@
|
||||||
|
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Element\Element;
|
|
||||||
use App\Element\UseCases\GetElement\GetElement;
|
use App\Element\UseCases\GetElement\GetElement;
|
||||||
use App\Element\UseCases\GetElement\GetElementRequest;
|
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\BadRequestException;
|
||||||
use App\Exceptions\NotFoundException;
|
use App\Exceptions\NotFoundException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class ElementController
|
class ElementController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(private GetElement $getElement)
|
||||||
private GetElement $getElement,
|
{
|
||||||
private UpdateElement $updateElement,
|
|
||||||
) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(?int $id): JsonResponse
|
public function show(?int $id): JsonResponse
|
||||||
|
|
@ -48,73 +42,15 @@ class ElementController
|
||||||
|
|
||||||
return new JsonResponse([
|
return new JsonResponse([
|
||||||
'childElements' => $childElements,
|
'childElements' => $childElements,
|
||||||
'element' => $this->buildElementPayload($element),
|
'element' => [
|
||||||
|
'id' => $element->getId(),
|
||||||
|
'title' => $element->getTitle(),
|
||||||
|
'description' => $element->getDescription(),
|
||||||
|
'iconImageUrl' => $element->getIconImageUrl(),
|
||||||
|
'richText' => $element->getRichText(),
|
||||||
|
'pdfPath' => $element->getPdfPath(),
|
||||||
|
'youtubeUrl' => $element->getYoutubeUrl(),
|
||||||
|
],
|
||||||
], 200);
|
], 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(),
|
|
||||||
'iconImageUrl' => $element->getIconImageUrl(),
|
|
||||||
'richText' => $element->getRichText(),
|
|
||||||
'pdfPath' => $element->getPdfPath(),
|
|
||||||
'youtubeUrl' => $element->getYoutubeUrl(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,61 +29,31 @@ class Element
|
||||||
return $this->title;
|
return $this->title;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTitle(string $title): void
|
|
||||||
{
|
|
||||||
$this->title = $title;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDescription(): string
|
public function getDescription(): string
|
||||||
{
|
{
|
||||||
return $this->description;
|
return $this->description;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setDescription(string $description): void
|
|
||||||
{
|
|
||||||
$this->description = $description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getIconImageUrl(): ?string
|
public function getIconImageUrl(): ?string
|
||||||
{
|
{
|
||||||
return $this->iconImageUrl;
|
return $this->iconImageUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setIconImageUrl(?string $iconImageUrl): void
|
|
||||||
{
|
|
||||||
$this->iconImageUrl = $iconImageUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRichText(): string
|
public function getRichText(): string
|
||||||
{
|
{
|
||||||
return $this->richText;
|
return $this->richText;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setRichText(string $richText): void
|
|
||||||
{
|
|
||||||
$this->richText = $richText;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPdfPath(): ?string
|
public function getPdfPath(): ?string
|
||||||
{
|
{
|
||||||
return $this->pdfPath;
|
return $this->pdfPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setPdfPath(?string $pdfPath): void
|
|
||||||
{
|
|
||||||
$this->pdfPath = $pdfPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getYoutubeUrl(): ?string
|
public function getYoutubeUrl(): ?string
|
||||||
{
|
{
|
||||||
return $this->youtubeUrl;
|
return $this->youtubeUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setYoutubeUrl(?string $youtubeUrl): void
|
|
||||||
{
|
|
||||||
$this->youtubeUrl = $youtubeUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getSet(): Set
|
public function getSet(): Set
|
||||||
{
|
{
|
||||||
return $this->set;
|
return $this->set;
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ interface ElementRepository
|
||||||
{
|
{
|
||||||
public function create(CreateElementDto $dto): Element;
|
public function create(CreateElementDto $dto): Element;
|
||||||
|
|
||||||
public function update(Element $element): Element;
|
|
||||||
|
|
||||||
public function find(int $id): ?Element;
|
public function find(int $id): ?Element;
|
||||||
|
|
||||||
public function findRootBySet(DomainSet $set): ?Element;
|
public function findRootBySet(DomainSet $set): ?Element;
|
||||||
|
|
|
||||||
|
|
@ -38,28 +38,6 @@ 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
|
public function find(int $id): ?Element
|
||||||
{
|
{
|
||||||
$model = ElementModel::find($id);
|
$model = ElementModel::find($id);
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\UpdateElement;
|
|
||||||
|
|
||||||
class UpdateDescriptionRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $id,
|
|
||||||
public ?string $description,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
<?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,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\UpdateElement;
|
|
||||||
|
|
||||||
class UpdateIconImageUrlRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $id,
|
|
||||||
public ?string $iconImageUrl,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\UpdateElement;
|
|
||||||
|
|
||||||
class UpdatePdfPathRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $id,
|
|
||||||
public ?string $pdfPath,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\UpdateElement;
|
|
||||||
|
|
||||||
class UpdateRichTextRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $id,
|
|
||||||
public ?string $richText,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<?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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\UpdateElement;
|
|
||||||
|
|
||||||
class UpdateTitleRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $id,
|
|
||||||
public ?string $title,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\UpdateElement;
|
|
||||||
|
|
||||||
class UpdateYoutubeUrlRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $id,
|
|
||||||
public ?string $youtubeUrl,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,7 +13,7 @@ $allowedOrigins = array_values(array_filter(array_map(
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'paths' => ['api/*'],
|
'paths' => ['api/*'],
|
||||||
'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS'],
|
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
|
||||||
'allowed_origins' => $allowedOrigins,
|
'allowed_origins' => $allowedOrigins,
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [],
|
||||||
'allowed_headers' => [
|
'allowed_headers' => [
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,3 @@ 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']);
|
Route::get('/elements/{id}', [ElementController::class, 'show']);
|
||||||
Route::patch('/elements/{id}', [ElementController::class, 'update'])
|
|
||||||
->middleware(AuthMiddleware::class);
|
|
||||||
|
|
|
||||||
|
|
@ -33,14 +33,6 @@ class FakeElementRepository implements ElementRepository
|
||||||
return $element;
|
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
|
public function find(int $id): ?Element
|
||||||
{
|
{
|
||||||
if (! isset($this->elementsById[$id])) {
|
if (! isset($this->elementsById[$id])) {
|
||||||
|
|
|
||||||
|
|
@ -21,23 +21,4 @@ class CorsTest extends TestCase
|
||||||
);
|
);
|
||||||
$response->assertHeader('Access-Control-Allow-Credentials', 'true');
|
$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')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,10 @@
|
||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
use App\Auth\CreateSessionDto;
|
|
||||||
use App\Auth\SessionRepository;
|
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Element\ElementRepository;
|
use App\Element\ElementRepository;
|
||||||
use App\Set\CreateSetDto;
|
use App\Set\CreateSetDto;
|
||||||
use App\Set\SetRepository;
|
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 Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
|
@ -99,116 +92,4 @@ class ElementsEndpointTest extends TestCase
|
||||||
'error' => 'Element not found',
|
'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'),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,6 @@ use App\Controllers\ElementController;
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Element\Element;
|
use App\Element\Element;
|
||||||
use App\Element\UseCases\GetElement\GetElement;
|
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 App\Set\Set as DomainSet;
|
||||||
use Tests\Fakes\FakeElementRepository;
|
use Tests\Fakes\FakeElementRepository;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
@ -27,16 +20,7 @@ class ElementControllerTest extends TestCase
|
||||||
{
|
{
|
||||||
$this->elementRepo = new FakeElementRepository();
|
$this->elementRepo = new FakeElementRepository();
|
||||||
$getElement = new GetElement($this->elementRepo);
|
$getElement = new GetElement($this->elementRepo);
|
||||||
$updateElement = new UpdateElement(
|
$this->controller = new ElementController($getElement);
|
||||||
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
|
public function testShowReturnsElementPayload(): void
|
||||||
|
|
|
||||||
|
|
@ -68,39 +68,4 @@ class ElementTest extends TestCase
|
||||||
$this->assertNull($rootElement->getYoutubeUrl());
|
$this->assertNull($rootElement->getYoutubeUrl());
|
||||||
$this->assertNull($rootElement->getParentElement());
|
$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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,175 +0,0 @@
|
||||||
<?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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
<?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,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
frontend/rabbi_gerzi/cypress.config.mjs
Normal file
16
frontend/rabbi_gerzi/cypress.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
@ -1,123 +0,0 @@
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
/// <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 {}
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
import './commands'
|
// Loaded automatically before every spec file in cypress/e2e/.
|
||||||
|
// Intentionally minimal - add shared commands here when needed.
|
||||||
beforeEach(() => {
|
|
||||||
cy.resetDb()
|
|
||||||
cy.clearCookies()
|
|
||||||
cy.clearLocalStorage()
|
|
||||||
})
|
|
||||||
|
|
||||||
export {}
|
export {}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "../tsconfig.app.json",
|
|
||||||
"include": ["./**/*.ts"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["cypress", "node"],
|
|
||||||
"isolatedModules": false,
|
|
||||||
"noUncheckedIndexedAccess": false,
|
|
||||||
"noEmit": false,
|
|
||||||
"allowImportingTsExtensions": false,
|
|
||||||
"ignoreDeprecations": "6.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
174
frontend/rabbi_gerzi/package-lock.json
generated
174
frontend/rabbi_gerzi/package-lock.json
generated
|
|
@ -16,7 +16,6 @@
|
||||||
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
|
|
@ -31,7 +30,6 @@
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"oxfmt": "^0.45.0",
|
"oxfmt": "^0.45.0",
|
||||||
"oxlint": "~1.60.0",
|
"oxlint": "~1.60.0",
|
||||||
"pg": "^8.21.0",
|
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
"vite-plugin-vue-devtools": "^8.1.1",
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
|
@ -2346,18 +2344,6 @@
|
||||||
"undici-types": "~7.16.0"
|
"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": {
|
"node_modules/@types/sinonjs__fake-timers": {
|
||||||
"version": "8.1.1",
|
"version": "8.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz",
|
||||||
|
|
@ -6509,103 +6495,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
|
@ -6722,49 +6611,6 @@
|
||||||
"node": ">=4"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
|
|
@ -7255,16 +7101,6 @@
|
||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/sshpk": {
|
||||||
"version": "1.18.0",
|
"version": "1.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||||
|
|
@ -8390,16 +8226,6 @@
|
||||||
"node": ">=12"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@
|
||||||
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
||||||
"@tsconfig/node24": "^24.0.4",
|
"@tsconfig/node24": "^24.0.4",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/pg": "^8.20.0",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||||
"@vue/eslint-config-typescript": "^14.7.0",
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
|
|
@ -40,7 +39,6 @@
|
||||||
"npm-run-all2": "^8.0.4",
|
"npm-run-all2": "^8.0.4",
|
||||||
"oxfmt": "^0.45.0",
|
"oxfmt": "^0.45.0",
|
||||||
"oxlint": "~1.60.0",
|
"oxlint": "~1.60.0",
|
||||||
"pg": "^8.21.0",
|
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"vite": "^8.0.8",
|
"vite": "^8.0.8",
|
||||||
"vite-plugin-vue-devtools": "^8.1.1",
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { RouterLink, useRoute, type RouteLocationRaw } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
interface ContextualNavAction {
|
|
||||||
label: string
|
|
||||||
to: RouteLocationRaw
|
|
||||||
}
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const signatureImage = {
|
const signatureImage = {
|
||||||
src: '/assets/signature.png',
|
src: '/assets/signature.png',
|
||||||
|
|
@ -30,42 +24,6 @@ const navItems = [
|
||||||
{ label: 'Contact', href: '/contact', icon: '➤' },
|
{ label: 'Contact', href: '/contact', icon: '➤' },
|
||||||
{ label: 'Donate', href: '/donate', 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -85,14 +43,6 @@ const contextualNavAction = computed<ContextualNavAction | null>(() => {
|
||||||
</span>
|
</span>
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
|
||||||
v-if="contextualNavAction !== null"
|
|
||||||
:to="contextualNavAction.to"
|
|
||||||
class="site-header__nav-link"
|
|
||||||
data-cy="navbar-element-mode"
|
|
||||||
>
|
|
||||||
{{ contextualNavAction.label }}
|
|
||||||
</RouterLink>
|
|
||||||
<button
|
<button
|
||||||
v-if="authStore.isAuthenticated"
|
v-if="authStore.isAuthenticated"
|
||||||
data-cy="navbar-logout"
|
data-cy="navbar-logout"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,6 @@ import HomePage from '@/views/HomePage.vue'
|
||||||
import LoginPage from '@/views/LoginPage.vue'
|
import LoginPage from '@/views/LoginPage.vue'
|
||||||
import MediaPage from '@/views/MediaPage.vue'
|
import MediaPage from '@/views/MediaPage.vue'
|
||||||
import ElementPage from '@/views/ElementPage.vue'
|
import ElementPage from '@/views/ElementPage.vue'
|
||||||
import AdminElementPage from '@/views/AdminElementPage.vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
|
@ -29,29 +27,7 @@ const router = createRouter({
|
||||||
name: 'element',
|
name: 'element',
|
||||||
component: ElementPage,
|
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
|
export default router
|
||||||
|
|
|
||||||
|
|
@ -19,23 +19,6 @@ interface ElementResponse {
|
||||||
childElements: ChildElement[]
|
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
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
||||||
|
|
||||||
export const useElementsStore = defineStore('elements', () => {
|
export const useElementsStore = defineStore('elements', () => {
|
||||||
|
|
@ -43,14 +26,11 @@ export const useElementsStore = defineStore('elements', () => {
|
||||||
const childElements = ref<ChildElement[]>([])
|
const childElements = ref<ChildElement[]>([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const isSaving = ref(false)
|
|
||||||
const saveError = ref<string | null>(null)
|
|
||||||
|
|
||||||
async function fetchElement(elementId: string): Promise<void> {
|
async function fetchElement(elementId: string): Promise<void> {
|
||||||
element.value = null
|
element.value = null
|
||||||
childElements.value = []
|
childElements.value = []
|
||||||
error.value = null
|
error.value = null
|
||||||
saveError.value = null
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -79,60 +59,5 @@ export const useElementsStore = defineStore('elements', () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateElement(elementId: string, input: UpdateElementInput): Promise<boolean> {
|
return { element, childElements, isLoading, error, fetchElement }
|
||||||
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,
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,318 +0,0 @@
|
||||||
<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>
|
|
||||||
|
|
@ -7,10 +7,5 @@
|
||||||
{
|
{
|
||||||
"path": "./tsconfig.app.json"
|
"path": "./tsconfig.app.json"
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"compilerOptions": {
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"ignoreDeprecations": "6.0"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue