From f4b42973cb4532115ea69dbdf41d628469ed310f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 21 Jun 2026 22:13:59 +0300 Subject: [PATCH] delete child elements --- backend/app/Controllers/ElementController.php | 25 ++++++ backend/app/Element/ElementRepository.php | 2 + .../app/Element/EloquentElementRepository.php | 12 +++ .../DeleteChildElement/DeleteChildElement.php | 83 +++++++++++++++++++ .../DeleteChildElementRequest.php | 12 +++ backend/routes/api.php | 5 ++ backend/tests/Fakes/FakeElementRepository.php | 5 ++ .../Controllers/ElementControllerTest.php | 2 + frontend/rabbi_gerzi/src/stores/elements.ts | 60 ++++++++++++++ .../src/views/AdminElementPage.vue | 82 ++++++++++++++---- 10 files changed, 271 insertions(+), 17 deletions(-) create mode 100644 backend/app/Element/UseCases/DeleteChildElement/DeleteChildElement.php create mode 100644 backend/app/Element/UseCases/DeleteChildElement/DeleteChildElementRequest.php diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 69ef8ce..5164e59 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -5,6 +5,8 @@ namespace App\Controllers; 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\GetElementRequest; use App\Element\UseCases\UpdateElement\UpdateElement; @@ -21,6 +23,7 @@ class ElementController public function __construct( private GetElement $getElement, private CreateChildElement $createChildElement, + private DeleteChildElement $deleteChildElement, private UpdateElement $updateElement, private FileUploader $fileUploader, ) { @@ -82,6 +85,28 @@ class ElementController ], 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 { $file = $this->uploadedFileInput($request, 'file'); diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index e4fa74e..f759322 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -10,6 +10,8 @@ interface ElementRepository public function update(Element $element): Element; + public function delete(Element $element): void; + public function find(int $id): ?Element; public function findRootBySet(DomainSet $set): ?Element; diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 771bc4c..77a110b 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -63,6 +63,18 @@ class EloquentElementRepository implements ElementRepository 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 { $model = ElementModel::find($id); diff --git a/backend/app/Element/UseCases/DeleteChildElement/DeleteChildElement.php b/backend/app/Element/UseCases/DeleteChildElement/DeleteChildElement.php new file mode 100644 index 0000000..954de01 --- /dev/null +++ b/backend/app/Element/UseCases/DeleteChildElement/DeleteChildElement.php @@ -0,0 +1,83 @@ +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); + } +} diff --git a/backend/app/Element/UseCases/DeleteChildElement/DeleteChildElementRequest.php b/backend/app/Element/UseCases/DeleteChildElement/DeleteChildElementRequest.php new file mode 100644 index 0000000..e2573d1 --- /dev/null +++ b/backend/app/Element/UseCases/DeleteChildElement/DeleteChildElementRequest.php @@ -0,0 +1,12 @@ +middleware(AuthMiddleware::class); +Route::delete('/elements/{parentId}/children/{childId}', [ + ElementController::class, + 'deleteChild', +]) + ->middleware(AuthMiddleware::class); Route::post('/element/update', [ElementController::class, 'update']) ->middleware(AuthMiddleware::class); diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 18f1a6b..4e08547 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -42,6 +42,11 @@ class FakeElementRepository implements ElementRepository return $this->cloneElement($updatedElement); } + public function delete(Element $element): void + { + unset($this->elementsById[$element->getId()]); + } + public function find(int $id): ?Element { if (! isset($this->elementsById[$id])) { diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index 9e3d9ff..c0ce0a6 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -6,6 +6,7 @@ use App\Controllers\ElementController; use App\Element\CreateElementDto; 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\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateElement; @@ -63,6 +64,7 @@ class ElementControllerTest extends TestCase $this->controller = new ElementController( $getElement, new CreateChildElement($this->elementRepo), + new DeleteChildElement($this->elementRepo, $this->fileUploader), $updateElement, $this->fileUploader, ); diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index cf3488a..d72683c 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -73,6 +73,7 @@ export const useElementsStore = defineStore('elements', () => { childElements.value = [] error.value = null saveError.value = null + childActionError.value = null isLoading.value = true try { @@ -139,6 +140,36 @@ export const useElementsStore = defineStore('elements', () => { } } + async function deleteChildElement( + parentElementId: string, + childElementId: number, + ): Promise { + 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 { return await saveElementPatch( elementId, @@ -314,6 +345,34 @@ export const useElementsStore = defineStore('elements', () => { return data.element } + async function handleDeleteChildElementResponse( + response: Response, + childElementId: number, + ): Promise { + 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( response: Response, fallbackMessage: string, @@ -341,6 +400,7 @@ export const useElementsStore = defineStore('elements', () => { fetchElement, updateElement, createChildElement, + deleteChildElement, uploadElementIconImage, uploadElementShortPdf, uploadElementLongPdf, diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index 9a0632d..75b257a 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -143,6 +143,29 @@ async function handleAddChild(): Promise { }) } +async function handleRemoveChild(childElementId: number): Promise { + 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 { iconImageInput.value?.click() } @@ -500,23 +523,35 @@ function resetInput(changeEvent: Event): void { v-for="childElement in childElements" :key="childElement.id" class="admin-element-page__child-item" + data-cy="admin-child-item" > - + + {{ childElement.title }} + +

+ {{ childElement.description }} +

+ +