diff --git a/backend/app/Controllers/ElementController.php b/backend/app/Controllers/ElementController.php index 36528d4..0101dea 100644 --- a/backend/app/Controllers/ElementController.php +++ b/backend/app/Controllers/ElementController.php @@ -9,6 +9,8 @@ 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\ReorderChildElements\ReorderChildElements; +use App\Element\UseCases\ReorderChildElements\ReorderChildElementsRequest; use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateElementRequest; use App\Exceptions\BadRequestException; @@ -24,6 +26,7 @@ class ElementController private GetElement $getElement, private CreateChildElement $createChildElement, private DeleteChildElement $deleteChildElement, + private ReorderChildElements $reorderChildElements, private UpdateElement $updateElement, private FileUploader $fileUploader, ) { @@ -104,6 +107,37 @@ class ElementController return new JsonResponse(null, 204); } + public function reorderChildren( + Request $request, + ?int $parentId, + ): JsonResponse { + try { + $childElements = $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentId, + childElementIds: $this->intArrayInput( + $request, + 'childElementIds', + ), + ) + ); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); + } catch (NotFoundException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 404); + } + + return new JsonResponse([ + 'childElements' => $this->buildElementSummaryPayloads( + $childElements, + ), + ], 200); + } + public function update(Request $request): JsonResponse { $file = $this->uploadedFileInput($request, 'file'); @@ -158,6 +192,38 @@ class ElementController return null; } + /** + * @return int[]|null + */ + private function intArrayInput(Request $request, string $key): ?array + { + if (! $request->exists($key)) { + return null; + } + + $value = $request->input($key); + if (! is_array($value)) { + return null; + } + + $integerValues = []; + foreach ($value as $item) { + if (is_int($item)) { + $integerValues[] = $item; + continue; + } + + if (is_string($item) && ctype_digit($item)) { + $integerValues[] = (int) $item; + continue; + } + + return null; + } + + return $integerValues; + } + private function stringInput(Request $request, string $key): ?string { if (! $request->exists($key)) { diff --git a/backend/app/Element/ElementModel.php b/backend/app/Element/ElementModel.php index 3a8a435..dfd12b1 100644 --- a/backend/app/Element/ElementModel.php +++ b/backend/app/Element/ElementModel.php @@ -16,6 +16,7 @@ use Illuminate\Database\Eloquent\Model; * @property string|null $long_pdf_path * @property string|null $youtube_url * @property int|null $parent_element_id + * @property int $sort_order * * @method static Builder|ElementModel newModelQuery() * @method static Builder|ElementModel newQuery() @@ -30,6 +31,7 @@ use Illuminate\Database\Eloquent\Model; * @method static Builder|ElementModel whereShortPdfPath($value) * @method static Builder|ElementModel whereLongPdfPath($value) * @method static Builder|ElementModel whereYoutubeUrl($value) + * @method static Builder|ElementModel whereSortOrder($value) * * @mixin \Eloquent */ @@ -49,10 +51,12 @@ class ElementModel extends Model 'long_pdf_path', 'youtube_url', 'parent_element_id', + 'sort_order', ]; protected $casts = [ 'set_id' => 'integer', 'parent_element_id' => 'integer', + 'sort_order' => 'integer', ]; } diff --git a/backend/app/Element/ElementRepository.php b/backend/app/Element/ElementRepository.php index f759322..e1f84e4 100644 --- a/backend/app/Element/ElementRepository.php +++ b/backend/app/Element/ElementRepository.php @@ -25,4 +25,13 @@ interface ElementRepository * @return Element[] */ public function findByParentElement(Element $parentElement): array; + + /** + * @param int[] $childElementIds + * @return Element[] + */ + public function reorderChildren( + Element $parentElement, + array $childElementIds, + ): array; } diff --git a/backend/app/Element/EloquentElementRepository.php b/backend/app/Element/EloquentElementRepository.php index 77a110b..f585eb1 100644 --- a/backend/app/Element/EloquentElementRepository.php +++ b/backend/app/Element/EloquentElementRepository.php @@ -5,6 +5,7 @@ namespace App\Element; use App\Set\Set as DomainSet; use App\Set\SetRepository; use DomainException; +use Illuminate\Support\Facades\DB; class EloquentElementRepository implements ElementRepository { @@ -14,6 +15,7 @@ class EloquentElementRepository implements ElementRepository public function create(CreateElementDto $dto): Element { + $parentElementId = $dto->parentElement?->getId(); $model = ElementModel::create([ 'set_id' => $dto->set->getId(), 'title' => $dto->title, @@ -23,7 +25,8 @@ class EloquentElementRepository implements ElementRepository 'short_pdf_path' => $dto->shortPdfPath, 'long_pdf_path' => $dto->longPdfPath, 'youtube_url' => $dto->youtubeUrl, - 'parent_element_id' => $dto->parentElement?->getId(), + 'parent_element_id' => $parentElementId, + 'sort_order' => $this->nextSortOrder($dto->set, $parentElementId), ]); return new Element( @@ -86,6 +89,7 @@ class EloquentElementRepository implements ElementRepository { $model = ElementModel::where('set_id', $set->getId()) ->whereNull('parent_element_id') + ->orderBy('sort_order') ->orderBy('id') ->first(); @@ -117,6 +121,7 @@ class EloquentElementRepository implements ElementRepository 'parent_element_id', $parentElement->getId(), ) + ->orderBy('sort_order') ->orderBy('id') ->get(); $elements = []; @@ -127,6 +132,46 @@ class EloquentElementRepository implements ElementRepository return $elements; } + /** + * @param int[] $childElementIds + * @return Element[] + */ + public function reorderChildren( + Element $parentElement, + array $childElementIds, + ): array { + DB::transaction(function () use ($parentElement, $childElementIds) { + $sortOrder = 1; + foreach ($childElementIds as $childElementId) { + ElementModel::where('id', $childElementId) + ->where('parent_element_id', $parentElement->getId()) + ->update(['sort_order' => $sortOrder]); + $sortOrder++; + } + }); + + return $this->findByParentElement($parentElement); + } + + private function nextSortOrder( + DomainSet $set, + ?int $parentElementId, + ): int { + $query = ElementModel::where('set_id', $set->getId()); + if ($parentElementId === null) { + $query->whereNull('parent_element_id'); + } else { + $query->where('parent_element_id', $parentElementId); + } + + $currentMaxSortOrder = $query->max('sort_order'); + if ($currentMaxSortOrder === null) { + return 1; + } + + return (int) $currentMaxSortOrder + 1; + } + private function toDomain(ElementModel $model): Element { $set = $this->setRepo->find($model->set_id); diff --git a/backend/app/Element/UseCases/ReorderChildElements/ReorderChildElements.php b/backend/app/Element/UseCases/ReorderChildElements/ReorderChildElements.php new file mode 100644 index 0000000..45d94c0 --- /dev/null +++ b/backend/app/Element/UseCases/ReorderChildElements/ReorderChildElements.php @@ -0,0 +1,154 @@ +parentElementId === null) { + throw new BadRequestException('parentElementId is required'); + } + + if ($request->childElementIds === null) { + throw new BadRequestException('childElementIds is required'); + } + + $parentElement = $this->elementRepository->find( + $request->parentElementId, + ); + if ($parentElement === null) { + throw new NotFoundException('Parent element not found'); + } + + $childElementIds = $this->validatedChildElementIds( + $request->childElementIds, + ); + $directChildElementIds = $this->elementIds( + $this->elementRepository->findByParentElement($parentElement), + ); + + $this->validateNoDuplicateIds($childElementIds); + $this->validateAllIdsAreDirectChildren( + $childElementIds, + $directChildElementIds, + ); + $this->validateEveryDirectChildWasSubmitted( + $childElementIds, + $directChildElementIds, + ); + + return $this->elementRepository->reorderChildren( + $parentElement, + $childElementIds, + ); + } + + /** + * @param mixed[] $childElementIds + * @return int[] + * @throws BadRequestException + */ + private function validatedChildElementIds(array $childElementIds): array + { + $validatedChildElementIds = []; + foreach ($childElementIds as $childElementId) { + if (! is_int($childElementId)) { + throw new BadRequestException( + 'childElementIds must contain integers', + ); + } + + $validatedChildElementIds[] = $childElementId; + } + + return $validatedChildElementIds; + } + + /** + * @param int[] $childElementIds + * @throws BadRequestException + */ + private function validateNoDuplicateIds(array $childElementIds): void + { + $seenChildElementIds = []; + foreach ($childElementIds as $childElementId) { + if (isset($seenChildElementIds[$childElementId])) { + throw new BadRequestException( + 'Child order contains duplicate ids', + ); + } + + $seenChildElementIds[$childElementId] = true; + } + } + + /** + * @param int[] $childElementIds + * @param int[] $directChildElementIds + * @throws BadRequestException + */ + private function validateAllIdsAreDirectChildren( + array $childElementIds, + array $directChildElementIds, + ): void { + $directChildElementIdsById = []; + foreach ($directChildElementIds as $directChildElementId) { + $directChildElementIdsById[$directChildElementId] = true; + } + + foreach ($childElementIds as $childElementId) { + if (! isset($directChildElementIdsById[$childElementId])) { + throw new BadRequestException( + 'Child order contains invalid child', + ); + } + } + } + + /** + * @param int[] $childElementIds + * @param int[] $directChildElementIds + * @throws BadRequestException + */ + private function validateEveryDirectChildWasSubmitted( + array $childElementIds, + array $directChildElementIds, + ): void { + if (count($childElementIds) === count($directChildElementIds)) { + return; + } + + throw new BadRequestException( + 'Child order must include every direct child', + ); + } + + /** + * @param Element[] $elements + * @return int[] + */ + private function elementIds(array $elements): array + { + $elementIds = []; + foreach ($elements as $element) { + $elementIds[] = $element->getId(); + } + + return $elementIds; + } +} diff --git a/backend/app/Element/UseCases/ReorderChildElements/ReorderChildElementsRequest.php b/backend/app/Element/UseCases/ReorderChildElements/ReorderChildElementsRequest.php new file mode 100644 index 0000000..d8e3267 --- /dev/null +++ b/backend/app/Element/UseCases/ReorderChildElements/ReorderChildElementsRequest.php @@ -0,0 +1,15 @@ + ['api/*'], - 'allowed_methods' => ['GET', 'POST', 'PATCH', 'OPTIONS', 'DELETE'], + 'allowed_methods' => [ + 'GET', + 'POST', + 'PATCH', + 'OPTIONS', + 'DELETE', + 'PUT', + ], 'allowed_origins' => $allowedOrigins, 'allowed_origins_patterns' => [], 'allowed_headers' => [ diff --git a/backend/database/migrations/2026_05_24_000001_elements_table.php b/backend/database/migrations/2026_05_24_000001_elements_table.php index 1941e03..030593c 100644 --- a/backend/database/migrations/2026_05_24_000001_elements_table.php +++ b/backend/database/migrations/2026_05_24_000001_elements_table.php @@ -21,6 +21,7 @@ return new class extends Migration $table->foreignId('parent_element_id') ->nullable() ->constrained('elements'); + $table->unsignedInteger('sort_order'); }); } diff --git a/backend/routes/api.php b/backend/routes/api.php index 1eb4ef1..f084592 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -17,6 +17,11 @@ Route::post('/elements/{parentId}/children', [ 'createChild', ]) ->middleware(AuthMiddleware::class); +Route::put('/elements/{parentId}/children/order', [ + ElementController::class, + 'reorderChildren', +]) + ->middleware(AuthMiddleware::class); Route::delete('/elements/{parentId}/children/{childId}', [ ElementController::class, 'deleteChild', diff --git a/backend/tests/Fakes/FakeElementRepository.php b/backend/tests/Fakes/FakeElementRepository.php index 4e08547..41eaa74 100644 --- a/backend/tests/Fakes/FakeElementRepository.php +++ b/backend/tests/Fakes/FakeElementRepository.php @@ -14,6 +14,11 @@ class FakeElementRepository implements ElementRepository */ private array $elementsById = []; + /** + * @var array + */ + private array $sortOrdersById = []; + public function create(CreateElementDto $dto): Element { $id = count($this->elementsById) + 1; @@ -30,6 +35,9 @@ class FakeElementRepository implements ElementRepository parentElement: $dto->parentElement, ); $this->elementsById[$id] = $element; + $this->sortOrdersById[$id] = $this->nextSortOrder( + $dto->parentElement, + ); return $element; } @@ -45,6 +53,7 @@ class FakeElementRepository implements ElementRepository public function delete(Element $element): void { unset($this->elementsById[$element->getId()]); + unset($this->sortOrdersById[$element->getId()]); } public function find(int $id): ?Element @@ -100,10 +109,43 @@ class FakeElementRepository implements ElementRepository $elements[] = $this->cloneElement($element); } } + usort($elements, function ( + Element $firstElement, + Element $secondElement, + ): int { + $firstSortOrder = $this->sortOrdersById[ + $firstElement->getId() + ] ?? $firstElement->getId(); + $secondSortOrder = $this->sortOrdersById[ + $secondElement->getId() + ] ?? $secondElement->getId(); + if ($firstSortOrder === $secondSortOrder) { + return $firstElement->getId() <=> $secondElement->getId(); + } + + return $firstSortOrder <=> $secondSortOrder; + }); return $elements; } + /** + * @param int[] $childElementIds + * @return Element[] + */ + public function reorderChildren( + Element $parentElement, + array $childElementIds, + ): array { + $sortOrder = 1; + foreach ($childElementIds as $childElementId) { + $this->sortOrdersById[$childElementId] = $sortOrder; + $sortOrder++; + } + + return $this->findByParentElement($parentElement); + } + private function cloneElement(Element $element): Element { $parentElement = $element->getParentElement(); @@ -124,4 +166,24 @@ class FakeElementRepository implements ElementRepository parentElement: $parentElement, ); } + + private function nextSortOrder(?Element $parentElement): int + { + $parentElementId = $parentElement?->getId(); + $nextSortOrder = 1; + foreach ($this->elementsById as $element) { + $currentParentElement = $element->getParentElement(); + $currentParentElementId = $currentParentElement?->getId(); + if ($currentParentElementId !== $parentElementId) { + continue; + } + + $currentSortOrder = $this->sortOrdersById[$element->getId()] ?? 0; + if ($currentSortOrder >= $nextSortOrder) { + $nextSortOrder = $currentSortOrder + 1; + } + } + + return $nextSortOrder; + } } diff --git a/backend/tests/Feature/ElementsEndpointTest.php b/backend/tests/Feature/ElementsEndpointTest.php index a914dd9..329e932 100644 --- a/backend/tests/Feature/ElementsEndpointTest.php +++ b/backend/tests/Feature/ElementsEndpointTest.php @@ -297,6 +297,234 @@ class ElementsEndpointTest extends TestCase ); } + public function testReorderChildElementsRequiresAuthentication(): 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, + '

