Merge branch 'feature/admin-element-edit'
This commit is contained in:
commit
77dc4173b6
42 changed files with 2170 additions and 35 deletions
|
|
@ -20,6 +20,9 @@ intentionally omitted here - update this section as entities land.
|
|||
- Do not write unit tests for concrete repository implementations
|
||||
(e.g. `Postgres*Repository`). They are exercised by e2e tests.
|
||||
Use cases are tested with fake repositories.
|
||||
- Do not write reflection-only contract tests for repository interface
|
||||
method names, parameter counts, parameter names, or type hints. PHP
|
||||
interfaces already enforce those signatures; test behavior instead.
|
||||
- Repository methods that find records by a foreign key should accept
|
||||
the related entity, not a raw id (e.g. `findBySet(Set $set)`, not
|
||||
`findBySetId(int $setId)`).
|
||||
|
|
|
|||
|
|
@ -2,16 +2,22 @@
|
|||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\UseCases\GetElement\GetElement;
|
||||
use App\Element\UseCases\GetElement\GetElementRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdateElement;
|
||||
use App\Element\UseCases\UpdateElement\UpdateElementRequest;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ElementController
|
||||
{
|
||||
public function __construct(private GetElement $getElement)
|
||||
{
|
||||
public function __construct(
|
||||
private GetElement $getElement,
|
||||
private UpdateElement $updateElement,
|
||||
) {
|
||||
}
|
||||
|
||||
public function show(?int $id): JsonResponse
|
||||
|
|
@ -42,7 +48,66 @@ class ElementController
|
|||
|
||||
return new JsonResponse([
|
||||
'childElements' => $childElements,
|
||||
'element' => [
|
||||
'element' => $this->buildElementPayload($element),
|
||||
], 200);
|
||||
}
|
||||
|
||||
public function update(?int $id, Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$element = $this->updateElement->execute(
|
||||
new UpdateElementRequest(
|
||||
id: $id,
|
||||
title: $this->stringInput($request, 'title'),
|
||||
description: $this->stringInput($request, 'description'),
|
||||
iconImageUrl: $this->stringInput(
|
||||
$request,
|
||||
'iconImageUrl',
|
||||
),
|
||||
richText: $this->stringInput($request, 'richText'),
|
||||
pdfPath: $this->stringInput($request, 'pdfPath'),
|
||||
youtubeUrl: $this->stringInput($request, 'youtubeUrl'),
|
||||
)
|
||||
);
|
||||
} catch (BadRequestException $exception) {
|
||||
return new JsonResponse([
|
||||
'error' => $exception->getMessage(),
|
||||
], 400);
|
||||
} catch (NotFoundException $exception) {
|
||||
return new JsonResponse([
|
||||
'error' => $exception->getMessage(),
|
||||
], 404);
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'element' => $this->buildElementPayload($element),
|
||||
], 200);
|
||||
}
|
||||
|
||||
private function stringInput(Request $request, string $key): ?string
|
||||
{
|
||||
$value = $request->input($key);
|
||||
if (! is_string($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* id: int,
|
||||
* title: string,
|
||||
* description: string,
|
||||
* iconImageUrl: string|null,
|
||||
* richText: string,
|
||||
* pdfPath: string|null,
|
||||
* youtubeUrl: string|null,
|
||||
* }
|
||||
*/
|
||||
private function buildElementPayload(Element $element): array
|
||||
{
|
||||
return [
|
||||
'id' => $element->getId(),
|
||||
'title' => $element->getTitle(),
|
||||
'description' => $element->getDescription(),
|
||||
|
|
@ -50,7 +115,6 @@ class ElementController
|
|||
'richText' => $element->getRichText(),
|
||||
'pdfPath' => $element->getPdfPath(),
|
||||
'youtubeUrl' => $element->getYoutubeUrl(),
|
||||
],
|
||||
], 200);
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,31 +29,61 @@ class Element
|
|||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(string $title): void
|
||||
{
|
||||
$this->title = $title;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(string $description): void
|
||||
{
|
||||
$this->description = $description;
|
||||
}
|
||||
|
||||
public function getIconImageUrl(): ?string
|
||||
{
|
||||
return $this->iconImageUrl;
|
||||
}
|
||||
|
||||
public function setIconImageUrl(?string $iconImageUrl): void
|
||||
{
|
||||
$this->iconImageUrl = $iconImageUrl;
|
||||
}
|
||||
|
||||
public function getRichText(): string
|
||||
{
|
||||
return $this->richText;
|
||||
}
|
||||
|
||||
public function setRichText(string $richText): void
|
||||
{
|
||||
$this->richText = $richText;
|
||||
}
|
||||
|
||||
public function getPdfPath(): ?string
|
||||
{
|
||||
return $this->pdfPath;
|
||||
}
|
||||
|
||||
public function setPdfPath(?string $pdfPath): void
|
||||
{
|
||||
$this->pdfPath = $pdfPath;
|
||||
}
|
||||
|
||||
public function getYoutubeUrl(): ?string
|
||||
{
|
||||
return $this->youtubeUrl;
|
||||
}
|
||||
|
||||
public function setYoutubeUrl(?string $youtubeUrl): void
|
||||
{
|
||||
$this->youtubeUrl = $youtubeUrl;
|
||||
}
|
||||
|
||||
public function getSet(): Set
|
||||
{
|
||||
return $this->set;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ interface ElementRepository
|
|||
{
|
||||
public function create(CreateElementDto $dto): Element;
|
||||
|
||||
public function update(Element $element): Element;
|
||||
|
||||
public function find(int $id): ?Element;
|
||||
|
||||
public function findRootBySet(DomainSet $set): ?Element;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,28 @@ class EloquentElementRepository implements ElementRepository
|
|||
);
|
||||
}
|
||||
|
||||
public function update(Element $element): Element
|
||||
{
|
||||
$model = ElementModel::find($element->getId());
|
||||
if ($model === null) {
|
||||
throw new DomainException(
|
||||
"Element with id: {$element->getId()} doesnt exist"
|
||||
);
|
||||
}
|
||||
|
||||
$model->set_id = $element->getSet()->getId();
|
||||
$model->title = $element->getTitle();
|
||||
$model->description = $element->getDescription();
|
||||
$model->icon_image_url = $element->getIconImageUrl();
|
||||
$model->rich_text = $element->getRichText();
|
||||
$model->pdf_path = $element->getPdfPath();
|
||||
$model->youtube_url = $element->getYoutubeUrl();
|
||||
$model->parent_element_id = $element->getParentElement()?->getId();
|
||||
$model->save();
|
||||
|
||||
return $this->toDomain($model);
|
||||
}
|
||||
|
||||
public function find(int $id): ?Element
|
||||
{
|
||||
$model = ElementModel::find($id);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdateDescription
|
||||
{
|
||||
public function __construct(private ElementRepository $elementRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdateDescriptionRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
if ($request->description === null) {
|
||||
throw new BadRequestException('description is required');
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$element->setDescription($request->description);
|
||||
|
||||
return $this->elementRepository->update($element);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
class UpdateDescriptionRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $description,
|
||||
) {
|
||||
}
|
||||
}
|
||||
89
backend/app/Element/UseCases/UpdateElement/UpdateElement.php
Normal file
89
backend/app/Element/UseCases/UpdateElement/UpdateElement.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdateElement
|
||||
{
|
||||
public function __construct(
|
||||
private UpdateTitle $updateTitle,
|
||||
private UpdateDescription $updateDescription,
|
||||
private UpdateIconImageUrl $updateIconImageUrl,
|
||||
private UpdateRichText $updateRichText,
|
||||
private UpdatePdfPath $updatePdfPath,
|
||||
private UpdateYoutubeUrl $updateYoutubeUrl,
|
||||
private ElementRepository $elementRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdateElementRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
$hasNoFields = $request->title === null
|
||||
&& $request->description === null
|
||||
&& $request->iconImageUrl === null
|
||||
&& $request->richText === null
|
||||
&& $request->pdfPath === null
|
||||
&& $request->youtubeUrl === null;
|
||||
if ($hasNoFields) {
|
||||
throw new BadRequestException('nothing to update');
|
||||
}
|
||||
|
||||
if ($request->title !== null) {
|
||||
$this->updateTitle->execute(new UpdateTitleRequest(
|
||||
id: $request->id,
|
||||
title: $request->title,
|
||||
));
|
||||
}
|
||||
if ($request->description !== null) {
|
||||
$this->updateDescription->execute(new UpdateDescriptionRequest(
|
||||
id: $request->id,
|
||||
description: $request->description,
|
||||
));
|
||||
}
|
||||
if ($request->iconImageUrl !== null) {
|
||||
$this->updateIconImageUrl->execute(
|
||||
new UpdateIconImageUrlRequest(
|
||||
id: $request->id,
|
||||
iconImageUrl: $request->iconImageUrl,
|
||||
)
|
||||
);
|
||||
}
|
||||
if ($request->richText !== null) {
|
||||
$this->updateRichText->execute(new UpdateRichTextRequest(
|
||||
id: $request->id,
|
||||
richText: $request->richText,
|
||||
));
|
||||
}
|
||||
if ($request->pdfPath !== null) {
|
||||
$this->updatePdfPath->execute(new UpdatePdfPathRequest(
|
||||
id: $request->id,
|
||||
pdfPath: $request->pdfPath,
|
||||
));
|
||||
}
|
||||
if ($request->youtubeUrl !== null) {
|
||||
$this->updateYoutubeUrl->execute(new UpdateYoutubeUrlRequest(
|
||||
id: $request->id,
|
||||
youtubeUrl: $request->youtubeUrl,
|
||||
));
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
return $element;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
class UpdateElementRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $title,
|
||||
public ?string $description,
|
||||
public ?string $iconImageUrl,
|
||||
public ?string $richText,
|
||||
public ?string $pdfPath,
|
||||
public ?string $youtubeUrl,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdateIconImageUrl
|
||||
{
|
||||
public function __construct(private ElementRepository $elementRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdateIconImageUrlRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
if ($request->iconImageUrl === null) {
|
||||
throw new BadRequestException('iconImageUrl is required');
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$element->setIconImageUrl($this->nullableString(
|
||||
$request->iconImageUrl,
|
||||
));
|
||||
|
||||
return $this->elementRepository->update($element);
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
class UpdateIconImageUrlRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $iconImageUrl,
|
||||
) {
|
||||
}
|
||||
}
|
||||
48
backend/app/Element/UseCases/UpdateElement/UpdatePdfPath.php
Normal file
48
backend/app/Element/UseCases/UpdateElement/UpdatePdfPath.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdatePdfPath
|
||||
{
|
||||
public function __construct(private ElementRepository $elementRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdatePdfPathRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
if ($request->pdfPath === null) {
|
||||
throw new BadRequestException('pdfPath is required');
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$element->setPdfPath($this->nullableString($request->pdfPath));
|
||||
|
||||
return $this->elementRepository->update($element);
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
class UpdatePdfPathRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $pdfPath,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdateRichText
|
||||
{
|
||||
public function __construct(private ElementRepository $elementRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdateRichTextRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
if ($request->richText === null) {
|
||||
throw new BadRequestException('richText is required');
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$element->setRichText($request->richText);
|
||||
|
||||
return $this->elementRepository->update($element);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
class UpdateRichTextRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $richText,
|
||||
) {
|
||||
}
|
||||
}
|
||||
39
backend/app/Element/UseCases/UpdateElement/UpdateTitle.php
Normal file
39
backend/app/Element/UseCases/UpdateElement/UpdateTitle.php
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdateTitle
|
||||
{
|
||||
public function __construct(private ElementRepository $elementRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdateTitleRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
if ($request->title === null || trim($request->title) === '') {
|
||||
throw new BadRequestException('title is required');
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$element->setTitle(trim($request->title));
|
||||
|
||||
return $this->elementRepository->update($element);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
class UpdateTitleRequest
|
||||
{
|
||||
public function __construct(
|
||||
public ?int $id,
|
||||
public ?string $title,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Element\UseCases\UpdateElement;
|
||||
|
||||
use App\Element\Element;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
|
||||
class UpdateYoutubeUrl
|
||||
{
|
||||
public function __construct(private ElementRepository $elementRepository)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws BadRequestException
|
||||
* @throws NotFoundException
|
||||
*/
|
||||
public function execute(UpdateYoutubeUrlRequest $request): Element
|
||||
{
|
||||
if ($request->id === null) {
|
||||
throw new BadRequestException('id is required');
|
||||
}
|
||||
|
||||
if ($request->youtubeUrl === null) {
|
||||
throw new BadRequestException('youtubeUrl is required');
|
||||
}
|
||||
|
||||
$element = $this->elementRepository->find($request->id);
|
||||
if ($element === null) {
|
||||
throw new NotFoundException('Element not found');
|
||||
}
|
||||
|
||||
$element->setYoutubeUrl($this->nullableString(
|
||||
$request->youtubeUrl,
|
||||
));
|
||||
|
||||
return $this->elementRepository->update($element);
|
||||
}
|
||||
|
||||
private function nullableString(string $value): ?string
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?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 [
|
||||
'paths' => ['api/*'],
|
||||
'allowed_methods' => ['GET', 'POST', 'OPTIONS'],
|
||||
'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS'],
|
||||
'allowed_origins' => $allowedOrigins,
|
||||
'allowed_origins_patterns' => [],
|
||||
'allowed_headers' => [
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ Route::get('/me', [AuthController::class, 'me'])
|
|||
->middleware(AuthMiddleware::class);
|
||||
Route::get('/sets', [SetController::class, 'index']);
|
||||
Route::get('/elements/{id}', [ElementController::class, 'show']);
|
||||
Route::patch('/elements/{id}', [ElementController::class, 'update'])
|
||||
->middleware(AuthMiddleware::class);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ class FakeElementRepository implements ElementRepository
|
|||
return $element;
|
||||
}
|
||||
|
||||
public function update(Element $element): Element
|
||||
{
|
||||
$updatedElement = $this->cloneElement($element);
|
||||
$this->elementsById[$updatedElement->getId()] = $updatedElement;
|
||||
|
||||
return $this->cloneElement($updatedElement);
|
||||
}
|
||||
|
||||
public function find(int $id): ?Element
|
||||
{
|
||||
if (! isset($this->elementsById[$id])) {
|
||||
|
|
|
|||
|
|
@ -21,4 +21,23 @@ class CorsTest extends TestCase
|
|||
);
|
||||
$response->assertHeader('Access-Control-Allow-Credentials', 'true');
|
||||
}
|
||||
|
||||
public function testAllowsPatchPreflight(): void
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Origin' => 'http://localhost:5173',
|
||||
'Access-Control-Request-Method' => 'PATCH',
|
||||
'Access-Control-Request-Headers' => 'content-type',
|
||||
])->options('/api/elements/1');
|
||||
|
||||
$response->assertNoContent();
|
||||
$response->assertHeader(
|
||||
'Access-Control-Allow-Origin',
|
||||
'http://localhost:5173'
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
'PATCH',
|
||||
$response->headers->get('Access-Control-Allow-Methods')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@
|
|||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Auth\CreateSessionDto;
|
||||
use App\Auth\SessionRepository;
|
||||
use App\Element\CreateElementDto;
|
||||
use App\Element\ElementRepository;
|
||||
use App\Set\CreateSetDto;
|
||||
use App\Set\SetRepository;
|
||||
use App\Shared\ValueObject\EmailAddress;
|
||||
use App\User\CreateUserDto;
|
||||
use App\User\UserRepository;
|
||||
use DateTimeImmutable;
|
||||
use DateTimeZone;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -92,4 +99,116 @@ class ElementsEndpointTest extends TestCase
|
|||
'error' => 'Element not found',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testUpdateRequiresAuthentication(): void
|
||||
{
|
||||
$setRepository = app(SetRepository::class);
|
||||
$elementRepository = app(ElementRepository::class);
|
||||
$set = $setRepository->create(new CreateSetDto(
|
||||
name: 'Baderech HaAvodah',
|
||||
description: 'A structured path for growth',
|
||||
iconImageUrl: '/assets/baderech-haavodah-icon.png',
|
||||
));
|
||||
$element = $elementRepository->create(new CreateElementDto(
|
||||
set: $set,
|
||||
title: 'Baderech HaAvodah',
|
||||
description: 'A structured path for growth',
|
||||
iconImageUrl: '/assets/baderech-haavodah-icon.png',
|
||||
richText: '<p>A structured path for growth</p>',
|
||||
pdfPath: '/assets/pdfs/baderech.pdf',
|
||||
youtubeUrl: null,
|
||||
parentElement: null,
|
||||
));
|
||||
|
||||
$response = $this->patchJson("/api/elements/{$element->getId()}", [
|
||||
'title' => 'Updated title',
|
||||
'description' => 'Updated description',
|
||||
'iconImageUrl' => '/assets/updated-icon.png',
|
||||
'richText' => '<p>Updated rich text</p>',
|
||||
'pdfPath' => '/assets/pdfs/updated.pdf',
|
||||
'youtubeUrl' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
]);
|
||||
|
||||
$response->assertUnauthorized();
|
||||
$response->assertExactJson([
|
||||
'error' => 'unauthenticated',
|
||||
]);
|
||||
}
|
||||
|
||||
public function testAuthenticatedUpdateReturnsElementPayload(): void
|
||||
{
|
||||
$sampleYoutubeUrl = 'https://www.youtube.com/watch?v='
|
||||
. 'dQw4w9WgXcQ';
|
||||
$setRepository = app(SetRepository::class);
|
||||
$elementRepository = app(ElementRepository::class);
|
||||
$set = $setRepository->create(new CreateSetDto(
|
||||
name: 'Baderech HaAvodah',
|
||||
description: 'A structured path for growth',
|
||||
iconImageUrl: '/assets/baderech-haavodah-icon.png',
|
||||
));
|
||||
$element = $elementRepository->create(new CreateElementDto(
|
||||
set: $set,
|
||||
title: 'Baderech HaAvodah',
|
||||
description: 'A structured path for growth',
|
||||
iconImageUrl: '/assets/baderech-haavodah-icon.png',
|
||||
richText: '<p>A structured path for growth</p>',
|
||||
pdfPath: '/assets/pdfs/baderech.pdf',
|
||||
youtubeUrl: null,
|
||||
parentElement: null,
|
||||
));
|
||||
$this->createSession('valid-token');
|
||||
|
||||
$response = $this->withCredentials()
|
||||
->withUnencryptedCookie('auth_token', 'valid-token')
|
||||
->patchJson("/api/elements/{$element->getId()}", [
|
||||
'title' => 'Updated title',
|
||||
'description' => 'Updated description',
|
||||
'iconImageUrl' => '/assets/updated-icon.png',
|
||||
'richText' => '<p>Updated rich text</p>',
|
||||
'pdfPath' => '/assets/pdfs/updated.pdf',
|
||||
'youtubeUrl' => $sampleYoutubeUrl,
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertExactJson([
|
||||
'element' => [
|
||||
'id' => $element->getId(),
|
||||
'title' => 'Updated title',
|
||||
'description' => 'Updated description',
|
||||
'iconImageUrl' => '/assets/updated-icon.png',
|
||||
'richText' => '<p>Updated rich text</p>',
|
||||
'pdfPath' => '/assets/pdfs/updated.pdf',
|
||||
'youtubeUrl' => $sampleYoutubeUrl,
|
||||
],
|
||||
]);
|
||||
$updatedElement = $elementRepository->find($element->getId());
|
||||
$this->assertSame('Updated title', $updatedElement->getTitle());
|
||||
$this->assertSame(
|
||||
'<p>Updated rich text</p>',
|
||||
$updatedElement->getRichText(),
|
||||
);
|
||||
}
|
||||
|
||||
private function createSession(string $token): void
|
||||
{
|
||||
$userRepository = app(UserRepository::class);
|
||||
$sessionRepository = app(SessionRepository::class);
|
||||
$user = $userRepository->create(new CreateUserDto(
|
||||
email: new EmailAddress('admin@example.com'),
|
||||
passwordHash: 'password-hash',
|
||||
));
|
||||
$now = new DateTimeImmutable(
|
||||
'2026-05-01T00:00:00',
|
||||
new DateTimeZone('UTC'),
|
||||
);
|
||||
$sessionRepository->create(new CreateSessionDto(
|
||||
token: $token,
|
||||
user: $user,
|
||||
createdAt: $now,
|
||||
expiresAt: new DateTimeImmutable(
|
||||
'2099-01-01T00:00:00',
|
||||
new DateTimeZone('UTC'),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ use App\Controllers\ElementController;
|
|||
use App\Element\CreateElementDto;
|
||||
use App\Element\Element;
|
||||
use App\Element\UseCases\GetElement\GetElement;
|
||||
use App\Element\UseCases\UpdateElement\UpdateDescription;
|
||||
use App\Element\UseCases\UpdateElement\UpdateElement;
|
||||
use App\Element\UseCases\UpdateElement\UpdateIconImageUrl;
|
||||
use App\Element\UseCases\UpdateElement\UpdatePdfPath;
|
||||
use App\Element\UseCases\UpdateElement\UpdateRichText;
|
||||
use App\Element\UseCases\UpdateElement\UpdateTitle;
|
||||
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl;
|
||||
use App\Set\Set as DomainSet;
|
||||
use Tests\Fakes\FakeElementRepository;
|
||||
use Tests\TestCase;
|
||||
|
|
@ -20,7 +27,16 @@ class ElementControllerTest extends TestCase
|
|||
{
|
||||
$this->elementRepo = new FakeElementRepository();
|
||||
$getElement = new GetElement($this->elementRepo);
|
||||
$this->controller = new ElementController($getElement);
|
||||
$updateElement = new UpdateElement(
|
||||
new UpdateTitle($this->elementRepo),
|
||||
new UpdateDescription($this->elementRepo),
|
||||
new UpdateIconImageUrl($this->elementRepo),
|
||||
new UpdateRichText($this->elementRepo),
|
||||
new UpdatePdfPath($this->elementRepo),
|
||||
new UpdateYoutubeUrl($this->elementRepo),
|
||||
$this->elementRepo,
|
||||
);
|
||||
$this->controller = new ElementController($getElement, $updateElement);
|
||||
}
|
||||
|
||||
public function testShowReturnsElementPayload(): void
|
||||
|
|
|
|||
|
|
@ -68,4 +68,39 @@ class ElementTest extends TestCase
|
|||
$this->assertNull($rootElement->getYoutubeUrl());
|
||||
$this->assertNull($rootElement->getParentElement());
|
||||
}
|
||||
|
||||
public function testUpdatesEditableFields(): void
|
||||
{
|
||||
$set = new DomainSet(
|
||||
id: 1,
|
||||
name: 'Daily learning',
|
||||
description: 'Daily learning description',
|
||||
iconImageUrl: '/assets/daily-learning-icon.svg',
|
||||
);
|
||||
$element = new Element(
|
||||
id: 1,
|
||||
title: 'Original',
|
||||
description: 'Original description',
|
||||
iconImageUrl: '/assets/original.svg',
|
||||
richText: '<p>Original</p>',
|
||||
pdfPath: '/assets/pdfs/original.pdf',
|
||||
youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU',
|
||||
set: $set,
|
||||
parentElement: null,
|
||||
);
|
||||
|
||||
$element->setTitle('Updated');
|
||||
$element->setDescription('Updated description');
|
||||
$element->setIconImageUrl(null);
|
||||
$element->setRichText('<p>Updated</p>');
|
||||
$element->setPdfPath(null);
|
||||
$element->setYoutubeUrl(null);
|
||||
|
||||
$this->assertSame('Updated', $element->getTitle());
|
||||
$this->assertSame('Updated description', $element->getDescription());
|
||||
$this->assertNull($element->getIconImageUrl());
|
||||
$this->assertSame('<p>Updated</p>', $element->getRichText());
|
||||
$this->assertNull($element->getPdfPath());
|
||||
$this->assertNull($element->getYoutubeUrl());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
175
backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php
Normal file
175
backend/tests/Unit/Element/UseCases/UpdateElementFieldsTest.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Element\UseCases;
|
||||
|
||||
use App\Element\CreateElementDto;
|
||||
use App\Element\Element;
|
||||
use App\Element\UseCases\UpdateElement\UpdateDescription;
|
||||
use App\Element\UseCases\UpdateElement\UpdateDescriptionRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdateIconImageUrl;
|
||||
use App\Element\UseCases\UpdateElement\UpdateIconImageUrlRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdatePdfPath;
|
||||
use App\Element\UseCases\UpdateElement\UpdatePdfPathRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdateRichText;
|
||||
use App\Element\UseCases\UpdateElement\UpdateRichTextRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdateTitle;
|
||||
use App\Element\UseCases\UpdateElement\UpdateTitleRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl;
|
||||
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrlRequest;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Set\Set as DomainSet;
|
||||
use Tests\Fakes\FakeElementRepository;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UpdateElementFieldsTest extends TestCase
|
||||
{
|
||||
private FakeElementRepository $elementRepo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->elementRepo = new FakeElementRepository();
|
||||
}
|
||||
|
||||
public function testUpdateTitleUpdatesOnlyTitle(): void
|
||||
{
|
||||
$element = $this->createFilledElement();
|
||||
$updateTitle = new UpdateTitle($this->elementRepo);
|
||||
|
||||
$updatedElement = $updateTitle->execute(new UpdateTitleRequest(
|
||||
id: $element->getId(),
|
||||
title: 'Updated title',
|
||||
));
|
||||
|
||||
$this->assertSame('Updated title', $updatedElement->getTitle());
|
||||
$this->assertSame(
|
||||
$element->getDescription(),
|
||||
$updatedElement->getDescription(),
|
||||
);
|
||||
$this->assertSame(
|
||||
$element->getRichText(),
|
||||
$updatedElement->getRichText(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpdateTitleRejectsBlankTitle(): void
|
||||
{
|
||||
$this->createFilledElement();
|
||||
$updateTitle = new UpdateTitle($this->elementRepo);
|
||||
|
||||
$this->expectException(BadRequestException::class);
|
||||
$this->expectExceptionMessage('title is required');
|
||||
|
||||
$updateTitle->execute(new UpdateTitleRequest(id: 1, title: ''));
|
||||
}
|
||||
|
||||
public function testUpdateDescriptionUpdatesOnlyDescription(): void
|
||||
{
|
||||
$element = $this->createFilledElement();
|
||||
$updateDescription = new UpdateDescription($this->elementRepo);
|
||||
|
||||
$updatedElement = $updateDescription->execute(
|
||||
new UpdateDescriptionRequest(
|
||||
id: $element->getId(),
|
||||
description: 'Updated description',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
'Updated description',
|
||||
$updatedElement->getDescription(),
|
||||
);
|
||||
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
|
||||
$this->assertSame(
|
||||
$element->getYoutubeUrl(),
|
||||
$updatedElement->getYoutubeUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpdateIconImageUrlClearsEmptyString(): void
|
||||
{
|
||||
$element = $this->createFilledElement();
|
||||
$updateIconImageUrl = new UpdateIconImageUrl($this->elementRepo);
|
||||
|
||||
$updatedElement = $updateIconImageUrl->execute(
|
||||
new UpdateIconImageUrlRequest(
|
||||
id: $element->getId(),
|
||||
iconImageUrl: '',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertNull($updatedElement->getIconImageUrl());
|
||||
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
|
||||
}
|
||||
|
||||
public function testUpdateRichTextUpdatesOnlyRichText(): void
|
||||
{
|
||||
$element = $this->createFilledElement();
|
||||
$updateRichText = new UpdateRichText($this->elementRepo);
|
||||
|
||||
$updatedElement = $updateRichText->execute(
|
||||
new UpdateRichTextRequest(
|
||||
id: $element->getId(),
|
||||
richText: '<p>Updated rich text</p>',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertSame(
|
||||
'<p>Updated rich text</p>',
|
||||
$updatedElement->getRichText(),
|
||||
);
|
||||
$this->assertSame(
|
||||
$element->getDescription(),
|
||||
$updatedElement->getDescription(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpdatePdfPathClearsEmptyString(): void
|
||||
{
|
||||
$element = $this->createFilledElement();
|
||||
$updatePdfPath = new UpdatePdfPath($this->elementRepo);
|
||||
|
||||
$updatedElement = $updatePdfPath->execute(
|
||||
new UpdatePdfPathRequest(id: $element->getId(), pdfPath: '')
|
||||
);
|
||||
|
||||
$this->assertNull($updatedElement->getPdfPath());
|
||||
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
|
||||
}
|
||||
|
||||
public function testUpdateYoutubeUrlClearsEmptyString(): void
|
||||
{
|
||||
$element = $this->createFilledElement();
|
||||
$updateYoutubeUrl = new UpdateYoutubeUrl($this->elementRepo);
|
||||
|
||||
$updatedElement = $updateYoutubeUrl->execute(
|
||||
new UpdateYoutubeUrlRequest(
|
||||
id: $element->getId(),
|
||||
youtubeUrl: '',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertNull($updatedElement->getYoutubeUrl());
|
||||
$this->assertSame($element->getTitle(), $updatedElement->getTitle());
|
||||
}
|
||||
|
||||
private function createFilledElement(): Element
|
||||
{
|
||||
$set = new DomainSet(
|
||||
id: 1,
|
||||
name: 'Baderech',
|
||||
description: 'Baderech description',
|
||||
iconImageUrl: '/assets/baderech-icon.png',
|
||||
);
|
||||
|
||||
return $this->elementRepo->create(new CreateElementDto(
|
||||
set: $set,
|
||||
title: 'Original title',
|
||||
description: 'Original description',
|
||||
iconImageUrl: '/assets/original-icon.png',
|
||||
richText: '<p>Original rich text</p>',
|
||||
pdfPath: '/assets/pdfs/original.pdf',
|
||||
youtubeUrl: 'https://www.youtube.com/watch?v=yHx-r4p6hHU',
|
||||
parentElement: null,
|
||||
));
|
||||
}
|
||||
}
|
||||
270
backend/tests/Unit/Element/UseCases/UpdateElementTest.php
Normal file
270
backend/tests/Unit/Element/UseCases/UpdateElementTest.php
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Unit\Element\UseCases;
|
||||
|
||||
use App\Element\CreateElementDto;
|
||||
use App\Element\Element;
|
||||
use App\Element\UseCases\UpdateElement\UpdateDescription;
|
||||
use App\Element\UseCases\UpdateElement\UpdateIconImageUrl;
|
||||
use App\Element\UseCases\UpdateElement\UpdateElement;
|
||||
use App\Element\UseCases\UpdateElement\UpdateElementRequest;
|
||||
use App\Element\UseCases\UpdateElement\UpdatePdfPath;
|
||||
use App\Element\UseCases\UpdateElement\UpdateRichText;
|
||||
use App\Element\UseCases\UpdateElement\UpdateTitle;
|
||||
use App\Element\UseCases\UpdateElement\UpdateYoutubeUrl;
|
||||
use App\Exceptions\BadRequestException;
|
||||
use App\Exceptions\NotFoundException;
|
||||
use App\Set\Set as DomainSet;
|
||||
use Tests\Fakes\FakeElementRepository;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UpdateElementTest extends TestCase
|
||||
{
|
||||
private FakeElementRepository $elementRepo;
|
||||
|
||||
private UpdateElement $updateElement;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->elementRepo = new FakeElementRepository();
|
||||
$this->updateElement = new UpdateElement(
|
||||
new UpdateTitle($this->elementRepo),
|
||||
new UpdateDescription($this->elementRepo),
|
||||
new UpdateIconImageUrl($this->elementRepo),
|
||||
new UpdateRichText($this->elementRepo),
|
||||
new UpdatePdfPath($this->elementRepo),
|
||||
new UpdateYoutubeUrl($this->elementRepo),
|
||||
$this->elementRepo,
|
||||
);
|
||||
}
|
||||
|
||||
public function testUpdatesContentFields(): void
|
||||
{
|
||||
$set = $this->createSet(1, 'Baderech');
|
||||
$element = $this->createElement(
|
||||
$set,
|
||||
'Original title',
|
||||
'Original description',
|
||||
'/assets/original-icon.png',
|
||||
'<p>Original rich text</p>',
|
||||
'/assets/pdfs/original.pdf',
|
||||
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
|
||||
null,
|
||||
);
|
||||
|
||||
$updatedElement = $this->updateElement->execute(
|
||||
new UpdateElementRequest(
|
||||
id: $element->getId(),
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
iconImageUrl: '/assets/updated-icon.png',
|
||||
richText: '<p>Updated rich text</p>',
|
||||
pdfPath: '/assets/pdfs/updated.pdf',
|
||||
youtubeUrl: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertSame($element->getId(), $updatedElement->getId());
|
||||
$this->assertSame('Updated title', $updatedElement->getTitle());
|
||||
$this->assertSame(
|
||||
'Updated description',
|
||||
$updatedElement->getDescription(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'/assets/updated-icon.png',
|
||||
$updatedElement->getIconImageUrl(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'<p>Updated rich text</p>',
|
||||
$updatedElement->getRichText(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'/assets/pdfs/updated.pdf',
|
||||
$updatedElement->getPdfPath(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||
$updatedElement->getYoutubeUrl(),
|
||||
);
|
||||
|
||||
$persistedElement = $this->elementRepo->find($element->getId());
|
||||
$this->assertInstanceOf(Element::class, $persistedElement);
|
||||
$this->assertSame('Updated title', $persistedElement->getTitle());
|
||||
}
|
||||
|
||||
public function testConvertsEmptyNullableFieldsToNull(): void
|
||||
{
|
||||
$set = $this->createSet(1, 'Baderech');
|
||||
$element = $this->createElement(
|
||||
$set,
|
||||
'Original title',
|
||||
'Original description',
|
||||
'/assets/original-icon.png',
|
||||
'<p>Original rich text</p>',
|
||||
'/assets/pdfs/original.pdf',
|
||||
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
|
||||
null,
|
||||
);
|
||||
|
||||
$updatedElement = $this->updateElement->execute(
|
||||
new UpdateElementRequest(
|
||||
id: $element->getId(),
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
iconImageUrl: '',
|
||||
richText: '<p>Updated rich text</p>',
|
||||
pdfPath: '',
|
||||
youtubeUrl: '',
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertNull($updatedElement->getIconImageUrl());
|
||||
$this->assertNull($updatedElement->getPdfPath());
|
||||
$this->assertNull($updatedElement->getYoutubeUrl());
|
||||
}
|
||||
|
||||
public function testUpdatesOnlySuppliedFields(): void
|
||||
{
|
||||
$set = $this->createSet(1, 'Baderech');
|
||||
$element = $this->createElement(
|
||||
$set,
|
||||
'Original title',
|
||||
'Original description',
|
||||
'/assets/original-icon.png',
|
||||
'<p>Original rich text</p>',
|
||||
'/assets/pdfs/original.pdf',
|
||||
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
|
||||
null,
|
||||
);
|
||||
|
||||
$updatedElement = $this->updateElement->execute(
|
||||
new UpdateElementRequest(
|
||||
id: $element->getId(),
|
||||
title: 'Updated title',
|
||||
description: null,
|
||||
iconImageUrl: null,
|
||||
richText: null,
|
||||
pdfPath: null,
|
||||
youtubeUrl: null,
|
||||
)
|
||||
);
|
||||
|
||||
$this->assertSame('Updated title', $updatedElement->getTitle());
|
||||
$this->assertSame(
|
||||
'Original description',
|
||||
$updatedElement->getDescription(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'/assets/original-icon.png',
|
||||
$updatedElement->getIconImageUrl(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'<p>Original rich text</p>',
|
||||
$updatedElement->getRichText(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'/assets/pdfs/original.pdf',
|
||||
$updatedElement->getPdfPath(),
|
||||
);
|
||||
$this->assertSame(
|
||||
'https://www.youtube.com/watch?v=yHx-r4p6hHU&t=1s',
|
||||
$updatedElement->getYoutubeUrl(),
|
||||
);
|
||||
}
|
||||
|
||||
public function testThrowsWhenIdMissing(): void
|
||||
{
|
||||
$this->expectException(BadRequestException::class);
|
||||
$this->expectExceptionMessage('id is required');
|
||||
|
||||
$this->updateElement->execute(new UpdateElementRequest(
|
||||
id: null,
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
iconImageUrl: null,
|
||||
richText: '<p>Updated rich text</p>',
|
||||
pdfPath: null,
|
||||
youtubeUrl: null,
|
||||
));
|
||||
}
|
||||
|
||||
public function testThrowsWhenNothingProvided(): void
|
||||
{
|
||||
$this->expectException(BadRequestException::class);
|
||||
$this->expectExceptionMessage('nothing to update');
|
||||
|
||||
$this->updateElement->execute(new UpdateElementRequest(
|
||||
id: 1,
|
||||
title: null,
|
||||
description: null,
|
||||
iconImageUrl: null,
|
||||
richText: null,
|
||||
pdfPath: null,
|
||||
youtubeUrl: null,
|
||||
));
|
||||
}
|
||||
|
||||
public function testThrowsWhenTitleIsBlank(): void
|
||||
{
|
||||
$this->expectException(BadRequestException::class);
|
||||
$this->expectExceptionMessage('title is required');
|
||||
|
||||
$this->updateElement->execute(new UpdateElementRequest(
|
||||
id: 1,
|
||||
title: '',
|
||||
description: 'Updated description',
|
||||
iconImageUrl: null,
|
||||
richText: '<p>Updated rich text</p>',
|
||||
pdfPath: null,
|
||||
youtubeUrl: null,
|
||||
));
|
||||
}
|
||||
|
||||
public function testThrowsWhenElementDoesNotExist(): void
|
||||
{
|
||||
$this->expectException(NotFoundException::class);
|
||||
$this->expectExceptionMessage('Element not found');
|
||||
|
||||
$this->updateElement->execute(new UpdateElementRequest(
|
||||
id: 999,
|
||||
title: 'Updated title',
|
||||
description: 'Updated description',
|
||||
iconImageUrl: null,
|
||||
richText: '<p>Updated rich text</p>',
|
||||
pdfPath: null,
|
||||
youtubeUrl: null,
|
||||
));
|
||||
}
|
||||
|
||||
private function createSet(int $id, string $name): DomainSet
|
||||
{
|
||||
return new DomainSet(
|
||||
id: $id,
|
||||
name: $name,
|
||||
description: "$name description",
|
||||
iconImageUrl: '/assets/baderech-icon.png',
|
||||
);
|
||||
}
|
||||
|
||||
private function createElement(
|
||||
DomainSet $set,
|
||||
string $title,
|
||||
string $description,
|
||||
?string $iconImageUrl,
|
||||
string $richText,
|
||||
?string $pdfPath,
|
||||
?string $youtubeUrl,
|
||||
?Element $parentElement,
|
||||
): Element {
|
||||
return $this->elementRepo->create(new CreateElementDto(
|
||||
set: $set,
|
||||
title: $title,
|
||||
description: $description,
|
||||
iconImageUrl: $iconImageUrl,
|
||||
richText: $richText,
|
||||
pdfPath: $pdfPath,
|
||||
youtubeUrl: $youtubeUrl,
|
||||
parentElement: $parentElement,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { defineConfig } from 'cypress'
|
||||
import createBundler from '@bahmutov/cypress-esbuild-preprocessor'
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5173',
|
||||
specPattern: 'cypress/e2e/**/*.cy.ts',
|
||||
supportFile: 'cypress/support/e2e.ts',
|
||||
fixturesFolder: false,
|
||||
video: false,
|
||||
screenshotOnRunFailure: false,
|
||||
setupNodeEvents(onEvent) {
|
||||
onEvent('file:preprocessor', createBundler())
|
||||
},
|
||||
},
|
||||
})
|
||||
97
frontend/rabbi_gerzi/cypress.config.ts
Normal file
97
frontend/rabbi_gerzi/cypress.config.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { execSync } from 'node:child_process'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defineConfig } from 'cypress'
|
||||
import createBundler from '@bahmutov/cypress-esbuild-preprocessor'
|
||||
import { Client } from 'pg'
|
||||
|
||||
const configDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
const backendDirectory = resolve(configDirectory, '../../backend')
|
||||
const socketDirectory = resolve(configDirectory, '../../.postgres')
|
||||
const appDatabase = 'postgres'
|
||||
const templateDatabase = 'postgres_cypress_template'
|
||||
|
||||
function controlClient(): Client {
|
||||
return new Client({
|
||||
host: socketDirectory,
|
||||
database: 'template1',
|
||||
user: 'postgres',
|
||||
})
|
||||
}
|
||||
|
||||
async function buildTemplate(): Promise<void> {
|
||||
const setupClient = controlClient()
|
||||
await setupClient.connect()
|
||||
await setupClient.query(
|
||||
`UPDATE pg_database SET datistemplate = false
|
||||
WHERE datname = $1`,
|
||||
[templateDatabase],
|
||||
)
|
||||
await setupClient.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname = $1 AND pid <> pg_backend_pid()`,
|
||||
[templateDatabase],
|
||||
)
|
||||
await setupClient.query(`DROP DATABASE IF EXISTS ${templateDatabase}`)
|
||||
await setupClient.query(`CREATE DATABASE ${templateDatabase}`)
|
||||
await setupClient.end()
|
||||
|
||||
const seedEnvironment = { ...process.env, DB_DATABASE: templateDatabase }
|
||||
execSync('php artisan migrate:fresh --force', {
|
||||
cwd: backendDirectory,
|
||||
stdio: 'pipe',
|
||||
env: seedEnvironment,
|
||||
})
|
||||
execSync('php artisan db:seed --force', {
|
||||
cwd: backendDirectory,
|
||||
stdio: 'pipe',
|
||||
env: seedEnvironment,
|
||||
})
|
||||
|
||||
const markClient = controlClient()
|
||||
await markClient.connect()
|
||||
await markClient.query(
|
||||
`UPDATE pg_database SET datistemplate = true
|
||||
WHERE datname = $1`,
|
||||
[templateDatabase],
|
||||
)
|
||||
await markClient.end()
|
||||
}
|
||||
|
||||
async function resetFromTemplate(): Promise<null> {
|
||||
const client = controlClient()
|
||||
await client.connect()
|
||||
await client.query(
|
||||
`SELECT pg_terminate_backend(pid) FROM pg_stat_activity
|
||||
WHERE datname IN ($1, $2) AND pid <> pg_backend_pid()`,
|
||||
[appDatabase, templateDatabase],
|
||||
)
|
||||
await client.query(`DROP DATABASE IF EXISTS ${appDatabase}`)
|
||||
await client.query(
|
||||
`CREATE DATABASE ${appDatabase}
|
||||
TEMPLATE ${templateDatabase}`,
|
||||
)
|
||||
await client.end()
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: 'http://localhost:5173',
|
||||
specPattern: 'cypress/e2e/**/*.cy.ts',
|
||||
supportFile: 'cypress/support/e2e.ts',
|
||||
fixturesFolder: false,
|
||||
video: false,
|
||||
screenshotOnRunFailure: false,
|
||||
setupNodeEvents(onEvent) {
|
||||
onEvent('before:run', async function () {
|
||||
await buildTemplate()
|
||||
})
|
||||
onEvent('file:preprocessor', createBundler())
|
||||
onEvent('task', {
|
||||
'db:reset': resetFromTemplate,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
123
frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts
Normal file
123
frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
function loginAsAdmin(): void {
|
||||
cy.visit('/login')
|
||||
|
||||
cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test')
|
||||
cy.get('[data-cy="login-password"]').type('password123!@#')
|
||||
cy.get('[data-cy="login-submit"]').click()
|
||||
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/')
|
||||
cy.getCookie('auth_token').should('exist')
|
||||
}
|
||||
|
||||
function fillElementForm(
|
||||
title: string,
|
||||
description: string,
|
||||
iconImageUrl: string,
|
||||
richText: string,
|
||||
pdfPath: string,
|
||||
youtubeUrl: string,
|
||||
): void {
|
||||
cy.get('[data-cy="admin-element-title"]').clear().type(title)
|
||||
cy.get('[data-cy="admin-element-description"]').clear().type(description)
|
||||
cy.get('[data-cy="admin-element-icon-image-url"]')
|
||||
.clear()
|
||||
.type(iconImageUrl)
|
||||
cy.get('[data-cy="admin-element-rich-text"]').clear()
|
||||
if (richText !== '') {
|
||||
cy.get('[data-cy="admin-element-rich-text"]').type(richText, {
|
||||
parseSpecialCharSequences: false,
|
||||
})
|
||||
}
|
||||
cy.get('[data-cy="admin-element-pdf-path"]').clear()
|
||||
if (pdfPath !== '') {
|
||||
cy.get('[data-cy="admin-element-pdf-path"]').type(pdfPath)
|
||||
}
|
||||
cy.get('[data-cy="admin-element-youtube-url"]').clear()
|
||||
if (youtubeUrl !== '') {
|
||||
cy.get('[data-cy="admin-element-youtube-url"]').type(youtubeUrl)
|
||||
}
|
||||
}
|
||||
|
||||
describe('admin element editing', () => {
|
||||
it('does not show the edit link to logged-out users', () => {
|
||||
cy.visit('/element/1')
|
||||
|
||||
cy.contains('header.site-header a', 'Edit Element').should('not.exist')
|
||||
})
|
||||
|
||||
it('shows the edit link on public element pages to logged-in users', () => {
|
||||
loginAsAdmin()
|
||||
|
||||
cy.visit('/element/1')
|
||||
|
||||
cy.contains('header.site-header a', 'Edit Element')
|
||||
.should('be.visible')
|
||||
.and('have.attr', 'href', '/admin/element/1')
|
||||
.click()
|
||||
cy.location('pathname').should('eq', '/admin/element/1')
|
||||
})
|
||||
|
||||
it('redirects logged-out admin element visits to login', () => {
|
||||
cy.visit('/admin/element/1')
|
||||
|
||||
cy.location('pathname').should('eq', '/login')
|
||||
})
|
||||
|
||||
it('shows the view link on admin element pages', () => {
|
||||
loginAsAdmin()
|
||||
|
||||
cy.visit('/admin/element/1')
|
||||
|
||||
cy.contains('header.site-header a', 'View Element')
|
||||
.should('be.visible')
|
||||
.and('have.attr', 'href', '/element/1')
|
||||
cy.contains('header.site-header a', 'Edit Element').should('not.exist')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.should('have.value', 'Baderech HaAvodah')
|
||||
})
|
||||
|
||||
it('saves element edits and restores the seed content through the UI', () => {
|
||||
const originalTitle = 'Baderech HaAvodah'
|
||||
const originalDescription = 'a structured path for inner and outer growth'
|
||||
const originalIconImageUrl = '/assets/baderech-haavodah-icon.png'
|
||||
const updatedTitle = 'Baderech HaAvodah Updated'
|
||||
const updatedDescription = 'Updated admin description'
|
||||
const updatedRichText = '<p>Updated admin rich text</p>'
|
||||
|
||||
loginAsAdmin()
|
||||
cy.visit('/admin/element/1')
|
||||
|
||||
fillElementForm(
|
||||
updatedTitle,
|
||||
updatedDescription,
|
||||
originalIconImageUrl,
|
||||
updatedRichText,
|
||||
'',
|
||||
'',
|
||||
)
|
||||
cy.get('[data-cy="admin-element-save"]').click()
|
||||
|
||||
cy.get('[data-cy="admin-element-status"]')
|
||||
.should('be.visible')
|
||||
.and('contain.text', 'Saved')
|
||||
cy.contains('header.site-header a', 'View Element').click()
|
||||
cy.location('pathname').should('eq', '/element/1')
|
||||
cy.contains('h1', updatedTitle).should('be.visible')
|
||||
cy.get('[data-cy="element-rich-text"]')
|
||||
.should('contain.text', 'Updated admin rich text')
|
||||
|
||||
cy.visit('/admin/element/1')
|
||||
fillElementForm(
|
||||
originalTitle,
|
||||
originalDescription,
|
||||
originalIconImageUrl,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
)
|
||||
cy.get('[data-cy="admin-element-save"]').click()
|
||||
cy.get('[data-cy="admin-element-status"]')
|
||||
.should('be.visible')
|
||||
.and('contain.text', 'Saved')
|
||||
})
|
||||
})
|
||||
41
frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts
Normal file
41
frontend/rabbi_gerzi/cypress/e2e/database-reset.cy.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
function loginAsAdmin(): void {
|
||||
cy.visit('/login')
|
||||
|
||||
cy.get('[data-cy="login-email"]').type('admin@rabbigerzi.test')
|
||||
cy.get('[data-cy="login-password"]').type('password123!@#')
|
||||
cy.get('[data-cy="login-submit"]').click()
|
||||
|
||||
cy.url().should('eq', Cypress.config().baseUrl + '/')
|
||||
cy.getCookie('auth_token').should('exist')
|
||||
}
|
||||
|
||||
describe('cypress database reset', () => {
|
||||
it('can dirty element seed data', () => {
|
||||
loginAsAdmin()
|
||||
|
||||
cy.visit('/admin/element/1')
|
||||
cy.get('[data-cy="admin-element-title"]')
|
||||
.clear()
|
||||
.type('Dirty Cypress Element')
|
||||
cy.get('[data-cy="admin-element-description"]')
|
||||
.clear()
|
||||
.type('Dirty Cypress description')
|
||||
cy.get('[data-cy="admin-element-rich-text"]')
|
||||
.clear()
|
||||
.type('<p>Dirty Cypress rich text</p>', {
|
||||
parseSpecialCharSequences: false,
|
||||
})
|
||||
cy.get('[data-cy="admin-element-save"]').click()
|
||||
|
||||
cy.get('[data-cy="admin-element-status"]')
|
||||
.should('be.visible')
|
||||
.and('contain.text', 'Saved')
|
||||
})
|
||||
|
||||
it('starts the next test from seeded element data', () => {
|
||||
cy.visit('/element/1')
|
||||
|
||||
cy.contains('h1', 'Baderech HaAvodah').should('be.visible')
|
||||
cy.get('[data-cy="element-rich-text"]').should('not.exist')
|
||||
})
|
||||
})
|
||||
16
frontend/rabbi_gerzi/cypress/support/commands.ts
Normal file
16
frontend/rabbi_gerzi/cypress/support/commands.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// <reference types="cypress" />
|
||||
/* eslint-disable @typescript-eslint/no-namespace */
|
||||
|
||||
declare global {
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
resetDb(): Chainable<null>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Cypress.Commands.add('resetDb', function () {
|
||||
return cy.task<null>('db:reset')
|
||||
})
|
||||
|
||||
export {}
|
||||
|
|
@ -1,3 +1,9 @@
|
|||
// Loaded automatically before every spec file in cypress/e2e/.
|
||||
// Intentionally minimal - add shared commands here when needed.
|
||||
import './commands'
|
||||
|
||||
beforeEach(() => {
|
||||
cy.resetDb()
|
||||
cy.clearCookies()
|
||||
cy.clearLocalStorage()
|
||||
})
|
||||
|
||||
export {}
|
||||
|
|
|
|||
12
frontend/rabbi_gerzi/cypress/tsconfig.json
Normal file
12
frontend/rabbi_gerzi/cypress/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "../tsconfig.app.json",
|
||||
"include": ["./**/*.ts"],
|
||||
"compilerOptions": {
|
||||
"types": ["cypress", "node"],
|
||||
"isolatedModules": false,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"noEmit": false,
|
||||
"allowImportingTsExtensions": false,
|
||||
"ignoreDeprecations": "6.0"
|
||||
}
|
||||
}
|
||||
174
frontend/rabbi_gerzi/package-lock.json
generated
174
frontend/rabbi_gerzi/package-lock.json
generated
|
|
@ -16,6 +16,7 @@
|
|||
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
|
|
@ -30,6 +31,7 @@
|
|||
"npm-run-all2": "^8.0.4",
|
||||
"oxfmt": "^0.45.0",
|
||||
"oxlint": "~1.60.0",
|
||||
"pg": "^8.21.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
|
|
@ -2344,6 +2346,18 @@
|
|||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
|
||||
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
"pg-types": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sinonjs__fake-timers": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz",
|
||||
|
|
@ -6495,6 +6509,103 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.13.0",
|
||||
"pg-pool": "^3.14.0",
|
||||
"pg-protocol": "^1.14.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.13.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
"postgres-bytea": "~1.0.0",
|
||||
"postgres-date": "~1.0.4",
|
||||
"postgres-interval": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -6611,6 +6722,49 @@
|
|||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-array": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-bytea": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-date": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres-interval": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -7101,6 +7255,16 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/sshpk": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz",
|
||||
|
|
@ -8226,6 +8390,16 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/xtend": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.8",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"@vue/eslint-config-typescript": "^14.7.0",
|
||||
|
|
@ -39,6 +40,7 @@
|
|||
"npm-run-all2": "^8.0.4",
|
||||
"oxfmt": "^0.45.0",
|
||||
"oxlint": "~1.60.0",
|
||||
"pg": "^8.21.0",
|
||||
"typescript": "~6.0.0",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { RouterLink, useRoute, type RouteLocationRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
interface ContextualNavAction {
|
||||
label: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const route = useRoute()
|
||||
|
||||
const signatureImage = {
|
||||
src: '/assets/signature.png',
|
||||
|
|
@ -24,6 +30,42 @@ const navItems = [
|
|||
{ label: 'Contact', href: '/contact', icon: '➤' },
|
||||
{ label: 'Donate', href: '/donate', icon: '♡' },
|
||||
]
|
||||
|
||||
const routeElementId = computed(() => {
|
||||
const currentRouteElementId = route.params.id
|
||||
|
||||
if (Array.isArray(currentRouteElementId)) {
|
||||
return currentRouteElementId.join('/')
|
||||
}
|
||||
|
||||
if (typeof currentRouteElementId === 'string') {
|
||||
return currentRouteElementId
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const contextualNavAction = computed<ContextualNavAction | null>(() => {
|
||||
if (!authStore.isAuthenticated || routeElementId.value === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (route.name === 'element') {
|
||||
return {
|
||||
label: 'Edit Element',
|
||||
to: { name: 'admin-element', params: { id: routeElementId.value } },
|
||||
}
|
||||
}
|
||||
|
||||
if (route.name === 'admin-element') {
|
||||
return {
|
||||
label: 'View Element',
|
||||
to: { name: 'element', params: { id: routeElementId.value } },
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -43,6 +85,14 @@ const navItems = [
|
|||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="contextualNavAction !== null"
|
||||
:to="contextualNavAction.to"
|
||||
class="site-header__nav-link"
|
||||
data-cy="navbar-element-mode"
|
||||
>
|
||||
{{ contextualNavAction.label }}
|
||||
</RouterLink>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
data-cy="navbar-logout"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import HomePage from '@/views/HomePage.vue'
|
|||
import LoginPage from '@/views/LoginPage.vue'
|
||||
import MediaPage from '@/views/MediaPage.vue'
|
||||
import ElementPage from '@/views/ElementPage.vue'
|
||||
import AdminElementPage from '@/views/AdminElementPage.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -27,7 +29,29 @@ const router = createRouter({
|
|||
name: 'element',
|
||||
component: ElementPage,
|
||||
},
|
||||
{
|
||||
path: '/admin/element/:id',
|
||||
name: 'admin-element',
|
||||
component: AdminElementPage,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name !== 'admin-element') {
|
||||
return true
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
if (!authStore.isAuthenticated) {
|
||||
await authStore.fetchUser()
|
||||
}
|
||||
|
||||
if (!authStore.isAuthenticated) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
|
|
|
|||
|
|
@ -19,6 +19,23 @@ interface ElementResponse {
|
|||
childElements: ChildElement[]
|
||||
}
|
||||
|
||||
export interface UpdateElementInput {
|
||||
title: string
|
||||
description: string
|
||||
iconImageUrl: string
|
||||
richText: string
|
||||
pdfPath: string
|
||||
youtubeUrl: string
|
||||
}
|
||||
|
||||
interface UpdateElementResponse {
|
||||
element: Element
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
error?: string
|
||||
}
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string
|
||||
|
||||
export const useElementsStore = defineStore('elements', () => {
|
||||
|
|
@ -26,11 +43,14 @@ export const useElementsStore = defineStore('elements', () => {
|
|||
const childElements = ref<ChildElement[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const isSaving = ref(false)
|
||||
const saveError = ref<string | null>(null)
|
||||
|
||||
async function fetchElement(elementId: string): Promise<void> {
|
||||
element.value = null
|
||||
childElements.value = []
|
||||
error.value = null
|
||||
saveError.value = null
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
|
|
@ -59,5 +79,60 @@ export const useElementsStore = defineStore('elements', () => {
|
|||
}
|
||||
}
|
||||
|
||||
return { element, childElements, isLoading, error, fetchElement }
|
||||
async function updateElement(elementId: string, input: UpdateElementInput): Promise<boolean> {
|
||||
saveError.value = null
|
||||
isSaving.value = true
|
||||
|
||||
try {
|
||||
const encodedElementId = encodeURIComponent(elementId)
|
||||
const elementUrl = `${API_BASE_URL}/api/elements/${encodedElementId}`
|
||||
const response = await fetch(elementUrl, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(input),
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
saveError.value = 'Please log in again'
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status === 404) {
|
||||
saveError.value = 'Element not found'
|
||||
return false
|
||||
}
|
||||
|
||||
if (response.status === 400) {
|
||||
const data: ErrorResponse = await response.json()
|
||||
saveError.value = data.error ?? 'Could not save element'
|
||||
return false
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
saveError.value = 'Could not save element'
|
||||
return false
|
||||
}
|
||||
|
||||
const data: UpdateElementResponse = await response.json()
|
||||
element.value = data.element
|
||||
return true
|
||||
} catch {
|
||||
saveError.value = 'Network error - could not save element'
|
||||
return false
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
element,
|
||||
childElements,
|
||||
isLoading,
|
||||
error,
|
||||
isSaving,
|
||||
saveError,
|
||||
fetchElement,
|
||||
updateElement,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
318
frontend/rabbi_gerzi/src/views/AdminElementPage.vue
Normal file
318
frontend/rabbi_gerzi/src/views/AdminElementPage.vue
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import SiteHeader from '@/components/SiteHeader.vue'
|
||||
import { useElementsStore } from '@/stores/elements'
|
||||
|
||||
interface ElementForm {
|
||||
title: string
|
||||
description: string
|
||||
iconImageUrl: string
|
||||
richText: string
|
||||
pdfPath: string
|
||||
youtubeUrl: string
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const elementsStore = useElementsStore()
|
||||
const { element, isLoading, error, isSaving, saveError } = storeToRefs(elementsStore)
|
||||
const savedMessage = ref<string | null>(null)
|
||||
|
||||
const form = reactive<ElementForm>({
|
||||
title: '',
|
||||
description: '',
|
||||
iconImageUrl: '',
|
||||
richText: '',
|
||||
pdfPath: '',
|
||||
youtubeUrl: '',
|
||||
})
|
||||
|
||||
const elementId = computed(() => {
|
||||
const routeElementId = route.params.id
|
||||
|
||||
if (Array.isArray(routeElementId)) {
|
||||
return routeElementId.join('/')
|
||||
}
|
||||
|
||||
if (typeof routeElementId === 'string') {
|
||||
return routeElementId
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
return saveError.value ?? savedMessage.value
|
||||
})
|
||||
|
||||
watch(
|
||||
elementId,
|
||||
(currentElementId) => {
|
||||
if (currentElementId === '') {
|
||||
return
|
||||
}
|
||||
|
||||
savedMessage.value = null
|
||||
void elementsStore.fetchElement(currentElementId)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(element, (currentElement) => {
|
||||
if (currentElement === null) {
|
||||
return
|
||||
}
|
||||
|
||||
form.title = currentElement.title
|
||||
form.description = currentElement.description
|
||||
form.iconImageUrl = currentElement.iconImageUrl ?? ''
|
||||
form.richText = currentElement.richText
|
||||
form.pdfPath = currentElement.pdfPath ?? ''
|
||||
form.youtubeUrl = currentElement.youtubeUrl ?? ''
|
||||
})
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (elementId.value === '') {
|
||||
return
|
||||
}
|
||||
|
||||
savedMessage.value = null
|
||||
const saved = await elementsStore.updateElement(elementId.value, {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
iconImageUrl: form.iconImageUrl,
|
||||
richText: form.richText,
|
||||
pdfPath: form.pdfPath,
|
||||
youtubeUrl: form.youtubeUrl,
|
||||
})
|
||||
|
||||
if (saved) {
|
||||
savedMessage.value = 'Saved'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-element-page">
|
||||
<SiteHeader />
|
||||
<main class="admin-element-page__main" data-cy="admin-element-page">
|
||||
<header class="admin-element-page__header">
|
||||
<h1 class="admin-element-page__heading">Edit Element</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="isLoading" class="admin-element-page__status">Loading element...</p>
|
||||
<p
|
||||
v-else-if="error"
|
||||
class="admin-element-page__status admin-element-page__status--error"
|
||||
data-cy="admin-element-load-error"
|
||||
>
|
||||
{{ error }}
|
||||
</p>
|
||||
<form v-else-if="element" class="admin-element-page__form" @submit.prevent="handleSubmit">
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Title</span>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="admin-element-page__input"
|
||||
data-cy="admin-element-title"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Description</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="admin-element-page__textarea"
|
||||
data-cy="admin-element-description"
|
||||
rows="4"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Icon Image URL</span>
|
||||
<input
|
||||
v-model="form.iconImageUrl"
|
||||
class="admin-element-page__input"
|
||||
data-cy="admin-element-icon-image-url"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">Rich Text HTML</span>
|
||||
<textarea
|
||||
v-model="form.richText"
|
||||
class="admin-element-page__textarea admin-element-page__code"
|
||||
data-cy="admin-element-rich-text"
|
||||
rows="9"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">PDF Path</span>
|
||||
<input
|
||||
v-model="form.pdfPath"
|
||||
class="admin-element-page__input"
|
||||
data-cy="admin-element-pdf-path"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="admin-element-page__field">
|
||||
<span class="admin-element-page__label">YouTube URL</span>
|
||||
<input
|
||||
v-model="form.youtubeUrl"
|
||||
class="admin-element-page__input"
|
||||
data-cy="admin-element-youtube-url"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="admin-element-page__actions">
|
||||
<button
|
||||
class="admin-element-page__save"
|
||||
data-cy="admin-element-save"
|
||||
type="submit"
|
||||
:disabled="isSaving"
|
||||
>
|
||||
{{ isSaving ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<p
|
||||
v-if="statusMessage !== null"
|
||||
:class="['admin-element-page__status', 'admin-element-page__status--inline']"
|
||||
data-cy="admin-element-status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-element-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-white);
|
||||
}
|
||||
|
||||
.admin-element-page__main {
|
||||
min-height: 100vh;
|
||||
padding: 4rem 2rem 5rem;
|
||||
}
|
||||
|
||||
.admin-element-page__header,
|
||||
.admin-element-page__form,
|
||||
.admin-element-page__status {
|
||||
max-width: 48rem;
|
||||
margin-right: auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.admin-element-page__heading {
|
||||
margin: 0;
|
||||
color: #2c2c2c;
|
||||
font-family: var(--font-serif);
|
||||
font-size: 2rem;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-element-page__form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.admin-element-page__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.admin-element-page__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-element-page__input,
|
||||
.admin-element-page__textarea {
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-white);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-element-page__input:focus,
|
||||
.admin-element-page__textarea:focus {
|
||||
border-color: var(--color-slate);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-element-page__textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-element-page__code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.admin-element-page__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.admin-element-page__save {
|
||||
padding: 0.7rem 1.4rem;
|
||||
border: 1px solid var(--color-slate);
|
||||
border-radius: 4px;
|
||||
color: var(--color-white);
|
||||
background: var(--color-slate);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-element-page__save:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.admin-element-page__status {
|
||||
margin-top: 2rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-element-page__status--inline {
|
||||
margin: 0;
|
||||
color: var(--color-slate);
|
||||
}
|
||||
|
||||
.admin-element-page__status--error {
|
||||
color: #9f2f2f;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-element-page__main {
|
||||
padding: 2.5rem 1rem 4rem;
|
||||
}
|
||||
|
||||
.admin-element-page__actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,5 +7,10 @@
|
|||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
],
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"ignoreDeprecations": "6.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue