feat: ajout de la fonctionnalité de suppression de mangas, incluant une modale de confirmation pour l'utilisateur, la gestion des erreurs et l'intégration avec l'API pour supprimer les mangas et leurs chapitres associés. Mise à jour des composants Vue et ajout de tests pour valider cette nouvelle fonctionnalité.

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-07-23 16:42:54 +02:00
parent 7f9d583c94
commit f09f744a9b
12 changed files with 470 additions and 13 deletions

View File

@@ -377,4 +377,24 @@ export class ApiMangaRepository {
throw error;
}
}
async deleteManga(mangaId) {
try {
const response = await fetch(`/api/mangas/${mangaId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
}
});
if (!response.ok) {
throw new Error('Failed to delete manga');
}
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,128 @@
<template>
<TransitionRoot as="template" :show="isOpen">
<Dialog as="div" class="relative z-50" @close="closeModal">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0"
enter-to="opacity-100"
leave="ease-in duration-200"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</TransitionChild>
<div class="fixed inset-0 z-10 overflow-y-auto">
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<TransitionChild
as="template"
enter="ease-out duration-300"
enter-from="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enter-to="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leave-from="opacity-100 translate-y-0 sm:scale-100"
leave-to="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<DialogPanel class="relative transform overflow-hidden rounded-lg bg-white px-6 pb-6 pt-6 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
<div class="mb-6">
<DialogTitle as="h3" class="text-lg font-semibold leading-6 text-gray-900">
Supprimer le manga
</DialogTitle>
</div>
<!-- Error state -->
<div v-if="error" class="mb-6 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{{ error.message || 'Une erreur est survenue lors de la suppression.' }}
</div>
<!-- Warning message -->
<div class="mb-6">
<div class="flex items-center mb-4">
<ExclamationTriangleIcon class="h-6 w-6 text-red-500 mr-3" />
<span class="text-sm font-medium text-gray-900">Action irréversible</span>
</div>
<p class="text-sm text-gray-600 mb-4">
Êtes-vous sûr de vouloir supprimer le manga <strong>"{{ manga?.title }}"</strong> ?
</p>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex">
<ExclamationTriangleIcon class="h-5 w-5 text-yellow-400" />
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
Attention
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>Cette action supprimera définitivement :</p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>Le manga et toutes ses métadonnées</li>
<li>Tous les chapitres associés</li>
<li>Tous les fichiers CBZ téléchargés</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Action buttons -->
<div class="mt-6 flex justify-end space-x-3">
<button
type="button"
class="inline-flex justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
@click="closeModal"
:disabled="isLoading"
>
Annuler
</button>
<button
type="button"
class="inline-flex justify-center rounded-md border border-transparent bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click="confirmDelete"
:disabled="isLoading"
>
<ArrowPathIcon v-if="isLoading" class="h-4 w-4 mr-2 animate-spin" />
{{ isLoading ? 'Suppression...' : 'Supprimer définitivement' }}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</TransitionRoot>
</template>
<script setup>
import { Dialog, DialogPanel, DialogTitle, TransitionChild, TransitionRoot } from '@headlessui/vue';
import { ArrowPathIcon, ExclamationTriangleIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
isOpen: {
type: Boolean,
required: true
},
manga: {
type: Object,
default: null
},
isLoading: {
type: Boolean,
default: false
},
error: {
type: Object,
default: null
}
});
const emit = defineEmits(['close', 'confirm']);
const closeModal = () => {
emit('close');
};
const confirmDelete = () => {
emit('confirm');
};
</script>

View File

@@ -0,0 +1,49 @@
import { useMutation, useQueryClient } from '@tanstack/vue-query';
import { ref } from 'vue';
import { ApiMangaRepository } from '../../infrastructure/api/apiMangaRepository';
export function useMangaDelete() {
const mangaRepository = new ApiMangaRepository();
const queryClient = useQueryClient();
const isDeleteModalOpen = ref(false);
const deleteMutation = useMutation({
mutationFn: ({ mangaId }) => {
return mangaRepository.deleteManga(mangaId);
},
onSuccess: (data, variables) => {
// Invalider et refetch les listes de mangas
queryClient.invalidateQueries({ queryKey: ['mangas'] });
queryClient.invalidateQueries({ queryKey: ['manga-search'] });
}
});
const openDeleteModal = () => {
isDeleteModalOpen.value = true;
};
const closeDeleteModal = () => {
isDeleteModalOpen.value = false;
};
const deleteManga = async (mangaId) => {
try {
await deleteMutation.mutateAsync({ mangaId });
closeDeleteModal();
return true;
} catch (error) {
console.error('Erreur lors de la suppression du manga:', error);
throw error;
}
};
return {
isDeleteModalOpen,
openDeleteModal,
closeDeleteModal,
deleteManga,
isLoading: deleteMutation.isPending,
error: deleteMutation.error,
isSuccess: deleteMutation.isSuccess
};
}

View File

@@ -68,6 +68,16 @@
@close="closeManageChaptersModal"
@save="saveChaptersChanges"
/>
<!-- Modale de suppression du manga -->
<MangaDeleteModal
:is-open="isDeleteModalOpen"
:manga="currentManga"
:is-loading="isDeleting"
:error="deleteError"
@close="closeDeleteModal"
@confirm="confirmDeleteManga"
/>
</div>
<div v-else-if="isLoadingDetails" class="flex justify-center items-center h-64">
@@ -93,9 +103,10 @@
WrenchIcon
} from '@heroicons/vue/24/outline';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { useMangaDetails } from '../composables/useMangaDetails';
import { useMangaDelete } from '../composables/useMangaDelete';
import { useMangaDetails } from '../composables/useMangaDetails';
import { useMangaEdit } from '../composables/useMangaEdit';
import { useMangaMonitoring } from '../composables/useMangaMonitoring';
import { useMangaPreferredSources } from '../composables/useMangaPreferredSources';
@@ -103,6 +114,7 @@ import { useMangaRefresh } from '../composables/useMangaRefresh';
import { useMangaVolumes } from '../composables/useMangaVolumes';
import ManageChaptersModal from '../components/ManageChaptersModal.vue';
import MangaDeleteModal from '../components/MangaDeleteModal.vue';
import MangaEditModal from '../components/MangaEditModal.vue';
import MangaHeader from '../components/MangaHeader.vue';
import MangaPreferredSourcesModal from '../components/MangaPreferredSourcesModal.vue';
@@ -114,6 +126,7 @@ import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { useMangaStore } from '../../application/store/mangaStore';
const route = useRoute();
const router = useRouter();
const mangaStore = useMangaStore();
const mangaId = computed(() => route.params.id || null);
@@ -174,6 +187,16 @@ import { useMangaStore } from '../../application/store/mangaStore';
toggleError: monitoringError
} = useMangaMonitoring();
// Composable pour la suppression
const {
isDeleteModalOpen,
openDeleteModal,
closeDeleteModal,
deleteManga,
isLoading: isDeleting,
error: deleteError
} = useMangaDelete();
// Charger les chapitres dans le store quand le manga est chargé
watch(
mangaId,
@@ -202,6 +225,18 @@ import { useMangaStore } from '../../application/store/mangaStore';
chaptersError.value = null;
};
const confirmDeleteManga = async () => {
if (!mangaId.value) return;
try {
await deleteManga(mangaId.value);
// Rediriger vers la liste des mangas après suppression
router.push('/manga');
} catch (error) {
console.error('Erreur lors de la suppression du manga:', error);
}
};
const savePreferredSources = async (sourceIds) => {
try {
await saveSourcesOrder(sourceIds);
@@ -334,7 +369,7 @@ import { useMangaStore } from '../../application/store/mangaStore';
icon: TrashIcon,
label: 'Delete',
type: 'button',
onClick: () => console.log('Delete')
onClick: openDeleteModal
},
{
icon: isAllExpanded.value ? ChevronDoubleUpIcon : ChevronDoubleDownIcon,

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Domain\Manga\Application\Command;
use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteManga implements CommandInterface
{
public function __construct(
public string $mangaId
) {}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domain\Manga\Application\CommandHandler;
use App\Domain\Manga\Application\Command\DeleteManga;
use App\Domain\Manga\Domain\Contract\Repository\MangaRepositoryInterface;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use App\Domain\Shared\Domain\Contract\CommandHandlerInterface;
use App\Domain\Shared\Domain\Contract\CommandInterface;
readonly class DeleteMangaHandler implements CommandHandlerInterface
{
public function __construct(
private MangaRepositoryInterface $mangaRepository
) {}
public function handle(CommandInterface $command): void
{
assert($command instanceof DeleteManga);
$manga = $this->mangaRepository->findById($command->mangaId);
if (!$manga) {
throw new MangaNotFoundException(sprintf('Manga with id %s not found', $command->mangaId));
}
$this->mangaRepository->delete($manga);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor\DeleteMangaProcessor;
#[ApiResource(
shortName: 'Manga',
operations: [
new Delete(
uriTemplate: '/mangas/{id}',
processor: DeleteMangaProcessor::class,
name: 'delete_manga',
read: false,
openapiContext: [
'summary' => 'Delete a manga',
'description' => 'Permanently deletes a manga and all its associated chapters',
'parameters' => [
[
'name' => 'id',
'in' => 'path',
'required' => true,
'schema' => [
'type' => 'string'
],
'description' => 'The manga ID'
]
],
'responses' => [
'204' => [
'description' => 'Manga successfully deleted'
],
'404' => [
'description' => 'Manga not found'
]
]
]
)
]
)]
class DeleteMangaResource
{
public function __construct(
public string $id
) {}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domain\Manga\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Domain\Manga\Application\Command\DeleteManga;
use App\Domain\Manga\Application\CommandHandler\DeleteMangaHandler;
use App\Domain\Manga\Domain\Exception\MangaNotFoundException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
readonly class DeleteMangaProcessor implements ProcessorInterface
{
public function __construct(
private DeleteMangaHandler $handler
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): int
{
if (!isset($uriVariables['id'])) {
throw new \InvalidArgumentException('Manga ID is required');
}
try {
$command = new DeleteManga($uriVariables['id']);
$this->handler->handle($command);
return Response::HTTP_NO_CONTENT;
} catch (MangaNotFoundException $e) {
throw new NotFoundHttpException('Manga not found');
}
}
}

View File

@@ -15,6 +15,7 @@ class Manga
private readonly string $description,
private readonly string $author,
private readonly string $publicationYear,
private readonly bool $monitored = false,
private readonly array $preferredSources = [],
private readonly array $alternativeSlugs = [],
) {
@@ -73,4 +74,9 @@ class Manga
{
return $this->alternativeSlugs;
}
public function isMonitored(): bool
{
return $this->monitored;
}
}

View File

@@ -5,17 +5,26 @@ namespace App\Domain\Scraping\Infrastructure\EventListener;
use App\Domain\Manga\Domain\Event\ChapterReadyForScraping;
use App\Domain\Scraping\Application\Command\ScrapeChapter;
use App\Domain\Scraping\Application\CommandHandler\ScrapeChapterHandler;
use App\Domain\Scraping\Domain\Contract\Repository\ChapterRepositoryInterface;
use App\Domain\Scraping\Domain\Contract\Repository\MangaRepositoryInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
class AutoScrapingListener
{
public function __construct(
private readonly ScrapeChapterHandler $scrapeChapterHandler
private readonly ScrapeChapterHandler $scrapeChapterHandler,
private readonly ChapterRepositoryInterface $chapterRepository,
private readonly MangaRepositoryInterface $mangaRepository,
) {}
#[AsMessageHandler]
public function onChapterReadyForScraping(ChapterReadyForScraping $event): void
{
$chapter = $this->chapterRepository->getById($event->chapterId->getValue());
$manga = $this->mangaRepository->getById($chapter->mangaId);
if ($manga->isMonitored()) {
$this->scrapeChapterHandler->handle(new ScrapeChapter($event->chapterId->getValue()));
}
}
}

View File

@@ -35,14 +35,15 @@ readonly class LegacyMangaRepository implements MangaRepositoryInterface
}
return new Manga(
(string) $mangaEntity->getId(),
$mangaEntity->getTitle(),
$mangaEntity->getSlug(),
$mangaEntity->getDescription() ?? '',
$mangaEntity->getAuthor() ?? '',
(string) ($mangaEntity->getPublicationYear() ?? ''),
$preferredSourceIds,
$mangaEntity->getAlternativeSlugs() ?? []
id: (string) $mangaEntity->getId(),
title: $mangaEntity->getTitle(),
slug: $mangaEntity->getSlug(),
description: $mangaEntity->getDescription() ?? '',
author: $mangaEntity->getAuthor() ?? '',
publicationYear: (string) ($mangaEntity->getPublicationYear() ?? ''),
monitored: $mangaEntity->isMonitored() ?? false,
preferredSources: $preferredSourceIds,
alternativeSlugs: $mangaEntity->getAlternativeSlugs() ?? []
);
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Tests\Feature\Manga;
use App\Entity\Manga;
use App\Entity\Chapter;
use App\Factory\MangaFactory;
use App\Factory\ChapterFactory;
use App\Tests\Feature\AbstractApiTestCase;
use Symfony\Component\HttpFoundation\Response;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
class DeleteMangaTest extends AbstractApiTestCase
{
use ResetDatabase, Factories;
public function test_it_deletes_manga_with_chapters(): void
{
// Arrange
$manga = MangaFactory::createOne([
'title' => 'One Piece',
'slug' => 'one-piece'
]);
// Create chapters for the manga
ChapterFactory::createMany(3, [
'manga' => $manga,
'number' => 1.0,
'title' => 'Chapter 1',
'visible' => true
]);
ChapterFactory::createMany(2, [
'manga' => $manga,
'number' => 2.0,
'title' => 'Chapter 2',
'visible' => true
]);
ChapterFactory::createMany(1, [
'manga' => $manga,
'number' => 3.0,
'title' => 'Chapter 3',
'visible' => true
]);
$mangaId = $manga->getId();
// Verify chapters exist before deletion
$chaptersBefore = $this->entityManager->getRepository(Chapter::class)->findBy(['manga' => $mangaId]);
$this->assertCount(6, $chaptersBefore);
// Act
static::createClient()->request('DELETE', "/api/mangas/{$mangaId}");
// Then
$this->assertResponseStatusCodeSame(204);
// Verify the manga was deleted
$freshManga = $this->entityManager->find(Manga::class, $mangaId);
$this->assertNull($freshManga);
// Verify all chapters were also deleted (cascade)
$chaptersAfter = $this->entityManager->getRepository(Chapter::class)->findBy(['manga' => $mangaId]);
$this->assertCount(0, $chaptersAfter);
}
public function test_it_returns_404_for_non_existent_manga(): void
{
// When
static::createClient()->request('DELETE', '/api/mangas/999999');
// Then
$this->assertResponseStatusCodeSame(404);
}
public function test_it_returns_404_for_missing_id(): void
{
// When
static::createClient()->request('DELETE', '/api/mangas/');
// Then
$this->assertResponseStatusCodeSame(404);
}
}