A structured path for growth

', + null, + null, + null, + null, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'First child', + 'First child description', + null, + '', + null, + null, + null, + $parentElement, + ); + + $response = $this->putJson( + "/api/elements/{$parentElement->getId()}/children/order", + [ + 'childElementIds' => [$firstChildElement->getId()], + ], + ); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedReorderChildElementsPersistsOrder(): 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, + '

A structured path for growth

', + null, + null, + null, + null, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'First child', + 'First child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $elementRepository, + $set, + 'Second child', + 'Second child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $thirdChildElement = $this->createElement( + $elementRepository, + $set, + 'Third child', + 'Third child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->putJson( + "/api/elements/{$parentElement->getId()}/children/order", + [ + 'childElementIds' => [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + ], + ); + + $response->assertOk(); + $response->assertExactJson([ + 'childElements' => [ + [ + 'id' => $thirdChildElement->getId(), + 'title' => 'Third child', + 'description' => 'Third child description', + ], + [ + 'id' => $firstChildElement->getId(), + 'title' => 'First child', + 'description' => 'First child description', + ], + [ + 'id' => $secondChildElement->getId(), + 'title' => 'Second child', + 'description' => 'Second child description', + ], + ], + ]); + + $this->getJson("/api/elements/{$parentElement->getId()}") + ->assertJsonPath('childElements.0.id', $thirdChildElement->getId()) + ->assertJsonPath('childElements.1.id', $firstChildElement->getId()) + ->assertJsonPath( + 'childElements.2.id', + $secondChildElement->getId(), + ); + $this->getJson("/api/elements/{$thirdChildElement->getId()}") + ->assertJsonPath( + 'siblingElements.0.id', + $thirdChildElement->getId(), + ) + ->assertJsonPath( + 'siblingElements.1.id', + $firstChildElement->getId(), + ) + ->assertJsonPath( + 'siblingElements.2.id', + $secondChildElement->getId(), + ); + } + + public function testAuthenticatedReorderRejectsNestedChildElement(): 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, + '

