diff --git a/app/DTO/BulkDeletePhotoItems.php b/app/DTO/BulkDeletePhotoItems.php new file mode 100644 index 0000000..8ffce32 --- /dev/null +++ b/app/DTO/BulkDeletePhotoItems.php @@ -0,0 +1,51 @@ + + */ + public static function rules(): array + { + return [ + 'photo_ids' => [ + 'required', + 'array', + new PhotosBelongToUser, + ], + 'photo_ids.*' => ['required', 'exists:photos,id'], + 'item_ids' => ['array'], + 'item_ids.*' => ['required', 'exists:items,id'], + 'tag_ids' => ['array'], + 'tag_ids.*' => ['required', 'exists:tags,id'], + ]; + } + + /** + * @return string[] + */ + public static function messages(): array + { + return [ + 'photo_ids.required' => 'You must select at least one photo.', + 'photo_ids.*.required' => 'You must select at least one photo.', + 'photo_ids.*.exists' => 'The selected photo #:position does not exist.', + ]; + } +} diff --git a/app/DTO/BulkPhotoItems.php b/app/DTO/BulkPhotoItems.php index cfbec1d..bd1326d 100644 --- a/app/DTO/BulkPhotoItems.php +++ b/app/DTO/BulkPhotoItems.php @@ -2,8 +2,7 @@ namespace App\DTO; -use App\Models\Photo; -use Closure; +use App\Rules\PhotosBelongToUser; use Spatie\LaravelData\Data; class BulkPhotoItems extends Data @@ -26,20 +25,7 @@ public static function rules(): array 'photo_ids' => [ 'required', 'array', - function (string $attribute, mixed $value, Closure $fail): void { - if (! is_array($value)) { - return; - } - - $photosBelongsToOthers = Photo::query() - ->whereIn('id', $value) - ->where('user_id', '!=', auth()->id()) - ->exists(); - - if ($photosBelongsToOthers) { - $fail('You are not the owner of the photos.'); - } - }, + new PhotosBelongToUser, ], 'photo_ids.*' => ['required', 'exists:photos,id'], ]; diff --git a/app/Http/Controllers/Photos/BulkPhotoItemsController.php b/app/Http/Controllers/Photos/BulkPhotoItemsController.php index 7c3c5b0..d1f4bfb 100644 --- a/app/Http/Controllers/Photos/BulkPhotoItemsController.php +++ b/app/Http/Controllers/Photos/BulkPhotoItemsController.php @@ -2,10 +2,12 @@ namespace App\Http\Controllers\Photos; +use App\DTO\BulkDeletePhotoItems; use App\DTO\BulkPhotoItems; use App\Http\Controllers\Controller; use App\Models\Item; use App\Models\PhotoItem; +use App\Models\PhotoItemTag; use Illuminate\Support\Facades\DB; class BulkPhotoItemsController extends Controller @@ -35,4 +37,23 @@ public function store(BulkPhotoItems $bulkPhotoItems): void } }); } + + public function destroy(BulkDeletePhotoItems $bulkDeletePhotoItems): void + { + DB::transaction(function () use ($bulkDeletePhotoItems): void { + PhotoItem::query() + ->whereIn('photo_id', $bulkDeletePhotoItems->photo_ids) + ->whereIn('item_id', $bulkDeletePhotoItems->item_ids) + ->delete(); + + $photoItems = PhotoItem::query() + ->whereIn('photo_id', $bulkDeletePhotoItems->photo_ids) + ->pluck('id'); + + PhotoItemTag::query() + ->whereIn('photo_item_id', $photoItems) + ->whereIn('tag_id', $bulkDeletePhotoItems->tag_ids) + ->delete(); + }); + } } diff --git a/app/Rules/PhotosBelongToUser.php b/app/Rules/PhotosBelongToUser.php new file mode 100644 index 0000000..5c27321 --- /dev/null +++ b/app/Rules/PhotosBelongToUser.php @@ -0,0 +1,32 @@ +whereIn('id', $value) + ->where('user_id', '!=', auth()->id()) + ->exists(); + + if ($photosBelongsToOthers) { + $fail('You are not the owner of the photos.'); + } + } +} diff --git a/resources/js/Pages/Photos/Index.vue b/resources/js/Pages/Photos/Index.vue index f8c65ff..4d170d2 100644 --- a/resources/js/Pages/Photos/Index.vue +++ b/resources/js/Pages/Photos/Index.vue @@ -15,6 +15,7 @@ import Dropdown from "@/Components/Dropdown.vue"; import ToggleInput from "@/Components/ToggleInput.vue"; import InputLabel from "@/Components/InputLabel.vue"; import DropdownLink from "@/Components/DropdownLink.vue"; +import BulkRemoveItemsAndTags from "@/Pages/Photos/Partials/BulkRemoveItemsAndTags.vue"; const props = defineProps({ photos: Object, @@ -222,6 +223,14 @@ const exportData = (format) => { :tagShortcutsEnabled="tagShortcutsEnabled" @closeModalWithSuccess="clearSelection" > + +
diff --git a/resources/js/Pages/Photos/Partials/BulkRemoveItemsAndTags.vue b/resources/js/Pages/Photos/Partials/BulkRemoveItemsAndTags.vue new file mode 100644 index 0000000..d313bc8 --- /dev/null +++ b/resources/js/Pages/Photos/Partials/BulkRemoveItemsAndTags.vue @@ -0,0 +1,285 @@ + + + + + diff --git a/resources/js/Pages/Photos/Partials/TagSelector.vue b/resources/js/Pages/Photos/Partials/TagSelector.vue new file mode 100644 index 0000000..f2bb5f7 --- /dev/null +++ b/resources/js/Pages/Photos/Partials/TagSelector.vue @@ -0,0 +1,181 @@ + + + + + diff --git a/routes/web.php b/routes/web.php index 3da23b7..014933f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -50,12 +50,14 @@ Route::get('/upload', [UploadPhotosController::class, 'show'])->name('upload'); Route::post('/upload', [UploadPhotosController::class, 'store']); + Route::post('/photos/items', [BulkPhotoItemsController::class, 'store'])->name('bulk-photo-items.store'); + Route::delete('/photos/items', [BulkPhotoItemsController::class, 'destroy'])->name('bulk-photo-items.destroy'); + Route::get('/my-photos', [PhotosController::class, 'index'])->name('my-photos'); Route::get('/photos/export', ExportPhotosController::class)->name('photos.export'); Route::get('/photos/{photo}', [PhotosController::class, 'show'])->name('photos.show'); Route::delete('/photos/{photo}', [PhotosController::class, 'destroy'])->name('photos.destroy'); - Route::post('/photos/items', [BulkPhotoItemsController::class, 'store'])->name('bulk-photo-items.store'); Route::post('/photos/{photo}/tag-shortcuts/{tagShortcut}', ApplyTagShortcutController::class); Route::post('/photos/{photo}/items', [PhotoItemsController::class, 'store']); Route::post('/photo-items/{photoItem}', [PhotoItemsController::class, 'update']); diff --git a/tests/Feature/Tagging/RemoveItemAndTagFromPhotosInBulkTest.php b/tests/Feature/Tagging/RemoveItemAndTagFromPhotosInBulkTest.php new file mode 100644 index 0000000..289bb1b --- /dev/null +++ b/tests/Feature/Tagging/RemoveItemAndTagFromPhotosInBulkTest.php @@ -0,0 +1,56 @@ +create(); + + $photoA = Photo::factory()->create(['user_id' => $user->id]); + $itemA = Item::factory()->create(); + $photoItemA = PhotoItem::factory()->for($itemA)->for($photoA)->create(); + $tagA = Tag::factory()->create(); + PhotoItemTag::factory()->for($photoItemA)->for($tagA)->create(); + + $photoB = Photo::factory()->create(['user_id' => $user->id]); + $itemB = Item::factory()->create(); + $photoItemB = PhotoItem::factory()->for($itemB)->for($photoB)->create(); + $tagB = Tag::factory()->create(); + PhotoItemTag::factory()->for($photoItemB)->for($tagB)->create(); + + $this->assertDatabaseCount('photo_items', 2); + $this->assertDatabaseCount('photo_item_tag', 2); + + $response = $this->actingAs($user)->deleteJson('/photos/items', [ + 'photo_ids' => [$photoA->id, $photoB->id], + 'item_ids' => [$itemA->id], + 'tag_ids' => [$tagB->id], + ]); + + $response->assertOk(); + $this->assertDatabaseCount('photo_items', 1); + $this->assertDatabaseHas('photo_items', ['id' => $photoItemB->id]); + $this->assertDatabaseEmpty('photo_item_tag'); +}); + +test('a user can not remove an item from photos of another user', function (): void { + $user = User::factory()->create(); + $photo = Photo::factory()->create(); + $existingItem = Item::factory()->create(); + $photo->items()->attach($existingItem); + + $this->assertDatabaseCount('photo_items', 1); + + $response = $this->actingAs($user)->deleteJson('/photos/items', [ + 'photo_ids' => [$photo->id], + 'item_ids' => [$existingItem->id], + ]); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('photo_ids'); + $this->assertDatabaseCount('photo_items', 1); +});