feat: ajout de la gestion des jobs avec création, récupération, suppression et filtrage via l'API, incluant des entités, des composants Vue.js et des mises à jour de la documentation API

This commit is contained in:
ext.jeremy.guillot@maxicoffee.domains
2025-03-30 16:14:17 +02:00
parent 4d1d5b9f21
commit fd2d3cd640
21 changed files with 1538 additions and 184 deletions

View File

@@ -1,6 +1,6 @@
---
description:
globs: *.vue,*.js,assets/vue/app/*
globs: *.vue,*.js,assets/vue/app/*,assets/vue/app/**/*
alwaysApply: false
---
# Architecture Frontend Vue.js

View File

@@ -0,0 +1,124 @@
import { defineStore } from 'pinia';
import { ApiJobRepository } from '../../infrastructure/api/ApiJobRepository';
const jobRepository = new ApiJobRepository();
export const useActivityStore = defineStore('activity', {
state: () => ({
jobs: [],
loading: false,
error: null,
filter: {
status: ['pending', 'in_progress', 'failed'], // Par défaut, afficher les jobs actifs
sortBy: 'createdAt',
sortOrder: 'DESC'
}
}),
getters: {
activeJobs: state => state.jobs.filter(job => job.isActive()),
completedJobs: state => state.jobs.filter(job => job.isCompleted()),
failedJobs: state => state.jobs.filter(job => job.hasError()),
isLoading: state => state.loading,
hasError: state => !!state.error
},
actions: {
/**
* Charge la liste des jobs selon les filtres actuels
*/
async loadJobs() {
this.loading = true;
this.error = null;
try {
const options = {
page: 1, // Page fixée à 1
limit: 100, // Limite augmentée pour récupérer plus de jobs
sortBy: this.filter.sortBy,
sortOrder: this.filter.sortOrder,
status: this.filter.status
};
const jobCollection = await jobRepository.getJobs(options);
this.jobs = jobCollection.items;
} catch (error) {
this.error = error.message;
console.error('Error loading jobs:', error);
} finally {
this.loading = false;
}
},
/**
* Met à jour les filtres et recharge la liste
* @param {Object} filter
*/
async updateFilter(filter) {
this.filter = { ...this.filter, ...filter };
await this.loadJobs();
},
/**
* Supprime un job par son ID
* @param {string} id
*/
async deleteJob(id) {
this.loading = true;
this.error = null;
try {
await jobRepository.deleteJob(id);
// Supprimer le job de la liste locale
this.jobs = this.jobs.filter(job => job.id !== id);
} catch (error) {
this.error = error.message;
console.error('Error deleting job:', error);
} finally {
this.loading = false;
}
},
/**
* Supprime tous les jobs correspondant aux critères
* @param {Object} criteria
*/
async deleteJobs(criteria = {}) {
this.loading = true;
this.error = null;
try {
const deleted = await jobRepository.deleteJobs(criteria);
// Recharger la liste après suppression
await this.loadJobs();
return deleted;
} catch (error) {
this.error = error.message;
console.error('Error deleting jobs:', error);
} finally {
this.loading = false;
}
},
/**
* Supprime tous les jobs terminés
*/
async deleteCompletedJobs() {
return this.deleteJobs({ status: ['COMPLETED'] });
},
/**
* Supprime tous les jobs en erreur
*/
async deleteFailedJobs() {
return this.deleteJobs({ status: ['ERROR'] });
},
/**
* Supprime tous les jobs
*/
async deleteAllJobs() {
return this.deleteJobs({});
}
}
});

View File

@@ -0,0 +1,50 @@
export class Job {
constructor({
id,
type,
status,
progress = 0,
payload = {},
result = null,
error = null,
createdAt = new Date().toISOString(),
updatedAt = new Date().toISOString()
}) {
this.id = id;
this.type = type;
this.status = status;
this.progress = progress;
this.payload = payload;
this.result = result;
this.error = error;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
}
static create(data) {
return new Job(data);
}
isActive() {
return ['PENDING', 'IN_PROGRESS'].includes(this.status);
}
hasError() {
return this.status === 'ERROR';
}
isCompleted() {
return this.status === 'COMPLETED';
}
}
export class JobCollection {
constructor(items, total, page, limit, hasNextPage, hasPreviousPage) {
this.items = items.map(item => Job.create(item));
this.total = total;
this.page = page;
this.limit = limit;
this.hasNextPage = hasNextPage;
this.hasPreviousPage = hasPreviousPage;
}
}

View File

@@ -0,0 +1,42 @@
export class JobRepositoryInterface {
/**
* Récupère la liste des jobs
* @param {Object} options Les options de filtrage et pagination
* @param {number} options.page Numéro de la page
* @param {number} options.limit Nombre d'éléments par page
* @param {string} options.sortBy Champ pour le tri
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
* @param {Array<string>} options.status Liste des statuts à filtrer
* @returns {Promise<JobCollection>} Collection de jobs
*/
async getJobs(options) {
throw new Error('Not implemented');
}
/**
* Récupère un job par son ID
* @param {string} id Identifiant du job
* @returns {Promise<Job>} Job
*/
async getJobById(id) {
throw new Error('Not implemented');
}
/**
* Supprime un job
* @param {string} id Identifiant du job
* @returns {Promise<boolean>} Succès de l'opération
*/
async deleteJob(id) {
throw new Error('Not implemented');
}
/**
* Supprime tous les jobs correspondant aux critères
* @param {Object} criteria Critères de suppression
* @returns {Promise<number>} Nombre de jobs supprimés
*/
async deleteJobs(criteria) {
throw new Error('Not implemented');
}
}

View File

@@ -0,0 +1,124 @@
import { Job, JobCollection } from '../../domain/entities/job';
import { JobRepositoryInterface } from '../../domain/repository/JobRepositoryInterface';
export class ApiJobRepository extends JobRepositoryInterface {
/**
* Récupère la liste des jobs
* @param {Object} options Les options de filtrage et pagination
* @param {number} options.page Numéro de la page
* @param {number} options.limit Nombre d'éléments par page
* @param {string} options.sortBy Champ pour le tri
* @param {string} options.sortOrder Direction du tri ('ASC' ou 'DESC')
* @param {Array<string>} options.status Liste des statuts à filtrer
* @returns {Promise<JobCollection>} Collection de jobs
*/
async getJobs(options = {}) {
const { page = 1, limit = 100, sortBy = 'createdAt', sortOrder = 'DESC', status = [] } = options;
try {
let url = `/api/jobs?page=${page}&limit=${limit}&sortBy=${sortBy}&sortOrder=${sortOrder}`;
// Ajouter les filtres de statut s'ils sont fournis
if (status && status.length > 0) {
url += `&status=${status.join(',')}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch jobs');
}
const data = await response.json();
return new JobCollection(
data.items || [],
data.total || 0,
data.page || 1,
data.limit || limit,
!!data.hasNextPage,
!!data.hasPreviousPage
);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Récupère un job par son ID
* @param {string} id Identifiant du job
* @returns {Promise<Job>} Job
*/
async getJobById(id) {
try {
const response = await fetch(`/api/jobs/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch job');
}
const data = await response.json();
return Job.create(data);
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Supprime un job
* @param {string} id Identifiant du job
* @returns {Promise<boolean>} Succès de l'opération
*/
async deleteJob(id) {
try {
const response = await fetch(`/api/jobs/${id}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete job');
}
return true;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* Supprime tous les jobs correspondant aux critères
* @param {Object} criteria Critères de suppression
* @returns {Promise<number>} Nombre de jobs supprimés
*/
async deleteJobs(criteria = {}) {
try {
const queryParams = new URLSearchParams();
// Ajouter les critères à l'URL
Object.entries(criteria).forEach(([key, value]) => {
if (Array.isArray(value)) {
queryParams.append(key, value.join(','));
} else {
queryParams.append(key, value);
}
});
const response = await fetch(`/api/jobs?${queryParams.toString()}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Failed to delete jobs');
}
const data = await response.json();
return data.deleted || 0;
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
}

View File

@@ -0,0 +1,102 @@
<template>
<tr
class="border-b border-gray-200 hover:bg-gray-50 transition duration-150 ease-in-out"
:class="{
'bg-yellow-50': job.status === 'PENDING',
'bg-blue-50': job.status === 'IN_PROGRESS',
'bg-green-50': job.status === 'COMPLETED',
'bg-red-50': job.status === 'ERROR'
}">
<td class="py-4 px-4 text-center">
<input type="checkbox" class="form-checkbox h-5 w-5 text-green-600" />
</td>
<td class="py-4 px-4 font-medium">{{ job.type }}</td>
<td class="py-4 px-4">
<span
class="px-2 py-1 text-xs rounded-full"
:class="{
'bg-yellow-100 text-yellow-800': job.status === 'pending',
'bg-blue-100 text-blue-800': job.status === 'in_progress',
'bg-green-100 text-green-800': job.status === 'completed',
'bg-red-100 text-red-800': job.status === 'failed'
}">
{{ job.status }}
</span>
</td>
<td class="py-4 px-4">
<div v-if="job.error" class="text-sm text-red-600">
{{ job.error }}
</div>
<div v-else class="text-sm text-gray-600">
{{ formatDate(job.createdAt) }}
</div>
</td>
<td class="py-4 px-4">
<div v-if="job.status === 'IN_PROGRESS'" class="mt-2">
<div class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
:style="{ width: `${job.progress}%` }"></div>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
{{ job.progress }}%
</div>
</div>
</div>
<div v-else-if="job.status === 'COMPLETED'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-green-400 transition-all duration-300 ease-out"
style="width: 100%"></div>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
100%
</div>
</div>
<div v-else-if="job.status === 'ERROR'" class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-red-400 transition-all duration-300 ease-out"
style="width: 100%"></div>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-white">
Erreur
</div>
</div>
<div v-else class="relative bg-gray-200 rounded-full h-6 overflow-hidden">
<div
class="absolute top-0 left-0 h-full bg-yellow-400 transition-all duration-300 ease-out"
style="width: 0%"></div>
<div class="absolute inset-0 flex items-center justify-center text-xs font-semibold text-gray-600">
En attente
</div>
</div>
</td>
<td class="py-4 px-4">
<button
@click="onDelete"
class="text-red-500 hover:text-red-700 transition duration-150 ease-in-out"
title="Supprimer">
<TrashIcon class="h-5 w-5" />
</button>
</td>
</tr>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { TrashIcon } from '@heroicons/vue/24/outline';
const props = defineProps({
job: {
type: Object,
required: true
}
});
const emit = defineEmits(['delete']);
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString();
}
function onDelete() {
emit('delete', props.job.id);
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<Toolbar :config="toolbarConfig" class="mb-6" />
<div v-if="activityStore.loading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-10 w-10 border-t-2 border-b-2 border-indigo-500"></div>
</div>
<div v-else-if="activityStore.error" class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6">
<p>{{ activityStore.error }}</p>
</div>
<div v-else class="container mx-auto p-2">
<div class="bg-white overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full bg-white">
<thead>
<tr class="bg-gray-200 text-gray-800">
<th class="w-1/12 py-3 px-4 text-left">
<input
type="checkbox"
class="form-checkbox h-5 w-5 text-green-600"
@change="toggleSelectAll" />
</th>
<th class="w-2/12 py-3 px-4 text-left">Type</th>
<th class="w-2/12 py-3 px-4 text-left">Statut</th>
<th class="w-3/12 py-3 px-4 text-left">Informations</th>
<th class="w-3/12 py-3 px-4 text-left">Progression</th>
<th class="w-1/12 py-3 px-4 text-left">Actions</th>
</tr>
</thead>
<tbody class="text-gray-700">
<template v-if="activityStore.jobs.length === 0">
<tr>
<td colspan="6" class="py-4 px-4 text-center text-gray-500"
>Aucune activité trouvée avec les filtres actuels.</td
>
</tr>
</template>
<template v-else>
<JobItem
v-for="job in activityStore.jobs"
:key="job.id"
:job="job"
@delete="deleteJob" />
</template>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue';
import { useActivityStore } from '../../application/store/activityStore';
import JobItem from '../components/JobItem.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import { ArrowPathIcon, TrashIcon, FunnelIcon } from '@heroicons/vue/24/outline';
const activityStore = useActivityStore();
const selectedAll = ref(false);
// Statuts disponibles pour le filtre
const statusOptions = [
{ value: ['PENDING', 'IN_PROGRESS'], label: 'Actifs' },
{ value: ['PENDING', 'IN_PROGRESS', 'COMPLETED', 'ERROR'], label: 'Tous' },
{ value: ['COMPLETED'], label: 'Terminés' },
{ value: ['ERROR'], label: 'En erreur' }
];
// Index du statut actif
const activeStatusIndex = ref(0);
// Rendre toolbarConfig computed pour que le label du dropdown soit réactif
const toolbarConfig = computed(() => ({
leftSection: [
{
icon: FunnelIcon,
type: 'dropdown',
label: statusOptions[activeStatusIndex.value].label,
active: false,
items: statusOptions.map((option, index) => ({
label: option.label,
isSelected: index === activeStatusIndex.value,
onClick: () => setStatusFilter(index)
}))
}
],
rightSection: [
{
icon: ArrowPathIcon,
type: 'button',
label: 'Rafraîchir',
onClick: refreshJobs
},
{
icon: TrashIcon,
type: 'button',
label: 'Supprimer',
onClick: deleteVisibleJobs
}
]
}));
onMounted(() => {
loadJobs();
});
function loadJobs() {
activityStore.loadJobs();
}
function refreshJobs() {
loadJobs();
}
function toggleSelectAll() {
selectedAll.value = !selectedAll.value;
// La logique pour sélectionner tous les jobs serait ajoutée ici
}
function setStatusFilter(index) {
if (index >= 0 && index < statusOptions.length) {
activeStatusIndex.value = index;
activityStore.updateFilter({ status: statusOptions[index].value });
}
}
function deleteJob(id) {
if (confirm('Voulez-vous vraiment supprimer ce job ?')) {
activityStore.deleteJob(id);
}
}
function deleteVisibleJobs() {
if (confirm('Voulez-vous vraiment supprimer tous les jobs affichés ?')) {
activityStore.deleteJobs({ status: activityStore.filter.status });
}
}
</script>

View File

@@ -3,7 +3,9 @@
<Toolbar :config="toolbarConfig" class="sticky top-16 z-10" />
<div class="container mx-auto px-4">
<MangaGrid :mangas="collection?.items || []" />
<div v-if="isBackgroundLoading" class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
<div
v-if="isBackgroundLoading"
class="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded-lg shadow-lg">
Mise à jour en cours...
</div>
</div>
@@ -11,50 +13,73 @@
</template>
<script setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaGrid from '../components/MangaGrid.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import {
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { storeToRefs } from 'pinia';
import { useMangaStore } from '../../application/store/mangaStore';
import MangaGrid from '../components/MangaGrid.vue';
import Toolbar from '../../../../shared/components/ui/Toolbar.vue';
import {
ArrowPathIcon,
MagnifyingGlassIcon,
Cog6ToothIcon,
EyeIcon,
ArrowsUpDownIcon,
FunnelIcon
} from '@heroicons/vue/24/outline';
} from '@heroicons/vue/24/outline';
const router = useRouter();
const mangaStore = useMangaStore();
const router = useRouter();
const mangaStore = useMangaStore();
const {
collection,
loading,
error,
isBackgroundLoading
} = storeToRefs(mangaStore);
const { collection, loading, error, isBackgroundLoading } = storeToRefs(mangaStore);
onMounted(() => {
onMounted(() => {
mangaStore.loadCollection();
});
});
const toolbarConfig = {
const toolbarConfig = {
leftSection: [
{
icon: ArrowPathIcon,
label: 'Refresh',
type: 'button',
onClick: () => mangaStore.refreshCollectionInBackground(),
active: isBackgroundLoading
},
{ icon: MagnifyingGlassIcon, label: 'Search', onClick: () => {} }
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
],
rightSection: [
{ icon: Cog6ToothIcon, onClick: () => {} },
{ icon: EyeIcon, onClick: () => {} },
{ icon: ArrowsUpDownIcon, onClick: () => {} },
{ icon: FunnelIcon, onClick: () => {} }
{ icon: Cog6ToothIcon, type: 'button', onClick: () => {} },
{
icon: EyeIcon,
type: 'dropdown',
label: 'View',
items: [
{ label: 'List', onClick: () => {} },
{ label: 'Grid', onClick: () => {} }
]
};
},
{
icon: ArrowsUpDownIcon,
type: 'dropdown',
label: 'Sort',
items: [
{ label: 'Title', onClick: () => {} },
{ label: 'Author', onClick: () => {} },
{ label: 'Status', onClick: () => {} },
{ label: 'Year', onClick: () => {} }
]
},
{
icon: FunnelIcon,
type: 'dropdown',
label: 'Filter',
items: [
{ label: 'All', onClick: () => {} },
{ label: 'Completed', onClick: () => {} },
{ label: 'In Progress', onClick: () => {} }
]
}
]
};
</script>

View File

@@ -3,6 +3,7 @@ import Layout from '../shared/components/layout/Layout.vue';
import HomePage from '../domain/manga/presentation/pages/HomePage.vue';
import MangaDetails from '../domain/manga/presentation/pages/MangaDetails.vue';
import ChapterPage from '../domain/reader/presentation/pages/ChapterPage.vue';
import ActivityPage from '../domain/activity/presentation/pages/ActivityPage.vue';
// Placeholder component for new routes
const PlaceholderComponent = {
@@ -80,8 +81,7 @@ const routes = [
{
path: '/activity',
name: 'activity',
component: PlaceholderComponent,
props: { title: 'Activité' }
component: ActivityPage
},
// Paramètres
{

View File

@@ -0,0 +1,7 @@
<template>
<div class="min-h-14 mx-4 border-r opacity-50 border-green-500"></div>
</template>
<script setup>
// Pas de script nécessaire pour ce composant purement visuel
</script>

View File

@@ -1,47 +1,26 @@
<template>
<div :class="['bg-white shadow-sm px-4 py-2', $attrs.class]">
<div class="container mx-auto flex justify-between items-center">
<div :class="['bg-gray-800 p-3 min-h-14', $attrs.class]">
<div class="flex flex-row items-center justify-between">
<!-- Left section -->
<div class="flex items-center space-x-2">
<button
v-for="(item, index) in config.leftSection"
:key="index"
@click="item.onClick"
:class="[
'p-2 rounded-lg transition-colors',
item.active
? 'bg-green-100 text-green-600'
: 'hover:bg-gray-100'
]"
>
<component :is="item.icon" class="h-5 w-5" />
<span v-if="item.label" class="ml-2">{{ item.label }}</span>
</button>
</div>
<ToolbarSection :items="config.leftSection" />
<!-- Right section -->
<div class="flex items-center space-x-2">
<button
v-for="(item, index) in config.rightSection"
:key="index"
@click="item.onClick"
class="p-2 rounded-lg hover:bg-gray-100 transition-colors"
>
<component :is="item.icon" class="h-5 w-5" />
</button>
</div>
<ToolbarSection :items="config.rightSection" />
</div>
</div>
</template>
<script setup>
defineProps({
import ToolbarSection from './ToolbarSection.vue';
defineProps({
config: {
type: Object,
required: true,
validator: (value) => {
return value.leftSection && value.rightSection;
validator: value => {
// Vérifie que leftSection et rightSection sont des tableaux
return Array.isArray(value.leftSection) && Array.isArray(value.rightSection);
}
}
});
});
</script>

View File

@@ -0,0 +1,37 @@
<template>
<button
@click="$emit('click')"
:class="[
'flex flex-col items-center justify-center p-1 rounded group text-white min-w-[50px]',
active
? 'text-green-500' // Style actif
: 'hover:text-green-500' // Effet de survol
]"
:aria-label="label || 'Toolbar button'">
<component v-if="icon" :is="icon" class="h-6 w-6 mb-1" />
<span v-if="label" class="text-xs">{{ label }}</span>
</button>
</template>
<script setup>
import { computed } from 'vue';
defineProps({
icon: {
type: [Object, Function], // Heroicons sont souvent importés comme des objets/fonctions
required: false,
default: null
},
label: {
type: String,
required: false,
default: ''
},
active: {
type: Boolean,
default: false
}
});
defineEmits(['click']);
</script>

View File

@@ -0,0 +1,67 @@
<template>
<Menu as="div" class="relative inline-block">
<div>
<MenuButton
:class="[
'flex flex-col items-center justify-center p-1 rounded group text-white w-20 hover:text-green-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75',
active ? 'text-green-500' : ''
]"
:aria-label="label || 'Options'">
<component v-if="icon" :is="icon" class="h-6 w-6 mb-1" aria-hidden="true" />
<span v-if="label" class="text-xs truncate w-full px-1">{{ label }}</span>
</MenuButton>
</div>
<MenuItems
class="absolute left-0 mt-2 w-max origin-top-left rounded-sm bg-gray-800 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none z-10">
<div class="px-1 py-1">
<MenuItem v-for="(item, index) in items" :key="index" v-slot="{ active }" :disabled="item.disabled">
<button
:class="[
item.isSelected ? 'text-green-500' : active ? 'text-green-500' : 'text-white',
'group flex w-full items-center rounded-sm px-3 py-2 text-sm',
item.disabled ? 'opacity-50 cursor-not-allowed' : ''
]"
@click="item.onClick">
{{ item.label }}
</button>
</MenuItem>
</div>
</MenuItems>
</Menu>
</template>
<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue';
defineProps({
icon: {
type: [Object, Function],
required: false,
default: null
},
label: {
type: String,
required: false,
default: ''
},
active: {
type: Boolean,
default: false
},
items: {
type: Array,
required: true,
default: () => [],
validator: items => {
return items.every(
item =>
item &&
item.label &&
typeof item.onClick === 'function' &&
(item.isSelected === undefined || typeof item.isSelected === 'boolean')
);
}
}
});
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div class="flex items-center space-x-2">
<template v-for="(item, index) in items" :key="index">
<ToolbarButton
v-if="item.type === 'button'"
:icon="item.icon"
:label="item.label"
:active="item.active"
@click="item.onClick" />
<ToolbarDropdown
v-else-if="item.type === 'dropdown'"
:icon="item.icon"
:label="item.label"
:active="item.active"
:items="item.items" />
<Divider v-else-if="item.type === 'divider'" />
<!-- Ajoutez d'autres types d'éléments ici si nécessaire -->
</template>
</div>
</template>
<script setup>
import ToolbarButton from './ToolbarButton.vue';
import ToolbarDropdown from './ToolbarDropdown.vue';
import Divider from './Divider.vue';
defineProps({
items: {
type: Array,
required: true,
default: () => [],
validator: items => {
return items.every(
item =>
item &&
item.type &&
(item.type === 'button' ||
item.type === 'divider' ||
(item.type === 'dropdown' && Array.isArray(item.items)))
);
}
}
});
</script>

View File

@@ -12,6 +12,347 @@
}
],
"paths": {
"/api/jobs": {
"get": {
"operationId": "api_jobs_get_collection",
"tags": [
"Job"
],
"responses": {
"200": {
"description": "Job collection",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Job"
}
}
},
"application/ld+json": {
"schema": {
"type": "object",
"properties": {
"hydra:member": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Job.jsonld"
}
},
"hydra:totalItems": {
"type": "integer",
"minimum": 0
},
"hydra:view": {
"type": "object",
"properties": {
"@id": {
"type": "string",
"format": "iri-reference"
},
"@type": {
"type": "string"
},
"hydra:first": {
"type": "string",
"format": "iri-reference"
},
"hydra:last": {
"type": "string",
"format": "iri-reference"
},
"hydra:previous": {
"type": "string",
"format": "iri-reference"
},
"hydra:next": {
"type": "string",
"format": "iri-reference"
}
},
"example": {
"@id": "string",
"type": "string",
"hydra:first": "string",
"hydra:last": "string",
"hydra:previous": "string",
"hydra:next": "string"
}
},
"hydra:search": {
"type": "object",
"properties": {
"@type": {
"type": "string"
},
"hydra:template": {
"type": "string"
},
"hydra:variableRepresentation": {
"type": "string"
},
"hydra:mapping": {
"type": "array",
"items": {
"type": "object",
"properties": {
"@type": {
"type": "string"
},
"variable": {
"type": "string"
},
"property": {
"type": [
"string",
"null"
]
},
"required": {
"type": "boolean"
}
}
}
}
}
}
},
"required": [
"hydra:member"
]
}
},
"text/html": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Job"
}
}
},
"application/hal+json": {
"schema": {
"type": "object",
"properties": {
"_embedded": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Job.jsonhal"
}
},
"totalItems": {
"type": "integer",
"minimum": 0
},
"itemsPerPage": {
"type": "integer",
"minimum": 0
},
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"first": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"last": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"next": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
},
"previous": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
}
},
"required": [
"_links",
"_embedded"
]
}
}
}
}
},
"summary": "Retrieves the collection of Job resources.",
"description": "Retrieves the collection of Job resources.",
"parameters": [
{
"name": "status",
"in": "query",
"description": "Filtrer par status",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string",
"enum": [
"pending",
"in_progress",
"completed",
"failed"
],
"example": "pending"
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "type",
"in": "query",
"description": "Filtrer par type de job (ex: scraping_job)",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string"
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "createdAfter",
"in": "query",
"description": "Date de cr\u00e9ation minimum (format ISO8601)",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string",
"format": "date-time"
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "createdBefore",
"in": "query",
"description": "Date de cr\u00e9ation maximum (format ISO8601)",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string",
"format": "date-time"
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "page",
"in": "query",
"description": "Num\u00e9ro de la page",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "limit",
"in": "query",
"description": "Nombre d'\u00e9l\u00e9ments par page",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "integer",
"default": 20,
"minimum": 1
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "sortBy",
"in": "query",
"description": "Champ de tri",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string",
"enum": [
"createdAt",
"type",
"status"
],
"default": "createdAt"
},
"style": "form",
"explode": false,
"allowReserved": false
},
{
"name": "sortOrder",
"in": "query",
"description": "Ordre de tri",
"required": false,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string",
"enum": [
"ASC",
"DESC"
],
"default": "DESC"
},
"style": "form",
"explode": false,
"allowReserved": false
}
],
"deprecated": false
},
"parameters": []
},
"/api/manga/chapters/fetch": {
"post": {
"operationId": "api_mangachaptersfetch_post",
@@ -596,30 +937,24 @@
"description": "Creates a new manga by fetching its data from Mangadex using an external ID",
"parameters": [],
"requestBody": {
"description": "The new Manga resource",
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Manga"
"type": "object",
"required": [
"externalId"
],
"properties": {
"externalId": {
"type": "string",
"description": "The Mangadex ID of the manga"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/Manga.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/Manga"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/Manga.jsonhal"
}
}
},
"required": true
"required": false
},
"deprecated": false
},
@@ -1177,64 +1512,6 @@
"deprecated": false
},
"parameters": []
},
"/api/scraping/jobs/{jobId}/status": {
"get": {
"operationId": "api_scrapingjobs_jobIdstatus_get",
"tags": [
"Scraping"
],
"responses": {
"200": {
"description": "Scraping resource",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Scraping"
}
},
"application/ld+json": {
"schema": {
"$ref": "#/components/schemas/Scraping.jsonld"
}
},
"text/html": {
"schema": {
"$ref": "#/components/schemas/Scraping"
}
},
"application/hal+json": {
"schema": {
"$ref": "#/components/schemas/Scraping.jsonhal"
}
}
}
},
"404": {
"description": "Resource not found"
}
},
"summary": "Retrieves a Scraping resource.",
"description": "Retrieves a Scraping resource.",
"parameters": [
{
"name": "jobId",
"in": "path",
"description": "ScrapingStatusResponse identifier",
"required": true,
"deprecated": false,
"allowEmptyValue": false,
"schema": {
"type": "string"
},
"style": "simple",
"explode": false,
"allowReserved": false
}
],
"deprecated": false
},
"parameters": []
}
},
"components": {
@@ -1265,6 +1542,9 @@
"isVisible": {
"type": "boolean"
},
"isAvailable": {
"type": "boolean"
},
"createdAt": {
"type": "string"
}
@@ -1296,6 +1576,9 @@
"isVisible": {
"type": "boolean"
},
"isAvailable": {
"type": "boolean"
},
"createdAt": {
"type": "string"
}
@@ -1362,11 +1645,264 @@
"isVisible": {
"type": "boolean"
},
"isAvailable": {
"type": "boolean"
},
"createdAt": {
"type": "string"
}
}
},
"Job": {
"type": "object",
"description": "Liste des jobs",
"deprecated": false,
"properties": {
"id": {
"description": "Identifiant unique du job",
"type": "string"
},
"type": {
"description": "Type du job (ex: scraping_job)",
"type": "string"
},
"status": {
"description": "Status du job",
"type": "string",
"enum": [
"pending",
"in_progress",
"completed",
"failed",
"cancelled"
]
},
"createdAt": {
"description": "Date de cr\u00e9ation du job",
"type": "string",
"format": "date-time"
},
"startedAt": {
"description": "Date de d\u00e9but d'ex\u00e9cution du job",
"type": [
"string",
"null"
],
"format": "date-time"
},
"completedAt": {
"description": "Date de fin d'ex\u00e9cution du job",
"type": [
"string",
"null"
],
"format": "date-time"
},
"failureReason": {
"description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9",
"type": [
"string",
"null"
]
},
"attempts": {
"minimum": 0,
"description": "Nombre de tentatives effectu\u00e9es",
"type": "integer"
},
"maxAttempts": {
"exclusiveMinimum": 0,
"description": "Nombre maximum de tentatives autoris\u00e9es",
"type": "integer"
},
"context": {
"description": "Donn\u00e9es contextuelles du job",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"type",
"status",
"createdAt"
]
},
"Job.jsonhal": {
"type": "object",
"description": "Liste des jobs",
"deprecated": false,
"properties": {
"_links": {
"type": "object",
"properties": {
"self": {
"type": "object",
"properties": {
"href": {
"type": "string",
"format": "iri-reference"
}
}
}
}
},
"id": {
"description": "Identifiant unique du job",
"type": "string"
},
"type": {
"description": "Type du job (ex: scraping_job)",
"type": "string"
},
"status": {
"description": "Status du job",
"type": "string",
"enum": [
"pending",
"in_progress",
"completed",
"failed",
"cancelled"
]
},
"createdAt": {
"description": "Date de cr\u00e9ation du job",
"type": "string",
"format": "date-time"
},
"startedAt": {
"description": "Date de d\u00e9but d'ex\u00e9cution du job",
"type": [
"string",
"null"
],
"format": "date-time"
},
"completedAt": {
"description": "Date de fin d'ex\u00e9cution du job",
"type": [
"string",
"null"
],
"format": "date-time"
},
"failureReason": {
"description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9",
"type": [
"string",
"null"
]
},
"attempts": {
"minimum": 0,
"description": "Nombre de tentatives effectu\u00e9es",
"type": "integer"
},
"maxAttempts": {
"exclusiveMinimum": 0,
"description": "Nombre maximum de tentatives autoris\u00e9es",
"type": "integer"
},
"context": {
"description": "Donn\u00e9es contextuelles du job",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"type",
"status",
"createdAt"
]
},
"Job.jsonld": {
"type": "object",
"description": "Liste des jobs",
"deprecated": false,
"properties": {
"@id": {
"readOnly": true,
"type": "string"
},
"@type": {
"readOnly": true,
"type": "string"
},
"id": {
"description": "Identifiant unique du job",
"type": "string"
},
"type": {
"description": "Type du job (ex: scraping_job)",
"type": "string"
},
"status": {
"description": "Status du job",
"type": "string",
"enum": [
"pending",
"in_progress",
"completed",
"failed",
"cancelled"
]
},
"createdAt": {
"description": "Date de cr\u00e9ation du job",
"type": "string",
"format": "date-time"
},
"startedAt": {
"description": "Date de d\u00e9but d'ex\u00e9cution du job",
"type": [
"string",
"null"
],
"format": "date-time"
},
"completedAt": {
"description": "Date de fin d'ex\u00e9cution du job",
"type": [
"string",
"null"
],
"format": "date-time"
},
"failureReason": {
"description": "Raison de l'\u00e9chec si le job a \u00e9chou\u00e9",
"type": [
"string",
"null"
]
},
"attempts": {
"minimum": 0,
"description": "Nombre de tentatives effectu\u00e9es",
"type": "integer"
},
"maxAttempts": {
"exclusiveMinimum": 0,
"description": "Nombre maximum de tentatives autoris\u00e9es",
"type": "integer"
},
"context": {
"description": "Donn\u00e9es contextuelles du job",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"type",
"status",
"createdAt"
]
},
"Manga": {
"type": "object",
"description": "",
@@ -2284,6 +2820,12 @@
"null"
]
},
"thumbnailUrl": {
"type": [
"string",
"null"
]
},
"author": {
"type": "string"
},
@@ -2334,6 +2876,12 @@
"null"
]
},
"thumbnailUrl": {
"type": [
"string",
"null"
]
},
"author": {
"type": "string"
},
@@ -2419,6 +2967,12 @@
"null"
]
},
"thumbnailUrl": {
"type": [
"string",
"null"
]
},
"author": {
"type": "string"
},

