diff --git a/app/(reference-data)/genomes/[ref]/page.tsx b/app/(reference-data)/genomes/[ref]/page.tsx index 65ebfdb2..d7a287b5 100644 --- a/app/(reference-data)/genomes/[ref]/page.tsx +++ b/app/(reference-data)/genomes/[ref]/page.tsx @@ -16,7 +16,7 @@ export default function GenomeDetailPage(props: GenomeDetailProps) { const genomeRef = decodeURIComponent(params.ref); // Query for genome data - this would need to be implemented in the API - const { data: genomeData, isLoading, error } = useQuery({ + const { isLoading, error } = useQuery({ queryKey: ['genome', genomeRef], queryFn: async () => { // Placeholder - would need actual API endpoint diff --git a/app/events/plantseed2015/page.tsx b/app/events/plantseed2015/page.tsx index f2294445..f746a31a 100644 --- a/app/events/plantseed2015/page.tsx +++ b/app/events/plantseed2015/page.tsx @@ -42,9 +42,11 @@ export default function PlantSEED2015Page() { - 2015 Workshop Group Photo diff --git a/app/events/plantseed2016/page.tsx b/app/events/plantseed2016/page.tsx index 047d647e..55d007ef 100644 --- a/app/events/plantseed2016/page.tsx +++ b/app/events/plantseed2016/page.tsx @@ -42,9 +42,11 @@ export default function PlantSEED2016Page() { - 2016 Workshop Group Photo diff --git a/app/events/plantseed2017/page.tsx b/app/events/plantseed2017/page.tsx index ded423ea..18c3bf3b 100644 --- a/app/events/plantseed2017/page.tsx +++ b/app/events/plantseed2017/page.tsx @@ -42,9 +42,11 @@ export default function PlantSEED2017Page() { - 2017 Workshop Group Photo diff --git a/app/events/plantseed2018/page.tsx b/app/events/plantseed2018/page.tsx index daa9eff7..b63c17dc 100644 --- a/app/events/plantseed2018/page.tsx +++ b/app/events/plantseed2018/page.tsx @@ -54,9 +54,11 @@ export default function PlantSEED2018Page() { - 2018 Workshop Group Photo diff --git a/app/layout.tsx b/app/layout.tsx index 43f10ebf..9a472634 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,7 @@ import Providers from '@/components/Providers'; import { AuthProvider } from '@/components/auth/AuthProvider'; import type { Metadata } from "next"; import "./globals.css"; +import "@/styles/icomoon/style.css"; export const metadata: Metadata = { title: "ModelSEED", @@ -23,7 +24,6 @@ export default function RootLayout({ - (); + const ordered: string[] = []; + for (const match of matches) { + if (seen.has(match)) continue; + seen.add(match); + ordered.push(match); + } + return ordered; +} + function buildReactionRows(model: Record): Record[] { return asArray>(model.modelreactions ?? model.reactions).map((reaction, index) => ({ id: String((reaction.id ?? extractRefId(reaction.reaction_ref)) || `rxn-${index}`), @@ -185,6 +199,7 @@ function buildGeneRows(model: Record): Record[ return explicitGenes.map((gene, index) => ({ id: String((gene.id ?? extractRefId(gene.feature_ref)) || `gene-${index}`), reactions: toSearchableString(gene.reactions ?? []), + reactionIds: extractReactionIds(gene.reactions ?? []), functions: gene.functions ? toSearchableString(gene.functions) : 'N/A', })); } @@ -211,6 +226,7 @@ function buildGeneRows(model: Record): Record[ return Array.from(geneToReactions.entries()).map(([gene, reactions]) => ({ id: gene, reactions: Array.from(reactions).join(', '), + reactionIds: Array.from(reactions), functions: 'View details', })); } @@ -400,7 +416,36 @@ function buildTableConfig(model: Record): Record { + const row = params.row as Record; + const reactionIds = Array.isArray(row.reactionIds) + ? (row.reactionIds as string[]) + : extractReactionIds(params.value); + if (reactionIds.length === 0) { + return N/A; + } + return ( + + {reactionIds.map((rxnId) => ( + + ))} + + ); + }, + }, { field: 'functions', headerName: 'Functions', flex: 1, minWidth: 260 }, ], }, @@ -1711,7 +1756,8 @@ export default function ModelDetailPage({ params }: { params: Promise<{ path: st modelName, ); - const genomeRef = String(modelObject.genome_ref ?? modelObject.genome_id ?? '-'); + const genomeRefRaw = String(modelObject.genome_ref ?? modelObject.genome_id ?? '-'); + const genomeRef = genomeRefRaw.replace(/\|\|+$/, '').trim() || '-'; const visibleTabs = MODEL_TABS.filter((tab) => !(isPlantModel && tab.key === 'edits')); const activeTabVisible = visibleTabs.some((tab) => tab.key === activeTab); @@ -1727,16 +1773,7 @@ export default function ModelDetailPage({ params }: { params: Promise<{ path: st }, { label: 'Genome Ref', - value: genomeRef !== '-' ? ( - - {genomeRef} - - ) : '-', + value: genomeRef, }, { label: 'Type', value: String(modelObject.type ?? '-') }, { diff --git a/components/layout/DataControlHeader.tsx b/components/layout/DataControlHeader.tsx index 51d04a66..0e86c29b 100644 --- a/components/layout/DataControlHeader.tsx +++ b/components/layout/DataControlHeader.tsx @@ -25,13 +25,12 @@ import Divider from '@mui/material/Divider'; import Typography from '@mui/material/Typography'; import Checkbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; -import ToggleButton from '@mui/material/ToggleButton'; -import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; + import SearchIcon from '@mui/icons-material/Search'; -import AddIcon from '@mui/icons-material/Add'; + import CloseIcon from '@mui/icons-material/Close'; import { usePathname } from 'next/navigation'; -import { useEffect, useMemo, useState, type MouseEvent } from 'react'; +import { useEffect, useMemo, useState, useRef, useCallback, type MouseEvent } from 'react'; const NO_VALUE_OPERATORS = new Set(['isEmpty', 'isNotEmpty']); @@ -42,10 +41,19 @@ const STRING_OPERATORS = [ { value: 'doesNotEqual', label: 'does not equal' }, { value: 'startsWith', label: 'starts with' }, { value: 'endsWith', label: 'ends with' }, + { value: 'isAnyOf', label: 'is any of' }, { value: 'isEmpty', label: 'is empty' }, { value: 'isNotEmpty', label: 'is not empty' }, ]; +/** Operators whose value field should be treated as a comma-separated list. */ +const ARRAY_VALUE_OPERATORS = new Set(['isAnyOf']); + +/** Hint shown below the value input when an array operator is selected. */ +const ARRAY_OPERATOR_HINT: Record = { + isAnyOf: 'Comma-separated values, e.g. cpd00001, cpd00002', +}; + const NUMBER_OPERATORS = [ { value: '=', label: '=' }, { value: '!=', label: '!=' }, @@ -121,67 +129,198 @@ function CustomPagination() { function ToolbarSearchField() { const apiRef = useGridApiContext(); const pathname = usePathname(); - const filterModel = useGridSelector(apiRef, gridFilterModelSelector); - const value = filterModel?.quickFilterValues?.join(' ') ?? ''; + const [value, setValue] = useState(''); + const debounceRef = useRef | null>(null); const placeholder = useMemo(() => { - if (!pathname) return 'Search...'; - if (pathname.includes('/genomes/Annotations')) return 'Search subsystems...'; - if (pathname.includes('/biochem/reactions')) return 'Search reactions...'; - if (pathname.includes('/biochem/compounds')) return 'Search compounds...'; - if (pathname.includes('/reference-data/genomes')) return 'Search plant models...'; - if (pathname.includes('/reference-data/list-media')) return 'Search media...'; - if (pathname.includes('/my-models')) return 'Search my models...'; - if (pathname.includes('/myMedia')) return 'Search my media...'; + if (!pathname) return 'Find in page...'; + if (pathname.includes('/biochem/reactions')) return 'Find in reactions...'; + if (pathname.includes('/biochem/compounds')) return 'Find in compounds...'; + if (pathname.includes('/genomes/Annotations')) return 'Find in subsystems...'; + if (pathname.includes('/reference-data/genomes')) return 'Find in plant models...'; + if (pathname.includes('/reference-data/list-media')) return 'Find in media...'; + if (pathname.includes('/my-models')) return 'Find in my models...'; + if (pathname.includes('/my-jobs')) return 'Find in my jobs...'; + if (pathname.includes('/myMedia')) return 'Find in my media...'; + if (pathname.includes('/my-media')) return 'Find in my media...'; if (pathname.includes('/model/')) { - if (pathname.endsWith('/reactions')) return 'Search reactions...'; - if (pathname.endsWith('/compounds')) return 'Search compounds...'; - if (pathname.endsWith('/genes')) return 'Search genes...'; - if (pathname.endsWith('/compartments')) return 'Search compartments...'; - if (pathname.endsWith('/biomass')) return 'Search biomass...'; - if (pathname.endsWith('/pathways')) return 'Search pathways...'; - return 'Search model...'; + if (pathname.endsWith('/reactions')) return 'Find in reactions...'; + if (pathname.endsWith('/compounds')) return 'Find in compounds...'; + if (pathname.endsWith('/genes')) return 'Find in genes...'; + if (pathname.endsWith('/compartments')) return 'Find in compartments...'; + if (pathname.endsWith('/biomass')) return 'Find in biomass...'; + if (pathname.endsWith('/pathways')) return 'Find in pathways...'; + return 'Find in model...'; } - return 'Search...'; + return 'Find in page...'; }, [pathname]); - const handleChange = (next: string) => { - apiRef.current.setFilterModel({ - items: filterModel?.items ?? [], - logicOperator: filterModel?.logicOperator ?? GridLogicOperator.And, - quickFilterValues: next ? [next] : [], - quickFilterLogicOperator: filterModel?.quickFilterLogicOperator ?? GridLogicOperator.And, + /** Push search term into the DataGrid filter model as a quickFilter. + * This triggers onFilterModelChange on the page → server re-fetch → only + * matching rows are returned. GridHighlightText reads quickFilterValues + * and highlights the matched text in each cell automatically. */ + const applySearch = useCallback( + (term: string) => { + const current = apiRef.current.state.filter?.filterModel ?? { items: [] }; + apiRef.current.setFilterModel({ + items: (current.items ?? []) as import('@mui/x-data-grid').GridFilterItem[], + logicOperator: + (current.logicOperator as GridLogicOperator | undefined) ?? + GridLogicOperator.And, + quickFilterValues: term.trim() ? [term.trim()] : [], + quickFilterLogicOperator: + (current.quickFilterLogicOperator as GridLogicOperator | undefined) ?? + GridLogicOperator.And, + }); + }, + [apiRef], + ); + + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const term = e.target.value; + setValue(term); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => applySearch(term), 300); + }, + [applySearch], + ); + + const handleClear = useCallback(() => { + setValue(''); + if (debounceRef.current) clearTimeout(debounceRef.current); + applySearch(''); + }, [applySearch]); + + // Clear quickFilter on unmount so navigating away doesn't leave a stale filter + useEffect(() => { + const api = apiRef.current; + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + try { + const current = api.state.filter?.filterModel ?? { items: [] }; + api.setFilterModel({ + items: (current.items ?? []) as import('@mui/x-data-grid').GridFilterItem[], + logicOperator: + (current.logicOperator as GridLogicOperator | undefined) ?? + GridLogicOperator.And, + quickFilterValues: [], + quickFilterLogicOperator: + (current.quickFilterLogicOperator as GridLogicOperator | undefined) ?? + GridLogicOperator.And, + }); + } catch { + // api may be stale on unmount — safe to ignore + } + }; + }, [apiRef]); + + // Apply CSS Custom Highlight API to highlight matches in the grid + useEffect(() => { + const term = value.trim(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!term || typeof CSS === 'undefined' || !('highlights' in (CSS as any))) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof CSS !== 'undefined' && 'highlights' in (CSS as any)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((CSS as any).highlights as any).delete('search-results'); + } + return; + } + + const root = apiRef.current.rootElementRef?.current; + if (!root) return; + + const updateHighlights = () => { + if (!('Highlight' in window)) return; + const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); + const ranges: Range[] = []; + const lowerTerm = term.toLowerCase(); + + let node = treeWalker.nextNode(); + while (node) { + const text = node.textContent?.toLowerCase() || ''; + let startIndex = 0; + let index; + while ((index = text.indexOf(lowerTerm, startIndex)) !== -1) { + const range = new Range(); + range.setStart(node, index); + range.setEnd(node, index + lowerTerm.length); + ranges.push(range); + startIndex = index + lowerTerm.length; + } + node = treeWalker.nextNode(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const HighlightClass = (window as any).Highlight; + const highlight = new HighlightClass(...ranges); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((CSS as any).highlights as any).set('search-results', highlight); + }; + + // Delay slightly to allow DataGrid to render the new filtered rows + const timeout = setTimeout(() => { + updateHighlights(); + }, 100); + + const observer = new MutationObserver(() => { + updateHighlights(); }); - }; + + observer.observe(root, { childList: true, subtree: true, characterData: true }); + + return () => { + clearTimeout(timeout); + observer.disconnect(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof CSS !== 'undefined' && 'highlights' in (CSS as any)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ((CSS as any).highlights as any).delete('search-results'); + } + }; + }, [value, apiRef]); return ( - handleChange(e.target.value)} - size="small" - fullWidth - placeholder={placeholder} - InputProps={{ - startAdornment: ( - - - - ), - endAdornment: value ? ( - - handleChange('')} - edge="end" - > - - - - ) : undefined, - }} - sx={{ '& .MuiInputBase-input': { cursor: 'text' } }} - /> + <> +