diff --git a/frontend/src/components/SearchArticles.simple.test.tsx b/frontend/src/components/SearchArticles.simple.test.tsx index c9e13ce..8dfbb26 100644 --- a/frontend/src/components/SearchArticles.simple.test.tsx +++ b/frontend/src/components/SearchArticles.simple.test.tsx @@ -15,9 +15,14 @@ describe('SearchArticles Component', () => { test('renders search form correctly', () => { render(); - - expect(screen.getByText('Search SE Evidence Articles')).toBeInTheDocument(); - expect(screen.getByLabelText('Keywords (Title, Authors, Claim)')).toBeInTheDocument(); + + // Check for the main search input with the placeholder text + expect(screen.getByPlaceholderText('Enter keywords to search across titles, authors, and claims...')).toBeInTheDocument(); + + // Check for the search button expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument(); + + // Check for the Advanced Filters header + expect(screen.getByText('Advanced Filters')).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/frontend/src/components/SearchArticles.test.tsx b/frontend/src/components/SearchArticles.test.tsx index 9ba474a..8e5608b 100644 --- a/frontend/src/components/SearchArticles.test.tsx +++ b/frontend/src/components/SearchArticles.test.tsx @@ -45,15 +45,33 @@ describe('SearchArticles Component', () => { test('renders search form correctly', () => { render(); - - expect(screen.getByText('Search SE Evidence Articles')).toBeInTheDocument(); - expect(screen.getByLabelText('Keywords (Title, Authors, Claim)')).toBeInTheDocument(); - expect(screen.getByLabelText('SE Practice (Evidence Type)')).toBeInTheDocument(); - expect(screen.getByLabelText('Publication Year From')).toBeInTheDocument(); - expect(screen.getByLabelText('Publication Year To')).toBeInTheDocument(); - expect(screen.getByLabelText('Authors')).toBeInTheDocument(); - expect(screen.getByLabelText('Status')).toBeInTheDocument(); - expect(screen.getByLabelText('Source')).toBeInTheDocument(); + + // Check for the main search input with the placeholder text + expect(screen.getByPlaceholderText('Enter keywords to search across titles, authors, and claims...')).toBeInTheDocument(); + + // Check for the Advanced Filters header + expect(screen.getByText('Advanced Filters')).toBeInTheDocument(); + + // Click the advanced filters header to expand it + fireEvent.click(screen.getByText('Advanced Filters')); + + // Check for evidence type select (now visible after expansion) + expect(screen.getByLabelText('Select SE Practice type')).toBeInTheDocument(); + + // Check for status select + expect(screen.getByLabelText('Select article status')).toBeInTheDocument(); + + // Check for year from and to inputs + expect(screen.getByLabelText('Publication year from')).toBeInTheDocument(); + expect(screen.getByLabelText('Publication year to')).toBeInTheDocument(); + + // Check for authors input + expect(screen.getByLabelText('Filter by authors')).toBeInTheDocument(); + + // Check for source input + expect(screen.getByLabelText('Filter by source')).toBeInTheDocument(); + + // Check for the search button expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument(); }); @@ -64,13 +82,13 @@ describe('SearchArticles Component', () => { }); render(); - - const keywordInput = screen.getByLabelText('Keywords (Title, Authors, Claim)'); + + const keywordInput = screen.getByPlaceholderText('Enter keywords to search across titles, authors, and claims...'); fireEvent.change(keywordInput, { target: { value: 'test keyword' } }); - + const searchButton = screen.getByRole('button', { name: 'Search' }); fireEvent.click(searchButton); - + await waitFor(() => { expect(fetch).toHaveBeenCalledWith( `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/articles/search/advanced?keywords=test+keyword&sortBy=createdAt&sortDirection=desc` @@ -85,13 +103,16 @@ describe('SearchArticles Component', () => { }); render(); - - const evidenceTypeSelect = screen.getByLabelText('SE Practice (Evidence Type)'); + + // Click the advanced filters header to expand it + fireEvent.click(screen.getByText('Advanced Filters')); + + const evidenceTypeSelect = screen.getByLabelText('Select SE Practice type'); fireEvent.change(evidenceTypeSelect, { target: { value: EvidenceType.MODERATELY_SUPPORTS } }); - + const searchButton = screen.getByRole('button', { name: 'Search' }); fireEvent.click(searchButton); - + await waitFor(() => { expect(fetch).toHaveBeenCalledWith( `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/articles/search/advanced?evidenceType=Moderately+Supports&sortBy=createdAt&sortDirection=desc` @@ -106,16 +127,19 @@ describe('SearchArticles Component', () => { }); render(); - - const yearFromInput = screen.getByLabelText('Publication Year From'); + + // Click the advanced filters header to expand it + fireEvent.click(screen.getByText('Advanced Filters')); + + const yearFromInput = screen.getByLabelText('Publication year from'); fireEvent.change(yearFromInput, { target: { value: '2020' } }); - - const yearToInput = screen.getByLabelText('Publication Year To'); + + const yearToInput = screen.getByLabelText('Publication year to'); fireEvent.change(yearToInput, { target: { value: '2022' } }); - + const searchButton = screen.getByRole('button', { name: 'Search' }); fireEvent.click(searchButton); - + await waitFor(() => { expect(fetch).toHaveBeenCalledWith( `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/articles/search/advanced?pubYearFrom=2020&pubYearTo=2022&sortBy=createdAt&sortDirection=desc` @@ -130,20 +154,23 @@ describe('SearchArticles Component', () => { }); render(); - + + // Click the advanced filters header to expand it + fireEvent.click(screen.getByText('Advanced Filters')); + // Apply multiple filters - const keywordInput = screen.getByLabelText('Keywords (Title, Authors, Claim)'); + const keywordInput = screen.getByPlaceholderText('Enter keywords to search across titles, authors, and claims...'); fireEvent.change(keywordInput, { target: { value: 'test' } }); - - const evidenceTypeSelect = screen.getByLabelText('SE Practice (Evidence Type)'); + + const evidenceTypeSelect = screen.getByLabelText('Select SE Practice type'); fireEvent.change(evidenceTypeSelect, { target: { value: EvidenceType.WEAK_AGAINST } }); - - const authorsInput = screen.getByLabelText('Authors'); + + const authorsInput = screen.getByLabelText('Filter by authors'); fireEvent.change(authorsInput, { target: { value: 'Doe' } }); - + const searchButton = screen.getByRole('button', { name: 'Search' }); fireEvent.click(searchButton); - + await waitFor(() => { const fetchCallUrl = (global.fetch as jest.MockedFunction).mock.calls[0][0]; expect(fetchCallUrl).toContain('keywords=test'); @@ -161,14 +188,14 @@ describe('SearchArticles Component', () => { }); render(); - + const searchButton = screen.getByRole('button', { name: 'Search' }); fireEvent.click(searchButton); - + await waitFor(() => { expect(screen.getByText('Found 2 results')).toBeInTheDocument(); }); - + expect(screen.getByText('Test Article 1')).toBeInTheDocument(); expect(screen.getByText('John Doe')).toBeInTheDocument(); expect(screen.getByText('Journal of Testing')).toBeInTheDocument(); @@ -179,10 +206,10 @@ describe('SearchArticles Component', () => { (global.fetch as jest.MockedFunction).mockRejectedValueOnce(new Error('Network error')); render(); - + const searchButton = screen.getByRole('button', { name: 'Search' }); fireEvent.click(searchButton); - + await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); @@ -190,22 +217,25 @@ describe('SearchArticles Component', () => { test('clears all filters', async () => { render(); - + + // Click the advanced filters header to expand it + fireEvent.click(screen.getByText('Advanced Filters')); + // Apply some filters - const keywordInput = screen.getByLabelText('Keywords (Title, Authors, Claim)'); + const keywordInput = screen.getByPlaceholderText('Enter keywords to search across titles, authors, and claims...'); fireEvent.change(keywordInput, { target: { value: 'test' } }); - - const evidenceTypeSelect = screen.getByLabelText('SE Practice (Evidence Type)'); + + const evidenceTypeSelect = screen.getByLabelText('Select SE Practice type'); fireEvent.change(evidenceTypeSelect, { target: { value: EvidenceType.WEAK_AGAINST } }); - + // Verify filters are applied expect(keywordInput).toHaveValue('test'); expect(evidenceTypeSelect).toHaveValue(EvidenceType.WEAK_AGAINST); - + // Click clear filters - const clearButton = screen.getByRole('button', { name: 'Clear Filters' }); + const clearButton = screen.getByRole('button', { name: 'Clear All' }); fireEvent.click(clearButton); - + // Verify filters are cleared expect(keywordInput).toHaveValue(''); expect(evidenceTypeSelect).toHaveValue(''); diff --git a/frontend/src/components/SearchArticles.tsx b/frontend/src/components/SearchArticles.tsx index 480fee6..9fbbad9 100644 --- a/frontend/src/components/SearchArticles.tsx +++ b/frontend/src/components/SearchArticles.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useRef } from "react"; import { Article, EvidenceType, ArticleStatus } from "../types/article"; +import { exportToCSV } from "../utils/csv.utils"; import styles from "../styles/SearchPage.module.scss"; // Enhanced search functionality with real-time suggestions and search history @@ -542,9 +543,31 @@ const SearchArticles: React.FC = () => { Found {results.length} results - - Sorting: {sortField}{" "} - {sortDirection === "asc" ? "↑" : "↓"} + + + Sorting: {sortField}{" "} + {sortDirection === "asc" ? "↑" : "↓"} + + exportToCSV(results, `search-results-${new Date().toISOString().slice(0, 10)}.csv`)} + className={styles.exportButton} + title="Export results to CSV" + > + + + + Export CSV + diff --git a/frontend/src/components/SubmitterForm.tsx b/frontend/src/components/SubmitterForm.tsx index c08fb37..0a78433 100644 --- a/frontend/src/components/SubmitterForm.tsx +++ b/frontend/src/components/SubmitterForm.tsx @@ -2,6 +2,7 @@ import React, { useState } from "react"; import { Formik, Form, Field, ErrorMessage, FormikProps } from "formik"; import * as Yup from "yup"; import { EvidenceType } from "../types/article"; +import { useAuth } from "../contexts/AuthContext"; interface ArticleFormData { customId: string; @@ -12,7 +13,7 @@ interface ArticleFormData { doi: string; claim: string; evidence: string; - submitterEmail: string; + useEmailForNotification: boolean; } interface SubmitArticleProps { @@ -45,15 +46,14 @@ const validationSchema = Yup.object().shape({ .required("Claim is required") .min(10, "Claim must be at least 10 characters long"), evidence: Yup.string().required("Evidence type is required"), - submitterEmail: Yup.string() - .required("Email is required for notification of review results") - .email("Please enter a valid email address"), + useEmailForNotification: Yup.boolean(), }); const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); + const { user } = useAuth(); const handleSubmit = async (values: ArticleFormData) => { setSubmitting(true); @@ -61,10 +61,29 @@ const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { setSuccess(null); try { + // Prepare the submission payload + // If customId is empty, don't include it in the request so the backend can auto-generate it + let submissionValues: Partial | ArticleFormData; + if (!values.customId?.trim()) { + // Omit customId if it's empty + const { customId: _, ...valuesWithoutCustomId } = values; + submissionValues = valuesWithoutCustomId; + } else { + submissionValues = values; + } + + // Conditionally include submitterEmail based on user selection + let finalSubmissionValuesWithNotification; + if (values.useEmailForNotification && user?.email) { + finalSubmissionValuesWithNotification = { ...submissionValues, submitterEmail: user.email }; + } else { + finalSubmissionValuesWithNotification = { ...submissionValues, submitterEmail: '' }; + } + // Check if ID already exists - only if customId is provided - if (values.customId && values.customId.trim()) { + if (finalSubmissionValuesWithNotification.customId && finalSubmissionValuesWithNotification.customId.trim()) { const idCheckResponse = await fetch( - `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/articles/${values.customId}`, + `${process.env.NEXT_PUBLIC_BACKEND_URL}/api/articles/${finalSubmissionValuesWithNotification.customId}`, { method: "GET", headers: { @@ -75,21 +94,16 @@ const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { if (idCheckResponse.ok) { throw new Error( - `Article with ID '${values.customId}' already exists. Please choose a different ID.` + `Article with ID '${finalSubmissionValuesWithNotification.customId}' already exists. Please choose a different ID.` ); } } - // Prepare the submission payload - // If customId is empty, don't include it in the request so the backend can auto-generate it - let submissionValues; - if (!values.customId?.trim()) { - // Omit customId if it's empty - const { customId: _, ...valuesWithoutCustomId } = values; - submissionValues = valuesWithoutCustomId; - } else { - submissionValues = values; - } + // Extract only the properties we need for the submission + const { + useEmailForNotification, // eslint-disable-line @typescript-eslint/no-unused-vars + ...cleanSubmissionValues + } = finalSubmissionValuesWithNotification; // Proceed with submission const response = await fetch( @@ -100,7 +114,7 @@ const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { "Content-Type": "application/json", Authorization: `Bearer ${localStorage.getItem("token")}`, }, - body: JSON.stringify(submissionValues), + body: JSON.stringify(cleanSubmissionValues), } ); @@ -152,7 +166,7 @@ const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { return ( {success && ( - + = ({ onSubmitSuccess }) => { > - {success} + {success} )} {error && ( - + = ({ onSubmitSuccess }) => { > - {error} + {error} )} @@ -195,7 +209,7 @@ const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { doi: "", claim: "", evidence: "", - submitterEmail: "", + useEmailForNotification: true, // Default to checked }} validationSchema={validationSchema} onSubmit={handleSubmit} @@ -335,26 +349,24 @@ const SubmitterForm: React.FC = ({ onSubmitSuccess }) => { - - Email for Notifications * - - - - - You will receive review results at this email address - + Email for Notifications + + + + Send notification to my registered email ( + {user?.email || 'No email found'} ) + + diff --git a/frontend/src/styles/SearchPage.module.scss b/frontend/src/styles/SearchPage.module.scss index d514dbb..b7cfd70 100644 --- a/frontend/src/styles/SearchPage.module.scss +++ b/frontend/src/styles/SearchPage.module.scss @@ -445,6 +445,12 @@ color: #374151; } +.resultsActions { + display: flex; + align-items: center; + gap: 1rem; +} + .sortInfo { font-size: 0.875rem; color: #6b7280; @@ -455,6 +461,36 @@ color: #374151; } +.exportButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background-color: #10b981; // Green background + color: white; + border: 1px solid transparent; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background-color 0.15s; + + &:hover:not(:disabled) { + background-color: #059669; // Darker green on hover + } + + &:disabled { + background-color: #9ca3af; // Gray when disabled + cursor: not-allowed; + } +} + +.exportIcon { + width: 16px; + height: 16px; +} + .tableContainer { overflow-x: auto; border-radius: 8px; diff --git a/frontend/src/styles/design-system.scss b/frontend/src/styles/design-system.scss index 850114c..4cd9539 100644 --- a/frontend/src/styles/design-system.scss +++ b/frontend/src/styles/design-system.scss @@ -309,6 +309,14 @@ body { border: 1px solid var(--warning-200); } +// Alert icon styling +.alert-icon { + width: 1.25rem; + height: 1.25rem; + margin-right: var(--spacing-sm); + flex-shrink: 0; +} + // Utility classes .text-center { text-align: center; @@ -350,6 +358,50 @@ body { margin-top: var(--spacing-lg); } +.ml-sm { + margin-left: var(--spacing-sm); +} + +.mr-sm { + margin-right: var(--spacing-sm); +} + +// Checkbox styling +.form-checkbox { + width: 1.25rem; + height: 1.25rem; + min-width: 1.25rem; + min-height: 1.25rem; + border: 2px solid var(--neutral-400); + border-radius: var(--border-radius-sm); + appearance: none; + cursor: pointer; + position: relative; + transition: all var(--transition-fast); + + &:checked { + background-color: var(--primary-600); + border-color: var(--primary-600); + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + } + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); + } +} + .d-flex { display: flex; } diff --git a/frontend/src/utils/csv.utils.ts b/frontend/src/utils/csv.utils.ts new file mode 100644 index 0000000..c34f6b9 --- /dev/null +++ b/frontend/src/utils/csv.utils.ts @@ -0,0 +1,91 @@ +/** + * Utility function to export article data as CSV + */ +import { Article } from '../types/article'; + +export const exportToCSV = (articles: Article[], filename: string = 'search-results.csv'): void => { + if (!articles || articles.length === 0) { + console.warn('No articles to export'); + return; + } + + // Define CSV headers + const headers = [ + 'ID', + 'Title', + 'Authors', + 'Source', + 'Year', + 'DOI', + 'Claim', + 'Evidence Type', + 'Status', + 'Submitter ID', + 'Submitter Email', + 'Reviewer ID', + 'Review Comment', + 'Created At', + 'Updated At', + 'Is Duplicate', + 'Duplicate Of' + ]; + + // Prepare CSV content + const csvContent = [ + headers.join(','), + ...articles.map(article => { + return [ + `"${escapeCsvField(article.customId)}"`, + `"${escapeCsvField(article.title)}"`, + `"${escapeCsvField(article.authors)}"`, + `"${escapeCsvField(article.source)}"`, + `"${escapeCsvField(article.pubyear)}"`, + `"${escapeCsvField(article.doi)}"`, + `"${escapeCsvField(article.claim)}"`, + `"${escapeCsvField(article.evidence)}"`, + `"${escapeCsvField(article.status)}"`, + `"${escapeCsvField(article.submitterId || '')}"`, + `"${escapeCsvField(article.submitterEmail || '')}"`, + `"${escapeCsvField(article.reviewerId || '')}"`, + `"${escapeCsvField(article.reviewComment || '')}"`, + `"${escapeCsvField(article.createdAt || '')}"`, + `"${escapeCsvField(article.updatedAt || '')}"`, + `"${escapeCsvField(article.isDuplicate ? 'Yes' : 'No')}"`, + `"${escapeCsvField(article.duplicateOf || '')}"` + ].join(','); + }) + ].join('\n'); + + // Create and download the CSV file + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + + if (link.download !== undefined) { + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } else { + console.error('CSV download is not supported in this browser'); + } +}; + +// Helper function to escape CSV fields that might contain commas, quotes, or newlines +const escapeCsvField = (field: string | number | boolean | undefined | null): string => { + if (field === null || field === undefined) { + return ''; + } + + const str = String(field); + + // If the string contains commas, double quotes, or newlines, wrap it in double quotes + // Also escape any double quotes by replacing them with two double quotes + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return '"' + str.replace(/"/g, '""') + '"'; + } + + return str; +}; \ No newline at end of file
Found {results.length} results
- You will receive review results at this email address -