View File

@@ -9,8 +9,11 @@ use App\Domain\Shared\Domain\Model\JobStatus;
readonly class ListJobsQuery implements QueryInterface
{
/**
* @param JobStatus[] $statuses
*/
public function __construct(
public ?JobStatus $status = null,
public ?array $statuses = null,
public ?string $type = null,
public ?\DateTimeImmutable $createdAfter = null,
public ?\DateTimeImmutable $createdBefore = null,

View File

@@ -28,7 +28,7 @@ readonly class ListJobsQueryHandler implements QueryHandlerInterface
}
$criteria = [
'status' => $query->status,
'statuses' => $query->statuses,
'type' => $query->type,
'createdAfter' => $query->createdAfter,
'createdBefore' => $query->createdBefore,

View File

@@ -18,6 +18,7 @@ interface JobRepositoryInterface
/**
* @param array{
* status?: ?JobStatus,
* statuses?: ?array<JobStatus>,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable,
@@ -33,6 +34,7 @@ interface JobRepositoryInterface
/**
* @param array{
* status?: ?JobStatus,
* statuses?: ?array<JobStatus>,
* type?: ?string,
* createdAfter?: ?\DateTimeImmutable,
* createdBefore?: ?\DateTimeImmutable

View File

@@ -25,13 +25,26 @@ use Symfony\Component\Validator\Constraints as Assert;
[
'name' => 'status',
'in' => 'query',
'description' => 'Filtrer par status',
'description' => 'Filtrer par status (séparés par des virgules pour plusieurs statuts, ex: "pending,in_progress" ou sous forme de tableau avec "status[]=")',
'required' => false,
'schema' => [
'oneOf' => [
[
'type' => 'string',
'enum' => ['pending', 'in_progress', 'completed', 'failed'],
'example' => 'pending'
'example' => 'pending,in_progress'
],
[
'type' => 'array',
'items' => [
'type' => 'string',
'enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']
],
'example' => ['pending', 'in_progress']
]
]
],
'style' => 'form',
'explode' => false
],
[
'name' => 'type',
@@ -108,10 +121,7 @@ class GetJobListResource
#[Assert\NotBlank]
public readonly string $type,
#[ApiProperty(
description: 'Status du job',
openapiContext: ['enum' => ['pending', 'in_progress', 'completed', 'failed', 'cancelled']]
)]
#[ApiProperty(description: 'Status du job')]
#[Assert\NotBlank]
public readonly string $status,

View File

@@ -21,8 +21,34 @@ readonly class GetJobListStateProvider implements ProviderInterface
{
$filters = $context['filters'] ?? [];
$statuses = null;
if (isset($filters['status'])) {
if (is_array($filters['status'])) {
// Cas où status est passé en format tableau: status[]=pending&status[]=in_progress
$statusValues = $filters['status'];
} else {
// Cas où status est passé comme une chaîne séparée par des virgules: status=pending,in_progress
$statusValues = explode(',', $filters['status']);
}
$statuses = [];
foreach ($statusValues as $statusValue) {
try {
$statuses[] = JobStatus::from($statusValue);
} catch (\ValueError $e) {
// Ignorer les valeurs invalides
continue;
}
}
// Si aucun statut valide n'est trouvé, on met $statuses à null
if (empty($statuses)) {
$statuses = null;
}
}
$query = new ListJobsQuery(
status: isset($filters['status']) ? JobStatus::from($filters['status']) : null,
statuses: $statuses,
type: $filters['type'] ?? null,
createdAfter: isset($filters['createdAfter']) ? new \DateTimeImmutable($filters['createdAfter']) : null,
createdBefore: isset($filters['createdBefore']) ? new \DateTimeImmutable($filters['createdBefore']) : null,

View File

@@ -112,7 +112,15 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface
->select('j')
->from(JobEntity::class, 'j');
if (isset($criteria['status'])) {
if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) {
$expr = $qb->expr()->orX();
foreach ($criteria['statuses'] as $key => $status) {
$paramName = 'status' . $key;
$expr->add($qb->expr()->eq('j.status', ':' . $paramName));
$qb->setParameter($paramName, $status->value);
}
$qb->andWhere($expr);
} elseif (isset($criteria['status'])) {
$qb->andWhere('j.status = :status')
->setParameter('status', $criteria['status']->value);
}
@@ -155,7 +163,15 @@ readonly class DoctrineJobRepository implements JobRepositoryInterface
->select('COUNT(j.id)')
->from(JobEntity::class, 'j');
if (isset($criteria['status'])) {
if (isset($criteria['statuses']) && is_array($criteria['statuses']) && !empty($criteria['statuses'])) {
$expr = $qb->expr()->orX();
foreach ($criteria['statuses'] as $key => $status) {
$paramName = 'status' . $key;
$expr->add($qb->expr()->eq('j.status', ':' . $paramName));
$qb->setParameter($paramName, $status->value);
}
$qb->andWhere($expr);
} elseif (isset($criteria['status'])) {
$qb->andWhere('j.status = :status')
->setParameter('status', $criteria['status']->value);
}