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

@@ -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

@@ -1,60 +1,85 @@
<template>
<div>
<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">
Mise à jour en cours...
</div>
<div>
<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">
Mise à jour en cours...
</div>
</div>
</div>
</div>
</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 {
ArrowPathIcon,
MagnifyingGlassIcon,
Cog6ToothIcon,
EyeIcon,
ArrowsUpDownIcon,
FunnelIcon
} from '@heroicons/vue/24/outline';
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';
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(() => {
mangaStore.loadCollection();
});
onMounted(() => {
mangaStore.loadCollection();
});
const toolbarConfig = {
leftSection: [
{
icon: ArrowPathIcon,
label: 'Refresh',
onClick: () => mangaStore.refreshCollectionInBackground(),
active: isBackgroundLoading
},
{ icon: MagnifyingGlassIcon, label: 'Search', onClick: () => {} }
],
rightSection: [
{ icon: Cog6ToothIcon, onClick: () => {} },
{ icon: EyeIcon, onClick: () => {} },
{ icon: ArrowsUpDownIcon, onClick: () => {} },
{ icon: FunnelIcon, onClick: () => {} }
]
};
const toolbarConfig = {
leftSection: [
{
icon: ArrowPathIcon,
label: 'Refresh',
type: 'button',
onClick: () => mangaStore.refreshCollectionInBackground(),
active: isBackgroundLoading
},
{ icon: MagnifyingGlassIcon, label: 'Search', type: 'button', onClick: () => {} }
],
rightSection: [
{ 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>