Compare commits

...

9 commits

16 changed files with 904 additions and 16 deletions

View file

@ -40,11 +40,14 @@ intentionally omitted here - update this section as entities land.
## Migrations ## Migrations
- This project is not in production. By default, schema changes should update - This project is in production. Never edit, delete, rename, or reorder an
the relevant create-table migration so migrations describe the current desired existing migration file. Every schema change must use a new forward
schema from scratch. migration.
- Do not add alter-table or data-backfill migrations unless the user explicitly - Production migrations must preserve existing data, include any required
asks for production-style migration safety. 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. - Put seed data in seeders, not migrations.
## PHP rules ## PHP rules

View file

@ -11,6 +11,8 @@ use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRoot;
use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest; use App\Set\UseCases\CreateSetWithRoot\CreateSetWithRootRequest;
use App\Set\UseCases\DeleteSet\DeleteSet; use App\Set\UseCases\DeleteSet\DeleteSet;
use App\Set\UseCases\DeleteSet\DeleteSetRequest; 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\UpdateSet;
use App\Set\UseCases\UpdateSet\UpdateSetRequest; use App\Set\UseCases\UpdateSet\UpdateSetRequest;
use App\Shared\Files\Filesystem; use App\Shared\Files\Filesystem;
@ -26,19 +28,34 @@ class SetController
private CreateSetWithRoot $createSetWithRoot, private CreateSetWithRoot $createSetWithRoot,
private UpdateSet $updateSet, private UpdateSet $updateSet,
private DeleteSet $deleteSet, private DeleteSet $deleteSet,
private ReorderSets $reorderSets,
private Filesystem $filesystem, private Filesystem $filesystem,
) { ) {
} }
public function index(): JsonResponse public function index(): JsonResponse
{ {
$sets = []; return new JsonResponse([
foreach ($this->setRepository->getAll() as $set) { 'sets' => $this->buildSetPayloads(
$sets[] = $this->buildSetPayload($set); $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([ return new JsonResponse([
'sets' => $sets, 'sets' => $this->buildSetPayloads($sets),
], 200); ], 200);
} }
@ -141,6 +158,58 @@ class SetController
]; ];
} }
/**
* @param DomainSet[] $sets
* @return array<int, array{
* id: int,
* name: string,
* description: string,
* iconImageUrl: string,
* rootElementId: int|null
* }>
*/
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 private function stringInput(Request $request, string $key): ?string
{ {
if (! $request->exists($key)) { if (! $request->exists($key)) {

View file

@ -3,6 +3,7 @@
namespace App\Set; namespace App\Set;
use DomainException; use DomainException;
use Illuminate\Support\Facades\DB;
class EloquentSetRepository implements SetRepository class EloquentSetRepository implements SetRepository
{ {
@ -12,6 +13,7 @@ class EloquentSetRepository implements SetRepository
'name' => $dto->name, 'name' => $dto->name,
'description' => $dto->description, 'description' => $dto->description,
'icon_image_url' => $dto->iconImageUrl, 'icon_image_url' => $dto->iconImageUrl,
'sort_order' => $this->nextSortOrder(),
]); ]);
return $this->toDomain($model); return $this->toDomain($model);
@ -55,7 +57,7 @@ class EloquentSetRepository implements SetRepository
public function getAll(): array public function getAll(): array
{ {
$models = SetModel::orderBy('id')->get(); $models = SetModel::orderBy('sort_order')->orderBy('id')->get();
$sets = []; $sets = [];
foreach ($models as $model) { foreach ($models as $model) {
$sets[] = $this->toDomain($model); $sets[] = $this->toDomain($model);
@ -64,6 +66,30 @@ class EloquentSetRepository implements SetRepository
return $sets; 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 private function toDomain(SetModel $model): Set
{ {
return new Set( return new Set(

View file

@ -10,12 +10,14 @@ use Illuminate\Database\Eloquent\Model;
* @property string $name * @property string $name
* @property string $description * @property string $description
* @property string $icon_image_url * @property string $icon_image_url
* @property int $sort_order
* *
* @method static Builder<static>|SetModel newModelQuery() * @method static Builder<static>|SetModel newModelQuery()
* @method static Builder<static>|SetModel newQuery() * @method static Builder<static>|SetModel newQuery()
* @method static Builder<static>|SetModel query() * @method static Builder<static>|SetModel query()
* @method static Builder<static>|SetModel whereId($value) * @method static Builder<static>|SetModel whereId($value)
* @method static Builder<static>|SetModel whereName($value) * @method static Builder<static>|SetModel whereName($value)
* @method static Builder<static>|SetModel whereSortOrder($value)
* *
* @mixin \Eloquent * @mixin \Eloquent
*/ */
@ -25,5 +27,14 @@ class SetModel extends Model
public $timestamps = false; public $timestamps = false;
protected $fillable = ['name', 'description', 'icon_image_url']; protected $fillable = [
'name',
'description',
'icon_image_url',
'sort_order',
];
protected $casts = [
'sort_order' => 'integer',
];
} }

View file

@ -16,4 +16,10 @@ interface SetRepository
* @return Set[] * @return Set[]
*/ */
public function getAll(): array; public function getAll(): array;
/**
* @param int[] $setIds
* @return Set[]
*/
public function reorder(array $setIds): array;
} }

View file

@ -0,0 +1,128 @@
<?php
namespace App\Set\UseCases\ReorderSets;
use App\Exceptions\BadRequestException;
use App\Set\Set;
use App\Set\SetRepository;
class ReorderSets
{
public function __construct(private SetRepository $setRepository)
{
}
/**
* @return Set[]
* @throws BadRequestException
*/
public function execute(ReorderSetsRequest $request): array
{
if ($request->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;
}
}

View file

@ -0,0 +1,13 @@
<?php
namespace App\Set\UseCases\ReorderSets;
class ReorderSetsRequest
{
/**
* @param mixed[]|null $setIds
*/
public function __construct(public ?array $setIds)
{
}
}

View file

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('sets', function (Blueprint $table): void {
$table->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');
});
}
};

View file

@ -11,6 +11,8 @@ Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/me', [AuthController::class, 'me']) 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::put('/sets/order', [SetController::class, 'reorder'])
->middleware(AuthMiddleware::class);
Route::post('/sets', [SetController::class, 'create']) Route::post('/sets', [SetController::class, 'create'])
->middleware(AuthMiddleware::class); ->middleware(AuthMiddleware::class);
Route::post('/sets/{id}/update', [SetController::class, 'update']) Route::post('/sets/{id}/update', [SetController::class, 'update'])

View file

@ -13,6 +13,11 @@ class FakeSetRepository implements SetRepository
*/ */
private array $setsById = []; private array $setsById = [];
/**
* @var array<int, int>
*/
private array $sortOrdersById = [];
public function create(CreateSetDto $dto): DomainSet public function create(CreateSetDto $dto): DomainSet
{ {
$id = count($this->setsById) + 1; $id = count($this->setsById) + 1;
@ -23,6 +28,7 @@ class FakeSetRepository implements SetRepository
iconImageUrl: $dto->iconImageUrl, iconImageUrl: $dto->iconImageUrl,
); );
$this->setsById[$id] = $set; $this->setsById[$id] = $set;
$this->sortOrdersById[$id] = $this->nextSortOrder();
return $set; return $set;
} }
@ -38,6 +44,7 @@ class FakeSetRepository implements SetRepository
public function delete(DomainSet $set): void public function delete(DomainSet $set): void
{ {
unset($this->setsById[$set->getId()]); unset($this->setsById[$set->getId()]);
unset($this->sortOrdersById[$set->getId()]);
} }
public function find(int $id): ?DomainSet public function find(int $id): ?DomainSet
@ -58,10 +65,35 @@ class FakeSetRepository implements SetRepository
foreach ($this->setsById as $set) { foreach ($this->setsById as $set) {
$sets[] = $this->cloneSet($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; 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 private function cloneSet(DomainSet $set): DomainSet
{ {
return new DomainSet( return new DomainSet(
@ -71,4 +103,13 @@ class FakeSetRepository implements SetRepository
iconImageUrl: $set->getIconImageUrl(), iconImageUrl: $set->getIconImageUrl(),
); );
} }
private function nextSortOrder(): int
{
if ($this->sortOrdersById === []) {
return 1;
}
return max($this->sortOrdersById) + 1;
}
} }

View file

@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class SetOrderingMigrationTest extends TestCase
{
use RefreshDatabase;
private const MIGRATION_PATH =
'migrations/2026_07_31_000000_add_sort_order_to_sets_table.php';
public function testBackfillsExistingSetsInCurrentDisplayOrder(): void
{
$migration = $this->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;
}
}

View file

@ -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 public function testCreateSetRequiresAuthentication(): void
{ {
$response = $this->postJson('/api/sets', [ $response = $this->postJson('/api/sets', [

View file

@ -0,0 +1,135 @@
<?php
namespace Tests\Unit\Set\UseCases;
use App\Exceptions\BadRequestException;
use App\Set\CreateSetDto;
use App\Set\Set as DomainSet;
use App\Set\UseCases\ReorderSets\ReorderSets;
use App\Set\UseCases\ReorderSets\ReorderSetsRequest;
use Tests\Fakes\FakeSetRepository;
use Tests\TestCase;
class ReorderSetsTest extends TestCase
{
private FakeSetRepository $setRepository;
private ReorderSets $reorderSets;
protected function setUp(): void
{
$this->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;
}
}

View file

@ -188,6 +188,85 @@ describe('media page sets', () => {
cy.get('[data-cy="media-set-delete"]').should('not.exist') 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', () => { it('creates a set from the logged-in media page modal', () => {
loginAsAdmin() loginAsAdmin()
cy.visit('/media') cy.visit('/media')

View file

@ -43,9 +43,11 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
const isCreating = ref(false) const isCreating = ref(false)
const isUpdating = ref(false) const isUpdating = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const isReordering = ref(false)
const createError = ref<string | null>(null) const createError = ref<string | null>(null)
const updateError = ref<string | null>(null) const updateError = ref<string | null>(null)
const deleteError = ref<string | null>(null) const deleteError = ref<string | null>(null)
const reorderError = ref<string | null>(null)
async function fetchSets(): Promise<void> { async function fetchSets(): Promise<void> {
error.value = null error.value = null
@ -187,6 +189,43 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
} }
} }
async function reorderSets(setIds: number[]): Promise<boolean> {
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( async function errorMessage(
response: Response, response: Response,
fallbackMessage: string, fallbackMessage: string,
@ -206,12 +245,15 @@ export const useMediaSetsStore = defineStore('mediaSets', () => {
isCreating, isCreating,
isUpdating, isUpdating,
isDeleting, isDeleting,
isReordering,
createError, createError,
updateError, updateError,
deleteError, deleteError,
reorderError,
fetchSets, fetchSets,
createSet, createSet,
updateSet, updateSet,
deleteSet, deleteSet,
reorderSets,
} }
}) })

View file

@ -3,6 +3,8 @@ import { storeToRefs } from 'pinia'
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { import {
AlertTriangle as AlertTriangleIcon, AlertTriangle as AlertTriangleIcon,
ChevronDown as ChevronDownIcon,
ChevronUp as ChevronUpIcon,
Pencil as PencilIcon, Pencil as PencilIcon,
Plus as PlusIcon, Plus as PlusIcon,
Trash2 as TrashIcon, Trash2 as TrashIcon,
@ -18,6 +20,8 @@ interface CreateSetForm {
description: string description: string
} }
type SetMoveDirection = 'up' | 'down'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const mediaSetsStore = useMediaSetsStore() const mediaSetsStore = useMediaSetsStore()
@ -28,9 +32,11 @@ const {
isCreating, isCreating,
isUpdating, isUpdating,
isDeleting, isDeleting,
isReordering,
createError, createError,
updateError, updateError,
deleteError, deleteError,
reorderError,
} = storeToRefs(mediaSetsStore) } = storeToRefs(mediaSetsStore)
const isCreateModalOpen = ref(false) const isCreateModalOpen = ref(false)
const isEditModalOpen = ref(false) const isEditModalOpen = ref(false)
@ -42,6 +48,7 @@ const localCreateError = ref<string | null>(null)
const localEditError = ref<string | null>(null) const localEditError = ref<string | null>(null)
const editingSet = ref<MediaSet | null>(null) const editingSet = ref<MediaSet | null>(null)
const deletingSet = ref<MediaSet | null>(null) const deletingSet = ref<MediaSet | null>(null)
const setOrderStatus = ref<string | null>(null)
const createSetForm = reactive<CreateSetForm>({ const createSetForm = reactive<CreateSetForm>({
name: '', name: '',
@ -231,6 +238,42 @@ async function handleDeleteSet(): Promise<void> {
deletingSet.value = null deletingSet.value = null
} }
async function handleMoveSet(
setId: number,
direction: SetMoveDirection,
): Promise<void> {
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 { function openSet(mediaSet: MediaSet): void {
if (mediaSet.rootElementId === null) { if (mediaSet.rootElementId === null) {
return return
@ -287,6 +330,30 @@ function resetEditSetForm(): void {
</header> </header>
<section class="media-page__sets" aria-label="Featured sets"> <section class="media-page__sets" aria-label="Featured sets">
<p
v-if="setOrderStatus !== null"
:class="[
'media-page__status',
'media-page__status--success',
'media-page__order-message',
]"
data-cy="media-set-order-status"
aria-live="polite"
>
{{ setOrderStatus }}
</p>
<p
v-if="reorderError !== null"
:class="[
'media-page__status',
'media-page__status--error',
'media-page__order-message',
]"
data-cy="media-set-order-error"
role="alert"
>
{{ reorderError }}
</p>
<p v-if="isLoading" class="media-page__status">Loading media sets...</p> <p v-if="isLoading" class="media-page__status">Loading media sets...</p>
<p <p
v-else-if="error" v-else-if="error"
@ -302,7 +369,7 @@ function resetEditSetForm(): void {
No media sets are available yet. No media sets are available yet.
</p> </p>
<div v-else class="media-page__grid" data-cy="media-set-grid"> <div v-else class="media-page__grid" data-cy="media-set-grid">
<template v-for="mediaSet in sets" :key="mediaSet.id"> <template v-for="(mediaSet, setIndex) in sets" :key="mediaSet.id">
<article <article
v-if="authStore.isAuthenticated" v-if="authStore.isAuthenticated"
class="media-page__card media-page__card--managed" class="media-page__card media-page__card--managed"
@ -350,6 +417,34 @@ function resetEditSetForm(): void {
<TrashIcon :size="18" aria-hidden="true" /> <TrashIcon :size="18" aria-hidden="true" />
</button> </button>
</div> </div>
<div
class="media-page__card-order-actions"
aria-label="Set order actions"
@click.stop
>
<button
type="button"
class="media-page__card-action"
data-cy="media-set-move-up"
:disabled="isReordering || setIndex === 0"
:aria-label="`Move ${mediaSet.name} up`"
:title="`Move ${mediaSet.name} up`"
@click="handleMoveSet(mediaSet.id, 'up')"
>
<ChevronUpIcon :size="18" aria-hidden="true" />
</button>
<button
type="button"
class="media-page__card-action"
data-cy="media-set-move-down"
:disabled="isReordering || setIndex === sets.length - 1"
:aria-label="`Move ${mediaSet.name} down`"
:title="`Move ${mediaSet.name} down`"
@click="handleMoveSet(mediaSet.id, 'down')"
>
<ChevronDownIcon :size="18" aria-hidden="true" />
</button>
</div>
</article> </article>
<RouterLink <RouterLink
v-else-if="mediaSet.rootElementId !== null" v-else-if="mediaSet.rootElementId !== null"
@ -697,6 +792,15 @@ function resetEditSetForm(): void {
border-color: #e5b8b8; border-color: #e5b8b8;
} }
.media-page__status--success {
color: var(--color-olive);
border-color: #c8d0ba;
}
.media-page__order-message {
margin-bottom: 1.25rem;
}
.media-page__grid { .media-page__grid {
display: grid; display: grid;
max-width: 1745px; max-width: 1745px;
@ -762,6 +866,14 @@ a.media-page__card:focus-visible {
gap: 0.5rem; gap: 0.5rem;
} }
.media-page__card-order-actions {
position: absolute;
top: 1rem;
left: 1rem;
display: flex;
gap: 0.5rem;
}
.media-page__card-action { .media-page__card-action {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -776,21 +888,26 @@ a.media-page__card:focus-visible {
box-shadow: 0 8px 18px rgb(44 44 44 / 8%); box-shadow: 0 8px 18px rgb(44 44 44 / 8%);
} }
.media-page__card-action:hover, .media-page__card-action:not(:disabled):hover,
.media-page__card-action:focus-visible { .media-page__card-action:not(:disabled):focus-visible {
color: var(--color-olive); color: var(--color-olive);
border-color: #d4ad5f; border-color: #d4ad5f;
outline: 3px solid rgb(212 173 95 / 24%); outline: 3px solid rgb(212 173 95 / 24%);
outline-offset: 2px; outline-offset: 2px;
} }
.media-page__card-action--danger:hover, .media-page__card-action--danger:not(:disabled):hover,
.media-page__card-action--danger:focus-visible { .media-page__card-action--danger:not(:disabled):focus-visible {
color: #9f2d2d; color: #9f2d2d;
border-color: #d9a3a3; border-color: #d9a3a3;
outline-color: rgb(159 45 45 / 18%); outline-color: rgb(159 45 45 / 18%);
} }
.media-page__card-action:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.media-page__card-icon { .media-page__card-icon {
display: block; display: block;
width: 140px; width: 140px;