diff --git a/ai/backend-context.md b/ai/backend-context.md index 34729b2..3395a52 100644 --- a/ai/backend-context.md +++ b/ai/backend-context.md @@ -40,11 +40,14 @@ intentionally omitted here - update this section as entities land. ## Migrations -- This project is not in production. By default, schema changes should update - the relevant create-table migration so migrations describe the current desired - schema from scratch. -- Do not add alter-table or data-backfill migrations unless the user explicitly - asks for production-style migration safety. +- This project is in production. Never edit, delete, rename, or reorder an + existing migration file. Every schema change must use a new forward + migration. +- Production migrations must preserve existing data, include any required + backfill, and define a safe rollback. +- Plan schema changes for the deployment order and for compatibility between + the old and new application versions. Use expand/backfill/contract sequencing + when a single compatible migration is not safe. - Put seed data in seeders, not migrations. ## PHP rules diff --git a/backend/app/Controllers/SetController.php b/backend/app/Controllers/SetController.php index a3c89f6..31b142f 100644 --- a/backend/app/Controllers/SetController.php +++ b/backend/app/Controllers/SetController.php @@ -11,6 +11,8 @@ use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRoot; use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest; use App\Set\UseCases\DeleteSet\DeleteSet; use App\Set\UseCases\DeleteSet\DeleteSetRequest; +use App\Set\UseCases\ReorderSets\ReorderSets; +use App\Set\UseCases\ReorderSets\ReorderSetsRequest; use App\Set\UseCases\UpdateSet\UpdateSet; use App\Set\UseCases\UpdateSet\UpdateSetRequest; use App\Shared\Files\Filesystem; @@ -26,19 +28,34 @@ class SetController private CreateSetWithRoot $createSetWithRoot, private UpdateSet $updateSet, private DeleteSet $deleteSet, + private ReorderSets $reorderSets, private Filesystem $filesystem, ) { } public function index(): JsonResponse { - $sets = []; - foreach ($this->setRepository->getAll() as $set) { - $sets[] = $this->buildSetPayload($set); + return new JsonResponse([ + 'sets' => $this->buildSetPayloads( + $this->setRepository->getAll(), + ), + ], 200); + } + + public function reorder(Request $request): JsonResponse + { + try { + $sets = $this->reorderSets->execute(new ReorderSetsRequest( + setIds: $this->intArrayInput($request, 'setIds'), + )); + } catch (BadRequestException $exception) { + return new JsonResponse([ + 'error' => $exception->getMessage(), + ], 400); } return new JsonResponse([ - 'sets' => $sets, + 'sets' => $this->buildSetPayloads($sets), ], 200); } @@ -141,6 +158,58 @@ class SetController ]; } + /** + * @param DomainSet[] $sets + * @return array + */ + private function buildSetPayloads(array $sets): array + { + $setPayloads = []; + foreach ($sets as $set) { + $setPayloads[] = $this->buildSetPayload($set); + } + + return $setPayloads; + } + + /** + * @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/Set/EloquentSetRepository.php b/backend/app/Set/EloquentSetRepository.php index be7ac1d..08fce3f 100644 --- a/backend/app/Set/EloquentSetRepository.php +++ b/backend/app/Set/EloquentSetRepository.php @@ -3,6 +3,7 @@ namespace App\Set; use DomainException; +use Illuminate\Support\Facades\DB; class EloquentSetRepository implements SetRepository { @@ -12,6 +13,7 @@ class EloquentSetRepository implements SetRepository 'name' => $dto->name, 'description' => $dto->description, 'icon_image_url' => $dto->iconImageUrl, + 'sort_order' => $this->nextSortOrder(), ]); return $this->toDomain($model); @@ -55,7 +57,7 @@ class EloquentSetRepository implements SetRepository public function getAll(): array { - $models = SetModel::orderBy('id')->get(); + $models = SetModel::orderBy('sort_order')->orderBy('id')->get(); $sets = []; foreach ($models as $model) { $sets[] = $this->toDomain($model); @@ -64,6 +66,30 @@ class EloquentSetRepository implements SetRepository return $sets; } + public function reorder(array $setIds): array + { + DB::transaction(function () use ($setIds): void { + $sortOrder = 1; + foreach ($setIds as $setId) { + SetModel::where('id', $setId) + ->update(['sort_order' => $sortOrder]); + $sortOrder++; + } + }); + + return $this->getAll(); + } + + private function nextSortOrder(): int + { + $currentMaxSortOrder = SetModel::max('sort_order'); + if ($currentMaxSortOrder === null) { + return 1; + } + + return (int) $currentMaxSortOrder + 1; + } + private function toDomain(SetModel $model): Set { return new Set( diff --git a/backend/app/Set/SetModel.php b/backend/app/Set/SetModel.php index d80a6df..0422ee8 100644 --- a/backend/app/Set/SetModel.php +++ b/backend/app/Set/SetModel.php @@ -10,12 +10,14 @@ use Illuminate\Database\Eloquent\Model; * @property string $name * @property string $description * @property string $icon_image_url + * @property int $sort_order * * @method static Builder|SetModel newModelQuery() * @method static Builder|SetModel newQuery() * @method static Builder|SetModel query() * @method static Builder|SetModel whereId($value) * @method static Builder|SetModel whereName($value) + * @method static Builder|SetModel whereSortOrder($value) * * @mixin \Eloquent */ @@ -25,5 +27,14 @@ class SetModel extends Model public $timestamps = false; - protected $fillable = ['name', 'description', 'icon_image_url']; + protected $fillable = [ + 'name', + 'description', + 'icon_image_url', + 'sort_order', + ]; + + protected $casts = [ + 'sort_order' => 'integer', + ]; } diff --git a/backend/app/Set/SetRepository.php b/backend/app/Set/SetRepository.php index 79d9dde..bffbb02 100644 --- a/backend/app/Set/SetRepository.php +++ b/backend/app/Set/SetRepository.php @@ -16,4 +16,10 @@ interface SetRepository * @return Set[] */ public function getAll(): array; + + /** + * @param int[] $setIds + * @return Set[] + */ + public function reorder(array $setIds): array; } diff --git a/backend/app/Set/UseCases/ReorderSets/ReorderSets.php b/backend/app/Set/UseCases/ReorderSets/ReorderSets.php new file mode 100644 index 0000000..1937519 --- /dev/null +++ b/backend/app/Set/UseCases/ReorderSets/ReorderSets.php @@ -0,0 +1,128 @@ +setIds === null) { + throw new BadRequestException('setIds is required'); + } + + $setIds = $this->validatedSetIds($request->setIds); + $existingSetIds = $this->setIds($this->setRepository->getAll()); + + $this->validateNoDuplicateIds($setIds); + $this->validateAllIdsAreSets($setIds, $existingSetIds); + $this->validateEverySetWasSubmitted($setIds, $existingSetIds); + + return $this->setRepository->reorder($setIds); + } + + /** + * @param mixed[] $setIds + * @return int[] + * @throws BadRequestException + */ + private function validatedSetIds(array $setIds): array + { + $validatedSetIds = []; + foreach ($setIds as $setId) { + if (! is_int($setId)) { + throw new BadRequestException( + 'setIds must contain integers', + ); + } + + $validatedSetIds[] = $setId; + } + + return $validatedSetIds; + } + + /** + * @param int[] $setIds + * @throws BadRequestException + */ + private function validateNoDuplicateIds(array $setIds): void + { + $seenSetIds = []; + foreach ($setIds as $setId) { + if (isset($seenSetIds[$setId])) { + throw new BadRequestException( + 'Set order contains duplicate ids', + ); + } + + $seenSetIds[$setId] = true; + } + } + + /** + * @param int[] $setIds + * @param int[] $existingSetIds + * @throws BadRequestException + */ + private function validateAllIdsAreSets( + array $setIds, + array $existingSetIds, + ): void { + $existingSetIdsById = []; + foreach ($existingSetIds as $existingSetId) { + $existingSetIdsById[$existingSetId] = true; + } + + foreach ($setIds as $setId) { + if (! isset($existingSetIdsById[$setId])) { + throw new BadRequestException( + 'Set order contains invalid set', + ); + } + } + } + + /** + * @param int[] $setIds + * @param int[] $existingSetIds + * @throws BadRequestException + */ + private function validateEverySetWasSubmitted( + array $setIds, + array $existingSetIds, + ): void { + if (count($setIds) === count($existingSetIds)) { + return; + } + + throw new BadRequestException( + 'Set order must include every set', + ); + } + + /** + * @param Set[] $sets + * @return int[] + */ + private function setIds(array $sets): array + { + $setIds = []; + foreach ($sets as $set) { + $setIds[] = $set->getId(); + } + + return $setIds; + } +} diff --git a/backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php b/backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php new file mode 100644 index 0000000..86dfe00 --- /dev/null +++ b/backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php @@ -0,0 +1,13 @@ +unsignedInteger('sort_order')->nullable(); + }); + + $setIds = DB::table('sets')->orderBy('id')->pluck('id'); + $sortOrder = 1; + foreach ($setIds as $setId) { + DB::table('sets') + ->where('id', $setId) + ->update(['sort_order' => $sortOrder]); + $sortOrder++; + } + + Schema::table('sets', function (Blueprint $table): void { + $table->unsignedInteger('sort_order') + ->nullable(false) + ->change(); + }); + } + + public function down(): void + { + Schema::table('sets', function (Blueprint $table): void { + $table->dropColumn('sort_order'); + }); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index 12f02d8..0163757 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -11,6 +11,8 @@ Route::post('/logout', [AuthController::class, 'logout']); Route::get('/me', [AuthController::class, 'me']) ->middleware(AuthMiddleware::class); Route::get('/sets', [SetController::class, 'index']); +Route::put('/sets/order', [SetController::class, 'reorder']) + ->middleware(AuthMiddleware::class); Route::post('/sets', [SetController::class, 'create']) ->middleware(AuthMiddleware::class); Route::post('/sets/{id}/update', [SetController::class, 'update']) diff --git a/backend/tests/Fakes/FakeSetRepository.php b/backend/tests/Fakes/FakeSetRepository.php index f3efc1c..4cced39 100644 --- a/backend/tests/Fakes/FakeSetRepository.php +++ b/backend/tests/Fakes/FakeSetRepository.php @@ -13,6 +13,11 @@ class FakeSetRepository implements SetRepository */ private array $setsById = []; + /** + * @var array + */ + private array $sortOrdersById = []; + public function create(CreateSetDto $dto): DomainSet { $id = count($this->setsById) + 1; @@ -23,6 +28,7 @@ class FakeSetRepository implements SetRepository iconImageUrl: $dto->iconImageUrl, ); $this->setsById[$id] = $set; + $this->sortOrdersById[$id] = $this->nextSortOrder(); return $set; } @@ -38,6 +44,7 @@ class FakeSetRepository implements SetRepository public function delete(DomainSet $set): void { unset($this->setsById[$set->getId()]); + unset($this->sortOrdersById[$set->getId()]); } public function find(int $id): ?DomainSet @@ -58,10 +65,35 @@ class FakeSetRepository implements SetRepository foreach ($this->setsById as $set) { $sets[] = $this->cloneSet($set); } + usort($sets, function ( + DomainSet $firstSet, + DomainSet $secondSet, + ): int { + $firstSortOrder = $this->sortOrdersById[$firstSet->getId()] + ?? $firstSet->getId(); + $secondSortOrder = $this->sortOrdersById[$secondSet->getId()] + ?? $secondSet->getId(); + if ($firstSortOrder === $secondSortOrder) { + return $firstSet->getId() <=> $secondSet->getId(); + } + + return $firstSortOrder <=> $secondSortOrder; + }); return $sets; } + public function reorder(array $setIds): array + { + $sortOrder = 1; + foreach ($setIds as $setId) { + $this->sortOrdersById[$setId] = $sortOrder; + $sortOrder++; + } + + return $this->getAll(); + } + private function cloneSet(DomainSet $set): DomainSet { return new DomainSet( @@ -71,4 +103,13 @@ class FakeSetRepository implements SetRepository iconImageUrl: $set->getIconImageUrl(), ); } + + private function nextSortOrder(): int + { + if ($this->sortOrdersById === []) { + return 1; + } + + return max($this->sortOrdersById) + 1; + } } diff --git a/backend/tests/Feature/SetOrderingMigrationTest.php b/backend/tests/Feature/SetOrderingMigrationTest.php new file mode 100644 index 0000000..806b892 --- /dev/null +++ b/backend/tests/Feature/SetOrderingMigrationTest.php @@ -0,0 +1,86 @@ +runMigrationAgainstLegacySets(); + + $this->assertSame( + [2, 7], + DB::table('sets')->orderBy('sort_order')->pluck('id')->all(), + ); + $this->assertSame( + [1, 2], + DB::table('sets') + ->orderBy('sort_order') + ->pluck('sort_order') + ->all(), + ); + + $this->expectException(QueryException::class); + DB::table('sets')->insert([ + 'id' => 9, + 'name' => 'Unordered Set', + 'description' => 'A set without an explicit position', + 'icon_image_url' => '/assets/unordered.png', + ]); + } + + public function testRollbackRemovesOrderWithoutDeletingSets(): void + { + $migration = $this->runMigrationAgainstLegacySets(); + + $migration->down(); + + $this->assertFalse(Schema::hasColumn('sets', 'sort_order')); + $this->assertSame( + [2, 7], + DB::table('sets')->orderBy('id')->pluck('id')->all(), + ); + } + + private function runMigrationAgainstLegacySets(): object + { + if (Schema::hasColumn('sets', 'sort_order')) { + Schema::table('sets', function (Blueprint $table): void { + $table->dropColumn('sort_order'); + }); + } + + DB::table('sets')->delete(); + DB::table('sets')->insert([ + [ + 'id' => 7, + 'name' => 'Later Set', + 'description' => 'Created later', + 'icon_image_url' => '/assets/later.png', + ], + [ + 'id' => 2, + 'name' => 'Earlier Set', + 'description' => 'Created earlier', + 'icon_image_url' => '/assets/earlier.png', + ], + ]); + + $migration = require database_path(self::MIGRATION_PATH); + $migration->up(); + + return $migration; + } +} diff --git a/backend/tests/Feature/SetsEndpointTest.php b/backend/tests/Feature/SetsEndpointTest.php index b2afe90..d648b40 100644 --- a/backend/tests/Feature/SetsEndpointTest.php +++ b/backend/tests/Feature/SetsEndpointTest.php @@ -73,6 +73,98 @@ class SetsEndpointTest extends TestCase ]); } + public function testReorderSetsRequiresAuthentication(): void + { + $response = $this->putJson('/api/sets/order', [ + 'setIds' => [1, 2], + ]); + + $response->assertUnauthorized(); + $response->assertExactJson([ + 'error' => 'unauthenticated', + ]); + } + + public function testAuthenticatedReorderSetsPersistsPublicOrder(): void + { + $setRepository = app(SetRepository::class); + $firstSet = $setRepository->create(new CreateSetDto( + name: 'First Set', + description: 'First set description', + iconImageUrl: '/assets/first.png', + )); + $secondSet = $setRepository->create(new CreateSetDto( + name: 'Second Set', + description: 'Second set description', + iconImageUrl: '/assets/second.png', + )); + $thirdSet = $setRepository->create(new CreateSetDto( + name: 'Third Set', + description: 'Third set description', + iconImageUrl: '/assets/third.png', + )); + $this->createSession('valid-token'); + + $response = $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->putJson('/api/sets/order', [ + 'setIds' => [ + $thirdSet->getId(), + $firstSet->getId(), + $secondSet->getId(), + ], + ]); + + $response->assertOk(); + $response->assertJsonCount(3, 'sets'); + $response->assertJsonPath('sets.0.id', $thirdSet->getId()); + $response->assertJsonPath('sets.1.id', $firstSet->getId()); + $response->assertJsonPath('sets.2.id', $secondSet->getId()); + + $publicResponse = $this->getJson('/api/sets'); + $publicResponse->assertOk(); + $publicResponse->assertJsonPath('sets.0.id', $thirdSet->getId()); + $publicResponse->assertJsonPath('sets.1.id', $firstSet->getId()); + $publicResponse->assertJsonPath('sets.2.id', $secondSet->getId()); + } + + public function testCreatedSetAppendsToSavedOrder(): void + { + $setRepository = app(SetRepository::class); + $firstSet = $setRepository->create(new CreateSetDto( + name: 'First Set', + description: 'First set description', + iconImageUrl: '/assets/first.png', + )); + $secondSet = $setRepository->create(new CreateSetDto( + name: 'Second Set', + description: 'Second set description', + iconImageUrl: '/assets/second.png', + )); + $this->createSession('valid-token'); + $this->withCredentials() + ->withUnencryptedCookie('auth_token', 'valid-token') + ->putJson('/api/sets/order', [ + 'setIds' => [ + $secondSet->getId(), + $firstSet->getId(), + ], + ]) + ->assertOk(); + + $thirdSet = $setRepository->create(new CreateSetDto( + name: 'Third Set', + description: 'Third set description', + iconImageUrl: '/assets/third.png', + )); + + $publicResponse = $this->getJson('/api/sets'); + $publicResponse->assertOk(); + $publicResponse->assertJsonPath('sets.0.id', $secondSet->getId()); + $publicResponse->assertJsonPath('sets.1.id', $firstSet->getId()); + $publicResponse->assertJsonPath('sets.2.id', $thirdSet->getId()); + } + public function testCreateSetRequiresAuthentication(): void { $response = $this->postJson('/api/sets', [ diff --git a/backend/tests/Unit/Set/UseCases/ReorderSetsTest.php b/backend/tests/Unit/Set/UseCases/ReorderSetsTest.php new file mode 100644 index 0000000..e323a72 --- /dev/null +++ b/backend/tests/Unit/Set/UseCases/ReorderSetsTest.php @@ -0,0 +1,135 @@ +setRepository = new FakeSetRepository(); + $this->reorderSets = new ReorderSets($this->setRepository); + } + + public function testReordersEverySet(): void + { + $firstSet = $this->createSet('First Set'); + $secondSet = $this->createSet('Second Set'); + $thirdSet = $this->createSet('Third Set'); + + $sets = $this->reorderSets->execute(new ReorderSetsRequest( + setIds: [ + $thirdSet->getId(), + $firstSet->getId(), + $secondSet->getId(), + ], + )); + + $expectedSetIds = [ + $thirdSet->getId(), + $firstSet->getId(), + $secondSet->getId(), + ]; + $this->assertSame($expectedSetIds, $this->setIds($sets)); + $this->assertSame( + $expectedSetIds, + $this->setIds($this->setRepository->getAll()), + ); + } + + public function testThrowsWhenSetIdsAreMissing(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('setIds is required'); + + $this->reorderSets->execute(new ReorderSetsRequest(setIds: null)); + } + + public function testThrowsWhenSetIdsAreNotIntegers(): void + { + $firstSet = $this->createSet('First Set'); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('setIds must contain integers'); + + $this->reorderSets->execute(new ReorderSetsRequest( + setIds: [$firstSet->getId(), 'invalid'], + )); + } + + public function testThrowsWhenSetIdsContainDuplicates(): void + { + $firstSet = $this->createSet('First Set'); + $secondSet = $this->createSet('Second Set'); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Set order contains duplicate ids'); + + $this->reorderSets->execute(new ReorderSetsRequest( + setIds: [ + $firstSet->getId(), + $firstSet->getId(), + $secondSet->getId(), + ], + )); + } + + public function testThrowsWhenSetOrderContainsUnknownSet(): void + { + $firstSet = $this->createSet('First Set'); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Set order contains invalid set'); + + $this->reorderSets->execute(new ReorderSetsRequest( + setIds: [$firstSet->getId(), 999], + )); + } + + public function testThrowsWhenSetOrderOmitsSet(): void + { + $firstSet = $this->createSet('First Set'); + $this->createSet('Second Set'); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Set order must include every set'); + + $this->reorderSets->execute(new ReorderSetsRequest( + setIds: [$firstSet->getId()], + )); + } + + private function createSet(string $name): DomainSet + { + return $this->setRepository->create(new CreateSetDto( + name: $name, + description: "$name description", + iconImageUrl: "/assets/$name.png", + )); + } + + /** + * @param DomainSet[] $sets + * @return int[] + */ + private function setIds(array $sets): array + { + $setIds = []; + foreach ($sets as $set) { + $setIds[] = $set->getId(); + } + + return $setIds; + } +} diff --git a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts index 2bbb833..0573764 100644 --- a/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts +++ b/frontend/rabbi_gerzi/cypress/e2e/media.cy.ts @@ -188,6 +188,85 @@ describe('media page sets', () => { cy.get('[data-cy="media-set-delete"]').should('not.exist') }) + it('reorders sets and persists the public order', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/media') + cy.intercept('PUT', /\/api\/sets\/order$/).as('reorderSets') + + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .within(() => { + cy.get('[data-cy="media-set-move-up"]').click() + }) + cy.wait('@reorderSets') + + cy.get('[data-cy="media-set-order-status"]') + .should('be.visible') + .and('contain.text', 'Set order saved') + cy.get('[data-cy="media-set-card"]') + .eq(0) + .should('contain.text', 'Daily Learning') + .within(() => { + cy.get('[data-cy="media-set-move-up"]').should('be.disabled') + }) + cy.get('[data-cy="media-set-card"]') + .eq(1) + .should('contain.text', 'Baderech HaAvodah') + .within(() => { + cy.get('[data-cy="media-set-move-down"]').should('be.disabled') + }) + + cy.reload() + cy.get('[data-cy="media-set-card"]') + .eq(0) + .should('contain.text', 'Daily Learning') + cy.get('[data-cy="media-set-card"]') + .eq(1) + .should('contain.text', 'Baderech HaAvodah') + + cy.clearCookie('auth_token') + cy.reload() + cy.get('[data-cy="media-set-card"]') + .eq(0) + .should('contain.text', 'Daily Learning') + cy.get('[data-cy="media-set-card"]') + .eq(1) + .should('contain.text', 'Baderech HaAvodah') + cy.get('[data-cy="media-set-move-up"]').should('not.exist') + cy.get('[data-cy="media-set-move-down"]').should('not.exist') + + cy.resetDb() + }) + + it('preserves set order when saving fails', () => { + cy.resetDb() + loginAsAdmin() + cy.visit('/media') + cy.intercept('PUT', /\/api\/sets\/order$/, { + statusCode: 500, + body: { error: 'Unexpected failure' }, + }).as('reorderSets') + + cy.contains('[data-cy="media-set-card"]', 'Daily Learning') + .within(() => { + cy.get('[data-cy="media-set-move-up"]').click() + }) + cy.wait('@reorderSets') + + cy.get('[data-cy="media-set-order-error"]') + .should('be.visible') + .and('contain.text', 'Could not save set order') + cy.get('[data-cy="media-set-order-status"]').should('not.exist') + cy.get('[data-cy="media-set-card"]') + .eq(0) + .should('contain.text', 'Baderech HaAvodah') + cy.get('[data-cy="media-set-card"]') + .eq(1) + .should('contain.text', 'Daily Learning') + + cy.resetDb() + }) + it('creates a set from the logged-in media page modal', () => { loginAsAdmin() cy.visit('/media') diff --git a/frontend/rabbi_gerzi/src/stores/mediaSets.ts b/frontend/rabbi_gerzi/src/stores/mediaSets.ts index e669e85..f880b76 100644 --- a/frontend/rabbi_gerzi/src/stores/mediaSets.ts +++ b/frontend/rabbi_gerzi/src/stores/mediaSets.ts @@ -43,9 +43,11 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { const isCreating = ref(false) const isUpdating = ref(false) const isDeleting = ref(false) + const isReordering = ref(false) const createError = ref(null) const updateError = ref(null) const deleteError = ref(null) + const reorderError = ref(null) async function fetchSets(): Promise { error.value = null @@ -187,6 +189,43 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { } } + async function reorderSets(setIds: number[]): Promise { + reorderError.value = null + isReordering.value = true + + try { + const response = await fetch(`${API_BASE_URL}/api/sets/order`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ setIds }), + }) + + if (!response.ok) { + if (response.status === 401) { + reorderError.value = 'Please log in again' + } else if (response.status === 400) { + reorderError.value = await errorMessage( + response, + 'Could not save set order', + ) + } else { + reorderError.value = 'Could not save set order' + } + return false + } + + const data: SetsResponse = await response.json() + sets.value = data.sets + return true + } catch { + reorderError.value = 'Network error - could not save set order' + return false + } finally { + isReordering.value = false + } + } + async function errorMessage( response: Response, fallbackMessage: string, @@ -206,12 +245,15 @@ export const useMediaSetsStore = defineStore('mediaSets', () => { isCreating, isUpdating, isDeleting, + isReordering, createError, updateError, deleteError, + reorderError, fetchSets, createSet, updateSet, deleteSet, + reorderSets, } }) diff --git a/frontend/rabbi_gerzi/src/views/MediaPage.vue b/frontend/rabbi_gerzi/src/views/MediaPage.vue index 07f407b..7d81276 100644 --- a/frontend/rabbi_gerzi/src/views/MediaPage.vue +++ b/frontend/rabbi_gerzi/src/views/MediaPage.vue @@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia' import { computed, onMounted, reactive, ref } from 'vue' import { AlertTriangle as AlertTriangleIcon, + ChevronDown as ChevronDownIcon, + ChevronUp as ChevronUpIcon, Pencil as PencilIcon, Plus as PlusIcon, Trash2 as TrashIcon, @@ -18,6 +20,8 @@ interface CreateSetForm { description: string } +type SetMoveDirection = 'up' | 'down' + const router = useRouter() const authStore = useAuthStore() const mediaSetsStore = useMediaSetsStore() @@ -28,9 +32,11 @@ const { isCreating, isUpdating, isDeleting, + isReordering, createError, updateError, deleteError, + reorderError, } = storeToRefs(mediaSetsStore) const isCreateModalOpen = ref(false) const isEditModalOpen = ref(false) @@ -42,6 +48,7 @@ const localCreateError = ref(null) const localEditError = ref(null) const editingSet = ref(null) const deletingSet = ref(null) +const setOrderStatus = ref(null) const createSetForm = reactive({ name: '', @@ -231,6 +238,42 @@ async function handleDeleteSet(): Promise { deletingSet.value = null } +async function handleMoveSet( + setId: number, + direction: SetMoveDirection, +): Promise { + const currentIndex = sets.value.findIndex((mediaSet) => { + return mediaSet.id === setId + }) + if (currentIndex === -1) { + return + } + + const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1 + if (targetIndex < 0 || targetIndex >= sets.value.length) { + return + } + + const reorderedSets = [...sets.value] + const movingSet = reorderedSets[currentIndex] + const targetSet = reorderedSets[targetIndex] + if (movingSet === undefined || targetSet === undefined) { + return + } + + reorderedSets[currentIndex] = targetSet + reorderedSets[targetIndex] = movingSet + const setIds = reorderedSets.map((mediaSet) => { + return mediaSet.id + }) + + setOrderStatus.value = null + const saved = await mediaSetsStore.reorderSets(setIds) + if (saved) { + setOrderStatus.value = 'Set order saved' + } +} + function openSet(mediaSet: MediaSet): void { if (mediaSet.rootElementId === null) { return @@ -287,6 +330,30 @@ function resetEditSetForm(): void {
+

+ {{ setOrderStatus }} +

+

+ {{ reorderError }} +

Loading media sets...

-