feat(TaskManagement): implement centralized readonly field logic for task synchronization
- Added functionality to determine readonly fields based on task source (Jira, TFS) and status. - Updated EditTaskForm and TaskBasicFields components to utilize readonly fields for better user experience. - Introduced buildSyncUpdateData function to manage field preservation during synchronization. - Enhanced tests for readonly field logic to ensure correct behavior across different scenarios.
This commit is contained in:
@@ -143,6 +143,8 @@ export function EditTaskForm({
|
||||
}
|
||||
errors={errors}
|
||||
loading={loading}
|
||||
readonlyFields={task.readonlyFields || []}
|
||||
source={task.source}
|
||||
/>
|
||||
|
||||
<TaskJiraInfo task={task} />
|
||||
|
||||
@@ -19,6 +19,8 @@ interface TaskBasicFieldsProps {
|
||||
onDueDateChange: (date?: Date) => void;
|
||||
errors: Record<string, string>;
|
||||
loading: boolean;
|
||||
readonlyFields?: string[];
|
||||
source?: string; // Source de la tâche pour déterminer le message (jira, tfs, etc.)
|
||||
}
|
||||
|
||||
export function TaskBasicFields({
|
||||
@@ -34,31 +36,71 @@ export function TaskBasicFields({
|
||||
onDueDateChange,
|
||||
errors,
|
||||
loading,
|
||||
readonlyFields = [],
|
||||
source,
|
||||
}: TaskBasicFieldsProps) {
|
||||
const isTitleReadonly = readonlyFields.includes('title');
|
||||
const isDescriptionReadonly = readonlyFields.includes('description');
|
||||
const isStatusReadonly = readonlyFields.includes('status');
|
||||
const isPriorityReadonly = readonlyFields.includes('priority');
|
||||
const isDueDateReadonly = readonlyFields.includes('dueDate');
|
||||
|
||||
// Déterminer le message selon la source
|
||||
const getSyncMessage = (field: string) => {
|
||||
if (source === 'jira') {
|
||||
return field === 'title' || field === 'description' || field === 'dueDate'
|
||||
? '(synchronisé par Jira)'
|
||||
: '(sync)';
|
||||
} else if (source === 'tfs') {
|
||||
return field === 'title' || field === 'description' || field === 'dueDate'
|
||||
? '(synchronisé par TFS)'
|
||||
: '(sync)';
|
||||
}
|
||||
return '(sync)';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Titre */}
|
||||
<Input
|
||||
label="Titre *"
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Titre *
|
||||
</label>
|
||||
{isTitleReadonly && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] italic">
|
||||
{getSyncMessage('title')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => onTitleChange(e.target.value)}
|
||||
placeholder="Titre de la tâche..."
|
||||
error={errors.title}
|
||||
disabled={loading || isTitleReadonly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Description
|
||||
</label>
|
||||
{isDescriptionReadonly && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] italic">
|
||||
{getSyncMessage('description')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => onDescriptionChange(e.target.value)}
|
||||
placeholder="Description détaillée..."
|
||||
rows={4}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none"
|
||||
disabled={loading || isDescriptionReadonly}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm placeholder-[var(--muted-foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm resize-none disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--card-column)] disabled:text-[var(--muted-foreground)] disabled:border-[var(--border)]/30"
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="text-xs font-mono text-red-400 flex items-center gap-1">
|
||||
@@ -71,14 +113,21 @@ export function TaskBasicFields({
|
||||
{/* Priorité et Statut */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Priorité
|
||||
</label>
|
||||
{isPriorityReadonly && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] italic">
|
||||
{getSyncMessage('priority')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => onPriorityChange(e.target.value as TaskPriority)}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
disabled={loading || isPriorityReadonly}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--card-column)] disabled:text-[var(--muted-foreground)] disabled:border-[var(--border)]/30"
|
||||
>
|
||||
{getAllPriorities().map((priorityConfig) => (
|
||||
<option key={priorityConfig.key} value={priorityConfig.key}>
|
||||
@@ -89,14 +138,21 @@ export function TaskBasicFields({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
|
||||
Statut
|
||||
</label>
|
||||
{isStatusReadonly && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] italic">
|
||||
{getSyncMessage('status')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => onStatusChange(e.target.value as TaskStatus)}
|
||||
disabled={loading}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm"
|
||||
disabled={loading || isStatusReadonly}
|
||||
className="w-full px-3 py-2 bg-[var(--input)] border border-[var(--border)]/50 rounded-lg text-[var(--foreground)] font-mono text-sm focus:outline-none focus:ring-2 focus:ring-[var(--primary)]/50 focus:border-[var(--primary)]/50 hover:border-[var(--border)] transition-all duration-200 backdrop-blur-sm disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[var(--card-column)] disabled:text-[var(--muted-foreground)] disabled:border-[var(--border)]/30"
|
||||
>
|
||||
{getAllStatuses().map((statusConfig) => (
|
||||
<option key={statusConfig.key} value={statusConfig.key}>
|
||||
@@ -109,13 +165,20 @@ export function TaskBasicFields({
|
||||
|
||||
{/* Date d'échéance */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--foreground)] mb-2">
|
||||
Date d'échéance
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<label className="block text-sm font-medium text-[var(--foreground)]">
|
||||
Date d'échéance
|
||||
</label>
|
||||
{isDueDateReadonly && (
|
||||
<span className="text-xs text-[var(--muted-foreground)] italic">
|
||||
{getSyncMessage('dueDate')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<DateTimeInput
|
||||
value={ensureDate(dueDate) ?? undefined}
|
||||
onChange={(newDate) => onDueDateChange(newDate)}
|
||||
disabled={loading}
|
||||
disabled={loading || isDueDateReadonly}
|
||||
placeholder="Sélectionner une date d'échéance..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ export interface Task {
|
||||
assignee?: string;
|
||||
ownerId: string; // ID du propriétaire de la tâche
|
||||
todosCount?: number; // Nombre de todos reliés à cette tâche
|
||||
readonlyFields?: string[]; // Liste des champs en lecture seule (pour les tâches synchronisées)
|
||||
}
|
||||
|
||||
// Interface pour les tags
|
||||
|
||||
@@ -7,6 +7,7 @@ import { JiraTask } from '@/lib/types';
|
||||
import { prisma } from '@/services/core/database';
|
||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||
import { tagsService } from '../../task-management/tags';
|
||||
import { buildSyncUpdateData } from '../../task-management/readonly-fields';
|
||||
|
||||
export interface JiraConfig {
|
||||
enabled: boolean;
|
||||
@@ -527,50 +528,50 @@ export class JiraService {
|
||||
} else {
|
||||
// Toujours mettre à jour les données Jira (écrasement forcé)
|
||||
|
||||
// Utiliser la logique centralisée pour déterminer quels champs préserver
|
||||
const finalData = buildSyncUpdateData('jira', existingTask, taskData);
|
||||
|
||||
// Détecter les changements et créer la liste des modifications
|
||||
const changes: string[] = [];
|
||||
|
||||
// Préserver le titre et la priorité si modifiés localement
|
||||
const finalTitle =
|
||||
existingTask.title !== taskData.title
|
||||
? existingTask.title
|
||||
: taskData.title;
|
||||
const finalPriority =
|
||||
existingTask.priority !== taskData.priority
|
||||
? existingTask.priority
|
||||
: taskData.priority;
|
||||
// Préserver le statut archived local - ne jamais désarchiver depuis Jira
|
||||
const finalStatus =
|
||||
existingTask.status === 'archived' ? 'archived' : taskData.status;
|
||||
|
||||
if (existingTask.title !== taskData.title) {
|
||||
// Détecter les changements en comparant les valeurs finales avec les existantes
|
||||
if (existingTask.title !== finalData.title) {
|
||||
changes.push(`Titre: ${existingTask.title} → ${finalData.title}`);
|
||||
} else if (existingTask.title !== taskData.title) {
|
||||
// Valeur préservée (finalData === existingTask mais différent de taskData)
|
||||
changes.push(`Titre: préservé localement ("${existingTask.title}")`);
|
||||
}
|
||||
if (existingTask.description !== taskData.description) {
|
||||
|
||||
if (existingTask.description !== finalData.description) {
|
||||
changes.push(`Description modifiée`);
|
||||
}
|
||||
if (
|
||||
existingTask.status === 'archived' &&
|
||||
taskData.status !== 'archived'
|
||||
) {
|
||||
changes.push(`Statut: préservé localement (archived)`);
|
||||
} else if (existingTask.status !== finalStatus) {
|
||||
changes.push(`Statut: ${existingTask.status} → ${finalStatus}`);
|
||||
|
||||
if (existingTask.status !== finalData.status) {
|
||||
changes.push(`Statut: ${existingTask.status} → ${finalData.status}`);
|
||||
} else if (existingTask.status !== taskData.status) {
|
||||
// Valeur préservée (finalData === existingTask mais différent de taskData)
|
||||
changes.push(`Statut: préservé localement (${existingTask.status})`);
|
||||
}
|
||||
if (existingTask.priority !== taskData.priority) {
|
||||
|
||||
if (existingTask.priority !== finalData.priority) {
|
||||
changes.push(
|
||||
`Priorité: ${existingTask.priority} → ${finalData.priority}`
|
||||
);
|
||||
} else if (existingTask.priority !== taskData.priority) {
|
||||
// Valeur préservée (finalData === existingTask mais différent de taskData)
|
||||
changes.push(
|
||||
`Priorité: préservée localement (${existingTask.priority})`
|
||||
);
|
||||
}
|
||||
if (
|
||||
(existingTask.dueDate?.getTime() || null) !==
|
||||
(taskData.dueDate?.getTime() || null)
|
||||
(finalData.dueDate?.getTime() || null)
|
||||
) {
|
||||
const oldDate = existingTask.dueDate
|
||||
? formatDateForDisplay(existingTask.dueDate)
|
||||
: 'Aucune';
|
||||
const newDate = taskData.dueDate
|
||||
? formatDateForDisplay(taskData.dueDate)
|
||||
const newDate = finalData.dueDate
|
||||
? formatDateForDisplay(finalData.dueDate)
|
||||
: 'Aucune';
|
||||
changes.push(`Échéance: ${oldDate} → ${newDate}`);
|
||||
}
|
||||
@@ -604,7 +605,7 @@ export class JiraService {
|
||||
};
|
||||
}
|
||||
|
||||
// Préparer les données de mise à jour
|
||||
// Préparer les données de mise à jour en utilisant les valeurs finales déterminées par la logique centralisée
|
||||
const updateData: {
|
||||
title: string;
|
||||
description: string | null;
|
||||
@@ -618,11 +619,7 @@ export class JiraService {
|
||||
updatedAt: Date;
|
||||
completedAt?: Date;
|
||||
} = {
|
||||
title: finalTitle,
|
||||
description: taskData.description,
|
||||
status: finalStatus,
|
||||
priority: finalPriority,
|
||||
dueDate: taskData.dueDate,
|
||||
...finalData,
|
||||
jiraProject: taskData.jiraProject,
|
||||
jiraKey: taskData.jiraKey,
|
||||
jiraType: taskData.jiraType,
|
||||
@@ -631,7 +628,7 @@ export class JiraService {
|
||||
};
|
||||
|
||||
// Si la tâche passe à "done" et n'a pas encore de completedAt, le définir
|
||||
if (finalStatus === 'done' && !existingTask.completedAt) {
|
||||
if (finalData.status === 'done' && !existingTask.completedAt) {
|
||||
updateData.completedAt = new Date();
|
||||
console.log(` ✅ Tâche marquée comme terminée (completedAt défini)`);
|
||||
}
|
||||
@@ -657,6 +654,49 @@ export class JiraService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine quels champs sont en lecture seule pour une tâche Jira
|
||||
* Ces champs sont ceux qui sont écrasés par la synchronisation Jira
|
||||
* @deprecated Utiliser getReadonlyFieldsForTask() depuis readonly-fields.ts à la place
|
||||
*/
|
||||
async getReadonlyFields(taskId: string): Promise<string[]> {
|
||||
try {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
select: {
|
||||
source: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Si ce n'est pas une tâche Jira, aucun champ n'est en lecture seule
|
||||
if (!task || task.source !== 'jira') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Utiliser la logique centralisée
|
||||
const { getReadonlyFieldsForTask } = await import(
|
||||
'../../task-management/readonly-fields'
|
||||
);
|
||||
return getReadonlyFieldsForTask(
|
||||
'jira',
|
||||
task.status as
|
||||
| 'backlog'
|
||||
| 'todo'
|
||||
| 'in_progress'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
| 'archived'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Erreur lors de la détermination des champs readonly:',
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nettoie les tâches Jira qui ne sont plus assignées à l'utilisateur
|
||||
* Ne supprime PAS les tâches déjà terminées (done/archived) pour garder l'historique
|
||||
|
||||
@@ -9,6 +9,7 @@ import { prisma } from '@/services/core/database';
|
||||
import { parseDate, formatDateForDisplay } from '@/lib/date-utils';
|
||||
import { userPreferencesService } from '@/services/core/user-preferences';
|
||||
import { tagsService } from '../../task-management/tags';
|
||||
import { buildSyncUpdateData } from '../../task-management/readonly-fields';
|
||||
|
||||
export interface TfsConfig {
|
||||
enabled: boolean;
|
||||
@@ -674,18 +675,38 @@ export class TfsService {
|
||||
prTitle: pr.title,
|
||||
};
|
||||
} else {
|
||||
// Utiliser la logique centralisée pour déterminer quels champs préserver
|
||||
const finalData = buildSyncUpdateData('tfs', existingTask, taskData);
|
||||
|
||||
// Détecter les changements
|
||||
const changes: string[] = [];
|
||||
|
||||
if (existingTask.title !== taskData.title) {
|
||||
changes.push(`Titre: ${existingTask.title} → ${taskData.title}`);
|
||||
if (existingTask.title !== finalData.title) {
|
||||
changes.push(`Titre: ${existingTask.title} → ${finalData.title}`);
|
||||
}
|
||||
if (existingTask.status !== taskData.status) {
|
||||
changes.push(`Statut: ${existingTask.status} → ${taskData.status}`);
|
||||
if (existingTask.status !== finalData.status) {
|
||||
changes.push(`Statut: ${existingTask.status} → ${finalData.status}`);
|
||||
}
|
||||
if (existingTask.description !== taskData.description) {
|
||||
if (existingTask.description !== finalData.description) {
|
||||
changes.push('Description modifiée');
|
||||
}
|
||||
if (existingTask.priority !== finalData.priority) {
|
||||
changes.push(
|
||||
`Priorité: ${existingTask.priority} → ${finalData.priority}`
|
||||
);
|
||||
}
|
||||
if (
|
||||
(existingTask.dueDate?.getTime() || null) !==
|
||||
(finalData.dueDate?.getTime() || null)
|
||||
) {
|
||||
const oldDate = existingTask.dueDate
|
||||
? formatDateForDisplay(existingTask.dueDate)
|
||||
: 'Aucune';
|
||||
const newDate = finalData.dueDate
|
||||
? formatDateForDisplay(finalData.dueDate)
|
||||
: 'Aucune';
|
||||
changes.push(`Échéance: ${oldDate} → ${newDate}`);
|
||||
}
|
||||
if (existingTask.assignee !== taskData.assignee) {
|
||||
changes.push(
|
||||
`Assigné: ${existingTask.assignee} → ${taskData.assignee}`
|
||||
@@ -704,11 +725,17 @@ export class TfsService {
|
||||
};
|
||||
}
|
||||
|
||||
// Mettre à jour la tâche
|
||||
// Mettre à jour la tâche avec les données finales déterminées par la logique centralisée
|
||||
await prisma.task.update({
|
||||
where: { id: existingTask.id },
|
||||
data: {
|
||||
...taskData,
|
||||
...finalData,
|
||||
tfsProject: taskData.tfsProject,
|
||||
tfsPullRequestId: taskData.tfsPullRequestId,
|
||||
tfsRepository: taskData.tfsRepository,
|
||||
tfsSourceBranch: taskData.tfsSourceBranch,
|
||||
tfsTargetBranch: taskData.tfsTargetBranch,
|
||||
assignee: taskData.assignee,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -722,6 +749,49 @@ export class TfsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine quels champs sont en lecture seule pour une tâche TFS
|
||||
* Ces champs sont ceux qui sont écrasés par la synchronisation TFS
|
||||
* @deprecated Utiliser getReadonlyFieldsForTask() depuis readonly-fields.ts à la place
|
||||
*/
|
||||
async getReadonlyFields(taskId: string): Promise<string[]> {
|
||||
try {
|
||||
const task = await prisma.task.findUnique({
|
||||
where: { id: taskId },
|
||||
select: {
|
||||
source: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Si ce n'est pas une tâche TFS, aucun champ n'est en lecture seule
|
||||
if (!task || task.source !== 'tfs') {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Utiliser la logique centralisée
|
||||
const { getReadonlyFieldsForTask } = await import(
|
||||
'../../task-management/readonly-fields'
|
||||
);
|
||||
return getReadonlyFieldsForTask(
|
||||
'tfs',
|
||||
task.status as
|
||||
| 'backlog'
|
||||
| 'todo'
|
||||
| 'in_progress'
|
||||
| 'done'
|
||||
| 'cancelled'
|
||||
| 'archived'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Erreur lors de la détermination des champs readonly:',
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* S'assure que le tag TFS existe
|
||||
*/
|
||||
|
||||
255
src/services/task-management/__tests__/readonly-fields.test.ts
Normal file
255
src/services/task-management/__tests__/readonly-fields.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Tests unitaires pour la logique de détermination des champs readonly
|
||||
*/
|
||||
|
||||
import {
|
||||
getReadonlyFieldsForTask,
|
||||
shouldPreserveFieldDuringSync,
|
||||
buildSyncUpdateData,
|
||||
} from '../readonly-fields';
|
||||
|
||||
describe('getReadonlyFieldsForTask', () => {
|
||||
describe('Jira', () => {
|
||||
it('devrait retourner description et dueDate comme readonly', () => {
|
||||
const result = getReadonlyFieldsForTask('jira', 'todo');
|
||||
expect(result).toContain('description');
|
||||
expect(result).toContain('dueDate');
|
||||
expect(result).toContain('status');
|
||||
expect(result).not.toContain('title');
|
||||
expect(result).not.toContain('priority');
|
||||
});
|
||||
|
||||
it('ne devrait pas retourner status comme readonly si archived', () => {
|
||||
const result = getReadonlyFieldsForTask('jira', 'archived');
|
||||
expect(result).toContain('description');
|
||||
expect(result).toContain('dueDate');
|
||||
expect(result).not.toContain('status');
|
||||
});
|
||||
|
||||
it('devrait permettre title et priority comme modifiables', () => {
|
||||
const result = getReadonlyFieldsForTask('jira', 'todo');
|
||||
expect(result).not.toContain('title');
|
||||
expect(result).not.toContain('priority');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TFS', () => {
|
||||
it('devrait retourner tous les champs comme readonly', () => {
|
||||
const result = getReadonlyFieldsForTask('tfs', 'todo');
|
||||
expect(result).toContain('title');
|
||||
expect(result).toContain('description');
|
||||
expect(result).toContain('status');
|
||||
expect(result).toContain('priority');
|
||||
expect(result).toContain('dueDate');
|
||||
});
|
||||
|
||||
it('devrait retourner tous les champs même si archived', () => {
|
||||
const result = getReadonlyFieldsForTask('tfs', 'archived');
|
||||
expect(result).toContain('title');
|
||||
expect(result).toContain('status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Manual', () => {
|
||||
it('ne devrait retourner aucun champ readonly', () => {
|
||||
const result = getReadonlyFieldsForTask('manual', 'todo');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldPreserveFieldDuringSync', () => {
|
||||
describe('Jira', () => {
|
||||
it('devrait préserver title si modifié localement', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'title',
|
||||
'Titre local modifié',
|
||||
'Titre Jira original'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('ne devrait pas préserver title si identique', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'title',
|
||||
'Même titre',
|
||||
'Même titre'
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('devrait préserver priority si modifié localement', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'priority',
|
||||
'high',
|
||||
'medium'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('devrait préserver status si archived', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'status',
|
||||
'archived',
|
||||
'done',
|
||||
'archived'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('ne devrait pas préserver status si pas archived', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'status',
|
||||
'todo',
|
||||
'in_progress',
|
||||
'todo'
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('ne devrait jamais préserver description', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'description',
|
||||
'Description locale',
|
||||
'Description Jira'
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('ne devrait jamais préserver dueDate', () => {
|
||||
const result = shouldPreserveFieldDuringSync(
|
||||
'jira',
|
||||
'dueDate',
|
||||
new Date('2024-01-01'),
|
||||
new Date('2024-01-02')
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TFS', () => {
|
||||
it('ne devrait jamais préserver aucun champ', () => {
|
||||
expect(
|
||||
shouldPreserveFieldDuringSync('tfs', 'title', 'Local', 'TFS')
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPreserveFieldDuringSync('tfs', 'description', 'Local', 'TFS')
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPreserveFieldDuringSync('tfs', 'status', 'todo', 'done')
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPreserveFieldDuringSync('tfs', 'priority', 'high', 'medium')
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldPreserveFieldDuringSync(
|
||||
'tfs',
|
||||
'dueDate',
|
||||
new Date('2024-01-01'),
|
||||
new Date('2024-01-02')
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSyncUpdateData', () => {
|
||||
describe('Jira', () => {
|
||||
const existingTask = {
|
||||
title: 'Tâche locale',
|
||||
description: 'Description locale',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const syncData = {
|
||||
title: 'Tâche Jira',
|
||||
description: 'Description Jira',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
dueDate: new Date('2024-01-02'),
|
||||
};
|
||||
|
||||
it('devrait préserver title si modifié localement', () => {
|
||||
const result = buildSyncUpdateData('jira', existingTask, syncData);
|
||||
expect(result.title).toBe('Tâche locale');
|
||||
});
|
||||
|
||||
it('devrait préserver priority si modifié localement', () => {
|
||||
const result = buildSyncUpdateData('jira', existingTask, syncData);
|
||||
expect(result.priority).toBe('high');
|
||||
});
|
||||
|
||||
it('devrait écraser description', () => {
|
||||
const result = buildSyncUpdateData('jira', existingTask, syncData);
|
||||
expect(result.description).toBe('Description Jira');
|
||||
});
|
||||
|
||||
it('devrait écraser dueDate', () => {
|
||||
const result = buildSyncUpdateData('jira', existingTask, syncData);
|
||||
expect(result.dueDate).toEqual(new Date('2024-01-02'));
|
||||
});
|
||||
|
||||
it('devrait écraser status si pas archived', () => {
|
||||
const result = buildSyncUpdateData('jira', existingTask, syncData);
|
||||
expect(result.status).toBe('in_progress');
|
||||
});
|
||||
|
||||
it('devrait préserver status si archived', () => {
|
||||
const archivedTask = { ...existingTask, status: 'archived' };
|
||||
const result = buildSyncUpdateData('jira', archivedTask, syncData);
|
||||
expect(result.status).toBe('archived');
|
||||
});
|
||||
|
||||
it('devrait utiliser syncData si title identique', () => {
|
||||
const sameTitleTask = { ...existingTask, title: 'Tâche Jira' };
|
||||
const result = buildSyncUpdateData('jira', sameTitleTask, syncData);
|
||||
expect(result.title).toBe('Tâche Jira');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TFS', () => {
|
||||
const existingTask = {
|
||||
title: 'Tâche locale',
|
||||
description: 'Description locale',
|
||||
status: 'todo',
|
||||
priority: 'high',
|
||||
dueDate: new Date('2024-01-01'),
|
||||
};
|
||||
|
||||
const syncData = {
|
||||
title: 'PR: Tâche TFS',
|
||||
description: 'Description TFS',
|
||||
status: 'done',
|
||||
priority: 'medium',
|
||||
dueDate: null,
|
||||
};
|
||||
|
||||
it('devrait écraser tous les champs', () => {
|
||||
const result = buildSyncUpdateData('tfs', existingTask, syncData);
|
||||
expect(result.title).toBe('PR: Tâche TFS');
|
||||
expect(result.description).toBe('Description TFS');
|
||||
expect(result.status).toBe('done');
|
||||
expect(result.priority).toBe('medium');
|
||||
expect(result.dueDate).toBeNull();
|
||||
});
|
||||
|
||||
it('devrait écraser même si valeurs différentes localement', () => {
|
||||
const modifiedTask = {
|
||||
...existingTask,
|
||||
title: 'Titre modifié localement',
|
||||
priority: 'low',
|
||||
};
|
||||
const result = buildSyncUpdateData('tfs', modifiedTask, syncData);
|
||||
expect(result.title).toBe('PR: Tâche TFS');
|
||||
expect(result.priority).toBe('medium');
|
||||
});
|
||||
});
|
||||
});
|
||||
163
src/services/task-management/readonly-fields.ts
Normal file
163
src/services/task-management/readonly-fields.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Détermine quels champs sont en lecture seule pour une tâche synchronisée
|
||||
* Cette logique centralisée évite la duplication et facilite la maintenance
|
||||
*/
|
||||
|
||||
import { TaskSource, TaskStatus } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* Détermine les champs en lecture seule selon la source de synchronisation
|
||||
* @param source - Source de la tâche (jira, tfs, manual)
|
||||
* @param status - Statut actuel de la tâche (pour les règles spécifiques comme archived)
|
||||
* @returns Liste des noms de champs en lecture seule
|
||||
*/
|
||||
export function getReadonlyFieldsForTask(
|
||||
source: TaskSource,
|
||||
status?: TaskStatus
|
||||
): string[] {
|
||||
const readonlyFields: string[] = [];
|
||||
|
||||
if (source === 'jira') {
|
||||
// description: toujours écrasé par Jira → non modifiable
|
||||
readonlyFields.push('description');
|
||||
// dueDate: toujours écrasé par Jira → non modifiable
|
||||
readonlyFields.push('dueDate');
|
||||
// status: écrasé par Jira SAUF si archived (car archived est préservé localement)
|
||||
if (status !== 'archived') {
|
||||
readonlyFields.push('status');
|
||||
}
|
||||
// title et priority: peuvent être préservés si modifiés localement → modifiables
|
||||
} else if (source === 'tfs') {
|
||||
// Pour TFS, tous les champs synchronisés sont écrasés → non modifiables
|
||||
readonlyFields.push('title');
|
||||
readonlyFields.push('description');
|
||||
readonlyFields.push('status');
|
||||
readonlyFields.push('priority');
|
||||
readonlyFields.push('dueDate');
|
||||
}
|
||||
// Pour 'manual', aucun champ n'est readonly
|
||||
|
||||
return readonlyFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Détermine si un champ doit être préservé lors de la synchronisation
|
||||
* Cette fonction centralise la logique métier de préservation des champs
|
||||
* @param source - Source de la tâche (jira, tfs, manual)
|
||||
* @param field - Nom du champ à vérifier
|
||||
* @param existingValue - Valeur actuelle dans la base de données
|
||||
* @param syncValue - Valeur venant de la source externe (Jira/TFS)
|
||||
* @param currentStatus - Statut actuel de la tâche (pour les règles spécifiques)
|
||||
* @returns true si le champ doit être préservé (pas écrasé), false sinon
|
||||
*/
|
||||
export function shouldPreserveFieldDuringSync(
|
||||
source: TaskSource,
|
||||
field: 'title' | 'description' | 'status' | 'priority' | 'dueDate',
|
||||
existingValue: unknown,
|
||||
syncValue: unknown,
|
||||
currentStatus?: TaskStatus
|
||||
): boolean {
|
||||
if (source === 'jira') {
|
||||
// Pour Jira, certains champs peuvent être préservés s'ils ont été modifiés localement
|
||||
if (field === 'title' || field === 'priority') {
|
||||
// Préserver si la valeur locale est différente de celle de Jira (modifié localement)
|
||||
return existingValue !== syncValue;
|
||||
}
|
||||
if (field === 'status') {
|
||||
// Préserver le statut archived local - ne jamais désarchiver depuis Jira
|
||||
return currentStatus === 'archived';
|
||||
}
|
||||
// description et dueDate: toujours écrasés
|
||||
return false;
|
||||
} else if (source === 'tfs') {
|
||||
// Pour TFS, tous les champs sont écrasés (aucun n'est préservé)
|
||||
return false;
|
||||
}
|
||||
// Pour 'manual', pas de synchronisation
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface pour les données de synchronisation d'une tâche
|
||||
*/
|
||||
export interface SyncTaskData {
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface pour les données existantes d'une tâche
|
||||
*/
|
||||
export interface ExistingTaskData {
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit les données de mise à jour en appliquant la logique de préservation centralisée
|
||||
* @param source - Source de la tâche (jira, tfs, manual)
|
||||
* @param existingTask - Données existantes de la tâche
|
||||
* @param syncData - Données venant de la source externe
|
||||
* @returns Données finales à utiliser pour la mise à jour
|
||||
*/
|
||||
export function buildSyncUpdateData(
|
||||
source: TaskSource,
|
||||
existingTask: ExistingTaskData,
|
||||
syncData: SyncTaskData
|
||||
): SyncTaskData {
|
||||
const currentStatus = existingTask.status as TaskStatus;
|
||||
|
||||
return {
|
||||
title: shouldPreserveFieldDuringSync(
|
||||
source,
|
||||
'title',
|
||||
existingTask.title,
|
||||
syncData.title
|
||||
)
|
||||
? existingTask.title
|
||||
: syncData.title,
|
||||
|
||||
description: shouldPreserveFieldDuringSync(
|
||||
source,
|
||||
'description',
|
||||
existingTask.description,
|
||||
syncData.description
|
||||
)
|
||||
? existingTask.description
|
||||
: syncData.description,
|
||||
|
||||
status: shouldPreserveFieldDuringSync(
|
||||
source,
|
||||
'status',
|
||||
existingTask.status,
|
||||
syncData.status,
|
||||
currentStatus
|
||||
)
|
||||
? existingTask.status
|
||||
: syncData.status,
|
||||
|
||||
priority: shouldPreserveFieldDuringSync(
|
||||
source,
|
||||
'priority',
|
||||
existingTask.priority,
|
||||
syncData.priority
|
||||
)
|
||||
? existingTask.priority
|
||||
: syncData.priority,
|
||||
|
||||
dueDate: shouldPreserveFieldDuringSync(
|
||||
source,
|
||||
'dueDate',
|
||||
existingTask.dueDate,
|
||||
syncData.dueDate
|
||||
)
|
||||
? existingTask.dueDate
|
||||
: syncData.dueDate,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { getToday } from '@/lib/date-utils';
|
||||
import { tagsService } from './tags';
|
||||
import { getReadonlyFieldsForTask } from './readonly-fields';
|
||||
|
||||
/**
|
||||
* Service pour la gestion des tâches (version standalone)
|
||||
@@ -438,6 +439,12 @@ export class TasksService {
|
||||
todosCount = prismaTask._count.dailyCheckboxes || 0;
|
||||
}
|
||||
|
||||
// Déterminer les champs en lecture seule selon la source de synchronisation
|
||||
const readonlyFields = getReadonlyFieldsForTask(
|
||||
prismaTask.source as TaskSource,
|
||||
prismaTask.status as TaskStatus
|
||||
);
|
||||
|
||||
return {
|
||||
id: prismaTask.id,
|
||||
title: prismaTask.title,
|
||||
@@ -475,6 +482,7 @@ export class TasksService {
|
||||
assignee: prismaTask.assignee ?? undefined,
|
||||
ownerId: prismaTask.ownerId,
|
||||
todosCount: todosCount,
|
||||
readonlyFields: readonlyFields.length > 0 ? readonlyFields : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user