Compare commits
No commits in common. "90d28deffb2ed1425bb81691ac21a36b183906d3" and "416866a22c1d211fd2b21b7b5b9eaee6d26960be" have entirely different histories.
90d28deffb
...
416866a22c
18 changed files with 7 additions and 1181 deletions
|
|
@ -3,10 +3,6 @@
|
||||||
namespace App\Controllers;
|
namespace App\Controllers;
|
||||||
|
|
||||||
use App\Element\Element;
|
use App\Element\Element;
|
||||||
use App\Element\UseCases\CreateChildElement\CreateChildElement;
|
|
||||||
use App\Element\UseCases\CreateChildElement\CreateChildElementRequest;
|
|
||||||
use App\Element\UseCases\DeleteChildElement\DeleteChildElement;
|
|
||||||
use App\Element\UseCases\DeleteChildElement\DeleteChildElementRequest;
|
|
||||||
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\UpdateElement;
|
||||||
|
|
@ -22,8 +18,6 @@ class ElementController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private GetElement $getElement,
|
private GetElement $getElement,
|
||||||
private CreateChildElement $createChildElement,
|
|
||||||
private DeleteChildElement $deleteChildElement,
|
|
||||||
private UpdateElement $updateElement,
|
private UpdateElement $updateElement,
|
||||||
private FileUploader $fileUploader,
|
private FileUploader $fileUploader,
|
||||||
) {
|
) {
|
||||||
|
|
@ -61,52 +55,6 @@ class ElementController
|
||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createChild(Request $request, ?int $parentId): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$element = $this->createChildElement->execute(
|
|
||||||
new CreateChildElementRequest(
|
|
||||||
parentElementId: $parentId,
|
|
||||||
title: $this->stringInput($request, 'title'),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} 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),
|
|
||||||
], 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function deleteChild(?int $parentId, ?int $childId): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$this->deleteChildElement->execute(
|
|
||||||
new DeleteChildElementRequest(
|
|
||||||
parentElementId: $parentId,
|
|
||||||
childElementId: $childId,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (BadRequestException $exception) {
|
|
||||||
return new JsonResponse([
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
], 400);
|
|
||||||
} catch (NotFoundException $exception) {
|
|
||||||
return new JsonResponse([
|
|
||||||
'error' => $exception->getMessage(),
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse(null, 204);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request): JsonResponse
|
public function update(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$file = $this->uploadedFileInput($request, 'file');
|
$file = $this->uploadedFileInput($request, 'file');
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ interface ElementRepository
|
||||||
|
|
||||||
public function update(Element $element): Element;
|
public function update(Element $element): Element;
|
||||||
|
|
||||||
public function delete(Element $element): void;
|
|
||||||
|
|
||||||
public function find(int $id): ?Element;
|
public function find(int $id): ?Element;
|
||||||
|
|
||||||
public function findRootBySet(DomainSet $set): ?Element;
|
public function findRootBySet(DomainSet $set): ?Element;
|
||||||
|
|
|
||||||
|
|
@ -63,18 +63,6 @@ class EloquentElementRepository implements ElementRepository
|
||||||
return $this->toDomain($model);
|
return $this->toDomain($model);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Element $element): void
|
|
||||||
{
|
|
||||||
$model = ElementModel::find($element->getId());
|
|
||||||
if ($model === null) {
|
|
||||||
throw new DomainException(
|
|
||||||
"Element with id: {$element->getId()} doesnt exist"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$model->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?Element
|
public function find(int $id): ?Element
|
||||||
{
|
{
|
||||||
$model = ElementModel::find($id);
|
$model = ElementModel::find($id);
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\CreateChildElement;
|
|
||||||
|
|
||||||
use App\Element\CreateElementDto;
|
|
||||||
use App\Element\Element;
|
|
||||||
use App\Element\ElementRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
|
|
||||||
class CreateChildElement
|
|
||||||
{
|
|
||||||
public function __construct(private ElementRepository $elementRepository)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function execute(CreateChildElementRequest $request): Element
|
|
||||||
{
|
|
||||||
if ($request->parentElementId === null) {
|
|
||||||
throw new BadRequestException('parentElementId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->title === null || $request->title === '') {
|
|
||||||
throw new BadRequestException('title is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$parentElement = $this->elementRepository->find(
|
|
||||||
$request->parentElementId
|
|
||||||
);
|
|
||||||
if ($parentElement === null) {
|
|
||||||
throw new NotFoundException('Parent element not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->elementRepository->create(new CreateElementDto(
|
|
||||||
set: $parentElement->getSet(),
|
|
||||||
title: $request->title,
|
|
||||||
description: '',
|
|
||||||
iconImageUrl: null,
|
|
||||||
richText: '',
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
youtubeUrl: null,
|
|
||||||
parentElement: $parentElement,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\CreateChildElement;
|
|
||||||
|
|
||||||
class CreateChildElementRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $parentElementId,
|
|
||||||
public ?string $title,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\DeleteChildElement;
|
|
||||||
|
|
||||||
use App\Element\Element;
|
|
||||||
use App\Element\ElementRepository;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Shared\Files\FileUploader;
|
|
||||||
|
|
||||||
class DeleteChildElement
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private ElementRepository $elementRepository,
|
|
||||||
private FileUploader $fileUploader,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @throws BadRequestException
|
|
||||||
* @throws NotFoundException
|
|
||||||
*/
|
|
||||||
public function execute(DeleteChildElementRequest $request): void
|
|
||||||
{
|
|
||||||
if ($request->parentElementId === null) {
|
|
||||||
throw new BadRequestException('parentElementId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->childElementId === null) {
|
|
||||||
throw new BadRequestException('childElementId is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
$parentElement = $this->elementRepository->find(
|
|
||||||
$request->parentElementId
|
|
||||||
);
|
|
||||||
if ($parentElement === null) {
|
|
||||||
throw new NotFoundException('Parent element not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$childElement = $this->elementRepository->find(
|
|
||||||
$request->childElementId
|
|
||||||
);
|
|
||||||
if ($childElement === null) {
|
|
||||||
throw new NotFoundException('Child element not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
$actualParentElement = $childElement->getParentElement();
|
|
||||||
if (
|
|
||||||
$actualParentElement === null
|
|
||||||
|| $actualParentElement->getId() !== $parentElement->getId()
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
'Child element does not belong to parent'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->deleteSubtree($childElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function deleteSubtree(Element $element): void
|
|
||||||
{
|
|
||||||
$childElements = $this->elementRepository->findByParentElement(
|
|
||||||
$element
|
|
||||||
);
|
|
||||||
foreach ($childElements as $childElement) {
|
|
||||||
$this->deleteSubtree($childElement);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->deleteManagedFile($element->getIconImageUrl());
|
|
||||||
$this->deleteManagedFile($element->getShortPdfPath());
|
|
||||||
$this->deleteManagedFile($element->getLongPdfPath());
|
|
||||||
$this->elementRepository->delete($element);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function deleteManagedFile(?string $path): void
|
|
||||||
{
|
|
||||||
if ($path === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->fileUploader->delete($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Element\UseCases\DeleteChildElement;
|
|
||||||
|
|
||||||
class DeleteChildElementRequest
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
public ?int $parentElementId,
|
|
||||||
public ?int $childElementId,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -13,7 +13,7 @@ $allowedOrigins = array_values(array_filter(array_map(
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'paths' => ['api/*'],
|
'paths' => ['api/*'],
|
||||||
'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS', 'DELETE'],
|
'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS'],
|
||||||
'allowed_origins' => $allowedOrigins,
|
'allowed_origins' => $allowedOrigins,
|
||||||
'allowed_origins_patterns' => [],
|
'allowed_origins_patterns' => [],
|
||||||
'allowed_headers' => [
|
'allowed_headers' => [
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,5 @@ 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::post('/elements/{parentId}/children', [
|
|
||||||
ElementController::class,
|
|
||||||
'createChild',
|
|
||||||
])
|
|
||||||
->middleware(AuthMiddleware::class);
|
|
||||||
Route::delete('/elements/{parentId}/children/{childId}', [
|
|
||||||
ElementController::class,
|
|
||||||
'deleteChild',
|
|
||||||
])
|
|
||||||
->middleware(AuthMiddleware::class);
|
|
||||||
Route::post('/element/update', [ElementController::class, 'update'])
|
Route::post('/element/update', [ElementController::class, 'update'])
|
||||||
->middleware(AuthMiddleware::class);
|
->middleware(AuthMiddleware::class);
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,6 @@ class FakeElementRepository implements ElementRepository
|
||||||
return $this->cloneElement($updatedElement);
|
return $this->cloneElement($updatedElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Element $element): void
|
|
||||||
{
|
|
||||||
unset($this->elementsById[$element->getId()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function find(int $id): ?Element
|
public function find(int $id): ?Element
|
||||||
{
|
{
|
||||||
if (! isset($this->elementsById[$id])) {
|
if (! isset($this->elementsById[$id])) {
|
||||||
|
|
|
||||||
|
|
@ -142,267 +142,6 @@ class ElementsEndpointTest extends TestCase
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCreateChildRequiresAuthentication(): void
|
|
||||||
{
|
|
||||||
$setRepository = app(SetRepository::class);
|
|
||||||
$elementRepository = app(ElementRepository::class);
|
|
||||||
$set = $this->createSet($setRepository);
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Baderech HaAvodah',
|
|
||||||
'A structured path for growth',
|
|
||||||
null,
|
|
||||||
'<p>A structured path for growth</p>',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->postJson(
|
|
||||||
"/api/elements/{$parentElement->getId()}/children",
|
|
||||||
[
|
|
||||||
'title' => 'New child',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->assertUnauthorized();
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => 'unauthenticated',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthenticatedCreateChildReturnsElementPayload(): void
|
|
||||||
{
|
|
||||||
$setRepository = app(SetRepository::class);
|
|
||||||
$elementRepository = app(ElementRepository::class);
|
|
||||||
$set = $this->createSet($setRepository);
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Baderech HaAvodah',
|
|
||||||
'A structured path for growth',
|
|
||||||
null,
|
|
||||||
'<p>A structured path for growth</p>',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
$this->createSession('valid-token');
|
|
||||||
|
|
||||||
$response = $this->withCredentials()
|
|
||||||
->withUnencryptedCookie('auth_token', 'valid-token')
|
|
||||||
->postJson(
|
|
||||||
"/api/elements/{$parentElement->getId()}/children",
|
|
||||||
[
|
|
||||||
'title' => 'New admin child',
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->assertCreated();
|
|
||||||
$body = $response->json();
|
|
||||||
$childElementId = $body['element']['id'];
|
|
||||||
$response->assertExactJson([
|
|
||||||
'element' => [
|
|
||||||
'id' => $childElementId,
|
|
||||||
'title' => 'New admin child',
|
|
||||||
'description' => '',
|
|
||||||
'iconImageUrl' => null,
|
|
||||||
'richText' => '',
|
|
||||||
'shortPdfPath' => null,
|
|
||||||
'longPdfPath' => null,
|
|
||||||
'youtubeUrl' => null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$createdElement = $elementRepository->find($childElementId);
|
|
||||||
$this->assertNotNull($createdElement);
|
|
||||||
$this->assertSame(
|
|
||||||
$parentElement->getId(),
|
|
||||||
$createdElement->getParentElement()->getId(),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
$parentElement->getSet()->getId(),
|
|
||||||
$createdElement->getSet()->getId(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeleteChildRequiresAuthentication(): void
|
|
||||||
{
|
|
||||||
$setRepository = app(SetRepository::class);
|
|
||||||
$elementRepository = app(ElementRepository::class);
|
|
||||||
$set = $this->createSet($setRepository);
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Baderech HaAvodah',
|
|
||||||
'A structured path for growth',
|
|
||||||
null,
|
|
||||||
'<p>A structured path for growth</p>',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
$childElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Child',
|
|
||||||
'Child description',
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
$parentElement,
|
|
||||||
);
|
|
||||||
|
|
||||||
$response = $this->deleteJson(
|
|
||||||
"/api/elements/{$parentElement->getId()}"
|
|
||||||
. "/children/{$childElement->getId()}",
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->assertUnauthorized();
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => 'unauthenticated',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthenticatedDeleteChildRemovesSubtree(): void
|
|
||||||
{
|
|
||||||
Storage::fake('public');
|
|
||||||
Storage::disk('public')->put('element-icons/child.png', 'child icon');
|
|
||||||
Storage::disk('public')->put(
|
|
||||||
'element-pdfs/short/child.pdf',
|
|
||||||
'child short pdf',
|
|
||||||
);
|
|
||||||
Storage::disk('public')->put(
|
|
||||||
'element-pdfs/long/grandchild.pdf',
|
|
||||||
'grandchild long pdf',
|
|
||||||
);
|
|
||||||
$setRepository = app(SetRepository::class);
|
|
||||||
$elementRepository = app(ElementRepository::class);
|
|
||||||
$set = $this->createSet($setRepository);
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Baderech HaAvodah',
|
|
||||||
'A structured path for growth',
|
|
||||||
null,
|
|
||||||
'<p>A structured path for growth</p>',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
$childElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Child',
|
|
||||||
'Child description',
|
|
||||||
'element-icons/child.png',
|
|
||||||
'',
|
|
||||||
'element-pdfs/short/child.pdf',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
$parentElement,
|
|
||||||
);
|
|
||||||
$grandchildElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Grandchild',
|
|
||||||
'Grandchild description',
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
null,
|
|
||||||
'element-pdfs/long/grandchild.pdf',
|
|
||||||
null,
|
|
||||||
$childElement,
|
|
||||||
);
|
|
||||||
$this->createSession('valid-token');
|
|
||||||
|
|
||||||
$response = $this->withCredentials()
|
|
||||||
->withUnencryptedCookie('auth_token', 'valid-token')
|
|
||||||
->deleteJson(
|
|
||||||
"/api/elements/{$parentElement->getId()}"
|
|
||||||
. "/children/{$childElement->getId()}",
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->assertNoContent();
|
|
||||||
$this->assertNull($elementRepository->find($childElement->getId()));
|
|
||||||
$this->assertNull(
|
|
||||||
$elementRepository->find($grandchildElement->getId()),
|
|
||||||
);
|
|
||||||
Storage::disk('public')->assertMissing('element-icons/child.png');
|
|
||||||
Storage::disk('public')->assertMissing(
|
|
||||||
'element-pdfs/short/child.pdf',
|
|
||||||
);
|
|
||||||
Storage::disk('public')->assertMissing(
|
|
||||||
'element-pdfs/long/grandchild.pdf',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthenticatedDeleteChildRejectsNonDirectChild(): void
|
|
||||||
{
|
|
||||||
$setRepository = app(SetRepository::class);
|
|
||||||
$elementRepository = app(ElementRepository::class);
|
|
||||||
$set = $this->createSet($setRepository);
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Baderech HaAvodah',
|
|
||||||
'A structured path for growth',
|
|
||||||
null,
|
|
||||||
'<p>A structured path for growth</p>',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
$childElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Child',
|
|
||||||
'Child description',
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
$parentElement,
|
|
||||||
);
|
|
||||||
$grandchildElement = $this->createElement(
|
|
||||||
$elementRepository,
|
|
||||||
$set,
|
|
||||||
'Grandchild',
|
|
||||||
'Grandchild description',
|
|
||||||
null,
|
|
||||||
'',
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
$childElement,
|
|
||||||
);
|
|
||||||
$this->createSession('valid-token');
|
|
||||||
|
|
||||||
$response = $this->withCredentials()
|
|
||||||
->withUnencryptedCookie('auth_token', 'valid-token')
|
|
||||||
->deleteJson(
|
|
||||||
"/api/elements/{$parentElement->getId()}"
|
|
||||||
. "/children/{$grandchildElement->getId()}",
|
|
||||||
);
|
|
||||||
|
|
||||||
$response->assertBadRequest();
|
|
||||||
$response->assertExactJson([
|
|
||||||
'error' => 'Child element does not belong to parent',
|
|
||||||
]);
|
|
||||||
$this->assertNotNull(
|
|
||||||
$elementRepository->find($grandchildElement->getId()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testAuthenticatedUpdateReturnsElementPayload(): void
|
public function testAuthenticatedUpdateReturnsElementPayload(): void
|
||||||
{
|
{
|
||||||
$sampleYoutubeUrl = 'https://www.youtube.com/watch?v='
|
$sampleYoutubeUrl = 'https://www.youtube.com/watch?v='
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ namespace Tests\Unit\Controllers;
|
||||||
use App\Controllers\ElementController;
|
use App\Controllers\ElementController;
|
||||||
use App\Element\CreateElementDto;
|
use App\Element\CreateElementDto;
|
||||||
use App\Element\Element;
|
use App\Element\Element;
|
||||||
use App\Element\UseCases\CreateChildElement\CreateChildElement;
|
|
||||||
use App\Element\UseCases\DeleteChildElement\DeleteChildElement;
|
|
||||||
use App\Element\UseCases\GetElement\GetElement;
|
use App\Element\UseCases\GetElement\GetElement;
|
||||||
use App\Element\UseCases\UpdateElement\UpdateDescription;
|
use App\Element\UseCases\UpdateElement\UpdateDescription;
|
||||||
use App\Element\UseCases\UpdateElement\UpdateElement;
|
use App\Element\UseCases\UpdateElement\UpdateElement;
|
||||||
|
|
@ -63,8 +61,6 @@ class ElementControllerTest extends TestCase
|
||||||
);
|
);
|
||||||
$this->controller = new ElementController(
|
$this->controller = new ElementController(
|
||||||
$getElement,
|
$getElement,
|
||||||
new CreateChildElement($this->elementRepo),
|
|
||||||
new DeleteChildElement($this->elementRepo, $this->fileUploader),
|
|
||||||
$updateElement,
|
$updateElement,
|
||||||
$this->fileUploader,
|
$this->fileUploader,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\Element\UseCases;
|
|
||||||
|
|
||||||
use App\Element\CreateElementDto;
|
|
||||||
use App\Element\Element;
|
|
||||||
use App\Element\UseCases\CreateChildElement\CreateChildElement;
|
|
||||||
use App\Element\UseCases\CreateChildElement\CreateChildElementRequest;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Set\Set as DomainSet;
|
|
||||||
use Tests\Fakes\FakeElementRepository;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class CreateChildElementTest extends TestCase
|
|
||||||
{
|
|
||||||
private FakeElementRepository $elementRepository;
|
|
||||||
|
|
||||||
private CreateChildElement $createChildElement;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->elementRepository = new FakeElementRepository();
|
|
||||||
$this->createChildElement = new CreateChildElement(
|
|
||||||
$this->elementRepository,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testCreatesChildElementFromParentSet(): void
|
|
||||||
{
|
|
||||||
$parentElement = $this->createParentElement();
|
|
||||||
|
|
||||||
$childElement = $this->createChildElement->execute(
|
|
||||||
new CreateChildElementRequest(
|
|
||||||
parentElementId: $parentElement->getId(),
|
|
||||||
title: 'Admin child',
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->assertSame('Admin child', $childElement->getTitle());
|
|
||||||
$this->assertSame('', $childElement->getDescription());
|
|
||||||
$this->assertNull($childElement->getIconImageUrl());
|
|
||||||
$this->assertSame('', $childElement->getRichText());
|
|
||||||
$this->assertNull($childElement->getShortPdfPath());
|
|
||||||
$this->assertNull($childElement->getLongPdfPath());
|
|
||||||
$this->assertNull($childElement->getYoutubeUrl());
|
|
||||||
$this->assertSame(
|
|
||||||
$parentElement->getSet()->getId(),
|
|
||||||
$childElement->getSet()->getId(),
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
$parentElement->getId(),
|
|
||||||
$childElement->getParentElement()->getId(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenParentElementIsMissing(): void
|
|
||||||
{
|
|
||||||
$this->expectException(NotFoundException::class);
|
|
||||||
$this->expectExceptionMessage('Parent element not found');
|
|
||||||
|
|
||||||
$this->createChildElement->execute(new CreateChildElementRequest(
|
|
||||||
parentElementId: 999,
|
|
||||||
title: 'Missing parent child',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenTitleIsMissing(): void
|
|
||||||
{
|
|
||||||
$parentElement = $this->createParentElement();
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage('title is required');
|
|
||||||
|
|
||||||
$this->createChildElement->execute(new CreateChildElementRequest(
|
|
||||||
parentElementId: $parentElement->getId(),
|
|
||||||
title: '',
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createParentElement(): Element
|
|
||||||
{
|
|
||||||
$set = new DomainSet(
|
|
||||||
id: 1,
|
|
||||||
name: 'Baderech',
|
|
||||||
description: 'Baderech description',
|
|
||||||
iconImageUrl: '/assets/baderech-icon.png',
|
|
||||||
);
|
|
||||||
|
|
||||||
return $this->elementRepository->create(new CreateElementDto(
|
|
||||||
set: $set,
|
|
||||||
title: 'Parent',
|
|
||||||
description: 'Parent description',
|
|
||||||
iconImageUrl: null,
|
|
||||||
richText: '',
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
youtubeUrl: null,
|
|
||||||
parentElement: null,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,173 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Tests\Unit\Element\UseCases;
|
|
||||||
|
|
||||||
use App\Element\CreateElementDto;
|
|
||||||
use App\Element\Element;
|
|
||||||
use App\Element\UseCases\DeleteChildElement\DeleteChildElement;
|
|
||||||
use App\Element\UseCases\DeleteChildElement\DeleteChildElementRequest;
|
|
||||||
use App\Exceptions\BadRequestException;
|
|
||||||
use App\Exceptions\NotFoundException;
|
|
||||||
use App\Set\Set as DomainSet;
|
|
||||||
use Tests\Fakes\FakeElementRepository;
|
|
||||||
use Tests\Fakes\FakeFileUploader;
|
|
||||||
use Tests\TestCase;
|
|
||||||
|
|
||||||
class DeleteChildElementTest extends TestCase
|
|
||||||
{
|
|
||||||
private FakeElementRepository $elementRepository;
|
|
||||||
|
|
||||||
private FakeFileUploader $fileUploader;
|
|
||||||
|
|
||||||
private DeleteChildElement $deleteChildElement;
|
|
||||||
|
|
||||||
protected function setUp(): void
|
|
||||||
{
|
|
||||||
$this->elementRepository = new FakeElementRepository();
|
|
||||||
$this->fileUploader = new FakeFileUploader();
|
|
||||||
$this->deleteChildElement = new DeleteChildElement(
|
|
||||||
$this->elementRepository,
|
|
||||||
$this->fileUploader,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testDeletesChildSubtreeAndManagedFiles(): void
|
|
||||||
{
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
parentElement: null,
|
|
||||||
title: 'Parent',
|
|
||||||
iconImageUrl: null,
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
);
|
|
||||||
$childElement = $this->createElement(
|
|
||||||
parentElement: $parentElement,
|
|
||||||
title: 'Child',
|
|
||||||
iconImageUrl: 'element-icons/child.png',
|
|
||||||
shortPdfPath: 'element-pdfs/short/child.pdf',
|
|
||||||
longPdfPath: 'element-pdfs/long/child.pdf',
|
|
||||||
);
|
|
||||||
$grandchildElement = $this->createElement(
|
|
||||||
parentElement: $childElement,
|
|
||||||
title: 'Grandchild',
|
|
||||||
iconImageUrl: 'element-icons/grandchild.png',
|
|
||||||
shortPdfPath: 'element-pdfs/short/grandchild.pdf',
|
|
||||||
longPdfPath: 'element-pdfs/long/grandchild.pdf',
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->deleteChildElement->execute(new DeleteChildElementRequest(
|
|
||||||
parentElementId: $parentElement->getId(),
|
|
||||||
childElementId: $childElement->getId(),
|
|
||||||
));
|
|
||||||
|
|
||||||
$this->assertNull(
|
|
||||||
$this->elementRepository->find($childElement->getId()),
|
|
||||||
);
|
|
||||||
$this->assertNull(
|
|
||||||
$this->elementRepository->find($grandchildElement->getId()),
|
|
||||||
);
|
|
||||||
$this->assertSame([
|
|
||||||
'element-icons/grandchild.png',
|
|
||||||
'element-pdfs/short/grandchild.pdf',
|
|
||||||
'element-pdfs/long/grandchild.pdf',
|
|
||||||
'element-icons/child.png',
|
|
||||||
'element-pdfs/short/child.pdf',
|
|
||||||
'element-pdfs/long/child.pdf',
|
|
||||||
], $this->fileUploader->deletedPaths);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenParentElementIsMissing(): void
|
|
||||||
{
|
|
||||||
$this->expectException(NotFoundException::class);
|
|
||||||
$this->expectExceptionMessage('Parent element not found');
|
|
||||||
|
|
||||||
$this->deleteChildElement->execute(new DeleteChildElementRequest(
|
|
||||||
parentElementId: 999,
|
|
||||||
childElementId: 1,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenChildElementIsMissing(): void
|
|
||||||
{
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
parentElement: null,
|
|
||||||
title: 'Parent',
|
|
||||||
iconImageUrl: null,
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(NotFoundException::class);
|
|
||||||
$this->expectExceptionMessage('Child element not found');
|
|
||||||
|
|
||||||
$this->deleteChildElement->execute(new DeleteChildElementRequest(
|
|
||||||
parentElementId: $parentElement->getId(),
|
|
||||||
childElementId: 999,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function testThrowsWhenChildIsNotDirectChild(): void
|
|
||||||
{
|
|
||||||
$parentElement = $this->createElement(
|
|
||||||
parentElement: null,
|
|
||||||
title: 'Parent',
|
|
||||||
iconImageUrl: null,
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
);
|
|
||||||
$childElement = $this->createElement(
|
|
||||||
parentElement: $parentElement,
|
|
||||||
title: 'Child',
|
|
||||||
iconImageUrl: null,
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
);
|
|
||||||
$grandchildElement = $this->createElement(
|
|
||||||
parentElement: $childElement,
|
|
||||||
title: 'Grandchild',
|
|
||||||
iconImageUrl: null,
|
|
||||||
shortPdfPath: null,
|
|
||||||
longPdfPath: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->expectException(BadRequestException::class);
|
|
||||||
$this->expectExceptionMessage(
|
|
||||||
'Child element does not belong to parent'
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->deleteChildElement->execute(new DeleteChildElementRequest(
|
|
||||||
parentElementId: $parentElement->getId(),
|
|
||||||
childElementId: $grandchildElement->getId(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createElement(
|
|
||||||
?Element $parentElement,
|
|
||||||
string $title,
|
|
||||||
?string $iconImageUrl,
|
|
||||||
?string $shortPdfPath,
|
|
||||||
?string $longPdfPath,
|
|
||||||
): Element {
|
|
||||||
return $this->elementRepository->create(new CreateElementDto(
|
|
||||||
set: $this->createSet(),
|
|
||||||
title: $title,
|
|
||||||
description: "$title description",
|
|
||||||
iconImageUrl: $iconImageUrl,
|
|
||||||
richText: '',
|
|
||||||
shortPdfPath: $shortPdfPath,
|
|
||||||
longPdfPath: $longPdfPath,
|
|
||||||
youtubeUrl: null,
|
|
||||||
parentElement: $parentElement,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createSet(): DomainSet
|
|
||||||
{
|
|
||||||
return new DomainSet(
|
|
||||||
id: 1,
|
|
||||||
name: 'Baderech',
|
|
||||||
description: 'Baderech description',
|
|
||||||
iconImageUrl: '/assets/baderech-icon.png',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -139,58 +139,6 @@ describe('admin element editing', () => {
|
||||||
cy.get('[data-cy="element-rich-text"]').should('not.exist')
|
cy.get('[data-cy="element-rich-text"]').should('not.exist')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('adds a child element and opens it for editing', () => {
|
|
||||||
cy.resetDb()
|
|
||||||
loginAsAdmin()
|
|
||||||
cy.visit('/admin/element/1')
|
|
||||||
cy.intercept('POST', /\/api\/elements\/1\/children$/)
|
|
||||||
.as('createChildElement')
|
|
||||||
|
|
||||||
cy.get('[data-cy="admin-child-title"]').type('Admin added child')
|
|
||||||
cy.get('[data-cy="admin-add-child"]').click()
|
|
||||||
cy.wait('@createChildElement')
|
|
||||||
|
|
||||||
cy.location('pathname').should('match', /^\/admin\/element\/\d+$/)
|
|
||||||
cy.get('[data-cy="admin-element-title"]')
|
|
||||||
.should('have.value', 'Admin added child')
|
|
||||||
cy.contains('header.site-header a', 'View Element').click()
|
|
||||||
cy.contains('h1', 'Admin added child').should('be.visible')
|
|
||||||
|
|
||||||
cy.visit('/element/1')
|
|
||||||
cy.get('[data-cy="child-element-list"]')
|
|
||||||
.should('contain.text', 'Admin added child')
|
|
||||||
|
|
||||||
cy.resetDb()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('removes a child element after confirmation', () => {
|
|
||||||
cy.resetDb()
|
|
||||||
loginAsAdmin()
|
|
||||||
cy.visit('/admin/element/1')
|
|
||||||
cy.intercept('DELETE', /\/api\/elements\/1\/children\/\d+$/)
|
|
||||||
.as('deleteChildElement')
|
|
||||||
cy.window().then((windowObject) => {
|
|
||||||
cy.stub(windowObject, 'confirm').returns(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.contains('[data-cy="admin-child-item"]', '1. Introduction')
|
|
||||||
.within(() => {
|
|
||||||
cy.contains('button', 'Remove').click()
|
|
||||||
})
|
|
||||||
cy.wait('@deleteChildElement')
|
|
||||||
|
|
||||||
cy.get('[data-cy="admin-child-status"]')
|
|
||||||
.should('be.visible')
|
|
||||||
.and('contain.text', 'Child element removed')
|
|
||||||
cy.contains('[data-cy="admin-child-item"]', '1. Introduction')
|
|
||||||
.should('not.exist')
|
|
||||||
cy.contains('header.site-header a', 'View Element').click()
|
|
||||||
cy.get('[data-cy="child-element-list"]')
|
|
||||||
.should('not.contain.text', '1. Introduction')
|
|
||||||
|
|
||||||
cy.resetDb()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uploads icon and pdf files immediately through the UI', () => {
|
it('uploads icon and pdf files immediately through the UI', () => {
|
||||||
cy.resetDb()
|
cy.resetDb()
|
||||||
loginAsAdmin()
|
loginAsAdmin()
|
||||||
|
|
|
||||||
|
|
@ -133,12 +133,10 @@ const isInTable = computed(() => {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return editorInstance.isActive('table')
|
||||||
editorInstance.isActive('table') ||
|
|| editorInstance.isActive('tableCell')
|
||||||
editorInstance.isActive('tableCell') ||
|
|| editorInstance.isActive('tableHeader')
|
||||||
editorInstance.isActive('tableHeader') ||
|
|| editorInstance.isActive('tableRow')
|
||||||
editorInstance.isActive('tableRow')
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,6 @@ export interface UpdateElementInput {
|
||||||
youtubeUrl: string
|
youtubeUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateChildElementInput {
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UpdateElementResponse {
|
interface UpdateElementResponse {
|
||||||
element: Element
|
element: Element
|
||||||
}
|
}
|
||||||
|
|
@ -52,7 +48,6 @@ interface ErrorResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
const ELEMENTS_URL = `${API_BASE_URL}/api/elements`
|
|
||||||
const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update`
|
const ELEMENT_UPDATE_URL = `${API_BASE_URL}/api/element/update`
|
||||||
|
|
||||||
export const useElementsStore = defineStore('elements', () => {
|
export const useElementsStore = defineStore('elements', () => {
|
||||||
|
|
@ -64,16 +59,13 @@ export const useElementsStore = defineStore('elements', () => {
|
||||||
const isUploadingIconImage = ref(false)
|
const isUploadingIconImage = ref(false)
|
||||||
const isUploadingShortPdf = ref(false)
|
const isUploadingShortPdf = ref(false)
|
||||||
const isUploadingLongPdf = ref(false)
|
const isUploadingLongPdf = ref(false)
|
||||||
const isManagingChildren = ref(false)
|
|
||||||
const saveError = ref<string | null>(null)
|
const saveError = ref<string | null>(null)
|
||||||
const childActionError = 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
|
saveError.value = null
|
||||||
childActionError.value = null
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -109,67 +101,6 @@ export const useElementsStore = defineStore('elements', () => {
|
||||||
return await saveElementPatch(elementId, input, 'Could not save element')
|
return await saveElementPatch(elementId, input, 'Could not save element')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createChildElement(
|
|
||||||
parentElementId: string,
|
|
||||||
input: CreateChildElementInput,
|
|
||||||
): Promise<Element | null> {
|
|
||||||
childActionError.value = null
|
|
||||||
isManagingChildren.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const encodedParentElementId = encodeURIComponent(parentElementId)
|
|
||||||
const response = await fetch(
|
|
||||||
`${ELEMENTS_URL}/${encodedParentElementId}/children`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return await handleChildElementResponse(
|
|
||||||
response,
|
|
||||||
'Could not add child element',
|
|
||||||
)
|
|
||||||
} catch {
|
|
||||||
childActionError.value = 'Network error - could not add child element'
|
|
||||||
return null
|
|
||||||
} finally {
|
|
||||||
isManagingChildren.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteChildElement(
|
|
||||||
parentElementId: string,
|
|
||||||
childElementId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
childActionError.value = null
|
|
||||||
isManagingChildren.value = true
|
|
||||||
|
|
||||||
try {
|
|
||||||
const encodedParentElementId = encodeURIComponent(parentElementId)
|
|
||||||
const encodedChildElementId = encodeURIComponent(
|
|
||||||
childElementId.toString(),
|
|
||||||
)
|
|
||||||
const response = await fetch(
|
|
||||||
`${ELEMENTS_URL}/${encodedParentElementId}`
|
|
||||||
+ `/children/${encodedChildElementId}`,
|
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
credentials: 'include',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
return await handleDeleteChildElementResponse(response, childElementId)
|
|
||||||
} catch {
|
|
||||||
childActionError.value = 'Network error - could not remove child element'
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
isManagingChildren.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearElementIconImage(elementId: string): Promise<boolean> {
|
async function clearElementIconImage(elementId: string): Promise<boolean> {
|
||||||
return await saveElementPatch(
|
return await saveElementPatch(
|
||||||
elementId,
|
elementId,
|
||||||
|
|
@ -322,57 +253,6 @@ export const useElementsStore = defineStore('elements', () => {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleChildElementResponse(
|
|
||||||
response: Response,
|
|
||||||
failureMessage: string,
|
|
||||||
): Promise<Element | null> {
|
|
||||||
if (response.status === 401) {
|
|
||||||
childActionError.value = 'Please log in again'
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 400 || response.status === 404) {
|
|
||||||
childActionError.value = await errorMessage(response, failureMessage)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
childActionError.value = failureMessage
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: UpdateElementResponse = await response.json()
|
|
||||||
return data.element
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteChildElementResponse(
|
|
||||||
response: Response,
|
|
||||||
childElementId: number,
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (response.status === 401) {
|
|
||||||
childActionError.value = 'Please log in again'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 400 || response.status === 404) {
|
|
||||||
childActionError.value = await errorMessage(
|
|
||||||
response,
|
|
||||||
'Could not remove child element',
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
childActionError.value = 'Could not remove child element'
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
childElements.value = childElements.value.filter((childElement) => {
|
|
||||||
return childElement.id !== childElementId
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function errorMessage(
|
async function errorMessage(
|
||||||
response: Response,
|
response: Response,
|
||||||
fallbackMessage: string,
|
fallbackMessage: string,
|
||||||
|
|
@ -394,13 +274,9 @@ export const useElementsStore = defineStore('elements', () => {
|
||||||
isUploadingIconImage,
|
isUploadingIconImage,
|
||||||
isUploadingShortPdf,
|
isUploadingShortPdf,
|
||||||
isUploadingLongPdf,
|
isUploadingLongPdf,
|
||||||
isManagingChildren,
|
|
||||||
saveError,
|
saveError,
|
||||||
childActionError,
|
|
||||||
fetchElement,
|
fetchElement,
|
||||||
updateElement,
|
updateElement,
|
||||||
createChildElement,
|
|
||||||
deleteChildElement,
|
|
||||||
uploadElementIconImage,
|
uploadElementIconImage,
|
||||||
uploadElementShortPdf,
|
uploadElementShortPdf,
|
||||||
uploadElementLongPdf,
|
uploadElementLongPdf,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { computed, reactive, ref, watch } from 'vue'
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import RichTextEditor from '@/components/RichTextEditor.vue'
|
import RichTextEditor from '@/components/RichTextEditor.vue'
|
||||||
import SiteHeader from '@/components/SiteHeader.vue'
|
import SiteHeader from '@/components/SiteHeader.vue'
|
||||||
import { useElementsStore } from '@/stores/elements'
|
import { useElementsStore } from '@/stores/elements'
|
||||||
|
|
@ -13,28 +13,19 @@ interface ElementForm {
|
||||||
youtubeUrl: string
|
youtubeUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChildElementForm {
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
|
||||||
const elementsStore = useElementsStore()
|
const elementsStore = useElementsStore()
|
||||||
const {
|
const {
|
||||||
element,
|
element,
|
||||||
childElements,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
isSaving,
|
isSaving,
|
||||||
isUploadingIconImage,
|
isUploadingIconImage,
|
||||||
isUploadingShortPdf,
|
isUploadingShortPdf,
|
||||||
isUploadingLongPdf,
|
isUploadingLongPdf,
|
||||||
isManagingChildren,
|
|
||||||
saveError,
|
saveError,
|
||||||
childActionError,
|
|
||||||
} = storeToRefs(elementsStore)
|
} = storeToRefs(elementsStore)
|
||||||
const savedMessage = ref<string | null>(null)
|
const savedMessage = ref<string | null>(null)
|
||||||
const childStatus = ref<string | null>(null)
|
|
||||||
const iconImageStatus = ref<string | null>(null)
|
const iconImageStatus = ref<string | null>(null)
|
||||||
const shortPdfStatus = ref<string | null>(null)
|
const shortPdfStatus = ref<string | null>(null)
|
||||||
const longPdfStatus = ref<string | null>(null)
|
const longPdfStatus = ref<string | null>(null)
|
||||||
|
|
@ -49,10 +40,6 @@ const form = reactive<ElementForm>({
|
||||||
youtubeUrl: '',
|
youtubeUrl: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const childForm = reactive<ChildElementForm>({
|
|
||||||
title: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const elementId = computed(() => {
|
const elementId = computed(() => {
|
||||||
const routeElementId = route.params.id
|
const routeElementId = route.params.id
|
||||||
|
|
||||||
|
|
@ -71,10 +58,6 @@ const statusMessage = computed(() => {
|
||||||
return saveError.value ?? savedMessage.value
|
return saveError.value ?? savedMessage.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const childStatusMessage = computed(() => {
|
|
||||||
return childActionError.value ?? childStatus.value
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
elementId,
|
elementId,
|
||||||
(currentElementId) => {
|
(currentElementId) => {
|
||||||
|
|
@ -83,8 +66,6 @@ watch(
|
||||||
}
|
}
|
||||||
|
|
||||||
savedMessage.value = null
|
savedMessage.value = null
|
||||||
childStatus.value = null
|
|
||||||
childForm.title = ''
|
|
||||||
iconImageStatus.value = null
|
iconImageStatus.value = null
|
||||||
shortPdfStatus.value = null
|
shortPdfStatus.value = null
|
||||||
longPdfStatus.value = null
|
longPdfStatus.value = null
|
||||||
|
|
@ -122,50 +103,6 @@ async function handleSubmit(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleAddChild(): Promise<void> {
|
|
||||||
if (elementId.value === '' || childForm.title === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
childStatus.value = null
|
|
||||||
const childElement = await elementsStore.createChildElement(elementId.value, {
|
|
||||||
title: childForm.title,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (childElement === null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
childForm.title = ''
|
|
||||||
await router.push({
|
|
||||||
name: 'admin-element',
|
|
||||||
params: { id: childElement.id.toString() },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRemoveChild(childElementId: number): Promise<void> {
|
|
||||||
if (elementId.value === '') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
'Delete this child element and all of its descendants?',
|
|
||||||
)
|
|
||||||
if (!confirmed) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
childStatus.value = null
|
|
||||||
const deleted = await elementsStore.deleteChildElement(
|
|
||||||
elementId.value,
|
|
||||||
childElementId,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (deleted) {
|
|
||||||
childStatus.value = 'Child element removed'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseIconImage(): void {
|
function chooseIconImage(): void {
|
||||||
iconImageInput.value?.click()
|
iconImageInput.value?.click()
|
||||||
}
|
}
|
||||||
|
|
@ -512,90 +449,6 @@ function resetInput(changeEvent: Event): void {
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<section class="admin-element-page__children">
|
|
||||||
<span class="admin-element-page__label">Child Elements</span>
|
|
||||||
<ul
|
|
||||||
v-if="childElements.length > 0"
|
|
||||||
class="admin-element-page__child-list"
|
|
||||||
data-cy="admin-child-list"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="childElement in childElements"
|
|
||||||
:key="childElement.id"
|
|
||||||
class="admin-element-page__child-item"
|
|
||||||
data-cy="admin-child-item"
|
|
||||||
>
|
|
||||||
<div class="admin-element-page__child-body">
|
|
||||||
<RouterLink
|
|
||||||
:to="{
|
|
||||||
name: 'admin-element',
|
|
||||||
params: { id: childElement.id },
|
|
||||||
}"
|
|
||||||
class="admin-element-page__child-link"
|
|
||||||
data-cy="admin-child-edit-link"
|
|
||||||
>
|
|
||||||
{{ childElement.title }}
|
|
||||||
</RouterLink>
|
|
||||||
<p
|
|
||||||
v-if="childElement.description !== ''"
|
|
||||||
class="admin-element-page__child-description"
|
|
||||||
>
|
|
||||||
{{ childElement.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="admin-element-page__secondary-button"
|
|
||||||
data-cy="admin-remove-child"
|
|
||||||
type="button"
|
|
||||||
:disabled="isManagingChildren"
|
|
||||||
@click="handleRemoveChild(childElement.id)"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<p
|
|
||||||
v-else
|
|
||||||
class="admin-element-page__empty-media"
|
|
||||||
data-cy="admin-child-empty"
|
|
||||||
>
|
|
||||||
No child elements
|
|
||||||
</p>
|
|
||||||
<div class="admin-element-page__child-add">
|
|
||||||
<label class="admin-element-page__child-title-field">
|
|
||||||
<span class="admin-element-page__label">New child title</span>
|
|
||||||
<input
|
|
||||||
v-model="childForm.title"
|
|
||||||
class="admin-element-page__input"
|
|
||||||
data-cy="admin-child-title"
|
|
||||||
type="text"
|
|
||||||
:disabled="isManagingChildren"
|
|
||||||
@keydown.enter.prevent="handleAddChild"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
class="admin-element-page__secondary-button"
|
|
||||||
data-cy="admin-add-child"
|
|
||||||
type="button"
|
|
||||||
:disabled="isManagingChildren || childForm.title === ''"
|
|
||||||
@click="handleAddChild"
|
|
||||||
>
|
|
||||||
{{ isManagingChildren ? 'Adding...' : 'Add child' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="childStatusMessage !== null"
|
|
||||||
:class="[
|
|
||||||
'admin-element-page__status',
|
|
||||||
'admin-element-page__status--inline',
|
|
||||||
]"
|
|
||||||
data-cy="admin-child-status"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
{{ childStatusMessage }}
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="admin-element-page__actions">
|
<div class="admin-element-page__actions">
|
||||||
<button
|
<button
|
||||||
class="admin-element-page__save"
|
class="admin-element-page__save"
|
||||||
|
|
@ -663,8 +516,7 @@ function resetInput(changeEvent: Event): void {
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-element-page__media-field,
|
.admin-element-page__media-field {
|
||||||
.admin-element-page__children {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|
@ -723,67 +575,6 @@ function resetInput(changeEvent: Event): void {
|
||||||
gap: 0.7rem;
|
gap: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-element-page__child-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.65rem;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.7rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-body {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-link {
|
|
||||||
color: var(--color-slate);
|
|
||||||
font-size: 0.95rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-link:hover,
|
|
||||||
.admin-element-page__child-link:focus-visible {
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-description {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-add {
|
|
||||||
display: flex;
|
|
||||||
align-items: end;
|
|
||||||
gap: 0.7rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-title-field {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__file-input {
|
.admin-element-page__file-input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
@ -872,14 +663,5 @@ function resetInput(changeEvent: Event): void {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-element-page__child-add {
|
|
||||||
align-items: stretch;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-element-page__child-item {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue