refactor(tracks): TrackHistory module, re-export, stories, tests
- Module track-history: types, useTrackHistory, Header, Empty, ItemRow, Pagination, Skeleton, trackHistoryUtils - Re-export from TrackHistory.tsx - Stories: Default, Loading, Empty, Error (MSW) - Tests: mock @/features/tracks/services/trackHistoryService, formatHistoryDate defensive, pagination/error tests fixed Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2f72c2cb8a
commit
1ea128b60d
13 changed files with 603 additions and 366 deletions
|
|
@ -1,32 +1,92 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { TrackHistory } from './TrackHistory';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { TrackHistory, TrackHistorySkeleton } from './TrackHistory';
|
||||
|
||||
// Ideally we mock the service, but here we might see a loading state if service fails.
|
||||
// Since we can't easily mock module imports without specific addon configuration in this environment,
|
||||
// we will accept the loading/error state as a valid visual test, OR rely on a mock service if one exists.
|
||||
// However, looking at the code, TrackHistory isn't using React Query, it's using useEffect.
|
||||
// So we can't seed it via QueryClient.
|
||||
// We accepted that limitation for now or we build a mock if required.
|
||||
// For now, let's render it - it will likely show "Loading" or "Error", which is still useful.
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Features/Tracks/TrackHistory',
|
||||
component: TrackHistory,
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-xl border p-4 rounded bg-kodo-background">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof TrackHistory>;
|
||||
const meta: Meta<typeof TrackHistory> = {
|
||||
title: 'Components/Features/Tracks/TrackHistory',
|
||||
component: TrackHistory,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Historique des modifications d'un track avec timeline et pagination.",
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="max-w-xl border p-4 rounded bg-kodo-background min-h-layout-story">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
trackId: 't1',
|
||||
args: { trackId: 't1' },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'MSW retourne 2 entrées (created, updated).',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => <TrackHistorySkeleton />,
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Skeleton de la timeline (header + 3 lignes).',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { trackId: 'empty' },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/tracks/empty/history', () =>
|
||||
HttpResponse.json({
|
||||
success: true,
|
||||
data: { history: [], total: 0, limit: 50, offset: 0 },
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
story: "MSW retourne une liste vide.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: { trackId: 'error' },
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
http.get('*/api/v1/tracks/error/history', () =>
|
||||
HttpResponse.json(
|
||||
{ success: false, error: { message: 'Track introuvable' } },
|
||||
{ status: 404 },
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
story: 'MSW retourne 404. Affichage Alert destructive.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,12 +4,25 @@ import { TrackHistory } from './TrackHistory';
|
|||
import {
|
||||
getTrackHistory,
|
||||
TrackHistoryError,
|
||||
TrackHistoryItem,
|
||||
} from '../services/trackHistoryService';
|
||||
} from '@/features/tracks/services/trackHistoryService';
|
||||
import type { TrackHistory } from '@/features/tracks/services/trackHistoryService';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
|
||||
// Mock services
|
||||
vi.mock('../services/trackHistoryService');
|
||||
// Mock services (path must match hook: track-history/useTrackHistory imports ../../services/trackHistoryService)
|
||||
vi.mock('@/features/tracks/services/trackHistoryService', () => ({
|
||||
getTrackHistory: vi.fn(),
|
||||
TrackHistoryError: class TrackHistoryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public retryable: boolean,
|
||||
public originalError?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TrackHistoryError';
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock('@/hooks/useToast');
|
||||
|
||||
describe('TrackHistory', () => {
|
||||
|
|
@ -23,7 +36,7 @@ describe('TrackHistory', () => {
|
|||
vi.mocked(useToast).mockReturnValue(mockToast);
|
||||
});
|
||||
|
||||
const mockHistoryItems: TrackHistoryItem[] = [
|
||||
const mockHistoryItems: TrackHistory[] = [
|
||||
{
|
||||
id: 1,
|
||||
track_id: 123,
|
||||
|
|
@ -120,14 +133,15 @@ describe('TrackHistory', () => {
|
|||
});
|
||||
|
||||
it('should display error message on load error', async () => {
|
||||
const error = new TrackHistoryError('Failed to load', 'SERVER', true);
|
||||
vi.mocked(getTrackHistory).mockRejectedValue(error);
|
||||
const loadError = new TrackHistoryError('Failed to load', 'SERVER', true);
|
||||
vi.mocked(getTrackHistory).mockImplementation(() =>
|
||||
Promise.reject(loadError),
|
||||
);
|
||||
|
||||
render(<TrackHistory trackId={123} />);
|
||||
render(<TrackHistory trackId="123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Failed to load')).toBeInTheDocument();
|
||||
});
|
||||
const message = await screen.findByText('Failed to load', {}, { timeout: 3000 });
|
||||
expect(message).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display old and new values when available', async () => {
|
||||
|
|
@ -147,7 +161,7 @@ describe('TrackHistory', () => {
|
|||
});
|
||||
|
||||
it('should handle pagination', async () => {
|
||||
const largeHistory: TrackHistoryItem[] = Array.from(
|
||||
const largeHistory: TrackHistory[] = Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => ({
|
||||
id: i + 1,
|
||||
|
|
@ -210,23 +224,37 @@ describe('TrackHistory', () => {
|
|||
});
|
||||
|
||||
it('should disable next button on last page', async () => {
|
||||
vi.mocked(getTrackHistory).mockResolvedValue({
|
||||
history: mockHistoryItems.slice(0, 2),
|
||||
total: 2,
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
});
|
||||
vi.mocked(getTrackHistory)
|
||||
.mockResolvedValueOnce({
|
||||
history: mockHistoryItems.slice(0, 2),
|
||||
total: 3,
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
history: mockHistoryItems.slice(2, 3),
|
||||
total: 3,
|
||||
limit: 2,
|
||||
offset: 2,
|
||||
});
|
||||
|
||||
render(<TrackHistory trackId={123} limit={2} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const nextButton = screen.getByText('Suivant');
|
||||
expect(nextButton).toBeDisabled();
|
||||
expect(screen.getByText(/Affichage 1 - 2 sur 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const nextButton = screen.getByText('Suivant');
|
||||
fireEvent.click(nextButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const nextButtonAfter = screen.getByText('Suivant');
|
||||
expect(nextButtonAfter).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display all action types correctly', async () => {
|
||||
const allActions: TrackHistoryItem[] = [
|
||||
const allActions: TrackHistory[] = [
|
||||
{
|
||||
id: 1,
|
||||
track_id: 123,
|
||||
|
|
|
|||
|
|
@ -1,324 +1,8 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
getTrackHistory,
|
||||
TrackHistory as TrackHistoryItem,
|
||||
TrackHistoryError,
|
||||
TrackHistoryAction,
|
||||
} from '../services/trackHistoryService';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
History,
|
||||
Calendar,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* TrackHistory Component
|
||||
* T0330: Composant pour afficher l'historique des modifications d'un track avec timeline
|
||||
* TrackHistory — re-export from feature module.
|
||||
*/
|
||||
|
||||
interface TrackHistoryProps {
|
||||
trackId: string;
|
||||
className?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export function TrackHistory({
|
||||
trackId,
|
||||
className,
|
||||
limit = 50,
|
||||
}: TrackHistoryProps) {
|
||||
const [history, setHistory] = useState<TrackHistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
const [currentLimit] = useState(limit);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [trackId, currentOffset]);
|
||||
|
||||
const loadHistory = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getTrackHistory(trackId, {
|
||||
limit: currentLimit,
|
||||
offset: currentOffset,
|
||||
});
|
||||
setHistory(data.history);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
if (err instanceof TrackHistoryError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Impossible de charger l'historique");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
const getActionIcon = (action: TrackHistoryAction) => {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return Plus;
|
||||
case 'updated':
|
||||
return Edit;
|
||||
case 'deleted':
|
||||
return Trash2;
|
||||
case 'published':
|
||||
return Eye;
|
||||
case 'unpublished':
|
||||
return EyeOff;
|
||||
case 'restored':
|
||||
return RotateCcw;
|
||||
default:
|
||||
return History;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionLabel = (action: TrackHistoryAction): string => {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return 'Créé';
|
||||
case 'updated':
|
||||
return 'Modifié';
|
||||
case 'deleted':
|
||||
return 'Supprimé';
|
||||
case 'published':
|
||||
return 'Publié';
|
||||
case 'unpublished':
|
||||
return 'Dépublié';
|
||||
case 'restored':
|
||||
return 'Restauré';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
};
|
||||
|
||||
const getActionColor = (action: TrackHistoryAction): string => {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return 'text-kodo-lime bg-kodo-lime/10';
|
||||
case 'updated':
|
||||
return 'text-kodo-steel bg-kodo-steel/10';
|
||||
case 'deleted':
|
||||
return 'text-kodo-red bg-kodo-red/10';
|
||||
case 'published':
|
||||
return 'text-kodo-magenta bg-kodo-magenta/10';
|
||||
case 'unpublished':
|
||||
return 'text-kodo-gold bg-kodo-gold/10';
|
||||
case 'restored':
|
||||
return 'text-cyan-600 bg-cyan-50';
|
||||
default:
|
||||
return 'text-kodo-content-dim bg-kodo-void';
|
||||
}
|
||||
};
|
||||
|
||||
const parseValue = (value?: string): any => {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const formatValue = (value: any): string => {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const handlePreviousPage = () => {
|
||||
if (currentOffset > 0) {
|
||||
setCurrentOffset(Math.max(0, currentOffset - currentLimit));
|
||||
}
|
||||
};
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (currentOffset + currentLimit < total) {
|
||||
setCurrentOffset(currentOffset + currentLimit);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center p-4', className)}>
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('p-4', className)}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasPreviousPage = currentOffset > 0;
|
||||
const hasNextPage = currentOffset + currentLimit < total;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">
|
||||
Historique des modifications
|
||||
</h3>
|
||||
{total > 0 && (
|
||||
<span className="text-sm text-muted-foreground">({total})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{history.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Aucune modification enregistrée</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
|
||||
|
||||
{/* Timeline items */}
|
||||
<div className="space-y-6">
|
||||
{history.map((item) => {
|
||||
const Icon = getActionIcon(item.action);
|
||||
const actionColor = getActionColor(item.action);
|
||||
const oldValue = parseValue(item.old_value);
|
||||
const newValue = parseValue(item.new_value);
|
||||
|
||||
return (
|
||||
<div key={item.id} className="relative flex gap-4">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 border-background',
|
||||
actionColor,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-2 pb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
actionColor.split(' ')[0],
|
||||
)}
|
||||
>
|
||||
{getActionLabel(item.action)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{item.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Values comparison */}
|
||||
{(oldValue !== null || newValue !== null) && (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
{oldValue !== null && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Ancienne valeur:
|
||||
</div>
|
||||
<pre className="text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{formatValue(oldValue)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{newValue !== null && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Nouvelle valeur:
|
||||
</div>
|
||||
<pre className="text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{formatValue(newValue)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > currentLimit && (
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Affichage {currentOffset + 1} -{' '}
|
||||
{Math.min(currentOffset + currentLimit, total)} sur {total}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePreviousPage}
|
||||
disabled={!hasPreviousPage}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export {
|
||||
TrackHistory,
|
||||
TrackHistorySkeleton,
|
||||
} from './track-history';
|
||||
export type { TrackHistoryProps } from './track-history';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTrackHistory } from './useTrackHistory';
|
||||
import { TrackHistoryHeader } from './TrackHistoryHeader';
|
||||
import { TrackHistoryEmpty } from './TrackHistoryEmpty';
|
||||
import { TrackHistoryItemRow } from './TrackHistoryItemRow';
|
||||
import { TrackHistoryPagination } from './TrackHistoryPagination';
|
||||
import type { TrackHistoryProps } from './types';
|
||||
|
||||
export function TrackHistory({
|
||||
trackId,
|
||||
className,
|
||||
limit = 50,
|
||||
}: TrackHistoryProps) {
|
||||
const {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
currentOffset,
|
||||
limit: currentLimit,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
handlePreviousPage,
|
||||
handleNextPage,
|
||||
} = useTrackHistory(trackId, limit);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center p-4 min-h-layout-story',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<LoadingSpinner size="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={cn('p-4', className)}>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
<TrackHistoryHeader total={total} />
|
||||
|
||||
{history.length === 0 ? (
|
||||
<TrackHistoryEmpty />
|
||||
) : (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
|
||||
<div className="space-y-6">
|
||||
{history.map((item) => (
|
||||
<TrackHistoryItemRow key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{total > currentLimit && (
|
||||
<TrackHistoryPagination
|
||||
currentOffset={currentOffset}
|
||||
limit={currentLimit}
|
||||
total={total}
|
||||
hasPreviousPage={hasPreviousPage}
|
||||
hasNextPage={hasNextPage}
|
||||
onPrevious={handlePreviousPage}
|
||||
onNext={handleNextPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { History } from 'lucide-react';
|
||||
|
||||
export function TrackHistoryEmpty() {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<History className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p>Aucune modification enregistrée</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { History } from 'lucide-react';
|
||||
|
||||
interface TrackHistoryHeaderProps {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function TrackHistoryHeader({ total }: TrackHistoryHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Historique des modifications</h3>
|
||||
{total > 0 && (
|
||||
<span className="text-sm text-muted-foreground">({total})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { Calendar } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
getActionIcon,
|
||||
getActionLabel,
|
||||
getActionColor,
|
||||
formatHistoryDate,
|
||||
parseValue,
|
||||
formatValue,
|
||||
} from './trackHistoryUtils';
|
||||
import type { TrackHistoryItem } from '../../services/trackHistoryService';
|
||||
|
||||
interface TrackHistoryItemRowProps {
|
||||
item: TrackHistoryItem;
|
||||
}
|
||||
|
||||
export function TrackHistoryItemRow({ item }: TrackHistoryItemRowProps) {
|
||||
const Icon = getActionIcon(item.action);
|
||||
const actionColor = getActionColor(item.action);
|
||||
const oldValue = parseValue(item.old_value);
|
||||
const newValue = parseValue(item.new_value);
|
||||
|
||||
return (
|
||||
<div className="relative flex gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-12 w-12 items-center justify-center rounded-full border-2 border-background',
|
||||
actionColor,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2 pb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
actionColor.split(' ')[0],
|
||||
)}
|
||||
>
|
||||
{getActionLabel(item.action)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">#{item.id}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatHistoryDate(item.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(oldValue !== null || newValue !== null) && (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
|
||||
{oldValue !== null && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Ancienne valeur:
|
||||
</div>
|
||||
<pre className="text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{formatValue(oldValue)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{newValue !== null && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Nouvelle valeur:
|
||||
</div>
|
||||
<pre className="text-xs bg-background rounded p-2 overflow-x-auto">
|
||||
{formatValue(newValue)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface TrackHistoryPaginationProps {
|
||||
currentOffset: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
hasPreviousPage: boolean;
|
||||
hasNextPage: boolean;
|
||||
onPrevious: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export function TrackHistoryPagination({
|
||||
currentOffset,
|
||||
limit,
|
||||
total,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
onPrevious,
|
||||
onNext,
|
||||
}: TrackHistoryPaginationProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Affichage {currentOffset + 1} -{' '}
|
||||
{Math.min(currentOffset + limit, total)} sur {total}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={!hasPreviousPage}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
Précédent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={!hasNextPage}
|
||||
>
|
||||
Suivant
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Skeleton for TrackHistory — header + timeline placeholders.
|
||||
*/
|
||||
export function TrackHistorySkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-5 rounded bg-muted animate-pulse" />
|
||||
<div className="h-6 w-52 rounded bg-muted animate-pulse" />
|
||||
<div className="h-4 w-8 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative space-y-6">
|
||||
<div className="absolute left-6 top-0 bottom-0 w-0.5 bg-border" />
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="relative flex gap-4">
|
||||
<div className="relative z-10 h-12 w-12 rounded-full bg-muted animate-pulse" />
|
||||
<div className="flex-1 space-y-2 pb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-4 w-24 rounded bg-muted animate-pulse" />
|
||||
<div className="h-3 w-32 rounded bg-muted animate-pulse" />
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-muted animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
export { TrackHistory } from './TrackHistory';
|
||||
export { TrackHistorySkeleton } from './TrackHistorySkeleton';
|
||||
export { TrackHistoryHeader } from './TrackHistoryHeader';
|
||||
export { TrackHistoryEmpty } from './TrackHistoryEmpty';
|
||||
export { TrackHistoryItemRow } from './TrackHistoryItemRow';
|
||||
export { TrackHistoryPagination } from './TrackHistoryPagination';
|
||||
export { useTrackHistory } from './useTrackHistory';
|
||||
export type { TrackHistoryProps, TrackHistoryItem, TrackHistoryAction } from './types';
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import {
|
||||
History,
|
||||
Calendar,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import type { TrackHistoryAction } from '../../services/trackHistoryService';
|
||||
|
||||
export function formatHistoryDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
if (Number.isNaN(date.getTime())) return dateString;
|
||||
return new Intl.DateTimeFormat('fr-FR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getActionIcon(action: TrackHistoryAction): LucideIcon {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return Plus;
|
||||
case 'updated':
|
||||
return Edit;
|
||||
case 'deleted':
|
||||
return Trash2;
|
||||
case 'published':
|
||||
return Eye;
|
||||
case 'unpublished':
|
||||
return EyeOff;
|
||||
case 'restored':
|
||||
return RotateCcw;
|
||||
default:
|
||||
return History;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionLabel(action: TrackHistoryAction): string {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return 'Créé';
|
||||
case 'updated':
|
||||
return 'Modifié';
|
||||
case 'deleted':
|
||||
return 'Supprimé';
|
||||
case 'published':
|
||||
return 'Publié';
|
||||
case 'unpublished':
|
||||
return 'Dépublié';
|
||||
case 'restored':
|
||||
return 'Restauré';
|
||||
default:
|
||||
return action;
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionColor(action: TrackHistoryAction): string {
|
||||
switch (action) {
|
||||
case 'created':
|
||||
return 'text-kodo-lime bg-kodo-lime/10';
|
||||
case 'updated':
|
||||
return 'text-kodo-steel bg-kodo-steel/10';
|
||||
case 'deleted':
|
||||
return 'text-kodo-red bg-kodo-red/10';
|
||||
case 'published':
|
||||
return 'text-kodo-magenta bg-kodo-magenta/10';
|
||||
case 'unpublished':
|
||||
return 'text-kodo-gold bg-kodo-gold/10';
|
||||
case 'restored':
|
||||
return 'text-cyan-600 bg-cyan-50';
|
||||
default:
|
||||
return 'text-kodo-content-dim bg-kodo-void';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseValue(value?: string): unknown {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* TrackHistory — types.
|
||||
*/
|
||||
import type {
|
||||
TrackHistory as TrackHistoryItem,
|
||||
TrackHistoryAction,
|
||||
} from '../../services/trackHistoryService';
|
||||
|
||||
export type { TrackHistoryItem, TrackHistoryAction };
|
||||
|
||||
export interface TrackHistoryProps {
|
||||
trackId: string;
|
||||
className?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
getTrackHistory,
|
||||
TrackHistoryError,
|
||||
type TrackHistory as TrackHistoryItem,
|
||||
} from '../../services/trackHistoryService';
|
||||
|
||||
export function useTrackHistory(trackId: string, limit: number) {
|
||||
const [history, setHistory] = useState<TrackHistoryItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getTrackHistory(trackId, {
|
||||
limit,
|
||||
offset: currentOffset,
|
||||
});
|
||||
setHistory(data.history);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
if (err instanceof TrackHistoryError) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError("Impossible de charger l'historique");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [trackId, currentOffset, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [loadHistory]);
|
||||
|
||||
const hasPreviousPage = currentOffset > 0;
|
||||
const hasNextPage = currentOffset + limit < total;
|
||||
|
||||
const handlePreviousPage = useCallback(() => {
|
||||
if (currentOffset > 0) {
|
||||
setCurrentOffset((prev) => Math.max(0, prev - limit));
|
||||
}
|
||||
}, [currentOffset, limit]);
|
||||
|
||||
const handleNextPage = useCallback(() => {
|
||||
if (currentOffset + limit < total) {
|
||||
setCurrentOffset((prev) => prev + limit);
|
||||
}
|
||||
}, [currentOffset, limit, total]);
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
currentOffset,
|
||||
limit,
|
||||
hasPreviousPage,
|
||||
hasNextPage,
|
||||
handlePreviousPage,
|
||||
handleNextPage,
|
||||
reload: loadHistory,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue