From 381e11a5e4839cc86ccb01dfd89bf461ae364e2a Mon Sep 17 00:00:00 2001 From: oabrivard Date: Fri, 27 Mar 2026 14:32:48 +0100 Subject: [PATCH] refactor: delete dead Sources.tsx, move URL utils to utils/url.ts normalizeUrl and isValidUrl are now in ~/utils/url. ThemeManager and sources-utils tests import from the new location. The /sources route redirect to /themes is preserved in App.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 1 - frontend/src/__tests__/pages/sources.test.tsx | 240 --------- frontend/src/__tests__/sources-utils.test.ts | 2 +- frontend/src/pages/Sources.tsx | 481 ------------------ frontend/src/pages/ThemeManager.tsx | 2 +- frontend/src/utils/url.ts | 36 ++ 6 files changed, 38 insertions(+), 724 deletions(-) delete mode 100644 frontend/src/__tests__/pages/sources.test.tsx delete mode 100644 frontend/src/pages/Sources.tsx create mode 100644 frontend/src/utils/url.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 70cadaa..e768895 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,7 +13,6 @@ const Register = lazy(() => import('~/pages/Register')); const AuthVerify = lazy(() => import('~/pages/AuthVerify')); const Home = lazy(() => import('~/pages/Home')); const Settings = lazy(() => import('~/pages/Settings')); -const Sources = lazy(() => import('~/pages/Sources')); const ThemeManager = lazy(() => import('~/pages/ThemeManager')); const GenerateSynthesis = lazy(() => import('~/pages/GenerateSynthesis')); const SynthesisDetail = lazy(() => import('~/pages/SynthesisDetail')); diff --git a/frontend/src/__tests__/pages/sources.test.tsx b/frontend/src/__tests__/pages/sources.test.tsx deleted file mode 100644 index a76711e..0000000 --- a/frontend/src/__tests__/pages/sources.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { describe, it, expect, vi, afterEach } from 'vitest'; -import { screen, waitFor, fireEvent } from '@solidjs/testing-library'; -import { renderWithProviders } from '../test-utils'; -import type { Source, BulkImportResponse } from '~/types'; -import { MOCK_SOURCES } from '../fixtures'; - -// Mock the sources API module -vi.mock('~/api/sources', () => ({ - sourcesApi: { - list: vi.fn(), - create: vi.fn(), - remove: vi.fn(), - bulkImport: vi.fn(), - importCsv: vi.fn(), - exportCsv: vi.fn(), - }, -})); - -// Also mock syntheses since sources imports from it (fetchFile, triggerDownload) -vi.mock('~/api/syntheses', () => ({ - synthesesApi: { - list: vi.fn(), - remove: vi.fn(), - generate: vi.fn(), - progressUrl: vi.fn(), - }, - fetchFile: vi.fn(), - triggerDownload: vi.fn(), -})); - -import { sourcesApi } from '~/api/sources'; -import Sources from '~/pages/Sources'; - -const mockedList = vi.mocked(sourcesApi.list); -const mockedCreate = vi.mocked(sourcesApi.create); -const mockedRemove = vi.mocked(sourcesApi.remove); -const mockedBulkImport = vi.mocked(sourcesApi.bulkImport); -const mockedExportCsv = vi.mocked(sourcesApi.exportCsv); - -// Sources page tests use specific titles that the UI renders, so we define -// page-specific sources based on the shared fixture shape. -const pageSources: Source[] = [ - { ...MOCK_SOURCES[0], id: 'src1', title: 'OpenAI Blog', url: 'https://openai.com/blog', created_at: '2026-03-01T10:00:00Z' }, - { ...MOCK_SOURCES[1], id: 'src2', title: 'Google AI Blog', url: 'https://ai.googleblog.com', created_at: '2026-03-02T10:00:00Z' }, -]; - -afterEach(() => { - vi.clearAllMocks(); -}); - -describe('Sources Page', () => { - it('should render source list from API data', async () => { - mockedList.mockResolvedValue(pageSources); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('OpenAI Blog')).toBeInTheDocument(); - }); - expect(screen.getByText('Google AI Blog')).toBeInTheDocument(); - }); - - it('should show empty state when no sources', async () => { - mockedList.mockResolvedValue([]); - - renderWithProviders(() => ); - - await waitFor(() => { - expect( - screen.getByText('Aucune source personnalisee pour le moment.'), - ).toBeInTheDocument(); - }); - }); - - it('should validate empty title on add form submit', async () => { - mockedList.mockResolvedValue([]); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('Ajouter')).toBeInTheDocument(); - }); - - // Fill URL but not title - const urlInput = screen.getByPlaceholderText('https://...'); - fireEvent.input(urlInput, { target: { value: 'https://example.com' } }); - - const addButton = screen.getByText('Ajouter'); - fireEvent.click(addButton); - - await waitFor(() => { - expect(screen.getByText('Le titre est requis.')).toBeInTheDocument(); - }); - }); - - it('should validate empty URL on add form submit', async () => { - mockedList.mockResolvedValue([]); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('Ajouter')).toBeInTheDocument(); - }); - - const titleInput = screen.getByPlaceholderText( - 'Nom de la source (ex: Blog de Yann LeCun)', - ); - fireEvent.input(titleInput, { target: { value: 'My Blog' } }); - - const addButton = screen.getByText('Ajouter'); - fireEvent.click(addButton); - - await waitFor(() => { - expect(screen.getByText("L'URL est requise.")).toBeInTheDocument(); - }); - }); - - it('should call API and refresh list when add form is submitted', async () => { - const newSource: Source = { - id: 'src-new', - user_id: 'u1', - title: 'New Blog', - url: 'https://newblog.com', - is_preferred: false, - created_at: '2026-03-20T10:00:00Z', - }; - mockedCreate.mockResolvedValue(newSource); - // First call returns empty, second call after create returns the new source - mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce([newSource]); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('Ajouter')).toBeInTheDocument(); - }); - - const titleInput = screen.getByPlaceholderText( - 'Nom de la source (ex: Blog de Yann LeCun)', - ); - const urlInput = screen.getByPlaceholderText('https://...'); - - fireEvent.input(titleInput, { target: { value: 'New Blog' } }); - fireEvent.input(urlInput, { target: { value: 'https://newblog.com' } }); - - const addButton = screen.getByText('Ajouter'); - fireEvent.click(addButton); - - await waitFor(() => { - expect(mockedCreate).toHaveBeenCalledWith({ - title: 'New Blog', - url: 'https://newblog.com', - }); - }); - }); - - it('should handle delete with confirmation flow', async () => { - mockedRemove.mockResolvedValue(undefined); - // First call returns all sources, second call after delete returns one - mockedList - .mockResolvedValueOnce(pageSources) - .mockResolvedValueOnce([pageSources[1]]); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('OpenAI Blog')).toBeInTheDocument(); - }); - - // First click on delete for first source - const deleteButtons = screen.getAllByTitle('Supprimer'); - fireEvent.click(deleteButtons[0]); - - await waitFor(() => { - expect(screen.getByText('Confirmer ?')).toBeInTheDocument(); - }); - - // Second click to confirm - const confirmBtn = screen.getByText('Confirmer ?'); - fireEvent.click(confirmBtn); - - await waitFor(() => { - expect(mockedRemove).toHaveBeenCalledWith('src1'); - }); - }); - - it('should trigger bulk import API call', async () => { - const bulkResponse: BulkImportResponse = { - imported: 2, - skipped: 0, - errors: [], - }; - mockedBulkImport.mockResolvedValue(bulkResponse); - mockedList.mockResolvedValueOnce([]).mockResolvedValueOnce(pageSources); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('Importer les sources')).toBeInTheDocument(); - }); - - // The textarea has id="bulk-import" - const textarea = document.getElementById('bulk-import') as HTMLTextAreaElement; - expect(textarea).toBeTruthy(); - fireEvent.input(textarea, { - target: { - value: 'OpenAI Blog;https://openai.com/blog\nGoogle AI Blog;https://ai.googleblog.com', - }, - }); - - const importButton = screen.getByText('Importer les sources'); - fireEvent.click(importButton); - - await waitFor(() => { - expect(mockedBulkImport).toHaveBeenCalledWith({ - sources: [ - { title: 'OpenAI Blog', url: 'https://openai.com/blog' }, - { title: 'Google AI Blog', url: 'https://ai.googleblog.com' }, - ], - }); - }); - }); - - it('should trigger CSV export download', async () => { - mockedList.mockResolvedValue(pageSources); - mockedExportCsv.mockResolvedValue(undefined); - - renderWithProviders(() => ); - - await waitFor(() => { - expect(screen.getByText('Exporter en CSV')).toBeInTheDocument(); - }); - - const exportButton = screen.getByText('Exporter en CSV'); - fireEvent.click(exportButton); - - await waitFor(() => { - expect(mockedExportCsv).toHaveBeenCalled(); - }); - }); -}); diff --git a/frontend/src/__tests__/sources-utils.test.ts b/frontend/src/__tests__/sources-utils.test.ts index a616d69..5f55f79 100644 --- a/frontend/src/__tests__/sources-utils.test.ts +++ b/frontend/src/__tests__/sources-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { normalizeUrl, isValidUrl } from '~/pages/Sources'; +import { normalizeUrl, isValidUrl } from '~/utils/url'; describe('normalizeUrl', () => { it('should prepend https:// when no scheme is provided', () => { diff --git a/frontend/src/pages/Sources.tsx b/frontend/src/pages/Sources.tsx deleted file mode 100644 index 41aba23..0000000 --- a/frontend/src/pages/Sources.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import { - type Component, - createSignal, - onMount, - onCleanup, - Show, - For, -} from 'solid-js'; -import { - Plus, - Trash2, - Link as LinkIcon, - Download, - Upload, -} from 'lucide-solid'; -import Button from '~/components/ui/Button'; -import { sourcesApi } from '~/api/sources'; -import { useI18n } from '~/i18n'; -import { isApiError } from '~/types'; -import type { Source } from '~/types'; -import LoadingSpinner from '~/components/ui/LoadingSpinner'; - -/** - * Prepend https:// if the URL has no scheme. - */ -export function normalizeUrl(url: string): string { - // Strip smart quotes, zero-width chars, and other invisible formatting - // that browsers or rich-text editors inject when copy-pasting URLs - const cleaned = url - .trim() - .replace(/[\u200B-\u200D\uFEFF\u00A0]/g, '') // zero-width & non-breaking spaces - .replace(/^[\u201C\u201D\u201E\u201F\u2018\u2019\u00AB\u00BB"']+/, '') // leading quotes - .replace(/[\u201C\u201D\u201E\u201F\u2018\u2019\u00AB\u00BB"']+$/, '') // trailing quotes - .trim(); - if (!cleaned) return cleaned; - if ( - !cleaned.startsWith('http://') && - !cleaned.startsWith('https://') - ) { - return 'https://' + cleaned; - } - return cleaned; -} - -/** - * Basic URL validation: must start with http(s) and have a dot in the host. - */ -export function isValidUrl(url: string): boolean { - try { - const parsed = new URL(url); - return ( - (parsed.protocol === 'http:' || parsed.protocol === 'https:') && - parsed.hostname.includes('.') - ); - } catch { - return false; - } -} - -/** - * Sources management page for adding, deleting, and bulk-importing custom URLs. - * - * Key behaviors: - * - **Bulk import parsing**: The textarea accepts one source per line in - * `title;url` format. Lines are split on the first semicolon; the URL - * portion is normalized via {@link normalizeUrl}. - * - **CSV flow**: Export downloads a CSV via the backend; import uploads a - * `.csv` file as `multipart/form-data`. The file input is reset after - * each import so the same file can be re-selected. - * - **URL normalization**: {@link normalizeUrl} prepends `https://` when no - * scheme is present. {@link isValidUrl} then validates the result. - */ -const Sources: Component = () => { - const { t } = useI18n(); - - // ---- State ---- - const [sources, setSources] = createSignal([]); - const [loading, setLoading] = createSignal(true); - const [newTitle, setNewTitle] = createSignal(''); - const [newUrl, setNewUrl] = createSignal(''); - const [adding, setAdding] = createSignal(false); - const [addError, setAddError] = createSignal(null); - const [bulkText, setBulkText] = createSignal(''); - const [importing, setImporting] = createSignal(false); - const [importError, setImportError] = createSignal(null); - const [csvError, setCsvError] = createSignal(null); - const [confirmingDeleteId, setConfirmingDeleteId] = createSignal< - string | null - >(null); - - let deleteTimer: ReturnType | undefined; - let fileInputRef: HTMLInputElement | undefined; - - onCleanup(() => { - if (deleteTimer) clearTimeout(deleteTimer); - }); - - // ---- Data loading ---- - const fetchSources = async () => { - try { - const data = await sourcesApi.list(); - setSources(data); - } catch (err) { - console.error('Failed to load sources:', err); - } finally { - setLoading(false); - } - }; - - onMount(fetchSources); - - // ---- Add a single source ---- - const handleAddSource = async (e: SubmitEvent) => { - e.preventDefault(); - setAddError(null); - - const title = newTitle().trim(); - const rawUrl = newUrl().trim(); - - if (!title) { - setAddError(t('sources.titleRequired')); - return; - } - if (!rawUrl) { - setAddError(t('sources.urlRequired')); - return; - } - - const url = normalizeUrl(rawUrl); - if (!isValidUrl(url)) { - setAddError(t('sources.urlInvalid')); - return; - } - - setAdding(true); - try { - await sourcesApi.create({ title, url }); - setNewTitle(''); - setNewUrl(''); - await fetchSources(); - } catch (err) { - if (isApiError(err)) { - setAddError(err.message); - } else { - setAddError(t('sources.addError')); - } - } finally { - setAdding(false); - } - }; - - // ---- Delete with confirmation ---- - const handleDeleteClick = (id: string) => { - if (confirmingDeleteId() === id) { - // Second click: delete - performDelete(id); - } else { - // First click: enter confirm state - setConfirmingDeleteId(id); - if (deleteTimer) clearTimeout(deleteTimer); - deleteTimer = setTimeout(() => { - setConfirmingDeleteId(null); - }, 3000); - } - }; - - const performDelete = async (id: string) => { - if (deleteTimer) clearTimeout(deleteTimer); - setConfirmingDeleteId(null); - - try { - await sourcesApi.remove(id); - await fetchSources(); - } catch (err) { - console.error('Failed to delete source:', err); - } - }; - - // ---- CSV Export ---- - const handleExportCsv = async () => { - setCsvError(null); - try { - await sourcesApi.exportCsv(); - } catch (err) { - setCsvError(t('sources.exportError')); - } - }; - - // ---- CSV Import ---- - const handleImportCsv = async (e: Event) => { - const input = e.target as HTMLInputElement; - const file = input.files?.[0]; - if (!file) return; - - setImporting(true); - setCsvError(null); - - try { - await sourcesApi.importCsv(file); - await fetchSources(); - } catch (err) { - if (isApiError(err)) { - setCsvError(err.message); - } else { - setCsvError(t('sources.csvImportError')); - } - } finally { - setImporting(false); - // Reset the file input so the same file can be re-selected - input.value = ''; - } - }; - - // ---- Bulk Import ---- - const handleBulkImport = async (e: SubmitEvent) => { - e.preventDefault(); - if (!bulkText().trim()) return; - - setImporting(true); - setImportError(null); - - const lines = bulkText() - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0); - - const validSources: { title: string; url: string }[] = []; - - for (const line of lines) { - const parts = line.split(';'); - if (parts.length >= 2) { - const title = parts[0].trim(); - const url = normalizeUrl(parts.slice(1).join(';').trim()); - if (title && url) { - validSources.push({ title, url }); - } - } - } - - if (validSources.length === 0) { - setImportError(t('sources.bulkImportError')); - setImporting(false); - return; - } - - try { - await sourcesApi.bulkImport({ sources: validSources }); - setBulkText(''); - await fetchSources(); - } catch (err) { - if (isApiError(err)) { - setImportError(err.message); - } else { - setImportError(t('sources.bulkImportError')); - } - } finally { - setImporting(false); - } - }; - - // ---- Render ---- - return ( - }> -
- {/* Page header */} -
-

- {t('sources.title')} -

-

- {t('sources.subtitle')} -

-
- - {/* Section 1: Add a source */} -
-
-

- {t('sources.addTitle')} -

-
-
- - setNewTitle(e.currentTarget.value)} - /> -
-
- - setNewUrl(e.currentTarget.value)} - /> -
- -
- - {(msg) => ( -

{msg()}

- )} -
-
-
- - {/* Section 2: CSV Import / Export */} -
-
-

- {t('sources.csvSection')} -

-

- {t('sources.csvDescription')} -

-
- - -
- - {(msg) => ( -

{msg()}

- )} -
-
-
- - {/* Section 3: Bulk Import */} -
-
-

- {t('sources.bulkSection')} -

-

- {t('sources.bulkDescription')}{' '} - {t('sources.bulkFormat')} -

-
-
- -