feat: add sorting functionality to KanbanFilters

- Enhanced `KanbanFilters` to include sorting options, allowing users to sort tasks dynamically.
- Implemented dropdown for sorting with options rendered via portal to manage z-index issues.
- Updated `TasksContext` to handle sorting preferences and apply sorting logic to both pinned and regular tasks.
- Added `sortBy` property to `KanbanFilters` and user preferences for persistent sorting settings.
This commit is contained in:
Julien Froidefond
2025-09-15 08:32:01 +02:00
parent d681a6c127
commit c8cacf1714
4 changed files with 357 additions and 17 deletions

View File

@@ -1,11 +1,13 @@
'use client';
import { useState } from 'react';
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { TaskPriority } from '@/lib/types';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { useTasksContext } from '@/contexts/TasksContext';
import { getAllPriorities, getPriorityColorHex } from '@/lib/status-config';
import { SORT_OPTIONS } from '@/lib/sort-config';
export interface KanbanFilters {
search?: string;
@@ -15,6 +17,7 @@ export interface KanbanFilters {
compactView?: boolean;
swimlanesByTags?: boolean;
pinnedTag?: string; // Tag pour les objectifs principaux
sortBy?: string; // Clé de l'option de tri sélectionnée
}
interface KanbanFiltersProps {
@@ -25,6 +28,24 @@ interface KanbanFiltersProps {
export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps) {
const { tags: availableTags } = useTasksContext();
const [isExpanded, setIsExpanded] = useState(false);
const [isSortExpanded, setIsSortExpanded] = useState(false);
const sortDropdownRef = useRef<HTMLDivElement>(null);
const sortButtonRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
// Fermer le dropdown de tri en cliquant à l'extérieur
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) {
setIsSortExpanded(false);
}
}
if (isSortExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isSortExpanded]);
const handleSearchChange = (search: string) => {
onFiltersChange({ ...filters, search: search || undefined });
@@ -68,6 +89,24 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
});
};
const handleSortChange = (sortKey: string) => {
onFiltersChange({
...filters,
sortBy: sortKey
});
};
const handleSortToggle = () => {
if (!isSortExpanded && sortButtonRef.current) {
const rect = sortButtonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX
});
}
setIsSortExpanded(!isSortExpanded);
};
const handleClearFilters = () => {
onFiltersChange({});
};
@@ -139,11 +178,54 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
{filters.compactView ? 'Détaillée' : 'Compacte'}
</Button>
{/* Bouton de tri */}
<div className="relative" ref={sortDropdownRef}>
<Button
ref={sortButtonRef}
variant="ghost"
onClick={handleSortToggle}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
</svg>
Tris
<svg
className={`w-4 h-4 transition-transform ${isSortExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Button>
</div>
<Button
variant="ghost"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Filtres
{activeFiltersCount > 0 && (
<span className="bg-cyan-500 text-slate-900 text-xs px-2 py-0.5 rounded-full font-medium">
{activeFiltersCount}
</span>
)}
<svg
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
@@ -152,12 +234,6 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
Filtres
{activeFiltersCount > 0 && (
<span className="bg-cyan-500 text-slate-900 text-xs px-2 py-0.5 rounded-full font-medium">
{activeFiltersCount}
</span>
)}
</Button>
{activeFiltersCount > 0 && (
@@ -176,7 +252,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
<div className="mt-4 space-y-4 border-t border-slate-700/50 pt-4">
{/* Filtres par priorité */}
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
<label className="block text-xs font-mono font-medium text-slate-300 uppercase tracking-wider">
Priorités
</label>
<div className="flex flex-wrap gap-2">
@@ -184,7 +260,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
<button
key={priority.value}
onClick={() => handlePriorityToggle(priority.value)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-sm font-medium ${
className={`flex items-center gap-2 px-2 py-1 rounded-lg border transition-all text-xs font-medium ${
filters.priorities?.includes(priority.value)
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
: 'border-slate-600 bg-slate-800/50 text-slate-400 hover:border-slate-500'
@@ -203,7 +279,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
{/* Filtres par tags */}
{availableTags.length > 0 && (
<div className="space-y-2">
<label className="block text-sm font-mono font-medium text-slate-300 uppercase tracking-wider">
<label className="block text-xs font-mono font-medium text-slate-300 uppercase tracking-wider">
Tags
</label>
<div className="flex flex-wrap gap-2">
@@ -211,7 +287,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
<button
key={tag.id}
onClick={() => handleTagToggle(tag.name)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border transition-all text-sm font-medium ${
className={`flex items-center gap-2 px-2 py-1 rounded-lg border transition-all text-xs font-medium ${
filters.tags?.includes(tag.name)
? 'border-cyan-400 bg-cyan-400/10 text-cyan-400'
: 'border-slate-600 bg-slate-800/50 text-slate-400 hover:border-slate-500'
@@ -234,7 +310,7 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
<div className="text-xs text-slate-400 font-mono uppercase tracking-wider mb-2">
Filtres actifs
</div>
<div className="space-y-1 text-sm">
<div className="space-y-1 text-xs">
{filters.search && (
<div className="text-slate-300">
Recherche: <span className="text-cyan-400">&ldquo;{filters.search}&rdquo;</span>
@@ -256,6 +332,42 @@ export function KanbanFilters({ filters, onFiltersChange }: KanbanFiltersProps)
</div>
)}
</div>
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
{isSortExpanded && typeof window !== 'undefined' && createPortal(
<div
ref={sortDropdownRef}
className="fixed w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left
}}
>
{SORT_OPTIONS.map((option) => (
<button
key={option.key}
onClick={() => {
handleSortChange(option.key);
setIsSortExpanded(false);
}}
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-slate-700 transition-colors flex items-center gap-2 ${
(filters.sortBy || 'priority-desc') === option.key
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
: 'text-slate-300'
}`}
>
<span className="text-base">{option.icon}</span>
<span className="flex-1">{option.label}</span>
{(filters.sortBy || 'priority-desc') === option.key && (
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>,
document.body
)}
</div>
);
}

200
lib/sort-config.ts Normal file
View File

@@ -0,0 +1,200 @@
import { Task, TaskPriority } from './types';
import { getPriorityConfig } from './status-config';
export type SortField = 'priority' | 'tags' | 'createdAt' | 'updatedAt' | 'dueDate' | 'title';
export type SortDirection = 'asc' | 'desc';
export interface SortConfig {
field: SortField;
direction: SortDirection;
}
export interface SortOption {
key: string;
label: string;
field: SortField;
direction: SortDirection;
icon: string;
}
// Configuration des options de tri disponibles
export const SORT_OPTIONS: SortOption[] = [
{
key: 'priority-desc',
label: 'Priorité (Urgente → Faible)',
field: 'priority',
direction: 'desc',
icon: '🔥'
},
{
key: 'priority-asc',
label: 'Priorité (Faible → Urgente)',
field: 'priority',
direction: 'asc',
icon: '🔵'
},
{
key: 'tags-asc',
label: 'Tags (A → Z)',
field: 'tags',
direction: 'asc',
icon: '🏷️'
},
{
key: 'title-asc',
label: 'Titre (A → Z)',
field: 'title',
direction: 'asc',
icon: '📝'
},
{
key: 'title-desc',
label: 'Titre (Z → A)',
field: 'title',
direction: 'desc',
icon: '📝'
},
{
key: 'createdAt-desc',
label: 'Date création (Récent → Ancien)',
field: 'createdAt',
direction: 'desc',
icon: '📅'
},
{
key: 'createdAt-asc',
label: 'Date création (Ancien → Récent)',
field: 'createdAt',
direction: 'asc',
icon: '📅'
},
{
key: 'dueDate-asc',
label: 'Échéance (Proche → Lointaine)',
field: 'dueDate',
direction: 'asc',
icon: '⏰'
},
{
key: 'dueDate-desc',
label: 'Échéance (Lointaine → Proche)',
field: 'dueDate',
direction: 'desc',
icon: '⏰'
}
];
// Tri par défaut : Priorité (desc) puis Tags (asc)
export const DEFAULT_SORT: SortConfig[] = [
{ field: 'priority', direction: 'desc' },
{ field: 'tags', direction: 'asc' }
];
/**
* Compare deux valeurs selon la direction de tri
*/
function compareValues<T>(a: T, b: T, direction: SortDirection): number {
if (a === b) return 0;
if (a == null) return 1;
if (b == null) return -1;
const result = a < b ? -1 : 1;
return direction === 'asc' ? result : -result;
}
/**
* Obtient la valeur de priorité numérique pour le tri
*/
function getPriorityValue(priority: TaskPriority): number {
return getPriorityConfig(priority).order;
}
/**
* Obtient le premier tag pour le tri (ou chaîne vide si pas de tags)
*/
function getFirstTag(task: Task): string {
return task.tags?.[0]?.toLowerCase() || '';
}
/**
* Compare deux tâches selon un critère de tri
*/
function compareTasksByField(a: Task, b: Task, sortConfig: SortConfig): number {
const { field, direction } = sortConfig;
switch (field) {
case 'priority':
return compareValues(
getPriorityValue(a.priority),
getPriorityValue(b.priority),
direction
);
case 'tags':
return compareValues(
getFirstTag(a),
getFirstTag(b),
direction
);
case 'title':
return compareValues(
a.title.toLowerCase(),
b.title.toLowerCase(),
direction
);
case 'createdAt':
return compareValues(
new Date(a.createdAt).getTime(),
new Date(b.createdAt).getTime(),
direction
);
case 'updatedAt':
return compareValues(
new Date(a.updatedAt).getTime(),
new Date(b.updatedAt).getTime(),
direction
);
case 'dueDate':
return compareValues(
a.dueDate ? new Date(a.dueDate).getTime() : null,
b.dueDate ? new Date(b.dueDate).getTime() : null,
direction
);
default:
return 0;
}
}
/**
* Trie un tableau de tâches selon une configuration de tri multiple
*/
export function sortTasks(tasks: Task[], sortConfigs: SortConfig[] = DEFAULT_SORT): Task[] {
return [...tasks].sort((a, b) => {
for (const sortConfig of sortConfigs) {
const result = compareTasksByField(a, b, sortConfig);
if (result !== 0) {
return result;
}
}
return 0;
});
}
/**
* Utilitaire pour obtenir une option de tri par sa clé
*/
export function getSortOption(key: string): SortOption | undefined {
return SORT_OPTIONS.find(option => option.key === key);
}
/**
* Utilitaire pour créer une clé de tri
*/
export function createSortKey(field: SortField, direction: SortDirection): string {
return `${field}-${direction}`;
}

View File

@@ -6,6 +6,7 @@ export interface KanbanFilters {
tags?: string[];
priorities?: TaskPriority[];
showCompleted?: boolean;
sortBy?: string;
}
export interface ViewPreferences {

View File

@@ -7,6 +7,7 @@ import { userPreferencesService } from '@/services/user-preferences';
import { Task, Tag } from '@/lib/types';
import { CreateTaskData, UpdateTaskData, TaskFilters } from '@/clients/tasks-client';
import { KanbanFilters } from '@/components/kanban/KanbanFilters';
import { sortTasks, getSortOption, DEFAULT_SORT, createSortKey } from '@/lib/sort-config';
interface TasksContextType {
tasks: Task[];
@@ -69,6 +70,7 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
tags: savedFilters.tags,
priorities: savedFilters.priorities,
showCompleted: savedFilters.showCompleted,
sortBy: savedFilters.sortBy || createSortKey('priority', 'desc'), // Tri par défaut
compactView: savedViewPrefs.compactView,
swimlanesByTags: savedViewPrefs.swimlanesByTags
});
@@ -83,7 +85,8 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
search: newFilters.search,
tags: newFilters.tags,
priorities: newFilters.priorities,
showCompleted: newFilters.showCompleted
showCompleted: newFilters.showCompleted,
sortBy: newFilters.sortBy
});
// Sauvegarder les préférences de vue
@@ -94,7 +97,7 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
});
};
// Séparer les tâches épinglées (objectifs) des autres
// Séparer les tâches épinglées (objectifs) des autres et les trier
const { pinnedTasks, regularTasks } = useMemo(() => {
const pinnedTagNames = tags.filter(tag => tag.isPinned).map(tag => tag.name);
@@ -110,10 +113,20 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
}
});
return { pinnedTasks: pinned, regularTasks: regular };
}, [tasksState.tasks, tags]);
// Trier les tâches épinglées avec le même tri que les autres
const sortedPinned = kanbanFilters.sortBy ?
(() => {
const sortOption = getSortOption(kanbanFilters.sortBy);
return sortOption ?
sortTasks(pinned, [{ field: sortOption.field, direction: sortOption.direction }]) :
sortTasks(pinned, DEFAULT_SORT);
})() :
sortTasks(pinned, DEFAULT_SORT);
// Filtrage des tâches régulières (pas les épinglées)
return { pinnedTasks: sortedPinned, regularTasks: regular };
}, [tasksState.tasks, tags, kanbanFilters.sortBy]);
// Filtrage et tri des tâches régulières (pas les épinglées)
const filteredTasks = useMemo(() => {
let filtered = regularTasks;
@@ -143,6 +156,20 @@ export function TasksProvider({ children, initialTasks, initialStats, initialTag
);
}
// Tri des tâches
if (kanbanFilters.sortBy) {
const sortOption = getSortOption(kanbanFilters.sortBy);
if (sortOption) {
filtered = sortTasks(filtered, [{
field: sortOption.field,
direction: sortOption.direction
}]);
}
} else {
// Tri par défaut (priorité desc + tags asc)
filtered = sortTasks(filtered, DEFAULT_SORT);
}
return filtered;
}, [regularTasks, kanbanFilters]);