From 6cc9f6cd9740be95ad5d32345671ef0b50f97c53 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 1 May 2026 11:22:54 -0500 Subject: [PATCH 01/10] fix(lint): migrate event pages to next/image for external images Replace tags with from next/image in PlantSEED workshop pages. This aligns with Next.js image optimization best practices and resolves @next/next/no-img-element warnings. The images are external URLs served from ANL servers; next.config.ts already has unoptimized:true. --- app/events/plantseed2015/page.tsx | 4 +++- app/events/plantseed2016/page.tsx | 4 +++- app/events/plantseed2017/page.tsx | 4 +++- app/events/plantseed2018/page.tsx | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) 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 From 39091b6bae38950f04399b2af8c54de78833c50d Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 1 May 2026 11:23:13 -0500 Subject: [PATCH 02/10] fix(lint): replace manual stylesheet tag with Next.js-compliant import Remove tag from layout.tsx head and replace with proper import of icomoon/style.css. This resolves the @next/next/no-css-tags warning. The font URLs in style.css are updated to absolute paths (/icomoon/fonts/...) so they resolve correctly when the CSS is imported from styles/icomoon/. --- app/layout.tsx | 2 +- styles/icomoon/style.css | 142 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 styles/icomoon/style.css 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({ - Date: Fri, 1 May 2026 11:23:34 -0500 Subject: [PATCH 03/10] fix(lint): resolve exhaustive-deps and migrate to next/image in MoleculeRenderer - Add atomColors to useEffect dependency array to fix react-hooks/exhaustive-deps - Replace with from next/image to resolve @next/next/no-img-element - Import next/image in MoleculeRenderer.tsx --- components/ui/MoleculeRenderer.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/components/ui/MoleculeRenderer.tsx b/components/ui/MoleculeRenderer.tsx index 5b34939d..6f47e780 100644 --- a/components/ui/MoleculeRenderer.tsx +++ b/components/ui/MoleculeRenderer.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; +import Image from 'next/image'; import Skeleton from '@mui/material/Skeleton'; import { getRDKit } from '@/lib/rdkit'; import { getCompoundImageUrl } from '@/lib/api/biochem'; @@ -100,7 +101,7 @@ export default function MoleculeRenderer({ return () => { cancelled = true; }; - }, [smiles, atomColorsKey, width, height]); + }, [smiles, atomColorsKey, atomColors, width, height]); if (state === 'loading') { return ( @@ -134,12 +135,12 @@ export default function MoleculeRenderer({ if (state === 'png') { return ( - {alt Date: Fri, 1 May 2026 11:26:16 -0500 Subject: [PATCH 04/10] fix(lint): remove unused genomeData variable in genome detail page Remove unused genomeData from useQuery destructuring in genomes/[ref]/page.tsx. The query is disabled (enabled: false) and the data was never used, triggering @typescript-eslint/no-unused-vars warning. --- app/(reference-data)/genomes/[ref]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 0857b515b1513b912413968ac7af46ea5bfe6380 Mon Sep 17 00:00:00 2001 From: Sam Seaver Date: Fri, 1 May 2026 12:17:21 -0500 Subject: [PATCH 05/10] Adding deployment script and updating environment variables for reporting in website --- .env.example | 2 +- Dockerfile | 4 ++++ VERSION.md | 1 + app/about/version/page.tsx | 2 +- deploy_container.sh | 41 ++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 4 ++++ 6 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 VERSION.md create mode 100755 deploy_container.sh diff --git a/.env.example b/.env.example index e7f44b60..ea7e810f 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,7 @@ NEXT_PUBLIC_GIT_VERSION=3.0.0 NEXT_PUBLIC_GIT_BRANCH=staging NEXT_PUBLIC_GIT_COMMIT= # Optional build date override shown on /about/version -NEXT_PUBLIC_GIT_DATE= +NEXT_PUBLIC_DEPLOY_DATE= # --- Test Credentials --- # For E2E tests, you can provide a PATRIC token or username/password. diff --git a/Dockerfile b/Dockerfile index e42f005a..05a52b5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,10 @@ COPY . . ARG NEXT_PUBLIC_MODELSEED_API_URL ARG NEXT_PUBLIC_USE_MODELSEED_API ARG NEXT_PUBLIC_USE_NEW_PROXY +ARG NEXT_PUBLIC_GIT_VERSION +ARG NEXT_PUBLIC_GIT_COMMIT +ARG NEXT_PUBLIC_GIT_BRANCH +ARG NEXT_PUBLIC_DEPLOY_DATE # Build the application RUN npm run build diff --git a/VERSION.md b/VERSION.md new file mode 100644 index 00000000..4a36342f --- /dev/null +++ b/VERSION.md @@ -0,0 +1 @@ +3.0.0 diff --git a/app/about/version/page.tsx b/app/about/version/page.tsx index e85cf178..95375422 100644 --- a/app/about/version/page.tsx +++ b/app/about/version/page.tsx @@ -58,7 +58,7 @@ function getBranchName(): string { } function getBuildDate(): string { - const envDate = process.env.NEXT_PUBLIC_GIT_DATE; + const envDate = process.env.NEXT_PUBLIC_DEPLOY_DATE; if (envDate) return envDate; try { diff --git a/deploy_container.sh b/deploy_container.sh new file mode 100755 index 00000000..b03fdbb1 --- /dev/null +++ b/deploy_container.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# 1. Grab the manually set version from VERSION.md +# (Checks if the file exists, reads it, and strips any accidental whitespace/newlines) +if [ -f "VERSION.md" ]; then + export NEXT_PUBLIC_GIT_VERSION=$(cat VERSION.md | xargs) +else + echo "Warning: VERSION.md not found. Defaulting to 'unknown'." + export NEXT_PUBLIC_GIT_VERSION="unknown" +fi + +# 2. Get strictly the first 6 characters of the current commit hash +export NEXT_PUBLIC_GIT_COMMIT=$(git rev-parse HEAD 2>/dev/null | cut -c 1-6 || echo "unknown") + +# 3. Get the current Git branch +export NEXT_PUBLIC_GIT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown") + +# 4. Set human-readable date (e.g., "May 1, 2026") +export NEXT_PUBLIC_DEPLOY_DATE=$(date +"%B %-d, %Y") + +# Display the gathered metadata +echo "========================================" +echo "Ready to build ModelSEED UI:" +echo " Version: $NEXT_PUBLIC_GIT_VERSION" +echo " Commit: $NEXT_PUBLIC_GIT_COMMIT" +echo " Branch: $NEXT_PUBLIC_GIT_BRANCH" +echo " Date: $NEXT_PUBLIC_DEPLOY_DATE" +echo "========================================" +echo "" + +# Ask for confirmation +read -p "Trigger Build? [y/N]: " confirm + +# Check the user's input +if [[ "$confirm" =~ ^[Yy]$ ]]; then + echo "Starting build process..." + docker compose up -d --build +else + echo "Build aborted. No changes were made." + exit 0 +fi diff --git a/docker-compose.yml b/docker-compose.yml index 2e7e9b1b..0114e179 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,10 @@ services: build: context: . args: + - NEXT_PUBLIC_GIT_VERSION=${NEXT_PUBLIC_GIT_VERSION} + - NEXT_PUBLIC_GIT_COMMIT=${NEXT_PUBLIC_GIT_COMMIT} + - NEXT_PUBLIC_GIT_BRANCH=${NEXT_PUBLIC_GIT_BRANCH} + - NEXT_PUBLIC_DEPLOY_DATE=${NEXT_PUBLIC_DEPLOY_DATE} - NEXT_PUBLIC_MODELSEED_API_URL=https://staging.modelseed.org/PMS/ - NEXT_PUBLIC_USE_MODELSEED_API=true - NEXT_PUBLIC_USE_NEW_PROXY=true From 0ed36f3621438ddb73ecf3047628ad9dfd2096cc Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 1 May 2026 12:45:20 -0500 Subject: [PATCH 06/10] feat(ui): replace search bar with find-in-page style search - Add client-side text highlighting (like Ctrl+F) instead of DataGrid filtering - Show match counter (e.g., '3 of 25') - Add prev/next arrow buttons to navigate between matches - Use debounced highlighting (300ms) for performance - Keep Filter & Columns button untouched - Fix Solr query building (encode URI, fix field names, fix filter clause joining) --- components/layout/DataControlHeader.tsx | 248 +++++++++++++++++++----- lib/api/biochem.ts | 9 +- 2 files changed, 201 insertions(+), 56 deletions(-) diff --git a/components/layout/DataControlHeader.tsx b/components/layout/DataControlHeader.tsx index 51d04a66..ba0cfb0d 100644 --- a/components/layout/DataControlHeader.tsx +++ b/components/layout/DataControlHeader.tsx @@ -30,8 +30,10 @@ 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 ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; 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']); @@ -121,67 +123,209 @@ function CustomPagination() { function ToolbarSearchField() { const apiRef = useGridApiContext(); const pathname = usePathname(); - const filterModel = useGridSelector(apiRef, gridFilterModelSelector); - const value = filterModel?.quickFilterValues?.join(' ') ?? ''; + const [searchTerm, setSearchTerm] = useState(''); + const [matchIndex, setMatchIndex] = useState(0); + const [totalMatches, setTotalMatches] = useState(0); + const allMatchesRef = useRef([]); + const highlightTimeoutRef = 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('/myMedia')) 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, + const clearHighlights = useCallback(() => { + document.querySelectorAll('.search-highlight').forEach((el) => { + const parent = el.parentNode; + if (parent) { + parent.replaceChild(document.createTextNode(el.textContent ?? ''), el); + parent.normalize(); + } + }); + allMatchesRef.current = []; + }, []); + + const highlightText = useCallback((term: string) => { + clearHighlights(); + if (!term || term.trim().length === 0) { + setTotalMatches(0); + setMatchIndex(0); + return; + } + + const grid = document.querySelector('[role="grid"]'); + if (!grid) { + setTotalMatches(0); + setMatchIndex(0); + return; + } + + const matches: HTMLElement[] = []; + const caseSensitive = term === term.toLowerCase() ? false : term !== term.toLowerCase(); + const searchLower = caseSensitive ? term : term.toLowerCase(); + + // Get all text nodes in grid cells + const walker = document.createTreeWalker(grid, NodeFilter.SHOW_TEXT); + const textNodes: Text[] = []; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + const parent = node.parentElement; + if (parent && parent.closest('[role="gridcell"]')) { + textNodes.push(node); + } + } + + textNodes.forEach((textNode) => { + const text = textNode.textContent ?? ''; + const textCompare = caseSensitive ? text : text.toLowerCase(); + let idx = 0; + + while ((idx = textCompare.indexOf(searchLower, idx)) !== -1) { + const range = document.createRange(); + range.setStart(textNode, idx); + range.setEnd(textNode, idx + term.length); + + const mark = document.createElement('mark'); + mark.className = 'search-highlight'; + mark.style.backgroundColor = '#ffeb3b'; + mark.style.color = '#000'; + mark.style.padding = '0 2px'; + mark.style.borderRadius = '2px'; + range.surroundContents(mark); + matches.push(mark); + + idx += term.length; + } }); + + allMatchesRef.current = matches; + setTotalMatches(matches.length); + setMatchIndex(matches.length > 0 ? 0 : 0); + + if (matches.length > 0) { + matches[0].style.backgroundColor = '#ff9800'; + matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [clearHighlights]); + + const navigateToMatch = useCallback((index: number) => { + if (totalMatches === 0) return; + const clampedIndex = ((index % totalMatches) + totalMatches) % totalMatches; + setMatchIndex(clampedIndex); + + // Reset all highlights to default color + allMatchesRef.current.forEach((el, i) => { + el.style.backgroundColor = i === clampedIndex ? '#ff9800' : '#ffeb3b'; + }); + + const target = allMatchesRef.current[clampedIndex]; + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, [totalMatches]); + + useEffect(() => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + + highlightTimeoutRef.current = setTimeout(() => { + highlightText(searchTerm); + }, 300); + + return () => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + } + }; + }, [searchTerm, highlightText]); + + useEffect(() => { + return () => { + clearHighlights(); + }; + }, [clearHighlights]); + + const handleClear = () => { + setSearchTerm(''); + clearHighlights(); + setTotalMatches(0); + setMatchIndex(0); }; return ( - handleChange(e.target.value)} - size="small" - fullWidth - placeholder={placeholder} - InputProps={{ - startAdornment: ( - - - - ), - endAdornment: value ? ( - - handleChange('')} - edge="end" - > - - - - ) : undefined, - }} - sx={{ '& .MuiInputBase-input': { cursor: 'text' } }} - /> + + setSearchTerm(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + if (e.shiftKey) navigateToMatch(matchIndex - 1); + else navigateToMatch(matchIndex + 1); + } + }} + size="small" + fullWidth + placeholder={placeholder} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: searchTerm ? ( + + + + + + ) : undefined, + }} + sx={{ '& .MuiInputBase-input': { cursor: 'text' } }} + /> + {searchTerm && totalMatches > 0 && ( + + navigateToMatch(matchIndex - 1)} + > + + + + {matchIndex + 1} of {totalMatches} + + navigateToMatch(matchIndex + 1)} + > + + + + )} + ); } diff --git a/lib/api/biochem.ts b/lib/api/biochem.ts index 4e03d11f..c6228f8d 100644 --- a/lib/api/biochem.ts +++ b/lib/api/biochem.ts @@ -292,10 +292,10 @@ function buildSolrUrl(collection: string, opts: SolrQueryOpts = {}): string { const filterClauses = (filterModel?.items ?? []) .map((item) => buildFilterClause(item)) .filter((clause): clause is string => Boolean(clause)); - const filterLogic = filterModel?.logicOperator === 'or' ? 'OR' : 'AND'; + const filterLogic = filterModel?.logicOperator === 'or' ? ' OR ' : ' AND '; const combinedFilterClause = filterClauses.length > 1 - ? `(${filterClauses.join(` ${filterLogic} `)})` + ? `(${filterClauses.join(filterLogic)})` : (filterClauses[0] ?? ''); const queryColumnClauses = queryColumn @@ -328,7 +328,8 @@ function buildSolrUrl(collection: string, opts: SolrQueryOpts = {}): string { finalClauses.push('*'); } - url += `&q=${finalClauses.length > 0 ? finalClauses.join(' AND ') : '*'}`; + const qValue = finalClauses.length > 0 ? finalClauses.join(' AND ') : '*'; + url += `&q=${encodeURIComponent(qValue)}`; // Pagination if (limit) url += `&rows=${limit}`; @@ -403,7 +404,7 @@ const SYNONYM_FIELD_ALIAS = 'aliases'; const MIN_WILDCARD_QUERY_LENGTH = 3; /** Reaction search fields matching legacy `rxn_sFields`. */ -const RXN_SEARCH_FIELDS = ['id', 'name', 'status', 'ecs', 'synonyms', 'aliases', 'pathways', 'stoichiometry', 'notes']; +const RXN_SEARCH_FIELDS = ['id', 'name', 'status', 'ec_numbers', 'aliases', 'pathways', 'stoichiometry', 'notes']; /** Reaction visible fields matching legacy `rxnOpts.visible`. */ const RXN_VISIBLE = [ From 134937fd246f7b01eebb95231f9c1e9d5e30f545 Mon Sep 17 00:00:00 2001 From: VibhavSetlur Date: Fri, 1 May 2026 14:40:45 -0500 Subject: [PATCH 07/10] fix(search): harden dual-backend filtering and trim e2e suite Ensure Solr and modelseed-api search/filter paths both behave predictably, remove temporary biochem debug specs, and keep a focused Playwright suite that validates find-in-page and filter workflows. Co-authored-by: Cursor --- app/model/[...path]/page.tsx | 14 +- components/layout/DataControlHeader.tsx | 511 ++++++++---------- eslint.config.mjs | 5 + lib/api/biochem.ts | 184 +++++-- tests/e2e/biochem/compounds.spec.ts | 8 +- tests/e2e/biochem/reactions.spec.ts | 8 +- tests/e2e/biochem/search-find.spec.ts | 149 +++++ tests/unit/api/biochem-rest-filtering.test.ts | 60 ++ 8 files changed, 605 insertions(+), 334 deletions(-) create mode 100644 tests/e2e/biochem/search-find.spec.ts create mode 100644 tests/unit/api/biochem-rest-filtering.test.ts diff --git a/app/model/[...path]/page.tsx b/app/model/[...path]/page.tsx index 5f68aec1..10cd7360 100644 --- a/app/model/[...path]/page.tsx +++ b/app/model/[...path]/page.tsx @@ -1711,7 +1711,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 +1728,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 ba0cfb0d..e4651b61 100644 --- a/components/layout/DataControlHeader.tsx +++ b/components/layout/DataControlHeader.tsx @@ -25,13 +25,10 @@ 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 ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import { usePathname } from 'next/navigation'; import { useEffect, useMemo, useState, useRef, useCallback, type MouseEvent } from 'react'; @@ -44,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: '!=' }, @@ -123,11 +129,8 @@ function CustomPagination() { function ToolbarSearchField() { const apiRef = useGridApiContext(); const pathname = usePathname(); - const [searchTerm, setSearchTerm] = useState(''); - const [matchIndex, setMatchIndex] = useState(0); - const [totalMatches, setTotalMatches] = useState(0); - const allMatchesRef = useRef([]); - const highlightTimeoutRef = useRef | null>(null); + const [value, setValue] = useState(''); + const debounceRef = useRef | null>(null); const placeholder = useMemo(() => { if (!pathname) return 'Find in page...'; @@ -150,135 +153,141 @@ function ToolbarSearchField() { return 'Find in page...'; }, [pathname]); - const clearHighlights = useCallback(() => { - document.querySelectorAll('.search-highlight').forEach((el) => { - const parent = el.parentNode; - if (parent) { - parent.replaceChild(document.createTextNode(el.textContent ?? ''), el); - parent.normalize(); - } - }); - allMatchesRef.current = []; - }, []); - - const highlightText = useCallback((term: string) => { - clearHighlights(); - if (!term || term.trim().length === 0) { - setTotalMatches(0); - setMatchIndex(0); - return; - } + /** 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: GridLogicOperator.And, + }); + }, + [apiRef], + ); - const grid = document.querySelector('[role="grid"]'); - if (!grid) { - setTotalMatches(0); - setMatchIndex(0); - return; - } + 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 matches: HTMLElement[] = []; - const caseSensitive = term === term.toLowerCase() ? false : term !== term.toLowerCase(); - const searchLower = caseSensitive ? term : term.toLowerCase(); - - // Get all text nodes in grid cells - const walker = document.createTreeWalker(grid, NodeFilter.SHOW_TEXT); - const textNodes: Text[] = []; - while (walker.nextNode()) { - const node = walker.currentNode as Text; - const parent = node.parentElement; - if (parent && parent.closest('[role="gridcell"]')) { - textNodes.push(node); - } - } + const handleClear = useCallback(() => { + setValue(''); + if (debounceRef.current) clearTimeout(debounceRef.current); + applySearch(''); + }, [applySearch]); - textNodes.forEach((textNode) => { - const text = textNode.textContent ?? ''; - const textCompare = caseSensitive ? text : text.toLowerCase(); - let idx = 0; - - while ((idx = textCompare.indexOf(searchLower, idx)) !== -1) { - const range = document.createRange(); - range.setStart(textNode, idx); - range.setEnd(textNode, idx + term.length); - - const mark = document.createElement('mark'); - mark.className = 'search-highlight'; - mark.style.backgroundColor = '#ffeb3b'; - mark.style.color = '#000'; - mark.style.padding = '0 2px'; - mark.style.borderRadius = '2px'; - range.surroundContents(mark); - matches.push(mark); - - idx += term.length; + // 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: [], + }); + } catch { + // api may be stale on unmount — safe to ignore } - }); - - allMatchesRef.current = matches; - setTotalMatches(matches.length); - setMatchIndex(matches.length > 0 ? 0 : 0); + }; + }, [apiRef]); - if (matches.length > 0) { - matches[0].style.backgroundColor = '#ff9800'; - matches[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); + // 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).clear(); + } + return; } - }, [clearHighlights]); - const navigateToMatch = useCallback((index: number) => { - if (totalMatches === 0) return; - const clampedIndex = ((index % totalMatches) + totalMatches) % totalMatches; - setMatchIndex(clampedIndex); + 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(); + } - // Reset all highlights to default color - allMatchesRef.current.forEach((el, i) => { - el.style.backgroundColor = i === clampedIndex ? '#ff9800' : '#ffeb3b'; - }); + // 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); + }; - const target = allMatchesRef.current[clampedIndex]; - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } - }, [totalMatches]); + // Delay slightly to allow DataGrid to render the new filtered rows + const timeout = setTimeout(() => { + updateHighlights(); + }, 100); - useEffect(() => { - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); - } + const observer = new MutationObserver(() => { + updateHighlights(); + }); - highlightTimeoutRef.current = setTimeout(() => { - highlightText(searchTerm); - }, 300); + observer.observe(root, { childList: true, subtree: true, characterData: true }); return () => { - if (highlightTimeoutRef.current) { - clearTimeout(highlightTimeoutRef.current); + 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).clear(); } }; - }, [searchTerm, highlightText]); - - useEffect(() => { - return () => { - clearHighlights(); - }; - }, [clearHighlights]); - - const handleClear = () => { - setSearchTerm(''); - clearHighlights(); - setTotalMatches(0); - setMatchIndex(0); - }; + }, [value, apiRef]); return ( - + <> +