feat: add multiple skills addition and optimize evaluation handling

- Introduced `addMultipleSkillsToEvaluation` function in `EvaluationClientWrapper` for batch skill addition.
- Updated `SkillEvaluation` and `SkillSelector` components to utilize the new multiple skills addition feature.
- Implemented optimistic UI updates for skill level, mentor status, and learning status changes, enhancing user experience.
- Refactored evaluation state management to improve performance and maintainability.
- Added error handling and rollback mechanisms for better reliability during API interactions.
This commit is contained in:
Julien Froidefond
2025-08-21 15:07:57 +02:00
parent 2faa998cbe
commit dad172157b
4 changed files with 378 additions and 38 deletions

View File

@@ -1,9 +1,8 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useUser } from "@/hooks/use-user-context"; import { useUser } from "@/hooks/use-user-context";
import { UserEvaluation, Team } from "@/lib/types"; import { UserEvaluation, Team, SkillLevel } from "@/lib/types";
import { import {
updateSkillLevel as updateSkillLevelAction, updateSkillLevel as updateSkillLevelAction,
updateSkillMentorStatus as updateSkillMentorStatusAction, updateSkillMentorStatus as updateSkillMentorStatusAction,
@@ -24,16 +23,68 @@ export function EvaluationClientWrapper({
children, children,
}: EvaluationClientWrapperProps) { }: EvaluationClientWrapperProps) {
const { setUserInfo } = useUser(); const { setUserInfo } = useUser();
const router = useRouter();
// Wrapper functions that refresh the page after API calls // État local pour l'UI optimiste - commence avec les données SSR
const [currentEvaluation, setCurrentEvaluation] =
useState<UserEvaluation | null>(userEvaluation);
// Met à jour l'état local quand les props changent (SSR)
useEffect(() => {
setCurrentEvaluation(userEvaluation);
}, [userEvaluation]);
// Fonctions avec UI optimiste
const updateSkillLevel = async ( const updateSkillLevel = async (
category: string, category: string,
skillId: string, skillId: string,
level: any level: SkillLevel
) => { ) => {
await updateSkillLevelAction(category, skillId, level); if (!currentEvaluation) return;
router.refresh();
// Sauvegarder l'état actuel pour le rollback
const previousEvaluation = currentEvaluation;
try {
// Optimistic UI update - mettre à jour immédiatement l'interface
const updatedEvaluations = currentEvaluation.evaluations.map(
(catEval) => {
if (catEval.category === category) {
const existingSkill = catEval.skills.find(
(s) => s.skillId === skillId
);
const updatedSkills = existingSkill
? catEval.skills.map((skill) =>
skill.skillId === skillId ? { ...skill, level } : skill
)
: [
...catEval.skills,
{ skillId, level, canMentor: false, wantsToLearn: false },
];
return {
...catEval,
skills: updatedSkills,
};
}
return catEval;
}
);
const newEvaluation: UserEvaluation = {
...currentEvaluation,
evaluations: updatedEvaluations,
lastUpdated: new Date().toISOString(),
};
setCurrentEvaluation(newEvaluation);
// Appel API en arrière-plan
await updateSkillLevelAction(category, skillId, level);
} catch (error) {
console.error("Failed to update skill level:", error);
// Rollback optimiste en cas d'erreur
setCurrentEvaluation(previousEvaluation);
}
}; };
const updateSkillMentorStatus = async ( const updateSkillMentorStatus = async (
@@ -41,8 +92,39 @@ export function EvaluationClientWrapper({
skillId: string, skillId: string,
canMentor: boolean canMentor: boolean
) => { ) => {
await updateSkillMentorStatusAction(category, skillId, canMentor); if (!currentEvaluation) return;
router.refresh();
const previousEvaluation = currentEvaluation;
try {
const updatedEvaluations = currentEvaluation.evaluations.map(
(catEval) => {
if (catEval.category === category) {
const updatedSkills = catEval.skills.map((skill) =>
skill.skillId === skillId ? { ...skill, canMentor } : skill
);
return {
...catEval,
skills: updatedSkills,
};
}
return catEval;
}
);
const newEvaluation: UserEvaluation = {
...currentEvaluation,
evaluations: updatedEvaluations,
lastUpdated: new Date().toISOString(),
};
setCurrentEvaluation(newEvaluation);
await updateSkillMentorStatusAction(category, skillId, canMentor);
} catch (error) {
console.error("Failed to update skill mentor status:", error);
setCurrentEvaluation(previousEvaluation);
}
}; };
const updateSkillLearningStatus = async ( const updateSkillLearningStatus = async (
@@ -50,45 +132,204 @@ export function EvaluationClientWrapper({
skillId: string, skillId: string,
wantsToLearn: boolean wantsToLearn: boolean
) => { ) => {
await updateSkillLearningStatusAction(category, skillId, wantsToLearn); if (!currentEvaluation) return;
router.refresh();
const previousEvaluation = currentEvaluation;
try {
const updatedEvaluations = currentEvaluation.evaluations.map(
(catEval) => {
if (catEval.category === category) {
const updatedSkills = catEval.skills.map((skill) =>
skill.skillId === skillId ? { ...skill, wantsToLearn } : skill
);
return {
...catEval,
skills: updatedSkills,
};
}
return catEval;
}
);
const newEvaluation: UserEvaluation = {
...currentEvaluation,
evaluations: updatedEvaluations,
lastUpdated: new Date().toISOString(),
};
setCurrentEvaluation(newEvaluation);
await updateSkillLearningStatusAction(category, skillId, wantsToLearn);
} catch (error) {
console.error("Failed to update skill learning status:", error);
setCurrentEvaluation(previousEvaluation);
}
}; };
const addSkillToEvaluation = async (category: string, skillId: string) => { const addSkillToEvaluation = async (category: string, skillId: string) => {
await addSkillToEvaluationAction(category, skillId); if (!currentEvaluation) return;
router.refresh();
const previousEvaluation = currentEvaluation;
try {
const updatedEvaluations = currentEvaluation.evaluations.map(
(catEval) => {
if (catEval.category === category) {
if (!catEval.selectedSkillIds.includes(skillId)) {
return {
...catEval,
selectedSkillIds: [...catEval.selectedSkillIds, skillId],
skills: [
...catEval.skills,
{
skillId,
level: null,
canMentor: false,
wantsToLearn: false,
},
],
};
}
}
return catEval;
}
);
const newEvaluation: UserEvaluation = {
...currentEvaluation,
evaluations: updatedEvaluations,
lastUpdated: new Date().toISOString(),
};
setCurrentEvaluation(newEvaluation);
await addSkillToEvaluationAction(category, skillId);
} catch (error) {
console.error("Failed to add skill to evaluation:", error);
setCurrentEvaluation(previousEvaluation);
}
};
const addMultipleSkillsToEvaluation = async (
category: string,
skillIds: string[]
) => {
if (!currentEvaluation || skillIds.length === 0) return;
const previousEvaluation = currentEvaluation;
try {
const updatedEvaluations = currentEvaluation.evaluations.map(
(catEval) => {
if (catEval.category === category) {
// Filtrer seulement les compétences qui ne sont pas déjà sélectionnées
const newSkillIds = skillIds.filter(
(skillId) => !catEval.selectedSkillIds.includes(skillId)
);
if (newSkillIds.length > 0) {
return {
...catEval,
selectedSkillIds: [...catEval.selectedSkillIds, ...newSkillIds],
skills: [
...catEval.skills,
...newSkillIds.map((skillId) => ({
skillId,
level: null,
canMentor: false,
wantsToLearn: false,
})),
],
};
}
}
return catEval;
}
);
const newEvaluation: UserEvaluation = {
...currentEvaluation,
evaluations: updatedEvaluations,
lastUpdated: new Date().toISOString(),
};
setCurrentEvaluation(newEvaluation);
// Ajouter toutes les compétences en parallèle côté API
await Promise.all(
skillIds.map((skillId) => addSkillToEvaluationAction(category, skillId))
);
} catch (error) {
console.error("Failed to add multiple skills to evaluation:", error);
setCurrentEvaluation(previousEvaluation);
}
}; };
const removeSkillFromEvaluation = async ( const removeSkillFromEvaluation = async (
category: string, category: string,
skillId: string skillId: string
) => { ) => {
await removeSkillFromEvaluationAction(category, skillId); if (!currentEvaluation) return;
router.refresh();
const previousEvaluation = currentEvaluation;
try {
const updatedEvaluations = currentEvaluation.evaluations.map(
(catEval) => {
if (catEval.category === category) {
return {
...catEval,
selectedSkillIds: catEval.selectedSkillIds.filter(
(id) => id !== skillId
),
skills: catEval.skills.filter(
(skill) => skill.skillId !== skillId
),
};
}
return catEval;
}
);
const newEvaluation: UserEvaluation = {
...currentEvaluation,
evaluations: updatedEvaluations,
lastUpdated: new Date().toISOString(),
};
setCurrentEvaluation(newEvaluation);
await removeSkillFromEvaluationAction(category, skillId);
} catch (error) {
console.error("Failed to remove skill from evaluation:", error);
setCurrentEvaluation(previousEvaluation);
}
}; };
// Update user info in navigation when user evaluation is loaded // Update user info in navigation when user evaluation is loaded
useEffect(() => { useEffect(() => {
if (userEvaluation) { if (currentEvaluation) {
const teamName = const teamName =
teams.find((t) => t.id === userEvaluation.profile.teamId)?.name || ""; teams.find((t) => t.id === currentEvaluation.profile.teamId)?.name ||
"";
setUserInfo({ setUserInfo({
firstName: userEvaluation.profile.firstName, firstName: currentEvaluation.profile.firstName,
lastName: userEvaluation.profile.lastName, lastName: currentEvaluation.profile.lastName,
teamName, teamName,
}); });
} else { } else {
setUserInfo(null); setUserInfo(null);
} }
}, [userEvaluation, teams, setUserInfo]); }, [currentEvaluation, teams, setUserInfo]);
// Provide evaluation functions to children through React context or props // Provide evaluation functions to children through React context or props
return ( return (
<EvaluationProvider <EvaluationProvider
currentEvaluation={currentEvaluation}
updateSkillLevel={updateSkillLevel} updateSkillLevel={updateSkillLevel}
updateSkillMentorStatus={updateSkillMentorStatus} updateSkillMentorStatus={updateSkillMentorStatus}
updateSkillLearningStatus={updateSkillLearningStatus} updateSkillLearningStatus={updateSkillLearningStatus}
addSkillToEvaluation={addSkillToEvaluation} addSkillToEvaluation={addSkillToEvaluation}
addMultipleSkillsToEvaluation={addMultipleSkillsToEvaluation}
removeSkillFromEvaluation={removeSkillFromEvaluation} removeSkillFromEvaluation={removeSkillFromEvaluation}
> >
{children} {children}
@@ -100,6 +341,7 @@ export function EvaluationClientWrapper({
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
interface EvaluationContextType { interface EvaluationContextType {
currentEvaluation: UserEvaluation | null;
updateSkillLevel: (categoryId: string, skillId: string, level: any) => void; updateSkillLevel: (categoryId: string, skillId: string, level: any) => void;
updateSkillMentorStatus: ( updateSkillMentorStatus: (
categoryId: string, categoryId: string,
@@ -112,6 +354,10 @@ interface EvaluationContextType {
wantsToLearn: boolean wantsToLearn: boolean
) => void; ) => void;
addSkillToEvaluation: (categoryId: string, skillId: string) => void; addSkillToEvaluation: (categoryId: string, skillId: string) => void;
addMultipleSkillsToEvaluation: (
categoryId: string,
skillIds: string[]
) => void;
removeSkillFromEvaluation: (categoryId: string, skillId: string) => void; removeSkillFromEvaluation: (categoryId: string, skillId: string) => void;
} }
@@ -121,10 +367,12 @@ const EvaluationContext = createContext<EvaluationContextType | undefined>(
function EvaluationProvider({ function EvaluationProvider({
children, children,
currentEvaluation,
updateSkillLevel, updateSkillLevel,
updateSkillMentorStatus, updateSkillMentorStatus,
updateSkillLearningStatus, updateSkillLearningStatus,
addSkillToEvaluation, addSkillToEvaluation,
addMultipleSkillsToEvaluation,
removeSkillFromEvaluation, removeSkillFromEvaluation,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
@@ -132,10 +380,12 @@ function EvaluationProvider({
return ( return (
<EvaluationContext.Provider <EvaluationContext.Provider
value={{ value={{
currentEvaluation,
updateSkillLevel, updateSkillLevel,
updateSkillMentorStatus, updateSkillMentorStatus,
updateSkillLearningStatus, updateSkillLearningStatus,
addSkillToEvaluation, addSkillToEvaluation,
addMultipleSkillsToEvaluation,
removeSkillFromEvaluation, removeSkillFromEvaluation,
}} }}
> >

View File

@@ -23,12 +23,17 @@ export function SkillEvaluation({
evaluations, evaluations,
}: SkillEvaluationProps) { }: SkillEvaluationProps) {
const { const {
currentEvaluation: contextEvaluation,
updateSkillLevel, updateSkillLevel,
updateSkillMentorStatus, updateSkillMentorStatus,
updateSkillLearningStatus, updateSkillLearningStatus,
addSkillToEvaluation, addSkillToEvaluation,
addMultipleSkillsToEvaluation,
removeSkillFromEvaluation, removeSkillFromEvaluation,
} = useEvaluationContext(); } = useEvaluationContext();
// Utiliser l'évaluation du contexte (avec état optimiste) ou celle des props (SSR)
const activeEvaluations = contextEvaluation?.evaluations || evaluations;
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
const categoryParam = searchParams.get("category"); const categoryParam = searchParams.get("category");
@@ -57,7 +62,7 @@ export function SkillEvaluation({
const currentCategory = categories.find( const currentCategory = categories.find(
(cat) => cat.category === selectedCategory (cat) => cat.category === selectedCategory
); );
const currentEvaluation = evaluations.find( const currentEvaluation = activeEvaluations.find(
(evaluation) => evaluation.category === selectedCategory (evaluation) => evaluation.category === selectedCategory
); );
@@ -85,9 +90,10 @@ export function SkillEvaluation({
<div className="space-y-8"> <div className="space-y-8">
<SkillSelector <SkillSelector
categories={categories} categories={categories}
evaluations={evaluations} evaluations={activeEvaluations}
selectedCategory={selectedCategory} selectedCategory={selectedCategory}
onAddSkill={addSkillToEvaluation} onAddSkill={addSkillToEvaluation}
onAddMultipleSkills={addMultipleSkillsToEvaluation}
onRemoveSkill={removeSkillFromEvaluation} onRemoveSkill={removeSkillFromEvaluation}
/> />

View File

@@ -21,6 +21,7 @@ interface SkillSelectorProps {
evaluations: CategoryEvaluation[]; evaluations: CategoryEvaluation[];
selectedCategory: string; selectedCategory: string;
onAddSkill: (category: string, skillId: string) => void; onAddSkill: (category: string, skillId: string) => void;
onAddMultipleSkills?: (category: string, skillIds: string[]) => void;
onRemoveSkill: (category: string, skillId: string) => void; onRemoveSkill: (category: string, skillId: string) => void;
} }
@@ -29,11 +30,14 @@ export function SkillSelector({
evaluations, evaluations,
selectedCategory, selectedCategory,
onAddSkill, onAddSkill,
onAddMultipleSkills,
onRemoveSkill, onRemoveSkill,
}: SkillSelectorProps) { }: SkillSelectorProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isAddingAll, setIsAddingAll] = useState(false);
const [addingSkillIds, setAddingSkillIds] = useState<Set<string>>(new Set());
const currentCategory = categories.find( const currentCategory = categories.find(
(cat) => cat.category === selectedCategory (cat) => cat.category === selectedCategory
@@ -60,6 +64,47 @@ export function SkillSelector({
selectedSkillIds.includes(skill.id) selectedSkillIds.includes(skill.id)
); );
const handleAddSkill = async (skillId: string) => {
setAddingSkillIds((prev) => new Set(prev).add(skillId));
try {
await onAddSkill(selectedCategory, skillId);
} catch (error) {
console.error("Failed to add skill:", error);
} finally {
setAddingSkillIds((prev) => {
const next = new Set(prev);
next.delete(skillId);
return next;
});
}
};
const handleAddAllSkills = async () => {
setIsAddingAll(true);
try {
const skillIds = availableSkills.map((skill) => skill.id);
// Utiliser la fonction d'ajout multiple si disponible, sinon fallback sur l'ajout individuel
if (onAddMultipleSkills) {
await onAddMultipleSkills(selectedCategory, skillIds);
} else {
// Fallback: ajouter en séquentiel pour éviter les conflits d'état
for (const skill of availableSkills) {
await onAddSkill(selectedCategory, skill.id);
}
}
// Petit délai pour permettre à l'UI de se mettre à jour avant de fermer
setTimeout(() => {
setIsOpen(false);
}, 100);
} catch (error) {
console.error("Failed to add all skills:", error);
} finally {
setIsAddingAll(false);
}
};
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Selected Skills */} {/* Selected Skills */}
@@ -88,15 +133,30 @@ export function SkillSelector({
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{/* Search */} {/* Search and Add All */}
<div className="relative"> <div className="space-y-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <div className="relative">
<Input <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
placeholder="Rechercher une compétence..." <Input
value={searchTerm} placeholder="Rechercher une compétence..."
onChange={(e) => setSearchTerm(e.target.value)} value={searchTerm}
className="pl-10" onChange={(e) => setSearchTerm(e.target.value)}
/> className="pl-10"
/>
</div>
{availableSkills.length > 1 && (
<Button
onClick={handleAddAllSkills}
disabled={isAddingAll}
className="w-full gap-2 bg-green-600 hover:bg-green-700 text-white disabled:opacity-50"
>
<Plus className="h-4 w-4" />
{isAddingAll
? "Ajout en cours..."
: `Ajouter toutes les compétences (${availableSkills.length})`}
</Button>
)}
</div> </div>
{/* Create New Skill Button */} {/* Create New Skill Button */}
@@ -159,13 +219,16 @@ export function SkillSelector({
</div> </div>
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => handleAddSkill(skill.id)}
onAddSkill(selectedCategory, skill.id); disabled={
}} addingSkillIds.has(skill.id) || isAddingAll
className="gap-2" }
className="gap-2 disabled:opacity-50"
> >
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
Ajouter {addingSkillIds.has(skill.id)
? "Ajout..."
: "Ajouter"}
</Button> </Button>
</div> </div>
)) ))

View File

@@ -296,14 +296,21 @@ export function useEvaluation() {
const addSkillToEvaluation = async (category: string, skillId: string) => { const addSkillToEvaluation = async (category: string, skillId: string) => {
if (!userEvaluation) return; if (!userEvaluation) return;
// Sauvegarder l'état actuel pour le rollback
const previousEvaluation = userEvaluation;
try { try {
// Optimistic UI update - mettre à jour immédiatement l'interface
const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { const updatedEvaluations = userEvaluation.evaluations.map((catEval) => {
if (catEval.category === category) { if (catEval.category === category) {
if (!catEval.selectedSkillIds.includes(skillId)) { if (!catEval.selectedSkillIds.includes(skillId)) {
return { return {
...catEval, ...catEval,
selectedSkillIds: [...catEval.selectedSkillIds, skillId], selectedSkillIds: [...catEval.selectedSkillIds, skillId],
skills: [...catEval.skills, { skillId, level: null }], skills: [
...catEval.skills,
{ skillId, level: null, canMentor: false, wantsToLearn: false },
],
}; };
} }
} }
@@ -317,6 +324,8 @@ export function useEvaluation() {
}; };
setUserEvaluation(newEvaluation); setUserEvaluation(newEvaluation);
// Appel API en arrière-plan
await apiClient.addSkillToEvaluation( await apiClient.addSkillToEvaluation(
userEvaluation.profile, userEvaluation.profile,
category, category,
@@ -324,6 +333,9 @@ export function useEvaluation() {
); );
} catch (error) { } catch (error) {
console.error("Failed to add skill to evaluation:", error); console.error("Failed to add skill to evaluation:", error);
// Rollback optimiste en cas d'erreur
setUserEvaluation(previousEvaluation);
// Optionnel: afficher une notification d'erreur à l'utilisateur
} }
}; };
@@ -333,7 +345,11 @@ export function useEvaluation() {
) => { ) => {
if (!userEvaluation) return; if (!userEvaluation) return;
// Sauvegarder l'état actuel pour le rollback
const previousEvaluation = userEvaluation;
try { try {
// Optimistic UI update - mettre à jour immédiatement l'interface
const updatedEvaluations = userEvaluation.evaluations.map((catEval) => { const updatedEvaluations = userEvaluation.evaluations.map((catEval) => {
if (catEval.category === category) { if (catEval.category === category) {
return { return {
@@ -354,6 +370,8 @@ export function useEvaluation() {
}; };
setUserEvaluation(newEvaluation); setUserEvaluation(newEvaluation);
// Appel API en arrière-plan
await apiClient.removeSkillFromEvaluation( await apiClient.removeSkillFromEvaluation(
userEvaluation.profile, userEvaluation.profile,
category, category,
@@ -361,6 +379,9 @@ export function useEvaluation() {
); );
} catch (error) { } catch (error) {
console.error("Failed to remove skill from evaluation:", error); console.error("Failed to remove skill from evaluation:", error);
// Rollback optimiste en cas d'erreur
setUserEvaluation(previousEvaluation);
// Optionnel: afficher une notification d'erreur à l'utilisateur
} }
}; };