diff --git a/frontend/src/components/shared/SearchForm.jsx b/frontend/src/components/shared/SearchForm.jsx index 498c1b6..3641050 100644 --- a/frontend/src/components/shared/SearchForm.jsx +++ b/frontend/src/components/shared/SearchForm.jsx @@ -1,5 +1,4 @@ import React from "react"; -import DropdownTrigger from "./DropdownTrigger/DropdownTrigger"; import searchPublicationsIcon from "../../assets/icons/search-publications.svg"; import authorIcon from "../../assets/icons/author.svg"; import institutionIcon from "../../assets/icons/institution.svg"; @@ -7,16 +6,14 @@ import institutionIcon from "../../assets/icons/institution.svg"; const SearchForm = ({ searchKeyword, setSearchKeyword, - author, - setAuthor, - setAuthorObject, - institution, - setInstitution, - setInstitutionObject, + selectedAuthors = [], + setSelectedAuthors, + selectedInstitutions = [], + setSelectedInstitutions, onSearch, onOpenAdvancedFilters, - onAuthorClick, - onInstitutionClick, + onAuthorsClick, + onInstitutionsClick, loading, darkMode = false, description = "Enter keywords and apply filters to find relevant research" @@ -97,39 +94,120 @@ const SearchForm = ({ Author - Author + Authors
- { - setAuthor(""); - if (typeof setAuthorObject === 'function') setAuthorObject(null); - } : undefined} - darkMode={darkMode} - /> +
+ {selectedAuthors.length === 0 && ( + Click to search authors... + )} + {selectedAuthors.map(author => ( + + {author.display_name} + + + ))} +
+ {/* Institutions Multi-Select */}
Institution - Institution + Institutions
- { - setInstitution(""); - if (typeof setInstitutionObject === 'function') setInstitutionObject(null); - } : undefined} - darkMode={darkMode} - /> +
+ {selectedInstitutions.length === 0 && ( + Click to search institutions... + )} + {selectedInstitutions.map(inst => ( + + {inst.display_name} + + + ))} +
diff --git a/frontend/src/pages/collaboration_graph.js b/frontend/src/pages/collaboration_graph.js index 46d01cd..e116d29 100644 --- a/frontend/src/pages/collaboration_graph.js +++ b/frontend/src/pages/collaboration_graph.js @@ -6,7 +6,7 @@ import ApiCallInfoBox from '../components/shared/ApiCallInfoBox'; const GraphViewLight = ({ darkMode = true }) => { - const [selectedInstitution, setSelectedInstitution] = useState(null); + const [selectedInstitutions, setSelectedInstitutions] = useState([]); const [graphData, setGraphData] = useState({ nodes: [], links: [] }); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -14,7 +14,7 @@ const GraphViewLight = ({ darkMode = true }) => { const [searchTerm, setSearchTerm] = useState(''); const [triggerSearch, setTriggerSearch] = useState(false); - const [selectedAuthor, setSelectedAuthor] = useState(null); + const [selectedAuthors, setSelectedAuthors] = useState([]); const [authorInput, setAuthorInput] = useState(''); const [authorSuggestions, setAuthorSuggestions] = useState([]); const [showAuthorModal, setShowAuthorModal] = useState(false); @@ -67,46 +67,52 @@ const GraphViewLight = ({ darkMode = true }) => { }, [authorInput]); - // Only generate the graph when triggerSearch changes and selectedInstitution is set + // Only generate the graph when triggerSearch changes and at least one institution is selected useEffect(() => { - if (!selectedInstitution || !triggerSearch) return; + if (selectedInstitutions.length === 0 || !triggerSearch) return; setLoading(true); setError(null); // Track user inputs for disclaimer const inputs = []; - if (selectedInstitution && selectedInstitution.display_name) inputs.push({ category: 'Institution', value: selectedInstitution.display_name }); + if (selectedInstitutions.length > 0) inputs.push({ category: 'Institutions', value: selectedInstitutions.map(i => i.display_name).join(', ') }); if (searchTerm.trim()) inputs.push({ category: 'Keyword', value: searchTerm.trim() }); - if (selectedAuthor && selectedAuthor.display_name) inputs.push({ category: 'Author', value: selectedAuthor.display_name }); + if (selectedAuthors.length > 0) inputs.push({ category: 'Authors', value: selectedAuthors.map(a => a.display_name).join(', ') }); setUserInputs(inputs); // Track API calls for disclaimer const apiCallUrls = []; - const fetchInstitutionId = async () => { - if (!selectedInstitution) { + const fetchInstitutionIds = async () => { + if (!selectedInstitutions.length) { throw new Error('No institution selected'); } - if (!selectedInstitution.id) { - const res = await fetch(`https://api.openalex.org/institutions?search=${encodeURIComponent(selectedInstitution.display_name)}`); - const data = await res.json(); - const id = data.results[0]?.id?.split('/').pop(); - return id; - } - const id = selectedInstitution.id.split('/').pop(); - return id; + // Map to OpenAlex IDs + const ids = await Promise.all(selectedInstitutions.map(async (inst) => { + if (!inst.id) { + const res = await fetch(`https://api.openalex.org/institutions?search=${encodeURIComponent(inst.display_name)}`); + const data = await res.json(); + return data.results[0]?.id?.split('/').pop(); + } + return inst.id.split('/').pop(); + })); + return ids.filter(Boolean); }; // Fetch top collaborators using group_by - const fetchTopCollaborators = async (institutionId) => { - const filterParts = [`authorships.institutions.id:${institutionId}`]; + const fetchTopCollaborators = async (institutionIds) => { + const filterParts = []; + institutionIds.forEach(id => filterParts.push(`authorships.institutions.id:${id}`)); if (searchTerm.trim()) { filterParts.push(`title_and_abstract.search:${encodeURIComponent(searchTerm.trim())}`); } - if (selectedAuthor && selectedAuthor.id) { - // Use OpenAlex author id for author filter - const authorId = selectedAuthor.id.split('/').pop(); - filterParts.push(`authorships.author.id:${authorId}`); + if (selectedAuthors.length > 0) { + selectedAuthors.forEach(author => { + if (author.id) { + const authorId = author.id.split('/').pop(); + filterParts.push(`authorships.author.id:${authorId}`); + } + }); } const filterString = filterParts.join(','); const url = `https://api.openalex.org/works?filter=${filterString}&group_by=authorships.institutions.id&per_page=200`; @@ -114,8 +120,8 @@ const GraphViewLight = ({ darkMode = true }) => { const res = await fetch(url); const data = await res.json(); // Each group has a key (institution id) and count - // Filter out the selected institution itself - const groups = (data.group_by || []).filter(g => g.key !== `https://openalex.org/I${institutionId}`); + // Filter out the selected institutions themselves + const groups = (data.group_by || []).filter(g => !institutionIds.includes(g.key.split('/').pop())); // Sort by count and take top 10 const top10 = groups.sort((a, b) => b.count - a.count).slice(0, 10); return top10; @@ -132,17 +138,22 @@ const GraphViewLight = ({ darkMode = true }) => { }; // Fetch all works for the institution (and optionally author filter) - const fetchWorks = async (institutionId, collaboratorIds) => { - let filterParts = [`authorships.institutions.id:${institutionId}`]; + const fetchWorks = async (institutionIds, collaboratorIds) => { + let filterParts = []; + institutionIds.forEach(id => filterParts.push(`authorships.institutions.id:${id}`)); if (searchTerm.trim()) { filterParts.push(`title_and_abstract.search:${encodeURIComponent(searchTerm.trim())}`); } - if (selectedAuthor && selectedAuthor.id) { - const authorId = selectedAuthor.id.split('/').pop(); - filterParts.push(`authorships.author.id:${authorId}`); + if (selectedAuthors.length > 0) { + selectedAuthors.forEach(author => { + if (author.id) { + const authorId = author.id.split('/').pop(); + filterParts.push(`authorships.author.id:${authorId}`); + } + }); } // Only fetch works for top collaborators - if ((!selectedAuthor || !selectedAuthor.id) && collaboratorIds.length > 0) { + if (selectedAuthors.length === 0 && collaboratorIds.length > 0) { filterParts.push(`authorships.institutions.id:${collaboratorIds.map(id => id).join('|')}`); } const filterString = filterParts.join(','); @@ -164,98 +175,69 @@ const GraphViewLight = ({ darkMode = true }) => { const fetchAndBuild = async () => { try { - const institutionId = await fetchInstitutionId(); - const topCollaborators = await fetchTopCollaborators(institutionId); + const institutionIds = await fetchInstitutionIds(); + const topCollaborators = await fetchTopCollaborators(institutionIds); const collaboratorIds = topCollaborators.map(c => c.key.split('/').pop()); const collaboratorDetails = await fetchInstitutionDetails(collaboratorIds); + // Multi-institution/author support if (showAuthorsInGraph) { - // Show top 10 collaborators and authors who co-published with them - const works = await fetchWorks(institutionId, collaboratorIds); - - // Store works data globally for consistency + const works = await fetchWorks(institutionIds, collaboratorIds); setWorksData(works); - - // Collect authors who co-published with the main institution - const authorMap = new Map(); // id -> display_name + // Collect all authors and institutions + const authorMap = new Map(); works.forEach(work => { - // Only include authors if this work involves the main institution - const hasMainInstitution = work.authorships?.some(authorship => - authorship.institutions?.some(inst => inst.id.split('/').pop() === institutionId) - ); - - if (hasMainInstitution) { - work.authorships?.forEach(authorship => { - if (authorship.author && authorship.author.id && authorship.author.display_name) { - authorMap.set(authorship.author.id, authorship.author.display_name); - } - }); - } + work.authorships?.forEach(authorship => { + if (authorship.author && authorship.author.id && authorship.author.display_name) { + authorMap.set(authorship.author.id, authorship.author.display_name); + } + }); }); - - // Build institution nodes first + // Build institution nodes const nodes = [ - { id: institutionId, label: selectedInstitution.display_name, type: 'institution', main: true }, + ...institutionIds.map((id, idx) => ({ id, label: selectedInstitutions[idx]?.display_name || id, type: 'institution', main: true })), ...collaboratorDetails - .filter(inst => inst.id.split('/').pop() !== institutionId) + .filter(inst => !institutionIds.includes(inst.id.split('/').pop())) .map(inst => ({ id: inst.id.split('/').pop(), label: inst.display_name, type: 'institution' })) ]; - - // Build links: institution-to-institution and institution-to-author + // Build links const links = []; - - // Add institution-to-institution links with accurate counts - for (const collaborator of topCollaborators) { - const collaboratorId = collaborator.key.split('/').pop(); - try { - const filterString = `authorships.institutions.lineage:i${institutionId},authorships.institutions.lineage:i${collaboratorId}`; - const res = await fetch( - `https://api.openalex.org/works?filter=${filterString}&per_page=1` - ); - const data = await res.json(); - const count = data.meta?.count || 0; - - links.push({ - source: institutionId, - target: collaboratorId, - value: count - }); - } catch (e) { - console.error(`Error fetching count for institution collaboration ${institutionId}-${collaboratorId}:`, e); - // Fallback to the original count - links.push({ - source: institutionId, - target: collaboratorId, - value: collaborator.count - }); + // Institution-to-institution links + for (const mainId of institutionIds) { + for (const collaborator of topCollaborators) { + const collaboratorId = collaborator.key.split('/').pop(); + if (collaboratorId === mainId) continue; + try { + const filterString = `authorships.institutions.lineage:i${mainId},authorships.institutions.lineage:i${collaboratorId}`; + const res = await fetch( + `https://api.openalex.org/works?filter=${filterString}&per_page=1` + ); + const data = await res.json(); + const count = data.meta?.count || 0; + links.push({ source: mainId, target: collaboratorId, value: count }); + } catch (e) { + links.push({ source: mainId, target: collaboratorId, value: collaborator.count }); + } } } - - // Add institution-to-author links and detect institution-to-institution collaborations - const authorInstitutionMap = new Map(); // key: "instId-authorId", value: count - const institutionCollaborations = new Map(); // key: "instId1-instId2", value: count - - // Analyze works to find both author-institution and institution-institution collaborations + // Author-institution links + const authorInstitutionMap = new Map(); + const institutionCollaborations = new Map(); works.forEach(work => { const workInstitutions = new Set(); - const workAuthors = new Set(); - work.authorships?.forEach(authorship => { if (authorship.author && authorship.author.id) { const authorId = authorship.author.id; - workAuthors.add(authorId); - authorship.institutions?.forEach(inst => { const instId = inst.id.split('/').pop(); - if (instId === institutionId || collaboratorIds.includes(instId)) { + if (institutionIds.includes(instId) || collaboratorIds.includes(instId)) { workInstitutions.add(instId); authorInstitutionMap.set(`${instId}-${authorId}`, (authorInstitutionMap.get(`${instId}-${authorId}`) || 0) + 1); } }); } }); - - // If this work involves multiple institutions, create institution-to-institution collaboration + // Institution-to-institution collaborations if (workInstitutions.size > 1) { const institutions = Array.from(workInstitutions); for (let i = 0; i < institutions.length; i++) { @@ -266,83 +248,44 @@ const GraphViewLight = ({ darkMode = true }) => { } } }); - - // Add institution-to-institution links based on actual collaborations found for (const [collabKey, count] of institutionCollaborations) { const [inst1, inst2] = collabKey.split('-'); - links.push({ - source: inst1, - target: inst2, - value: count - }); + links.push({ source: inst1, target: inst2, value: count }); } - - // Add institution-to-author links for co-published papers - // Only add links for authors who co-published with the main institution - const authorsWithEdges = new Set(); // Track authors who have edges - + // Author nodes and links + const authorsWithEdges = new Set(); for (const [pair, count] of authorInstitutionMap) { const [instId, authorId] = pair.split('-'); - // Only include authors who co-published with the main institution - if (instId === institutionId || collaboratorIds.includes(instId)) { - try { - // Check if this author co-published with the main institution - const filterString = `authorships.author.id:${authorId},authorships.institutions.lineage:i${institutionId}`; - const res = await fetch( - `https://api.openalex.org/works?filter=${filterString}&per_page=1` - ); - const data = await res.json(); - const apiCount = data.meta?.count || 0; - - if (apiCount > 0) { - links.push({ - source: instId, - target: authorId, - value: apiCount, - type: 'co_published_with' - }); - authorsWithEdges.add(authorId); // Mark this author as having an edge - } - } catch (e) { - console.error(`Error fetching count for ${pair}:`, e); - // Fallback to the count from our analysis - if (count > 0) { - links.push({ - source: instId, - target: authorId, - value: count, - type: 'co_published_with' - }); - authorsWithEdges.add(authorId); // Mark this author as having an edge - } - } + if (institutionIds.includes(instId) || collaboratorIds.includes(instId)) { + links.push({ source: instId, target: authorId, value: count, type: 'co_published_with' }); + authorsWithEdges.add(authorId); } } - - // Only add author nodes for authors who have edges const authorsWithEdgesArray = Array.from(authorsWithEdges).map(authorId => ({ id: authorId, label: authorMap.get(authorId), type: 'author' })); - nodes.push(...authorsWithEdgesArray); - setGraphData({ nodes, links }); } else { - // Only add institution nodes and institution-to-institution links + // Only institution nodes and links const nodes = [ - { id: institutionId, label: selectedInstitution.display_name, type: 'institution', main: true }, + ...institutionIds.map((id, idx) => ({ id, label: selectedInstitutions[idx]?.display_name || id, type: 'institution', main: true })), ...collaboratorDetails - .filter(inst => inst.id.split('/').pop() !== institutionId) + .filter(inst => !institutionIds.includes(inst.id.split('/').pop())) .map(inst => ({ id: inst.id.split('/').pop(), label: inst.display_name, type: 'institution' })) ]; - const links = topCollaborators.map(c => ({ source: institutionId, target: c.key.split('/').pop(), value: c.count })); + const links = []; + for (const mainId of institutionIds) { + for (const collaborator of topCollaborators) { + const collaboratorId = collaborator.key.split('/').pop(); + if (collaboratorId === mainId) continue; + links.push({ source: mainId, target: collaboratorId, value: collaborator.count }); + } + } setGraphData({ nodes, links }); - return; } - - // Set API calls for disclaimer setApiCalls(apiCallUrls); } catch (e) { setError('Failed to fetch collaborators or build graph.'); @@ -353,7 +296,7 @@ const GraphViewLight = ({ darkMode = true }) => { fetchAndBuild(); setTriggerSearch(false); - }, [triggerSearch]); + }, [triggerSearch, selectedInstitutions, selectedAuthors, searchTerm, showAuthorsInGraph]); // Handle Enter key in search input const handleKeyDown = (e) => { @@ -495,7 +438,7 @@ const GraphViewLight = ({ darkMode = true }) => { }}>
- +
{ cursor: 'pointer', minHeight: '2.5rem', display: 'flex', - alignItems: 'center' + alignItems: 'center', + flexWrap: 'wrap', + gap: '0.5rem' }} onClick={() => { setShowInstitutionModal(true); @@ -515,47 +460,41 @@ const GraphViewLight = ({ darkMode = true }) => { setModalInstitutionSuggestions([]); }} > - - {selectedInstitution ? selectedInstitution.display_name : "Click to search institutions..."} - - {selectedInstitution && ( - + fontSize: 15 + }}> + {inst.display_name} + + + )) )}
@@ -563,9 +502,9 @@ const GraphViewLight = ({ darkMode = true }) => {
{/* Author Dropdown */}
- +
- Choose author and/or keyword to focus your search + Choose authors and/or keyword to focus your search
{ cursor: 'pointer', minHeight: '2.5rem', display: 'flex', - alignItems: 'center' + alignItems: 'center', + flexWrap: 'wrap', + gap: '0.5rem' }} onClick={() => { setShowAuthorModal(true); @@ -586,48 +527,41 @@ const GraphViewLight = ({ darkMode = true }) => { setModalAuthorSuggestions([]); }} > - - {authorInput || "Click to search authors..."} - - {selectedAuthor && ( - + fontSize: 15 + }}> + {author.display_name} + + + )) )}
@@ -652,15 +586,15 @@ const GraphViewLight = ({ darkMode = true }) => { setTriggerSearch(s => !s); setHasSearched(true); }} - disabled={!selectedInstitution || (!searchTerm.trim() && !selectedAuthor)} + disabled={selectedInstitutions.length === 0} style={{ padding: '10px 24px', fontSize: 16, borderRadius: 6, - background: (!selectedInstitution || (!searchTerm.trim() && !selectedAuthor)) ? '#ccc' : '#4F6AF6', + background: (selectedInstitutions.length === 0) ? '#ccc' : '#4F6AF6', color: '#fff', border: 'none', - cursor: (!selectedInstitution || (!searchTerm.trim() && !selectedAuthor)) ? 'not-allowed' : 'pointer', + cursor: (selectedInstitutions.length === 0) ? 'not-allowed' : 'pointer', boxShadow: '0 2px 8px rgba(25, 118, 210, 0.08)' }} > @@ -685,15 +619,21 @@ const GraphViewLight = ({ darkMode = true }) => { position: 'relative', }}> {/* Selected Institution in top left */} - {selectedInstitution && ( + {selectedInstitutions.length > 0 && (
- Selected Institution: {selectedInstitution.display_name} + Selected Institutions: + {selectedInstitutions.map(inst => ( + {inst.display_name} + ))}
)} @@ -885,8 +825,8 @@ const GraphViewLight = ({ darkMode = true }) => { {/* Legend */}
)} - {!loading && !error && selectedInstitution && graphData.nodes.length <= 1 && ( -
No collaborators found for this institution.
+ {!loading && !error && selectedInstitutions.length > 0 && graphData.nodes.length <= 1 && ( +
No collaborators found for the selected institution(s).
)} {selectedEdge && (
{ flexDirection: 'column', }}>
-

Select Author

+

Select Authors

+ {selectedAuthors.map((author, idx) => ( + + {author.display_name} + + + ))}
)} @@ -1272,8 +1199,10 @@ const GraphViewLight = ({ darkMode = true }) => {
{ - setSelectedAuthor(author); - setAuthorInput(author.display_name); + // Only add if not already selected + if (!selectedAuthors.some(a => a.id === author.id)) { + setSelectedAuthors([...selectedAuthors, author]); + } setShowAuthorModal(false); }} style={{ @@ -1334,7 +1263,7 @@ const GraphViewLight = ({ darkMode = true }) => { flexDirection: 'column', }}>
-

Select Institution

+

Select Institutions

+ {selectedInstitutions.map((inst, idx) => ( + + {inst.display_name} + + + ))}
)} @@ -1441,7 +1358,10 @@ const GraphViewLight = ({ darkMode = true }) => {
{ - setSelectedInstitution(institution); + // Only add if not already selected + if (!selectedInstitutions.some(i => i.id === institution.id)) { + setSelectedInstitutions([...selectedInstitutions, institution]); + } setShowInstitutionModal(false); }} style={{ diff --git a/frontend/src/pages/search.js b/frontend/src/pages/search.js index 41c1c5d..eb868f9 100644 --- a/frontend/src/pages/search.js +++ b/frontend/src/pages/search.js @@ -1,11 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { useLocation } from 'react-router-dom'; import TopBar from '../components/shared/TopBar'; import SearchHeader from '../components/shared/SearchHeader'; import SearchForm from '../components/shared/SearchForm'; import AdvancedFiltersDrawer from '../components/shared/AdvancedFiltersDrawer'; import SearchResultsList from '../components/shared/SearchResultsList'; -import ModalDropdown from '../components/shared/ModalDropdown'; import MultiSelectModalDropdown from '../components/shared/MultiSelectModalDropdown/MultiSelectModalDropdown'; import useDropdownSearch from '../hooks/useDropdownSearch'; import ApiCallInfoBox from '../components/shared/ApiCallInfoBox'; @@ -17,10 +16,9 @@ const SearchPageLight = ({ darkMode = true }) => { const location = useLocation(); // Main search/filter state const [searchKeyword, setSearchKeyword] = useState(""); - const [author, setAuthor] = useState(""); - const [authorObject, setAuthorObject] = useState(null); - const [institution, setInstitution] = useState(""); - const [institutionObject, setInstitutionObject] = useState(null); + // Multi-select for authors and institutions + const [selectedAuthors, setSelectedAuthors] = useState([]); + const [selectedInstitutions, setSelectedInstitutions] = useState([]); // Advanced filters const [publicationYear, setPublicationYear] = useState(""); const [startYear, setStartYear] = useState(""); @@ -135,10 +133,8 @@ const SearchPageLight = ({ darkMode = true }) => { // Function to clear all search fields const clearAllFields = () => { setSearchKeyword(""); - setAuthor(""); - setAuthorObject(null); - setInstitution(""); - setInstitutionObject(null); + setSelectedAuthors([]); + setSelectedInstitutions([]); setPublicationYear(""); setStartYear(""); setEndYear(""); @@ -210,17 +206,13 @@ const SearchPageLight = ({ darkMode = true }) => { } }; - // Fetch institution details by ID + // Fetch institution details by ID (for URL param support) const fetchInstitutionById = async (institutionId) => { try { const response = await fetch(`${OPENALEX_API_BASE}/institutions/I${institutionId}`); if (response.ok) { const institutionData = await response.json(); - setInstitution(institutionData.display_name); - setInstitutionObject({ - id: institutionData.id, - display_name: institutionData.display_name - }); + setSelectedInstitutions([{ id: institutionData.id, display_name: institutionData.display_name }]); } } catch (error) { console.error('Failed to fetch institution details:', error); @@ -239,8 +231,8 @@ const SearchPageLight = ({ darkMode = true }) => { // Track user inputs for disclaimer const inputs = []; if (searchKeyword.trim()) inputs.push({ category: 'Keywords', value: searchKeyword.trim() }); - if (authorObject && authorObject.display_name) inputs.push({ category: 'Author', value: authorObject.display_name }); - if (institutionObject && institutionObject.display_name) inputs.push({ category: 'Institution', value: institutionObject.display_name }); + if (selectedAuthors.length > 0) inputs.push({ category: 'Authors', value: selectedAuthors.map(a => a.display_name).join(', ') }); + if (selectedInstitutions.length > 0) inputs.push({ category: 'Institutions', value: selectedInstitutions.map(i => i.display_name).join(', ') }); if (selectedPublicationTypes.length > 0) inputs.push({ category: 'Publication Types', value: selectedPublicationTypes.map(pt => pt.display_name).join(', ') }); if (publicationYear.trim()) inputs.push({ category: 'Publication Year', value: publicationYear.trim() }); if (startYear.trim() && endYear.trim()) inputs.push({ category: 'Year Range', value: `${startYear.trim()}-${endYear.trim()}` }); @@ -254,15 +246,17 @@ const SearchPageLight = ({ darkMode = true }) => { // Format: title_and_abstract.search:keyword (OpenAlex handles multi-word automatically) filters.push(`title_and_abstract.search:${keyword}`); } - if (authorObject && authorObject.id) { - // Use the author object if available (from dropdown) - const authorId = authorObject.id.split('/').pop(); - filters.push(`authorships.author.id:A${authorId}`); + if (selectedAuthors.length > 0) { + selectedAuthors.forEach(a => { + const authorId = 'A' + a.id.split('/').pop(); + filters.push(`authorships.author.id:${authorId}`); + }); } - if (institutionObject && institutionObject.id) { - // Use the institution object if available (from URL params or dropdown) - const instId = institutionObject.id.split('/').pop(); - filters.push(`authorships.institutions.id:I${instId}`); + if (selectedInstitutions.length > 0) { + selectedInstitutions.forEach(i => { + const institutionId = 'I' + i.id.split('/').pop(); + filters.push(`authorships.institutions.id:${institutionId}`); + }); } if (selectedPublicationTypes.length > 0) { // Handle multiple publication types @@ -368,19 +362,17 @@ const SearchPageLight = ({ darkMode = true }) => { setShowAdvanced(true)} - onAuthorClick={() => { + onAuthorsClick={() => { setShowAuthorModal(true); clearAuthorSuggestions(); }} - onInstitutionClick={() => { + onInstitutionsClick={() => { setShowInstitutionModal(true); clearInstitutionSuggestions(); }} @@ -515,33 +507,39 @@ const SearchPageLight = ({ darkMode = true }) => {
)} - {/* Author Modal Dropdown */} - setShowAuthorModal(false)} - title="Select Author" + title="Select Authors" placeholder="Type to search authors..." onSearchChange={searchAuthors} suggestions={authorSuggestions} - onSelect={(author) => { - setAuthorObject(author); - setAuthor(author.display_name); + selectedItems={selectedAuthors} + onSelect={author => { + setSelectedAuthors(prev => prev.some(a => a.id === author.id) ? prev : [...prev, author]); + }} + onDeselect={author => { + setSelectedAuthors(prev => prev.filter(a => a.id !== author.id)); }} darkMode={darkMode} loading={authorLoading} /> - {/* Institution Modal Dropdown */} - setShowInstitutionModal(false)} - title="Select Institution" + title="Select Institutions" placeholder="Type to search institutions..." onSearchChange={searchInstitutions} suggestions={institutionSuggestions} - onSelect={(institution) => { - setInstitutionObject(institution); - setInstitution(institution.display_name); + selectedItems={selectedInstitutions} + onSelect={institution => { + setSelectedInstitutions(prev => prev.some(i => i.id === institution.id) ? prev : [...prev, institution]); + }} + onDeselect={institution => { + setSelectedInstitutions(prev => prev.filter(i => i.id !== institution.id)); }} darkMode={darkMode} loading={institutionLoading} diff --git a/frontend/src/pages/trend_graphs.js b/frontend/src/pages/trend_graphs.js index d03f6c7..203da1a 100644 --- a/frontend/src/pages/trend_graphs.js +++ b/frontend/src/pages/trend_graphs.js @@ -6,8 +6,6 @@ import SearchForm from "../components/shared/SearchForm"; import AdvancedFiltersDrawer from "../components/shared/AdvancedFiltersDrawer"; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, LineChart, Line } from 'recharts'; import { useNavigate } from "react-router-dom"; -import DropdownTrigger from "../components/shared/DropdownTrigger"; -import ModalDropdown from "../components/shared/ModalDropdown"; import MultiSelectModalDropdown from "../components/shared/MultiSelectModalDropdown/MultiSelectModalDropdown"; import useDropdownSearch from "../hooks/useDropdownSearch"; import ApiCallInfoBox from "../components/shared/ApiCallInfoBox"; @@ -20,10 +18,9 @@ export const PositionDetailLight = ({ darkMode = true }) => { // Main search/filter state const [searchKeyword, setSearchKeyword] = useState(""); - const [author, setAuthor] = useState(""); - const [authorObject, setAuthorObject] = useState(null); - const [institution, setInstitution] = useState(""); - const [institutionObject, setInstitutionObject] = useState(null); + // Multi-select authors/institutions + const [selectedAuthors, setSelectedAuthors] = useState([]); + const [selectedInstitutions, setSelectedInstitutions] = useState([]); // Advanced filters const [publicationYear, setPublicationYear] = useState(""); const [startYear, setStartYear] = useState(""); @@ -33,7 +30,6 @@ export const PositionDetailLight = ({ darkMode = true }) => { const [selectedJournals, setSelectedJournals] = useState([]); // UI state const [showAdvanced, setShowAdvanced] = useState(false); - const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Trend visualization state @@ -118,21 +114,6 @@ export const PositionDetailLight = ({ darkMode = true }) => { } }, [trendData]); - // Function to clear all search fields - const clearAllFields = () => { - setSearchKeyword(""); - setAuthor(""); - setAuthorObject(null); - setInstitution(""); - setInstitutionObject(null); - setPublicationYear(""); - setStartYear(""); - setEndYear(""); - setSelectedPublicationTypes([]); - setSelectedJournals([]); - setError(null); - }; - // Search handler for trend analysis const handleSearch = async () => { if (!searchKeyword.trim()) { @@ -152,8 +133,8 @@ export const PositionDetailLight = ({ darkMode = true }) => { // Track user inputs for disclaimer const inputs = []; if (searchKeyword.trim()) inputs.push({ category: 'Keywords', value: searchKeyword.trim() }); - if (authorObject && authorObject.display_name) inputs.push({ category: 'Author', value: authorObject.display_name }); - if (institutionObject && institutionObject.display_name) inputs.push({ category: 'Institution', value: institutionObject.display_name }); + if (selectedAuthors.length > 0) inputs.push({ category: 'Authors', value: selectedAuthors.map(a => a.display_name).join(', ') }); + if (selectedInstitutions.length > 0) inputs.push({ category: 'Institutions', value: selectedInstitutions.map(i => i.display_name).join(', ') }); if (selectedPublicationTypes.length > 0) inputs.push({ category: 'Publication Types', value: selectedPublicationTypes.map(pt => pt.display_name).join(', ') }); if (publicationYear.trim()) inputs.push({ category: 'Publication Year', value: publicationYear.trim() }); if (startYear.trim() && endYear.trim()) inputs.push({ category: 'Year Range', value: `${startYear.trim()}-${endYear.trim()}` }); @@ -168,19 +149,26 @@ export const PositionDetailLight = ({ darkMode = true }) => { const keyword = searchKeyword.trim(); filters.push(`title_and_abstract.search:${keyword}`); } - - // Add author filter - if (authorObject && authorObject.id) { - const authorId = authorObject.id.split('/').pop(); - filters.push(`authorships.author.id:A${authorId}`); + + // Add author filters (AND logic) + if (selectedAuthors.length > 0) { + selectedAuthors.forEach(author => { + if (author.id) { + const authorId = author.id.split('/').pop(); + filters.push(`authorships.author.id:A${authorId}`); + } + }); } - - // Add institution filter - if (institutionObject && institutionObject.id) { - const instId = institutionObject.id.split('/').pop(); - filters.push(`authorships.institutions.id:I${instId}`); + // Add institution filters (AND logic) + if (selectedInstitutions.length > 0) { + selectedInstitutions.forEach(inst => { + if (inst.id) { + const instId = inst.id.split('/').pop(); + filters.push(`authorships.institutions.id:I${instId}`); + } + }); } - + // Add publication types filter if (selectedPublicationTypes.length > 0) { const typeFilters = selectedPublicationTypes.map(pt => `type:${pt.id}`); @@ -329,12 +317,6 @@ export const PositionDetailLight = ({ darkMode = true }) => { // Add year filter searchParams.append('publication_year', data.year); - // Add institution filter if selected - if (institutionObject && institutionObject.id) { - const institutionId = institutionObject.id.split('/').pop(); - searchParams.append('institution_id', institutionId); - } - // Navigate to search page with filters navigate(`/search?${searchParams.toString()}`); }; @@ -362,16 +344,6 @@ export const PositionDetailLight = ({ darkMode = true }) => { Total Publications: {trendData.publication_count}
- - {institutionObject && ( -
- Institution: - - {institutionObject.display_name} - -
- )} -
); @@ -425,19 +397,17 @@ export const PositionDetailLight = ({ darkMode = true }) => { setShowAdvanced(true)} - onAuthorClick={() => { + onAuthorsClick={() => { setShowAuthorModal(true); clearAuthorSuggestions(); }} - onInstitutionClick={() => { + onInstitutionsClick={() => { setShowInstitutionModal(true); clearInstitutionSuggestions(); }} @@ -500,7 +470,7 @@ export const PositionDetailLight = ({ darkMode = true }) => {

Publication Count by Year - {institutionObject && ` - ${institutionObject.display_name}`} + {selectedInstitutions.length > 0 && ` - ${selectedInstitutions.map(i => i.display_name).join(', ')}`}

{
- {/* Author Modal Dropdown */} - setShowAuthorModal(false)} - title="Select Author" + title="Select Authors" placeholder="Type to search authors..." onSearchChange={searchAuthors} suggestions={authorSuggestions} + selectedItems={selectedAuthors} onSelect={(author) => { - setAuthorObject(author); - setAuthor(author.display_name); + setSelectedAuthors(prev => { + if (prev.some(a => a.id === author.id)) return prev; + return [...prev, author]; + }); + }} + onDeselect={(author) => { + setSelectedAuthors(prev => prev.filter(a => a.id !== author.id)); }} darkMode={darkMode} loading={authorLoading} /> - {/* Institution Modal Dropdown */} - setShowInstitutionModal(false)} - title="Select Institution" + title="Select Institutions" placeholder="Type to search institutions..." onSearchChange={searchInstitutions} suggestions={institutionSuggestions} + selectedItems={selectedInstitutions} onSelect={(institution) => { - setInstitutionObject(institution); - setInstitution(institution.display_name); + setSelectedInstitutions(prev => { + if (prev.some(i => i.id === institution.id)) return prev; + return [...prev, institution]; + }); + }} + onDeselect={(institution) => { + setSelectedInstitutions(prev => prev.filter(i => i.id !== institution.id)); }} darkMode={darkMode} loading={institutionLoading} diff --git a/frontend/src/pages/world_map.js b/frontend/src/pages/world_map.js index 4815673..c6468f4 100644 --- a/frontend/src/pages/world_map.js +++ b/frontend/src/pages/world_map.js @@ -4,7 +4,6 @@ import SearchHeader from "../components/shared/SearchHeader"; import SearchForm from "../components/shared/SearchForm"; import AdvancedFiltersDrawer from "../components/shared/AdvancedFiltersDrawer"; import WorldMapPapers from "../components/shared/WorldMapPapers/WorldMapPapers"; -import ModalDropdown from "../components/shared/ModalDropdown"; import MultiSelectModalDropdown from "../components/shared/MultiSelectModalDropdown/MultiSelectModalDropdown"; import useDropdownSearch from "../hooks/useDropdownSearch"; import ApiCallInfoBox from "../components/shared/ApiCallInfoBox"; @@ -15,10 +14,9 @@ import Particles from "../components/animated/SearchBackground/Particles"; const WorldMapPapersPage = () => { // Main search/filter state const [searchKeyword, setSearchKeyword] = useState(""); - const [author, setAuthor] = useState(""); - const [authorObject, setAuthorObject] = useState(null); - const [institution, setInstitution] = useState(""); - const [institutionObject, setInstitutionObject] = useState(null); + // Multi-select for authors and institutions + const [selectedAuthors, setSelectedAuthors] = useState([]); + const [selectedInstitutions, setSelectedInstitutions] = useState([]); // Advanced filters const [publicationYear, setPublicationYear] = useState(""); const [startYear, setStartYear] = useState(""); @@ -29,7 +27,7 @@ const WorldMapPapersPage = () => { // UI state const [showAdvanced, setShowAdvanced] = useState(false); const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + // Search trigger state const [triggerSearch, setTriggerSearch] = useState(false); // Search results state @@ -89,31 +87,19 @@ const WorldMapPapersPage = () => { } = useDropdownSearch('https://api.openalex.org/sources?filter=type:journal&search={query}&per_page=20'); // Function to clear all search fields - const clearAllFields = () => { - setSearchKeyword(""); - setAuthor(""); - setAuthorObject(null); - setInstitution(""); - setInstitutionObject(null); - setPublicationYear(""); - setStartYear(""); - setEndYear(""); - setSelectedPublicationTypes([]); - setSelectedJournals([]); - setError(null); - }; + // Search handler const handleSearch = async () => { setLoading(true); - setError(null); + setTriggerSearch(true); // Trigger the search // Track user inputs for disclaimer const inputs = []; if (searchKeyword.trim()) inputs.push({ category: 'Keywords', value: searchKeyword.trim() }); - if (authorObject && authorObject.display_name) inputs.push({ category: 'Author', value: authorObject.display_name }); - if (institutionObject && institutionObject.display_name) inputs.push({ category: 'Institution', value: institutionObject.display_name }); + if (selectedAuthors.length > 0) inputs.push({ category: 'Authors', value: selectedAuthors.map(a => a.display_name).join(', ') }); + if (selectedInstitutions.length > 0) inputs.push({ category: 'Institutions', value: selectedInstitutions.map(i => i.display_name).join(', ') }); if (selectedPublicationTypes.length > 0) inputs.push({ category: 'Publication Types', value: selectedPublicationTypes.map(pt => pt.display_name).join(', ') }); if (publicationYear.trim()) inputs.push({ category: 'Publication Year', value: publicationYear.trim() }); if (startYear.trim() && endYear.trim()) inputs.push({ category: 'Year Range', value: `${startYear.trim()}-${endYear.trim()}` }); @@ -126,13 +112,17 @@ const WorldMapPapersPage = () => { const keyword = searchKeyword.trim(); filters.push(`title_and_abstract.search:${keyword}`); } - if (authorObject && authorObject.id) { - const authorId = authorObject.id.split('/').pop(); - filters.push(`authorships.author.id:A${authorId}`); + if (selectedAuthors.length > 0) { + selectedAuthors.forEach(a => { + const authorId = a.id.split('/').pop(); + filters.push(`authorships.author.id:${authorId}`); + }); } - if (institutionObject && institutionObject.id) { - const instId = institutionObject.id.split('/').pop(); - filters.push(`authorships.institutions.id:I${instId}`); + if (selectedInstitutions.length > 0) { + selectedInstitutions.forEach(i => { + const institutionId = i.id.split('/').pop(); + filters.push(`authorships.institutions.id:${institutionId}`); + }); } if (selectedPublicationTypes.length > 0) { const typeFilters = selectedPublicationTypes.map(pt => `type:${pt.id}`); @@ -171,7 +161,6 @@ const WorldMapPapersPage = () => { setSearchResults(data.results || []); } catch (e) { - setError(e.message || 'Failed to fetch search results'); setSearchResults([]); } finally { setLoading(false); @@ -215,19 +204,17 @@ const WorldMapPapersPage = () => { setShowAdvanced(true)} - onAuthorClick={() => { + onAuthorsClick={() => { setShowAuthorModal(true); clearAuthorSuggestions(); }} - onInstitutionClick={() => { + onInstitutionsClick={() => { setShowInstitutionModal(true); clearInstitutionSuggestions(); }} @@ -235,6 +222,43 @@ const WorldMapPapersPage = () => { darkMode={true} description="Enter keywords and apply filters to locate research clusters" /> + {/* Authors Multi-Select Modal */} + setShowAuthorModal(false)} + title="Select Authors" + placeholder="Type to search authors..." + onSearchChange={searchAuthors} + suggestions={authorSuggestions} + selectedItems={selectedAuthors} + onSelect={author => { + setSelectedAuthors(prev => prev.some(a => a.id === author.id) ? prev : [...prev, author]); + }} + onDeselect={author => { + setSelectedAuthors(prev => prev.filter(a => a.id !== author.id)); + }} + darkMode={true} + loading={authorLoading} + /> + + {/* Institutions Multi-Select Modal */} + setShowInstitutionModal(false)} + title="Select Institutions" + placeholder="Type to search institutions..." + onSearchChange={searchInstitutions} + suggestions={institutionSuggestions} + selectedItems={selectedInstitutions} + onSelect={institution => { + setSelectedInstitutions(prev => prev.some(i => i.id === institution.id) ? prev : [...prev, institution]); + }} + onDeselect={institution => { + setSelectedInstitutions(prev => prev.filter(i => i.id !== institution.id)); + }} + darkMode={true} + loading={institutionLoading} + /> {
{ searchResults={searchResults} /> - {/* Author Modal Dropdown */} - setShowAuthorModal(false)} - title="Select Author" - placeholder="Type to search authors..." - onSearchChange={searchAuthors} - suggestions={authorSuggestions} - onSelect={(author) => { - setAuthorObject(author); - setAuthor(author.display_name); - }} - darkMode={true} - loading={authorLoading} - /> - - {/* Institution Modal Dropdown */} - setShowInstitutionModal(false)} - title="Select Institution" - placeholder="Type to search institutions..." - onSearchChange={searchInstitutions} - suggestions={institutionSuggestions} - onSelect={(institution) => { - setInstitutionObject(institution); - setInstitution(institution.display_name); - }} - darkMode={true} - loading={institutionLoading} - /> {/* Publication Types Multi-Select Modal */}