A structured path for growth

', + null, + null, + null, + null, + ); + $firstChildElement = $this->createElement( + $elementRepository, + $set, + 'First child', + 'First child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $secondChildElement = $this->createElement( + $elementRepository, + $set, + 'Second child', + 'Second child description', + null, + '', + null, + null, + null, + $parentElement, + ); + $nestedChildElement = $this->createElement( + $elementRepository, + $set, + 'Nested child', + 'Nested child description', + null, + '', + null, + null, + null, + $firstChildElement, + ); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->putJson( + "/api/elements/{$parentElement->getId()}/children/order", + [ + 'childElementIds' => [ + $firstChildElement->getId(), + $nestedChildElement->getId(), + $secondChildElement->getId(), + ], + ], + ); + + $response->assertBadRequest(); + $response->assertExactJson([ + 'error' => 'Child order contains invalid child', + ]); + } + public function testDeleteChildRequiresAuthentication(): void { $setRepository = app(SetRepository::class); diff --git a/backend/tests/Unit/Controllers/ElementControllerTest.php b/backend/tests/Unit/Controllers/ElementControllerTest.php index c32ac21..7704306 100644 --- a/backend/tests/Unit/Controllers/ElementControllerTest.php +++ b/backend/tests/Unit/Controllers/ElementControllerTest.php @@ -8,6 +8,7 @@ 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\ReorderChildElements\ReorderChildElements; use App\Element\UseCases\UpdateElement\UpdateDescription; use App\Element\UseCases\UpdateElement\UpdateElement; use App\Element\UseCases\UpdateElement\UpdateIconImage; @@ -65,6 +66,7 @@ class ElementControllerTest extends TestCase $getElement, new CreateChildElement($this->elementRepo), new DeleteChildElement($this->elementRepo, $this->fileUploader), + new ReorderChildElements($this->elementRepo), $updateElement, $this->fileUploader, ); diff --git a/backend/tests/Unit/Element/UseCases/ReorderChildElementsTest.php b/backend/tests/Unit/Element/UseCases/ReorderChildElementsTest.php new file mode 100644 index 0000000..1b4829f --- /dev/null +++ b/backend/tests/Unit/Element/UseCases/ReorderChildElementsTest.php @@ -0,0 +1,237 @@ +elementRepository = new FakeElementRepository(); + $this->reorderChildElements = new ReorderChildElements( + $this->elementRepository, + ); + } + + public function testReordersDirectChildElements(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Second child', + $parentElement, + ); + $thirdChildElement = $this->createElement( + $set, + 'Third child', + $parentElement, + ); + $this->createElement($set, 'Nested child', $firstChildElement); + + $childElements = $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + ) + ); + + $this->assertSame( + [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + $this->elementIds($childElements), + ); + $this->assertSame( + [ + $thirdChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + $this->elementIds( + $this->elementRepository->findByParentElement($parentElement), + ), + ); + } + + public function testThrowsWhenParentElementIsMissing(): void + { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Parent element not found'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: 999, + childElementIds: [1, 2], + ) + ); + } + + public function testThrowsWhenChildIdsAreMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('childElementIds is required'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: 1, + childElementIds: null, + ) + ); + } + + public function testThrowsWhenChildOrderHasDuplicates(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Second child', + $parentElement, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Child order contains duplicate ids'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [ + $firstChildElement->getId(), + $firstChildElement->getId(), + $secondChildElement->getId(), + ], + ) + ); + } + + public function testThrowsWhenChildOrderHasInvalidElement(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $secondChildElement = $this->createElement( + $set, + 'Second child', + $parentElement, + ); + $nestedChildElement = $this->createElement( + $set, + 'Nested child', + $firstChildElement, + ); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Child order contains invalid child'); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [ + $firstChildElement->getId(), + $nestedChildElement->getId(), + $secondChildElement->getId(), + ], + ) + ); + } + + public function testThrowsWhenChildOrderOmitsDirectChild(): void + { + $set = $this->createSet(1, 'Baderech'); + $parentElement = $this->createElement($set, 'Parent', null); + $firstChildElement = $this->createElement( + $set, + 'First child', + $parentElement, + ); + $this->createElement($set, 'Second child', $parentElement); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'Child order must include every direct child', + ); + + $this->reorderChildElements->execute( + new ReorderChildElementsRequest( + parentElementId: $parentElement->getId(), + childElementIds: [$firstChildElement->getId()], + ) + ); + } + + 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, + ?Element $parentElement, + ): Element { + return $this->elementRepository->create(new CreateElementDto( + set: $set, + title: $title, + description: "$title description", + iconImageUrl: null, + richText: '', + shortPdfPath: null, + longPdfPath: null, + youtubeUrl: null, + parentElement: $parentElement, + )); + } + + /** + * @param Element[] $elements + * @return int[] + */ + private function elementIds(array $elements): array + { + $elementIds = []; + foreach ($elements as $element) { + $elementIds[] = $element->getId(); + } + + return $elementIds; + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts index 8c1f7f3..ab70619 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/admin-element.cy.ts @@ -191,6 +191,46 @@ describe('admin element editing', () => { cy.resetDb() }) + it('reorders child elements through admin controls', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/admin/element/1') + cy.intercept('PUT', /\/api\/elements\/1\/children\/order$/) + .as('reorderChildElements') + + cy.contains('[data-cy="admin-child-item"]', '2. Foundations') + .within(() => { + cy.get('[data-cy="admin-move-child-up"]').click() + }) + cy.wait('@reorderChildElements') + + cy.get('[data-cy="admin-child-status"]') + .should('be.visible') + .and('contain.text', 'Child order saved') + cy.get('[data-cy="admin-child-item"]') + .eq(0) + .should('contain.text', '2. Foundations') + cy.get('[data-cy="admin-child-item"]') + .eq(1) + .should('contain.text', '1. Introduction') + + cy.contains('header.site-header a', 'View Element').click() + cy.get('[data-cy="child-element-link"]') + .eq(0) + .should('contain.text', '2. Foundations') + cy.get('[data-cy="child-element-link"]') + .eq(1) + .should('contain.text', '1. Introduction') + + cy.visit('/element/3') + cy.get('[data-cy="element-sibling-previous"]') + .should('have.attr', 'href', '/element/7') + cy.get('[data-cy="element-sibling-next"]') + .should('have.attr', 'href', '/element/2') + + cy.resetDb() + }) + it('uploads icon and pdf files immediately through the UI', () => { cy.resetDb() loginAsAdmin() diff --git a/frontend/rabbi_gerzi/src/stores/elements.ts b/frontend/rabbi_gerzi/src/stores/elements.ts index 5118f39..074ba04 100644 --- a/frontend/rabbi_gerzi/src/stores/elements.ts +++ b/frontend/rabbi_gerzi/src/stores/elements.ts @@ -38,6 +38,10 @@ interface UpdateElementResponse { element: Element } +interface ReorderChildElementsResponse { + childElements: ChildElement[] +} + interface ElementPatchInput { title?: string description?: string @@ -145,6 +149,34 @@ export const useElementsStore = defineStore('elements', () => { } } + async function reorderChildElements( + parentElementId: string, + childElementIds: number[], + ): Promise { + childActionError.value = null + isManagingChildren.value = true + + try { + const encodedParentElementId = encodeURIComponent(parentElementId) + const response = await fetch( + `${ELEMENTS_URL}/${encodedParentElementId}/children/order`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ childElementIds }), + }, + ) + + return await handleReorderChildElementsResponse(response) + } catch { + childActionError.value = 'Network error - could not save child order' + return false + } finally { + isManagingChildren.value = false + } + } + async function deleteChildElement( parentElementId: string, childElementId: number, @@ -350,6 +382,32 @@ export const useElementsStore = defineStore('elements', () => { return data.element } + async function handleReorderChildElementsResponse( + response: Response, + ): 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 save child order', + ) + return false + } + + if (!response.ok) { + childActionError.value = 'Could not save child order' + return false + } + + const data: ReorderChildElementsResponse = await response.json() + childElements.value = data.childElements + return true + } + async function handleDeleteChildElementResponse( response: Response, childElementId: number, @@ -406,6 +464,7 @@ export const useElementsStore = defineStore('elements', () => { fetchElement, updateElement, createChildElement, + reorderChildElements, deleteChildElement, uploadElementIconImage, uploadElementShortPdf, diff --git a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue index 29f66fe..2c66cfc 100644 --- a/frontend/rabbi_gerzi/src/views/AdminElementPage.vue +++ b/frontend/rabbi_gerzi/src/views/AdminElementPage.vue @@ -1,12 +1,18 @@