Compare commits
9 commits
3e02a83dd4
...
1cf3de423a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1cf3de423a | |||
| e5dba25678 | |||
| 20d2cd7090 | |||
| 543ee6628d | |||
| faed935436 | |||
| dc034bf393 | |||
| fde12ad561 | |||
| 7579c3c1b3 | |||
| 99ceeff5b8 |
16 changed files with 904 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
{
|
||||
if (! $request->exists($key)) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<static>|SetModel newModelQuery()
|
||||
* @method static Builder<static>|SetModel newQuery()
|
||||
* @method static Builder<static>|SetModel query()
|
||||
* @method static Builder<static>|SetModel whereId($value)
|
||||
* @method static Builder<static>|SetModel whereName($value)
|
||||
* @method static Builder<static>|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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,10 @@ interface SetRepository
|
|||
* @return Set[]
|
||||
*/
|
||||
public function getAll(): array;
|
||||
|
||||
/**
|
||||
* @param int[] $setIds
|
||||
* @return Set[]
|
||||
*/
|
||||
public function reorder(array $setIds): array;
|
||||
}
|
||||
|
|
|
|||
128
backend/app/Set/UseCases/ReorderSets/ReorderSets.php
Normal file
128
backend/app/Set/UseCases/ReorderSets/ReorderSets.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php
Normal file
13
backend/app/Set/UseCases/ReorderSets/ReorderSetsRequest.php
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace App\Set\UseCases\ReorderSets;
|
||||
|
||||
class ReorderSetsRequest
|
||||
{
|
||||
/**
|
||||
* @param mixed[]|null $setIds
|
||||
*/
|
||||
public function __construct(public ?array $setIds)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ class FakeSetRepository implements SetRepository
|
|||
*/
|
||||
private array $setsById = [];
|
||||
|
||||
/**
|
||||
* @var array<int, int>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
86
backend/tests/Feature/SetOrderingMigrationTest.php
Normal file
86
backend/tests/Feature/SetOrderingMigrationTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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', [
|
||||
|
|
|
|||
135
backend/tests/Unit/Set/UseCases/ReorderSetsTest.php
Normal file
135
backend/tests/Unit/Set/UseCases/ReorderSetsTest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
||||
const updateError = ref<string | null>(null)
|
||||
const deleteError = ref<string | null>(null)
|
||||
const reorderError = ref<string | null>(null)
|
||||
|
||||
async function fetchSets(): Promise<void> {
|
||||
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(
|
||||
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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null)
|
|||
const localEditError = ref<string | null>(null)
|
||||
const editingSet = ref<MediaSet | null>(null)
|
||||
const deletingSet = ref<MediaSet | null>(null)
|
||||
const setOrderStatus = ref<string | null>(null)
|
||||
|
||||
const createSetForm = reactive<CreateSetForm>({
|
||||
name: '',
|
||||
|
|
@ -231,6 +238,42 @@ async function handleDeleteSet(): Promise<void> {
|
|||
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 {
|
||||
if (mediaSet.rootElementId === null) {
|
||||
return
|
||||
|
|
@ -287,6 +330,30 @@ function resetEditSetForm(): void {
|
|||
</header>
|
||||
|
||||
<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-else-if="error"
|
||||
|
|
@ -302,7 +369,7 @@ function resetEditSetForm(): void {
|
|||
No media sets are available yet.
|
||||
</p>
|
||||
<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
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="media-page__card media-page__card--managed"
|
||||
|
|
@ -350,6 +417,34 @@ function resetEditSetForm(): void {
|
|||
<TrashIcon :size="18" aria-hidden="true" />
|
||||
</button>
|
||||
</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>
|
||||
<RouterLink
|
||||
v-else-if="mediaSet.rootElementId !== null"
|
||||
|
|
@ -697,6 +792,15 @@ function resetEditSetForm(): void {
|
|||
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 {
|
||||
display: grid;
|
||||
max-width: 1745px;
|
||||
|
|
@ -762,6 +866,14 @@ a.media-page__card:focus-visible {
|
|||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.media-page__card-order-actions {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.media-page__card-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -776,21 +888,26 @@ a.media-page__card:focus-visible {
|
|||
box-shadow: 0 8px 18px rgb(44 44 44 / 8%);
|
||||
}
|
||||
|
||||
.media-page__card-action:hover,
|
||||
.media-page__card-action:focus-visible {
|
||||
.media-page__card-action:not(:disabled):hover,
|
||||
.media-page__card-action:not(:disabled):focus-visible {
|
||||
color: var(--color-olive);
|
||||
border-color: #d4ad5f;
|
||||
outline: 3px solid rgb(212 173 95 / 24%);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.media-page__card-action--danger:hover,
|
||||
.media-page__card-action--danger:focus-visible {
|
||||
.media-page__card-action--danger:not(:disabled):hover,
|
||||
.media-page__card-action--danger:not(:disabled):focus-visible {
|
||||
color: #9f2d2d;
|
||||
border-color: #d9a3a3;
|
||||
outline-color: rgb(159 45 45 / 18%);
|
||||
}
|
||||
|
||||
.media-page__card-action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.media-page__card-icon {
|
||||
display: block;
|
||||
width: 140px;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue